f-id / src /id /llm /local.py
marcodsn's picture
replaced MiniCPM5-1B with MiniCPM4.1-8B
a354cf0
Raw
History Blame Contribute Delete
7.76 kB
"""In-process llama.cpp runtime for self-contained (no-API) deployment.
The Space runs a small local model (OpenBMB MiniCPM4.1-8B, GGUF) through the
llama.cpp runtime instead of calling a cloud API. Generation happens inside an
``@spaces.GPU`` function so it runs on Hugging Face ZeroGPU; off ZeroGPU the
decorator is a no-op and it falls back to CPU/Metal.
The public surface mimics the tiny slice of the OpenAI SDK that
``LLMClient`` uses (``client.chat.completions.create(...)`` returning an object
with ``.choices[0].message.content`` and ``.usage``), so the engine and the
client's call/retry logic stay unchanged.
"""
from __future__ import annotations
import os
import threading
from types import SimpleNamespace
from typing import Any
# Model selection (overridable via Space variables).
GGUF_REPO = os.getenv("F_ID_GGUF_REPO", "openbmb/MiniCPM4.1-8B-GGUF")
GGUF_FILE = os.getenv("F_ID_GGUF_FILE", "*[qQ]4_[kK]_[mM]*.gguf")
N_CTX = int(os.getenv("F_ID_CTX", "16384"))
# -1 offloads every layer to the GPU (ZeroGPU); set 0 to force CPU.
N_GPU_LAYERS = int(os.getenv("F_ID_N_GPU_LAYERS", "-1"))
GPU_DURATION = int(os.getenv("F_ID_GPU_DURATION", "120"))
def _env_flag(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in ("1", "true", "yes", "on")
# MiniCPM4.1 is a hybrid reasoning model: its chat template emits a `<think>` block
# unless `enable_thinking=False` is passed to the template render. Reasoning is
# disabled by default here because the long/looping chains stall play-time turns.
ENABLE_THINKING = _env_flag("F_ID_ENABLE_THINKING", False)
_LOCK = threading.Lock()
_LLM: Any = None # llama_cpp.Llama, built lazily
try: # ZeroGPU decorator; harmless no-op everywhere else.
import spaces # type: ignore
_gpu = spaces.GPU(duration=GPU_DURATION)
except Exception: # pragma: no cover - exercised only off ZeroGPU
def _gpu(fn): # type: ignore
return fn
def _preload_cuda() -> None:
"""Make the CUDA runtime resolvable for llama.cpp's CUDA-linked .so.
The prebuilt ``cu124`` ``llama-cpp-python`` wheel is dynamically linked
against ``libcudart``/``libcublas``, which are not on the loader path in the
HF Spaces image. We ship them as ``nvidia-*`` pip wheels and ``dlopen`` them
with ``RTLD_GLOBAL`` (cudart first) so ``libllama.so`` finds the symbols.
"""
if N_GPU_LAYERS == 0:
return
import ctypes
import glob
import site
roots: set[str] = set()
getsp = getattr(site, "getsitepackages", None)
if getsp:
roots.update(getsp())
import sys
roots.update(p for p in sys.path if p.endswith("site-packages"))
# Load order matters: cudart -> cublasLt -> cublas.
for pattern in (
"nvidia/cuda_runtime/lib/libcudart.so*",
"nvidia/cublas/lib/libcublasLt.so*",
"nvidia/cublas/lib/libcublas.so*",
):
for root in roots:
hits = glob.glob(os.path.join(root, pattern))
if hits:
try:
ctypes.CDLL(hits[0], mode=ctypes.RTLD_GLOBAL)
except OSError:
pass
break
def _install_no_think_handler(llm: Any) -> None:
"""Force the chat template to render with ``enable_thinking=False``.
``Llama.create_chat_completion`` does not forward arbitrary kwargs to the
Jinja chat template, so the only way to flip the hybrid model's reasoning
switch is to replace the chat handler with one that injects the flag. We
rebuild the formatter from the GGUF's embedded template the same way
llama-cpp-python does internally, subclassing it to pin the flag.
"""
template = (llm.metadata or {}).get("tokenizer.chat_template")
if not template:
return # no embedded template; nothing to override
from llama_cpp.llama_chat_format import Jinja2ChatFormatter
eos_id = llm.token_eos()
bos_id = llm.token_bos()
eos_token = llm._model.token_get_text(eos_id) if eos_id != -1 else ""
bos_token = llm._model.token_get_text(bos_id) if bos_id != -1 else ""
class _NoThinkFormatter(Jinja2ChatFormatter):
def __call__(self, **kwargs: Any): # type: ignore[override]
kwargs.setdefault("enable_thinking", False)
return super().__call__(**kwargs)
llm.chat_handler = _NoThinkFormatter(
template=template,
eos_token=eos_token,
bos_token=bos_token,
stop_token_ids=[eos_id],
).to_chat_handler()
def _load_model() -> Any:
"""Build (once) the llama.cpp model from the cached GGUF."""
global _LLM
if _LLM is None:
with _LOCK:
if _LLM is None:
_preload_cuda()
from llama_cpp import Llama
llm = Llama.from_pretrained(
repo_id=GGUF_REPO,
filename=GGUF_FILE,
n_ctx=N_CTX,
n_gpu_layers=N_GPU_LAYERS,
verbose=False,
)
if not ENABLE_THINKING:
try:
_install_no_think_handler(llm)
except Exception: # never block startup on the override
pass
_LLM = llm
return _LLM
def prefetch() -> None:
"""Download the GGUF to the HF cache on CPU (before any GPU allocation)."""
# huggingface_hub does not glob, so resolve the filename via the repo listing.
from fnmatch import fnmatch
from huggingface_hub import hf_hub_download, list_repo_files
files = [f for f in list_repo_files(GGUF_REPO) if fnmatch(f, GGUF_FILE)]
if files:
hf_hub_download(GGUF_REPO, files[0])
@_gpu
def _generate(
messages: list[dict[str, str]],
temperature: float,
top_p: float | None,
max_tokens: int | None,
json_mode: bool,
) -> tuple[str, int, int, int]:
llm = _load_model()
kwargs: dict[str, Any] = {"messages": messages, "temperature": temperature}
if top_p is not None:
kwargs["top_p"] = top_p
if max_tokens:
kwargs["max_tokens"] = max_tokens
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
out = llm.create_chat_completion(**kwargs)
choice = out["choices"][0]["message"]
text = choice.get("content") or ""
usage = out.get("usage") or {}
pt = int(usage.get("prompt_tokens", 0) or 0)
ct = int(usage.get("completion_tokens", 0) or 0)
tt = int(usage.get("total_tokens", 0) or (pt + ct))
return text, pt, ct, tt
class _Completions:
def create(
self,
*,
messages: list[dict[str, str]],
model: str | None = None,
temperature: float = 0.7,
top_p: float | None = None,
max_tokens: int | None = None,
response_format: dict[str, Any] | None = None,
**_: Any,
) -> SimpleNamespace:
from .client import LLMError
json_mode = bool(response_format) and response_format.get("type") == "json_object"
try:
text, pt, ct, tt = _generate(list(messages), temperature, top_p, max_tokens, json_mode)
except Exception as exc: # surface as the engine's expected error type
raise LLMError(f"local llama.cpp generation failed: {exc}") from exc
return SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content=text))],
usage=SimpleNamespace(prompt_tokens=pt, completion_tokens=ct, total_tokens=tt),
)
class LocalLlamaClient:
"""Drop-in stand-in for ``openai.OpenAI`` backed by in-process llama.cpp."""
def __init__(self) -> None:
self.chat = SimpleNamespace(completions=_Completions())