Spaces:
Running
Running
Replace Gradio SmolVLM app with DeepSeek-7B-base GGUF OpenAI-compatible API (Docker)
Browse files- .dockerignore +12 -0
- .gitignore +16 -0
- Dockerfile +52 -0
- README.md +60 -8
- app.py +0 -84
- main.py +250 -0
- packages.txt +0 -1
- requirements.txt +12 -8
.dockerignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Keep the build context (and the image) small and model-free.
|
| 2 |
+
*.gguf
|
| 3 |
+
*.bin
|
| 4 |
+
*.safetensors
|
| 5 |
+
venv/
|
| 6 |
+
.venv/
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.pyc
|
| 9 |
+
.git/
|
| 10 |
+
.github/
|
| 11 |
+
.env
|
| 12 |
+
*.md
|
.gitignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Never commit models or caches β the model is served from a HF model repo.
|
| 2 |
+
*.gguf
|
| 3 |
+
*.bin
|
| 4 |
+
*.safetensors
|
| 5 |
+
.cache/
|
| 6 |
+
huggingface/
|
| 7 |
+
hf_home/
|
| 8 |
+
|
| 9 |
+
# Python
|
| 10 |
+
venv/
|
| 11 |
+
.venv/
|
| 12 |
+
__pycache__/
|
| 13 |
+
*.pyc
|
| 14 |
+
|
| 15 |
+
# Local env
|
| 16 |
+
.env
|
Dockerfile
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"]
|
README.md
CHANGED
|
@@ -1,14 +1,66 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
|
| 8 |
-
python_version: '3.13'
|
| 9 |
-
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
license: apache-2.0
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: DeepSeek 7B GGUF API
|
| 3 |
+
emoji: π§
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
|
|
|
|
|
|
| 8 |
pinned: false
|
| 9 |
license: apache-2.0
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# DeepSeek-LLM-7B-base GGUF API (Docker Space)
|
| 13 |
+
|
| 14 |
+
An OpenAI-compatible FastAPI server that runs
|
| 15 |
+
**`TheBloke/deepseek-llm-7B-base-GGUF` β `deepseek-llm-7b-base.Q4_K_M.gguf`**
|
| 16 |
+
with `llama-cpp-python`. The model file is **not** stored in this repo β it is
|
| 17 |
+
downloaded from the Hugging Face model hub on container startup, so this code
|
| 18 |
+
repo stays tiny.
|
| 19 |
+
|
| 20 |
+
> β οΈ **The YAML block at the very top of this file is required.** Hugging Face
|
| 21 |
+
> reads `sdk: docker` and `app_port: 7860` from it to know how to build and
|
| 22 |
+
> route the Space. Do not delete it.
|
| 23 |
+
|
| 24 |
+
## Endpoints
|
| 25 |
+
|
| 26 |
+
Base URL: `https://electro0023-model.hf.space`
|
| 27 |
+
|
| 28 |
+
| Method | Path | Purpose |
|
| 29 |
+
|--------|------------------------|-----------------------------------------------|
|
| 30 |
+
| GET | `/health` | Liveness + whether the model finished loading |
|
| 31 |
+
| GET | `/v1/models` | OpenAI-style model list |
|
| 32 |
+
| POST | `/v1/chat/completions` | OpenAI-compatible chat completion |
|
| 33 |
+
| POST | `/generate` | Simple `{prompt, max_tokens}` β text |
|
| 34 |
+
|
| 35 |
+
## Quick test
|
| 36 |
+
|
| 37 |
+
```bash
|
| 38 |
+
curl https://electro0023-model.hf.space/health
|
| 39 |
+
|
| 40 |
+
curl -X POST https://electro0023-model.hf.space/v1/chat/completions \
|
| 41 |
+
-H "Content-Type: application/json" \
|
| 42 |
+
-d '{"model":"deepseek-7b-base","messages":[{"role":"user","content":"Newton'\''s second law is"}],"max_tokens":64}'
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
## Swapping models (no code changes)
|
| 46 |
+
|
| 47 |
+
Set Space **Variables** (Settings β Variables and secrets) and restart:
|
| 48 |
+
|
| 49 |
+
- `REPO_ID` β HF model repo holding the GGUF
|
| 50 |
+
- `FILENAME` β the exact `.gguf` file inside that repo
|
| 51 |
+
- `MODEL_ID` β name reported to API clients
|
| 52 |
+
- `CHAT_FORMAT` β chat template for instruct models (e.g. `llama-3`, `chatml`);
|
| 53 |
+
leave empty for base models
|
| 54 |
+
- `N_CTX`, `N_THREADS`, `N_GPU_LAYERS`, `DEFAULT_MAX_TOKENS` β tuning knobs
|
| 55 |
+
|
| 56 |
+
## Notes
|
| 57 |
+
|
| 58 |
+
- **This default model is a BASE model** (raw text completion, not
|
| 59 |
+
instruction-tuned). For chat/instruction behaviour, switch to
|
| 60 |
+
`TheBloke/deepseek-llm-7B-chat-GGUF` / `deepseek-llm-7b-chat.Q4_K_M.gguf`
|
| 61 |
+
with `CHAT_FORMAT=deepseek` β or any other instruct GGUF.
|
| 62 |
+
- On the free `cpu-basic` tier (2 vCPU, 16 GB RAM) a 7B Q4_K_M generates
|
| 63 |
+
roughly 1β3 tokens/sec. Expect long response times; set generous client
|
| 64 |
+
timeouts.
|
| 65 |
+
- First boot downloads ~4.1 GB, so allow several minutes before `/health`
|
| 66 |
+
reports `status: ok`.
|
app.py
DELETED
|
@@ -1,84 +0,0 @@
|
|
| 1 |
-
import subprocess
|
| 2 |
-
import sys
|
| 3 |
-
|
| 4 |
-
print("Installing dependencies...")
|
| 5 |
-
subprocess.run([
|
| 6 |
-
sys.executable, "-m", "pip", "install",
|
| 7 |
-
"--force-reinstall", "--no-deps",
|
| 8 |
-
"transformers==4.53.0",
|
| 9 |
-
"tokenizers==0.23.1",
|
| 10 |
-
], check=True)
|
| 11 |
-
print("Done installing")
|
| 12 |
-
|
| 13 |
-
import torch
|
| 14 |
-
import gradio as gr
|
| 15 |
-
from transformers import AutoProcessor, AutoModelForVision2Seq
|
| 16 |
-
from PIL import Image
|
| 17 |
-
|
| 18 |
-
print(f"Transformers version: {__import__('transformers').__version__}")
|
| 19 |
-
|
| 20 |
-
model_id = "HuggingFaceTB/SmolVLM-256M-Instruct"
|
| 21 |
-
|
| 22 |
-
print("Loading processor...")
|
| 23 |
-
processor = AutoProcessor.from_pretrained(model_id)
|
| 24 |
-
|
| 25 |
-
print("Loading model...")
|
| 26 |
-
model = AutoModelForVision2Seq.from_pretrained(
|
| 27 |
-
model_id,
|
| 28 |
-
torch_dtype=torch.float32,
|
| 29 |
-
device_map="auto"
|
| 30 |
-
)
|
| 31 |
-
model.eval()
|
| 32 |
-
print("Model ready!")
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def extract_text(image: Image.Image) -> str:
|
| 36 |
-
if image is None:
|
| 37 |
-
return "Please upload an image."
|
| 38 |
-
|
| 39 |
-
messages = [
|
| 40 |
-
{
|
| 41 |
-
"role": "user",
|
| 42 |
-
"content": [
|
| 43 |
-
{"type": "image"},
|
| 44 |
-
{"type": "text", "text": (
|
| 45 |
-
"Extract all text from this image exactly as it appears. "
|
| 46 |
-
"Preserve question numbers, options A B C D, "
|
| 47 |
-
"tables, and any mathematical or chemical expressions. "
|
| 48 |
-
"Format clearly."
|
| 49 |
-
)}
|
| 50 |
-
]
|
| 51 |
-
}
|
| 52 |
-
]
|
| 53 |
-
|
| 54 |
-
prompt = processor.apply_chat_template(
|
| 55 |
-
messages,
|
| 56 |
-
add_generation_prompt=True
|
| 57 |
-
)
|
| 58 |
-
|
| 59 |
-
inputs = processor(
|
| 60 |
-
text=prompt,
|
| 61 |
-
images=[image],
|
| 62 |
-
return_tensors="pt"
|
| 63 |
-
).to(model.device)
|
| 64 |
-
|
| 65 |
-
with torch.no_grad():
|
| 66 |
-
outputs = model.generate(
|
| 67 |
-
**inputs,
|
| 68 |
-
max_new_tokens=1024,
|
| 69 |
-
do_sample=False
|
| 70 |
-
)
|
| 71 |
-
|
| 72 |
-
generated = outputs[0][inputs["input_ids"].shape[1]:]
|
| 73 |
-
return processor.decode(generated, skip_special_tokens=True)
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
demo = gr.Interface(
|
| 77 |
-
fn=extract_text,
|
| 78 |
-
inputs=gr.Image(type="pil", label="Upload NEET Question Image"),
|
| 79 |
-
outputs=gr.Textbox(label="Extracted Text", lines=20),
|
| 80 |
-
title="NEET Question Extractor",
|
| 81 |
-
description="Upload scanned NEET question paper image to extract text"
|
| 82 |
-
)
|
| 83 |
-
|
| 84 |
-
demo.launch()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
main.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 27 |
+
import uuid
|
| 28 |
+
import logging
|
| 29 |
+
import threading
|
| 30 |
+
from contextlib import asynccontextmanager
|
| 31 |
+
from typing import List, Optional, Dict, Any
|
| 32 |
+
|
| 33 |
+
from fastapi import FastAPI, HTTPException
|
| 34 |
+
from pydantic import BaseModel, Field
|
| 35 |
+
from huggingface_hub import hf_hub_download
|
| 36 |
+
from llama_cpp import Llama
|
| 37 |
+
|
| 38 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 39 |
+
log = logging.getLogger("gguf-api")
|
| 40 |
+
|
| 41 |
+
# ββ Configuration (all via environment) βββββββββββββββββββββββββββββββββββββββ
|
| 42 |
+
# Defaults point at DeepSeek-LLM-7B-base (Q4_K_M), a PUBLIC GGUF β so the Space
|
| 43 |
+
# runs with zero setup. Override REPO_ID/FILENAME in the Space settings to swap
|
| 44 |
+
# models without touching the code.
|
| 45 |
+
REPO_ID = os.getenv("REPO_ID", "TheBloke/deepseek-llm-7B-base-GGUF").strip()
|
| 46 |
+
FILENAME = os.getenv("FILENAME", "deepseek-llm-7b-base.Q4_K_M.gguf").strip()
|
| 47 |
+
HF_TOKEN = os.getenv("HF_TOKEN") or None # None => anonymous (public repo)
|
| 48 |
+
MODEL_ID = os.getenv("MODEL_ID", "") or "deepseek-7b-base" # name reported to clients
|
| 49 |
+
N_CTX = int(os.getenv("N_CTX", "4096"))
|
| 50 |
+
# Default to the number of *usable* CPUs (respects cgroup cpuset), NOT os.cpu_count()
|
| 51 |
+
# which over-reports the host's cores inside a container and causes thread thrash.
|
| 52 |
+
_USABLE_CPUS = len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else (os.cpu_count() or 4)
|
| 53 |
+
N_THREADS = int(os.getenv("N_THREADS", str(_USABLE_CPUS)))
|
| 54 |
+
N_GPU_LAYERS = int(os.getenv("N_GPU_LAYERS", "0")) # 0 = CPU only; -1 = all layers on GPU
|
| 55 |
+
# deepseek-llm-7b-BASE has no chat template (it is a raw completion model), so no
|
| 56 |
+
# chat format is forced by default; llama-cpp falls back to its generic template.
|
| 57 |
+
# If you swap to an instruct/chat GGUF, set CHAT_FORMAT accordingly (e.g. "llama-3").
|
| 58 |
+
CHAT_FORMAT = os.getenv("CHAT_FORMAT", "").strip()
|
| 59 |
+
DEFAULT_MAX_TOKENS = int(os.getenv("DEFAULT_MAX_TOKENS", "512"))
|
| 60 |
+
|
| 61 |
+
# The loaded model lives here. It is None until startup finishes (or if it failed).
|
| 62 |
+
# llama-cpp is NOT thread-safe for concurrent generation on one context, so every
|
| 63 |
+
# call into the model is serialised behind this lock.
|
| 64 |
+
_llm: Optional[Llama] = None
|
| 65 |
+
_llm_lock = threading.Lock()
|
| 66 |
+
_load_error: Optional[str] = None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _download_and_load() -> None:
|
| 70 |
+
"""Download the .gguf from the HF model repo, then load it with llama-cpp."""
|
| 71 |
+
global _llm, _load_error
|
| 72 |
+
|
| 73 |
+
if not REPO_ID or not FILENAME:
|
| 74 |
+
_load_error = (
|
| 75 |
+
"REPO_ID and FILENAME must both be set. In your Space go to "
|
| 76 |
+
"Settings -> Variables and secrets and add REPO_ID (e.g. "
|
| 77 |
+
"'your-username/my-gguf-models') and FILENAME (e.g. 'model.Q4_K_M.gguf')."
|
| 78 |
+
)
|
| 79 |
+
log.error(_load_error)
|
| 80 |
+
return
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
log.info("Downloading model '%s' from repo '%s' ...", FILENAME, REPO_ID)
|
| 84 |
+
t0 = time.time()
|
| 85 |
+
# hf_hub_download caches under HF_HOME and returns the local file path.
|
| 86 |
+
# Re-runs are instant once the file is cached in the container layer.
|
| 87 |
+
model_path = hf_hub_download(
|
| 88 |
+
repo_id=REPO_ID,
|
| 89 |
+
filename=FILENAME,
|
| 90 |
+
token=HF_TOKEN, # ignored for public repos, required for private
|
| 91 |
+
)
|
| 92 |
+
log.info("Model downloaded to %s (%.1fs)", model_path, time.time() - t0)
|
| 93 |
+
|
| 94 |
+
kwargs: Dict[str, Any] = dict(
|
| 95 |
+
model_path=model_path,
|
| 96 |
+
n_ctx=N_CTX,
|
| 97 |
+
n_threads=N_THREADS,
|
| 98 |
+
n_gpu_layers=N_GPU_LAYERS,
|
| 99 |
+
verbose=False,
|
| 100 |
+
)
|
| 101 |
+
if CHAT_FORMAT:
|
| 102 |
+
kwargs["chat_format"] = CHAT_FORMAT
|
| 103 |
+
|
| 104 |
+
log.info("Loading model into memory (n_ctx=%d, n_gpu_layers=%d) ...", N_CTX, N_GPU_LAYERS)
|
| 105 |
+
t0 = time.time()
|
| 106 |
+
_llm = Llama(**kwargs)
|
| 107 |
+
log.info("Model ready (%.1fs).", time.time() - t0)
|
| 108 |
+
except Exception as exc: # keep the server up so /health can report the reason
|
| 109 |
+
_load_error = f"Failed to download/load model: {exc}"
|
| 110 |
+
log.exception(_load_error)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@asynccontextmanager
|
| 114 |
+
async def lifespan(app: FastAPI):
|
| 115 |
+
"""Start loading the model in a BACKGROUND thread so the HTTP port opens
|
| 116 |
+
immediately.
|
| 117 |
+
|
| 118 |
+
Hugging Face marks a Space "in error" if port 7860 doesn't open within its
|
| 119 |
+
startup window. Downloading a ~2 GB model and loading it *before* the server
|
| 120 |
+
binds would blow past that window (this is exactly what crashes naive Spaces).
|
| 121 |
+
Loading off-thread lets uvicorn bind 7860 in seconds; /health reports
|
| 122 |
+
"loading_or_error" until the model is ready, and inference endpoints return a
|
| 123 |
+
clear 503 until then instead of failing the whole container.
|
| 124 |
+
"""
|
| 125 |
+
threading.Thread(target=_download_and_load, name="model-loader", daemon=True).start()
|
| 126 |
+
yield
|
| 127 |
+
# (nothing to clean up: process exit releases the model)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
app = FastAPI(title="GGUF LLM API (HF Spaces)", version="1.0.0", lifespan=lifespan)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ββ Request/response schemas ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 134 |
+
class ChatMessage(BaseModel):
|
| 135 |
+
role: str
|
| 136 |
+
content: str = ""
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class ChatCompletionRequest(BaseModel):
|
| 140 |
+
model: Optional[str] = None
|
| 141 |
+
messages: List[ChatMessage]
|
| 142 |
+
temperature: float = 0.7
|
| 143 |
+
top_p: float = 0.95
|
| 144 |
+
max_tokens: Optional[int] = None
|
| 145 |
+
stop: Optional[List[str]] = None
|
| 146 |
+
# OpenAI JSON mode: {"type":"json_object"} -> llama.cpp grammar-constrains the
|
| 147 |
+
# reply to VALID JSON. Essential for small models, which otherwise emit
|
| 148 |
+
# unbalanced brackets (e.g. a stray "]" after each array element).
|
| 149 |
+
response_format: Optional[Dict[str, Any]] = None
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
class GenerateRequest(BaseModel):
|
| 153 |
+
prompt: str = Field(..., description="Plain text prompt for the model.",
|
| 154 |
+
examples=["State Newton's second law of motion in one sentence."])
|
| 155 |
+
max_tokens: int = Field(DEFAULT_MAX_TOKENS, description="Max tokens to generate.")
|
| 156 |
+
temperature: float = Field(0.7, description="Sampling temperature (0 = deterministic).")
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _require_model() -> Llama:
|
| 160 |
+
"""Return the loaded model or raise 503 with a helpful message."""
|
| 161 |
+
if _llm is None:
|
| 162 |
+
raise HTTPException(
|
| 163 |
+
status_code=503,
|
| 164 |
+
detail=_load_error or "Model is still loading, try again in a few seconds.",
|
| 165 |
+
)
|
| 166 |
+
return _llm
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# ββ Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 170 |
+
@app.get("/health")
|
| 171 |
+
def health() -> Dict[str, Any]:
|
| 172 |
+
"""Liveness probe. status='ok' only once the model is loaded and ready."""
|
| 173 |
+
# Report the CPU picture so thread over-subscription (the #1 cause of slow
|
| 174 |
+
# CPU generation) is visible: if cpu_count >> usable cores, llama.cpp threads
|
| 175 |
+
# thrash. N_THREADS is what we actually hand to llama.cpp.
|
| 176 |
+
usable = len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else None
|
| 177 |
+
return {
|
| 178 |
+
"status": "ok" if _llm is not None else "loading_or_error",
|
| 179 |
+
"model": MODEL_ID,
|
| 180 |
+
"repo_id": REPO_ID,
|
| 181 |
+
"filename": FILENAME,
|
| 182 |
+
"n_ctx": N_CTX,
|
| 183 |
+
"n_gpu_layers": N_GPU_LAYERS,
|
| 184 |
+
"n_threads": N_THREADS,
|
| 185 |
+
"cpu_count": os.cpu_count(),
|
| 186 |
+
"usable_cpus": usable,
|
| 187 |
+
"error": _load_error,
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@app.get("/v1/models")
|
| 192 |
+
def list_models() -> Dict[str, Any]:
|
| 193 |
+
"""OpenAI-shaped model list (a single loaded model)."""
|
| 194 |
+
return {"object": "list", "data": [{"id": MODEL_ID, "object": "model", "owned_by": "local"}]}
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
@app.post("/v1/chat/completions")
|
| 198 |
+
def chat_completions(req: ChatCompletionRequest) -> Dict[str, Any]:
|
| 199 |
+
"""OpenAI-compatible chat completion (non-streaming)."""
|
| 200 |
+
llm = _require_model()
|
| 201 |
+
if not req.messages:
|
| 202 |
+
raise HTTPException(status_code=400, detail="`messages` must not be empty.")
|
| 203 |
+
|
| 204 |
+
kwargs: Dict[str, Any] = dict(
|
| 205 |
+
messages=[m.model_dump() for m in req.messages],
|
| 206 |
+
temperature=req.temperature,
|
| 207 |
+
top_p=req.top_p,
|
| 208 |
+
max_tokens=req.max_tokens if req.max_tokens is not None else DEFAULT_MAX_TOKENS,
|
| 209 |
+
stop=req.stop,
|
| 210 |
+
)
|
| 211 |
+
# Honour OpenAI JSON mode -> llama.cpp constrains generation to valid JSON.
|
| 212 |
+
if req.response_format and req.response_format.get("type") == "json_object":
|
| 213 |
+
kwargs["response_format"] = {"type": "json_object"}
|
| 214 |
+
|
| 215 |
+
try:
|
| 216 |
+
with _llm_lock: # serialise access to the single llama.cpp context
|
| 217 |
+
result = llm.create_chat_completion(**kwargs)
|
| 218 |
+
except Exception as exc:
|
| 219 |
+
raise HTTPException(status_code=500, detail=f"generation failed: {exc}") from exc
|
| 220 |
+
|
| 221 |
+
# llama-cpp already returns an OpenAI-shaped dict; normalise id/model/created.
|
| 222 |
+
result["id"] = result.get("id") or f"chatcmpl-{uuid.uuid4().hex}"
|
| 223 |
+
result["model"] = MODEL_ID
|
| 224 |
+
result["created"] = result.get("created") or int(time.time())
|
| 225 |
+
return result
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
@app.post("/generate")
|
| 229 |
+
def generate(req: GenerateRequest) -> Dict[str, Any]:
|
| 230 |
+
"""Simplified endpoint: one prompt in, generated text out (plus timing)."""
|
| 231 |
+
llm = _require_model()
|
| 232 |
+
t0 = time.time()
|
| 233 |
+
try:
|
| 234 |
+
with _llm_lock:
|
| 235 |
+
result = llm.create_chat_completion(
|
| 236 |
+
messages=[{"role": "user", "content": req.prompt}],
|
| 237 |
+
temperature=req.temperature,
|
| 238 |
+
max_tokens=req.max_tokens,
|
| 239 |
+
)
|
| 240 |
+
except Exception as exc:
|
| 241 |
+
raise HTTPException(status_code=500, detail=f"generation failed: {exc}") from exc
|
| 242 |
+
|
| 243 |
+
usage = result.get("usage", {}) or {}
|
| 244 |
+
return {
|
| 245 |
+
"prompt": req.prompt,
|
| 246 |
+
"response": result["choices"][0]["message"]["content"],
|
| 247 |
+
"model": MODEL_ID,
|
| 248 |
+
"time_seconds": round(time.time() - t0, 2),
|
| 249 |
+
"output_tokens": usage.get("completion_tokens"),
|
| 250 |
+
}
|
packages.txt
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
python3-pip
|
|
|
|
|
|
requirements.txt
CHANGED
|
@@ -1,8 +1,12 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|