fomext commited on
Commit
085004d
·
verified ·
1 Parent(s): 7e4adf4

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +15 -14
  2. app.py +124 -44
  3. docker-compose.yml +10 -5
Dockerfile CHANGED
@@ -15,13 +15,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
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
@@ -30,15 +29,16 @@ RUN CMAKE_ARGS="-DLLAMA_CUBLAS=OFF -DLLAMA_METAL=OFF -DLLAMA_OPENCL=OFF" \
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 \
@@ -47,7 +47,8 @@ ENV MODEL_PATH=/models/qwen3-14b-q4_k_m.gguf \
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", "7860"]
 
15
  WORKDIR /app
16
 
17
  # ── Python deps ──────────────────────────────────────────────
18
+ RUN pip install --no-cache-dir \
19
+ fastapi==0.111.0 \
20
+ "uvicorn[standard]==0.29.0" \
21
+ pydantic==2.7.1
22
 
23
+ # Install llama-cpp-python CPU-only (compiled from source)
 
 
24
  RUN CMAKE_ARGS="-DLLAMA_CUBLAS=OFF -DLLAMA_METAL=OFF -DLLAMA_OPENCL=OFF" \
25
  FORCE_CMAKE=1 \
26
  pip install --no-cache-dir llama-cpp-python==0.2.77
 
29
  COPY app.py .
30
 
31
  # ── Model volume ─────────────────────────────────────────────
32
+ # The app auto-downloads the model on first boot into /models.
33
+ # Mount a named volume here so the download persists across restarts:
34
+ # docker run -v qwen3_models:/models ...
35
+ # Or pre-populate with your own GGUF:
36
  # docker run -v /path/to/models:/models ...
 
 
 
37
  RUN mkdir -p /models
38
 
39
+ # ── Runtime env defaults (override with -e or docker-compose) ─
40
  ENV MODEL_PATH=/models/qwen3-14b-q4_k_m.gguf \
41
+ MODEL_URL=https://huggingface.co/bartowski/Qwen3-14B-GGUF/resolve/main/Qwen3-14B-Q4_K_M.gguf \
42
  MODEL_ID=qwen3-14b \
43
  N_CTX=4096 \
44
  N_THREADS=8 \
 
47
 
48
  EXPOSE 8000
49
 
50
+ # Health check /health returns {"ready": true} once the model is loaded
51
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=600s --retries=20 \
52
+ CMD wget -qO- http://localhost:8000/health | grep -q '"ready": true' || exit 1
53
 
54
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
app.py CHANGED
@@ -2,6 +2,8 @@
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
@@ -10,10 +12,12 @@ 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
@@ -27,27 +31,95 @@ logger = logging.getLogger(__name__)
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
@@ -61,6 +133,16 @@ app.add_middleware(
61
  allow_headers=["*"],
62
  )
63
 
 
 
 
 
 
 
 
 
 
 
64
  # ---------------------------------------------------------------------------
65
  # Pydantic schemas (OpenAI-compatible subset)
66
  # ---------------------------------------------------------------------------
@@ -69,6 +151,7 @@ class Message(BaseModel):
69
  role: str
70
  content: str
71
 
 
72
  class ChatCompletionRequest(BaseModel):
73
  model: str = MODEL_ID
74
  messages: List[Message]
@@ -78,6 +161,7 @@ class ChatCompletionRequest(BaseModel):
78
  stream: Optional[bool] = False
79
  stop: Optional[List[str]] = None
80
 
 
81
  # ---------------------------------------------------------------------------
82
  # Helpers
83
  # ---------------------------------------------------------------------------
