fomext commited on
Commit
2ba36a2
·
verified ·
1 Parent(s): f51bd6a

Upload 5 files

Browse files
Files changed (5) hide show
  1. Dockerfile +53 -0
  2. README.md +120 -7
  3. app.py +224 -0
  4. docker-compose.yml +22 -0
  5. requirements.txt +6 -0
Dockerfile ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================
2
+ # Qwen3-14B – OpenAI-compatible API – CPU-only Docker image
3
+ # ============================================================
4
+ FROM python:3.11-slim
5
+
6
+ # Build-time deps for llama-cpp-python (needs a C++ compiler)
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ build-essential \
9
+ cmake \
10
+ git \
11
+ wget \
12
+ ca-certificates \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ WORKDIR /app
16
+
17
+ # ── Python deps ──────────────────────────────────────────────
18
+ # Install everything EXCEPT llama-cpp-python first (lighter layer)
19
+ COPY requirements.txt .
20
+ RUN pip install --no-cache-dir fastapi==0.111.0 uvicorn[standard]==0.29.0 pydantic==2.7.1
21
+
22
+ # Install llama-cpp-python with CPU-only build (no CUDA/Metal/OpenCL)
23
+ # CMAKE_ARGS forces a plain CPU build; FORCE_CMAKE=1 ensures the wheel
24
+ # is compiled from source so the flags are respected.
25
+ RUN CMAKE_ARGS="-DLLAMA_CUBLAS=OFF -DLLAMA_METAL=OFF -DLLAMA_OPENCL=OFF" \
26
+ FORCE_CMAKE=1 \
27
+ pip install --no-cache-dir llama-cpp-python==0.2.77
28
+
29
+ # ── App code ─────────────────────────────────────────────────
30
+ COPY app.py .
31
+
32
+ # ── Model volume ─────────────────────────────────────────────
33
+ # Mount your GGUF file here at runtime:
34
+ # docker run -v /path/to/models:/models ...
35
+ # OR bake it into the image by uncommenting the COPY line below
36
+ # (image will be ~9 GB for Q4_K_M):
37
+ # COPY models/qwen3-14b-q4_k_m.gguf /models/qwen3-14b-q4_k_m.gguf
38
+ RUN mkdir -p /models
39
+
40
+ # ── Runtime env defaults (override with -e flags) ────────────
41
+ ENV MODEL_PATH=/models/qwen3-14b-q4_k_m.gguf \
42
+ MODEL_ID=qwen3-14b \
43
+ N_CTX=4096 \
44
+ N_THREADS=8 \
45
+ N_BATCH=512 \
46
+ VERBOSE=false
47
+
48
+ EXPOSE 8000
49
+
50
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \
51
+ CMD wget -qO- http://localhost:8000/health || exit 1
52
+
53
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
README.md CHANGED
@@ -1,10 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: Fallback Module Trial
3
- emoji: 🌖
4
- colorFrom: indigo
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Qwen3-14B — OpenAI-compatible API (CPU · Docker)
2
+
3
+ A lightweight FastAPI server that wraps **Qwen3-14B** (GGUF quantised) and
4
+ exposes an **OpenAI-compatible REST API** so tools like **Paperclip** can talk
5
+ to it as a drop-in OpenAI provider.
6
+
7
+ ## Endpoints
8
+
9
+ | Method | Path | Description |
10
+ |--------|------|-------------|
11
+ | `GET` | `/v1/models` | List available models |
12
+ | `POST` | `/v1/chat/completions` | Chat completions (streaming + non-streaming) |
13
+ | `GET` | `/health` | Docker health check |
14
+
15
+ ---
16
+
17
+ ## 1 — Download the model
18
+
19
+ You need a **GGUF quantised** version of Qwen3-14B.
20
+ The recommended variant for CPU is **Q4_K_M** (~9 GB RAM at runtime).
21
+
22
+ ```bash
23
+ mkdir -p models
24
+
25
+ # Option A — Hugging Face CLI
26
+ pip install huggingface_hub
27
+ huggingface-cli download \
28
+ bartowski/Qwen3-14B-GGUF \
29
+ Qwen3-14B-Q4_K_M.gguf \
30
+ --local-dir ./models
31
+
32
+ # Option B — wget (find the direct URL on the HF repo)
33
+ # wget -O models/qwen3-14b-q4_k_m.gguf <url>
34
+ ```
35
+
36
+ Rename the file so it matches `MODEL_PATH` in `docker-compose.yml`
37
+ (or change the env var to match your filename).
38
+
39
+ ---
40
+
41
+ ## 2 — Build & run
42
+
43
+ ```bash
44
+ # Build the image (one-time, ~5–10 min — compiles llama-cpp from source)
45
+ docker compose build
46
+
47
+ # Start the server
48
+ docker compose up -d
49
+
50
+ # Tail logs
51
+ docker compose logs -f
52
+ ```
53
+
54
+ The API will be available at **http://localhost:8000** once the model has
55
+ loaded (allow ~1–3 min for the GGUF to map into memory on first start).
56
+
57
+ ---
58
+
59
+ ## 3 — Connect Paperclip
60
+
61
+ In Paperclip's settings, add a new **OpenAI-compatible** provider:
62
+
63
+ | Setting | Value |
64
+ |---------|-------|
65
+ | **Base URL** | `http://localhost:8000/v1` |
66
+ | **API Key** | *(any non-empty string, e.g. `local`)* |
67
+ | **Model** | `qwen3-14b` |
68
+
69
+ Paperclip will call `/v1/models` to verify the connection and
70
+ `/v1/chat/completions` for inference — both are implemented.
71
+
72
+ ---
73
+
74
+ ## 4 — Environment variables
75
+
76
+ | Variable | Default | Description |
77
+ |----------|---------|-------------|
78
+ | `MODEL_PATH` | `/models/qwen3-14b-q4_k_m.gguf` | Path to GGUF file inside container |
79
+ | `MODEL_ID` | `qwen3-14b` | Model name returned by the API |
80
+ | `N_CTX` | `4096` | Context window size (tokens) |
81
+ | `N_THREADS` | `8` | CPU threads — set to your physical core count |
82
+ | `N_BATCH` | `512` | Prompt processing batch size |
83
+ | `VERBOSE` | `false` | Enable llama.cpp verbose logging |
84
+
85
+ Override in `docker-compose.yml` or pass with `-e` flags to `docker run`.
86
+
87
  ---
