Spaces:
Sleeping
Sleeping
Upload 3 files
Browse files- Dockerfile +15 -14
- app.py +124 -44
- 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 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
| 21 |
|
| 22 |
-
# Install llama-cpp-python
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 51 |
-
|
|
|
|
| 52 |
|
| 53 |
-
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "
|
|
|
|
| 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
|
| 16 |
-
from fastapi.responses import StreamingResponse
|
| 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
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
| 36 |
|
| 37 |
# ---------------------------------------------------------------------------
|
| 38 |
-
#
|
| 39 |
# ---------------------------------------------------------------------------
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
)
|
| 50 |
-
logger.info("Model
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 122 |
-
yield _make_chunk("", None, request_id)
|
| 123 |
|
| 124 |
for chunk in gen:
|
| 125 |
-
choice
|
| 126 |
-
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
#
|
| 12 |
-
-
|
|
|
|
|
|
|
| 13 |
environment:
|
| 14 |
MODEL_PATH: /models/qwen3-14b-q4_k_m.gguf
|
|
|
|
|
|
|
| 15 |
MODEL_ID: qwen3-14b
|
| 16 |
N_CTX: "4096"
|
| 17 |
-
#
|
| 18 |
-
N_THREADS: "8"
|
| 19 |
N_BATCH: "512"
|
| 20 |
VERBOSE: "false"
|
| 21 |
restart: unless-stopped
|
| 22 |
-
|
|
|
|
|
|
|
|
|
| 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
|