@@ -100,9 +184,8 @@ def _make_chunk(delta_content: str, finish_reason: Optional[str], request_id: st
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():
@@ -115,15 +198,13 @@ async def _stream_response(request: ChatCompletionRequest, request_id: str) -> A
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:
@@ -141,7 +222,17 @@ async def _stream_response(request: ChatCompletionRequest, request_id: str) -> A
141
 
142
  @app.get("/")
143
  async def root():
144
- return {"status": "ok", "model": MODEL_ID}
 
 
 
 
 
 
 
 
 
 
145
 
146
 
147
  @app.get("/v1/models")
@@ -161,6 +252,8 @@ async def list_models():
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:
@@ -168,10 +261,7 @@ async def chat_completions(request: ChatCompletionRequest):
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
@@ -188,9 +278,8 @@ async def chat_completions(request: ChatCompletionRequest):
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}",
@@ -213,12 +302,3 @@ async def chat_completions(request: ChatCompletionRequest):
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}
 
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
+ Model is downloaded automatically on first boot if not already present.
7
  """
8
 
9
  import os
 
12
  import json
13
  import asyncio
14
  import logging
15
+ import threading
16
+ from pathlib import Path
17
  from typing import AsyncIterator, List, Optional
18
 
19
+ from fastapi import FastAPI, HTTPException
20
+ from fastapi.responses import StreamingResponse
21
  from fastapi.middleware.cors import CORSMiddleware
22
  from pydantic import BaseModel, Field
23
  from llama_cpp import Llama
 
31
  # ---------------------------------------------------------------------------
32
  # Config (override via environment variables)
33
  # ---------------------------------------------------------------------------
34
+ MODEL_PATH = os.environ.get("MODEL_PATH", "/models/qwen3-14b-q4_k_m.gguf")
35
+ MODEL_URL = os.environ.get("MODEL_URL",
36
+ "https://huggingface.co/bartowski/Qwen3-14B-GGUF/resolve/main/Qwen3-14B-Q4_K_M.gguf")
37
+ MODEL_ID = os.environ.get("MODEL_ID", "qwen3-14b")
38
+ N_CTX = int(os.environ.get("N_CTX", "4096"))
39
+ N_THREADS = int(os.environ.get("N_THREADS", str(os.cpu_count() or 4)))
40
+ N_BATCH = int(os.environ.get("N_BATCH", "512"))
41
+ VERBOSE = os.environ.get("VERBOSE", "false").lower() == "true"
42
 
43
  # ---------------------------------------------------------------------------
44
+ # Lazy model holder
45
  # ---------------------------------------------------------------------------
46
+ _llm: Optional[Llama] = None
47
+ _llm_lock = threading.Lock()
48
+ _llm_ready = threading.Event() # set once the model is loaded
49
+ _llm_error: Optional[str] = None # set if loading failed
50
+
51
+
52
+ def _download_model() -> None:
53
+ """Download the GGUF file from MODEL_URL if MODEL_PATH doesn't exist."""
54
+ path = Path(MODEL_PATH)
55
+ if path.exists():
56
+ logger.info(f"Model already present at {MODEL_PATH}")
57
+ return
58
+
59
+ path.parent.mkdir(parents=True, exist_ok=True)
60
+ logger.info(f"Model not found — downloading from {MODEL_URL} …")
61
+ logger.info("This will take a while on first boot (file is ~9 GB).")
62
+
63
+ import urllib.request
64
+
65
+ tmp = Path(str(MODEL_PATH) + ".part")
66
+ last_pct = -1
67
+
68
+ def _progress(block_num, block_size, total_size):
69
+ nonlocal last_pct
70
+ if total_size <= 0:
71
+ return
72
+ pct = int(block_num * block_size * 100 / total_size)
73
+ pct = min(pct, 100)
74
+ if pct != last_pct and pct % 5 == 0:
75
+ logger.info(f"Download progress: {pct}%")
76
+ last_pct = pct
77
+
78
+ urllib.request.urlretrieve(MODEL_URL, tmp, reporthook=_progress)
79
+ tmp.rename(path)
80
+ logger.info(f"Download complete → {MODEL_PATH}")
81
+
82
+
83
+ def _load_model_background() -> None:
84
+ """Download (if needed) then load the model. Runs in a daemon thread."""
85
+ global _llm, _llm_error
86
+ try:
87
+ _download_model()
88
+ logger.info(f"Loading model into memory from {MODEL_PATH} …")
89
+ llm = Llama(
90
+ model_path=MODEL_PATH,
91
+ n_ctx=N_CTX,
92
+ n_threads=N_THREADS,
93
+ n_batch=N_BATCH,
94
+ n_gpu_layers=0, # CPU only
95
+ verbose=VERBOSE,
96
+ chat_format="chatml", # Qwen3 uses ChatML
97
+ )
98
+ with _llm_lock:
99
+ _llm = llm
100
+ logger.info("Model loaded and ready ✓")
101
+ except Exception as exc:
102
+ _llm_error = str(exc)
103
+ logger.error(f"Failed to load model: {exc}")
104
+ finally:
105
+ _llm_ready.set()
106
+
107
+
108
+ def _get_llm() -> Llama:
109
+ """Return the loaded model or raise a 503 if it's not ready yet."""
110
+ if not _llm_ready.is_set():
111
+ raise HTTPException(
112
+ status_code=503,
113
+ detail="Model is still loading (or downloading). "
114
+ "Check /health for status and retry in a moment.",
115
+ )
116
+ if _llm_error:
117
+ raise HTTPException(
118
+ status_code=500,
119
+ detail=f"Model failed to load: {_llm_error}",
120
+ )
121
+ return _llm
122
+
123
 