88
+
89
+ ## 5 — Performance tips (CPU)
90
+
91
+ - Set `N_THREADS` to your **physical** core count (not hyper-threaded).
92
+ On a modern 8-core machine `N_THREADS=8` is a good start.
93
+ - Expect ~3–8 tokens/sec on a modern laptop; a server with many cores does better.
94
+ - If you have more RAM, try **Q5_K_M** or **Q6_K** for better quality.
95
+ - Reduce `N_CTX` to `2048` if you hit memory pressure.
96
+
97
  ---
98
 
99
+ ## Quick test (curl)
100
+
101
+ ```bash
102
+ # List models
103
+ curl http://localhost:8000/v1/models
104
+
105
+ # Non-streaming chat
106
+ curl http://localhost:8000/v1/chat/completions \
107
+ -H "Content-Type: application/json" \
108
+ -d '{
109
+ "model": "qwen3-14b",
110
+ "messages": [{"role": "user", "content": "Hello!"}],
111
+ "max_tokens": 256
112
+ }'
113
+
114
+ # Streaming chat
115
+ curl http://localhost:8000/v1/chat/completions \
116
+ -H "Content-Type: application/json" \
117
+ -d '{
118
+ "model": "qwen3-14b",
119
+ "messages": [{"role": "user", "content": "Tell me a joke."}],
120
+ "max_tokens": 256,
121
+ "stream": true
122
+ }'
123
+ ```
app.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenAI-compatible FastAPI wrapper for Qwen3-14B (GGUF / llama-cpp-python)
3
+ Endpoints: GET /v1/models, POST /v1/chat/completions
4
+ Supports streaming (SSE) and non-streaming responses.
5
+ """
6
+
7
+ import os
8
+ import time
9
+ import uuid
10
+ import json
11
+ import asyncio
12
+ import logging
13
+ from typing import AsyncIterator, List, Optional
14
+
15
+ from fastapi import FastAPI, HTTPException, Request
16
+ from fastapi.responses import StreamingResponse, JSONResponse
17
+ from fastapi.middleware.cors import CORSMiddleware
18
+ from pydantic import BaseModel, Field
19
+ from llama_cpp import Llama
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Logging
23
+ # ---------------------------------------------------------------------------
24
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Config (override via environment variables)
29
+ # ---------------------------------------------------------------------------
30
+ MODEL_PATH = os.environ.get("MODEL_PATH", "/models/qwen3-14b-q4_k_m.gguf")
31
+ MODEL_ID = os.environ.get("MODEL_ID", "qwen3-14b")
32
+ N_CTX = int(os.environ.get("N_CTX", "4096"))
33
+ N_THREADS = int(os.environ.get("N_THREADS", str(os.cpu_count() or 4)))
34
+ N_BATCH = int(os.environ.get("N_BATCH", "512"))
35
+ VERBOSE = os.environ.get("VERBOSE", "false").lower() == "true"
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Load model at startup
39
+ # ---------------------------------------------------------------------------
40
+ logger.info(f"Loading model from {MODEL_PATH} — this may take a few minutes on CPU …")
41
+ llm = Llama(
42
+ model_path=MODEL_PATH,
43
+ n_ctx=N_CTX,
44
+ n_threads=N_THREADS,
45
+ n_batch=N_BATCH,
46
+ n_gpu_layers=0, # CPU only
47
+ verbose=VERBOSE,
48
+ chat_format="chatml", # Qwen3 uses ChatML
49
+ )
50
+ logger.info("Model loaded ✓")
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # FastAPI app
54
+ # ---------------------------------------------------------------------------
55
+ app = FastAPI(title="Qwen3-14B OpenAI-compatible API", version="1.0.0")
56
+
57
+ app.add_middleware(
58
+ CORSMiddleware,
59
+ allow_origins=["*"],
60
+ allow_methods=["*"],
61
+ allow_headers=["*"],
62
+ )
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Pydantic schemas (OpenAI-compatible subset)
66
+ # ---------------------------------------------------------------------------
67
+
68
+ class Message(BaseModel):
69
+ role: str
70
+ content: str
71
+
72
+ class ChatCompletionRequest(BaseModel):
73
+ model: str = MODEL_ID
74
+ messages: List[Message]
75
+ max_tokens: Optional[int] = Field(default=1024, ge=1, le=8192)
76
+ temperature: Optional[float] = Field(default=0.7, ge=0.0, le=2.0)
77
+ top_p: Optional[float] = Field(default=0.9, ge=0.0, le=1.0)
78
+ stream: Optional[bool] = False
79
+ stop: Optional[List[str]] = None
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Helpers
83
+ # ---------------------------------------------------------------------------
84
+
85
+ def _make_chunk(delta_content: str, finish_reason: Optional[str], request_id: str) -> str:
86
+ chunk = {
87
+ "id": request_id,
88
+ "object": "chat.completion.chunk",
89
+ "created": int(time.time()),
90
+ "model": MODEL_ID,
91
+ "choices": [
92
+ {
93
+ "index": 0,
94
+ "delta": {"content": delta_content} if delta_content else {},
95
+ "finish_reason": finish_reason,
96
+ }
97
+ ],
98
+ }
99
+ return f"data: {json.dumps(chunk)}\n\n"
100
+
101
+
102
+ async def _stream_response(request: ChatCompletionRequest, request_id: str) -> AsyncIterator[str]:
103
+ """Run llama-cpp in a thread pool and yield SSE chunks."""
104
+ messages = [{"role": m.role, "content": m.content} for m in request.messages]
105
+
106
+ loop = asyncio.get_event_loop()
107
+
108
+ def _run():
109
+ return llm.create_chat_completion(
110
+ messages=messages,
111
+ max_tokens=request.max_tokens,
112
+ temperature=request.temperature,
113
+ top_p=request.top_p,
114
+ stop=request.stop or [],
115
+ stream=True,
116
+ )
117
+
118
+ # llama-cpp streaming returns a generator; run initial call in thread pool
119
+ gen = await loop.run_in_executor(None, _run)
120
+
121
+ # Yield first role delta
122
+ yield _make_chunk("", None, request_id)
123
+
124
+ for chunk in gen:
125
+ choice = chunk["choices"][0]
126
+ delta = choice.get("delta", {})
127
+ content = delta.get("content", "")
128
+ finish = choice.get("finish_reason")
129
+ if content:
130
+ yield _make_chunk(content, None, request_id)
131
+ if finish:
132
+ yield _make_chunk("", finish, request_id)
133
+ break
134
+
135
+ yield "data: [DONE]\n\n"
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Routes
140
+ # ---------------------------------------------------------------------------
141
+
142
+ @app.get("/")
143
+ async def root():
144
+ return {"status": "ok", "model": MODEL_ID}
145
+
146
+
147
+ @app.get("/v1/models")
148
+ async def list_models():
149
+ return {
150
+ "object": "list",
151
+ "data": [
152
+ {
153
+ "id": MODEL_ID,
154
+ "object": "model",
155
+ "created": 1700000000,
156
+ "owned_by": "local",
157
+ }
158
+ ],
159
+ }
160
+
161
+
162
+ @app.post("/v1/chat/completions")
163
+ async def chat_completions(request: ChatCompletionRequest):
164
+ messages = [{"role": m.role, "content": m.content} for m in request.messages]
165
+
166
+ if request.stream:
167
+ request_id = f"chatcmpl-{uuid.uuid4().hex}"
168
+ return StreamingResponse(
169
+ _stream_response(request, request_id),
170
+ media_type="text/event-stream",
171
+ headers={
172
+ "Cache-Control": "no-cache",
173
+ "X-Accel-Buffering": "no",
174
+ },
175
+ )
176
+
177
+ # Non-streaming
178
+ loop = asyncio.get_event_loop()
179
+
180
+ def _run():
181
+ return llm.create_chat_completion(
182
+ messages=messages,
183
+ max_tokens=request.max_tokens,
184
+ temperature=request.temperature,
185
+ top_p=request.top_p,
186
+ stop=request.stop or [],
187
+ stream=False,
188
+ )
189
+
190
+ result = await loop.run_in_executor(None, _run)
191
+
192
+ choice = result["choices"][0]
193
+ usage = result.get("usage", {})
194
+
195
+ return {
196
+ "id": f"chatcmpl-{uuid.uuid4().hex}",
197
+ "object": "chat.completion",
198
+ "created": int(time.time()),
199
+ "model": MODEL_ID,
200
+ "choices": [
201
+ {
202
+ "index": 0,
203
+ "message": {
204
+ "role": "assistant",
205
+ "content": choice["message"]["content"],
206
+ },
207
+ "finish_reason": choice.get("finish_reason", "stop"),
208
+ }
209
+ ],
210
+ "usage": {
211
+ "prompt_tokens": usage.get("prompt_tokens", 0),
212
+ "completion_tokens": usage.get("completion_tokens", 0),
213
+ "total_tokens": usage.get("total_tokens", 0),
214
+ },
215
+ }
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Health check (useful for Docker HEALTHCHECK)
220
+ # ---------------------------------------------------------------------------
221
+
222
+ @app.get("/health")
223
+ async def health():
224
+ return {"status": "healthy", "model": MODEL_ID}
docker-compose.yml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: "3.9"
2
+
3
+ services:
4
+ qwen3-api:
5
+ build: .
6
+ image: qwen3-api:latest
7
+ container_name: qwen3-api
8
+ ports:
9
+ - "8000:8000"
10
+ volumes:
11
+ # Put your GGUF file in ./models/ on the host
12
+ - ./models:/models:ro
13
+ environment:
14
+ MODEL_PATH: /models/qwen3-14b-q4_k_m.gguf
15
+ MODEL_ID: qwen3-14b
16
+ N_CTX: "4096"
17
+ # Set to number of physical CPU cores for best performance
18
+ N_THREADS: "8"
19
+ N_BATCH: "512"
20
+ VERBOSE: "false"
21
+ restart: unless-stopped
22
+ # CPU-only — no deploy.resources.reservations needed
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.111.0
2
+ uvicorn[standard]==0.29.0
3
+ pydantic==2.7.1
4
+ # llama-cpp-python is installed via pip in the Dockerfile
5
+ # with CPU-only build flags (no CUDA / Metal)
6
+ llama-cpp-python==0.2.77