124
  # ---------------------------------------------------------------------------
125
  # FastAPI app
 
133
  allow_headers=["*"],
134
  )
135
 
136
+
137
+ @app.on_event("startup")
138
+ async def startup_event():
139
+ """Kick off model download + load in a background thread so the server
140
+ starts immediately and stays responsive during the (long) load phase."""
141
+ t = threading.Thread(target=_load_model_background, daemon=True)
142
+ t.start()
143
+ logger.info("Server is up. Model loading in background — see /health for status.")
144
+
145
+
146
  # ---------------------------------------------------------------------------
147
  # Pydantic schemas (OpenAI-compatible subset)
148
  # ---------------------------------------------------------------------------
 
151
  role: str
152
  content: str
153
 
154
+
155
  class ChatCompletionRequest(BaseModel):
156
  model: str = MODEL_ID
157
  messages: List[Message]
 
161
  stream: Optional[bool] = False
162
  stop: Optional[List[str]] = None
163
 
164
+
165
  # ---------------------------------------------------------------------------
166
  # Helpers
167
  # ---------------------------------------------------------------------------
 
184
 
185
 
186
  async def _stream_response(request: ChatCompletionRequest, request_id: str) -> AsyncIterator[str]:
187
+ llm = _get_llm()
188
  messages = [{"role": m.role, "content": m.content} for m in request.messages]
 
189
  loop = asyncio.get_event_loop()
190
 
191
  def _run():
 
198
  stream=True,
199
  )
200
 
 
201
  gen = await loop.run_in_executor(None, _run)
202
 
203
+ yield _make_chunk("", None, request_id) # opening delta
 
204
 
205
  for chunk in gen:
206
+ choice = chunk["choices"][0]
207
+ delta = choice.get("delta", {})
208
  content = delta.get("content", "")
209
  finish = choice.get("finish_reason")
210
  if content:
 
222
 
223
  @app.get("/")
224
  async def root():
225
+ ready = _llm_ready.is_set() and _llm is not None
226
+ return {"status": "ready" if ready else "loading", "model": MODEL_ID}
227
+
228
+
229
+ @app.get("/health")
230
+ async def health():
231
+ if not _llm_ready.is_set():
232
+ return {"status": "loading", "model": MODEL_ID, "ready": False}
233
+ if _llm_error:
234
+ return {"status": "error", "error": _llm_error, "ready": False}
235
+ return {"status": "healthy", "model": MODEL_ID, "ready": True}
236
 
237
 
238
  @app.get("/v1/models")
 
252
 
253
  @app.post("/v1/chat/completions")
254
  async def chat_completions(request: ChatCompletionRequest):
255
+ llm = _get_llm() # raises 503 if not ready
256
+
257
  messages = [{"role": m.role, "content": m.content} for m in request.messages]
258
 
259
  if request.stream:
 
261
  return StreamingResponse(
262
  _stream_response(request, request_id),
263
  media_type="text/event-stream",
264
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
 
 
 
265
  )
266
 
267
  # Non-streaming
 
278
  )
279
 
280
  result = await loop.run_in_executor(None, _run)
281
+ choice = result["choices"][0]
282
+ usage = result.get("usage", {})
 
283
 
284
  return {
285
  "id": f"chatcmpl-{uuid.uuid4().hex}",
 
302
  "total_tokens": usage.get("total_tokens", 0),
303
  },
304
  }
 
 
 
 
 
 
 
 
 
docker-compose.yml CHANGED
@@ -8,15 +8,20 @@ services:
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
 
 
 
8
  ports:
9
  - "8000:8000"
10
  volumes:
11
+ # Named volume model is downloaded here on first boot and reused after
12
+ - qwen3_models:/models
13
+ # Alternative: mount your own pre-downloaded folder instead:
14
+ # - ./models:/models
15
  environment:
16
  MODEL_PATH: /models/qwen3-14b-q4_k_m.gguf
17
+ # Override MODEL_URL to use a different GGUF variant (e.g. Q5_K_M)
18
+ MODEL_URL: https://huggingface.co/bartowski/Qwen3-14B-GGUF/resolve/main/Qwen3-14B-Q4_K_M.gguf
19
  MODEL_ID: qwen3-14b
20
  N_CTX: "4096"
21
+ N_THREADS: "8" # set to your physical CPU core count
 
22
  N_BATCH: "512"
23
  VERBOSE: "false"
24
  restart: unless-stopped
25
+
26
+ volumes:
27
+ qwen3_models: # persists the downloaded GGUF across container restarts