"""llama.cpp runtime wrappers -- the only module that imports llama_cpp.
Both runtimes expose the same `chat(messages, grammar) -> str` contract the
conversation engine depends on, and let the model's own chat template (from the
GGUF metadata) format the messages, so swapping Nemotron <-> MiniCPM is a config
change.
- `LlamaRuntime`: loads the model once at startup. For local / CPU / a plain GPU
Space where the model can persist in memory across turns.
- `ZeroGpuLlamaRuntime`: for HF Spaces ZeroGPU, where a real GPU exists only inside
an `@spaces.GPU` function. The model is therefore constructed *inside* that
function on every call (the llama.cpp maintainer's documented pattern). This is
cheap to do correctly because the engine holds no conversation state in the
runtime -- `conversation.respond` rebuilds the full prompt each turn.
"""
from __future__ import annotations
import os
# The model's `` block ends a generation; we stop on it for speed, then
# restore the tag so the parser regex still matches. Shared by both runtimes.
STOP = [""]
def _finalize(text: str) -> str:
if "" in text and "" not in text:
text += ""
return text
def _complete(llm, messages: list[dict], grammar: str, temperature: float, max_tokens: int) -> str:
"""Grammar-constrained completion shared by both runtimes; returns finalized text."""
from llama_cpp import LlamaGrammar
compiled = LlamaGrammar.from_string(grammar, verbose=False)
result = llm.create_chat_completion(
messages=messages,
grammar=compiled,
temperature=temperature,
max_tokens=max_tokens,
stop=STOP,
)
return _finalize(result["choices"][0]["message"]["content"])
# --- ZeroGPU entrypoint -----------------------------------------------------
# ZeroGPU requires at least one @spaces.GPU function to be DECORATED AT STARTUP
# (i.e. at import time). Decorating lazily on the first request fails with
# "No @spaces.GPU function detected during startup", and a function decorated after
# startup never actually receives a GPU. So the GPU entrypoint lives at module scope
# and is decorated when this module is imported -- app.py imports the module eagerly
# on a Space (see `_on_zerogpu`) to make that happen before ZeroGPU's startup scan.
#
# `spaces` is imported unconditionally: off a ZeroGPU Space it is a true no-op
# (@spaces.GPU returns the function unchanged), so no try/except shim is needed --
# we just declare `spaces` as a dependency so it's installed in local dev too.
import spaces
def _gpu_duration() -> int:
try:
return int(os.environ.get("SECOND_DEGREE_GPU_DURATION", "120"))
except ValueError:
return 120
@spaces.GPU(duration=_gpu_duration())
def _zerogpu_chat(
model_path: str,
messages: list[dict],
grammar: str,
n_ctx: int,
temperature: float,
max_tokens: int,
flash_attn: bool,
) -> str:
"""Build the model on the GPU attached for this call and generate one turn.
A real GPU exists only inside this @spaces.GPU function, so the `Llama` object is
constructed per call with all layers offloaded -- the llama.cpp maintainer's
documented ZeroGPU pattern. Correct because `conversation.respond` rebuilds the
full prompt each turn and keeps no conversation state in the runtime.
"""
from llama_cpp import Llama
llm = Llama(
model_path=model_path,
n_gpu_layers=-1, # offload everything to the attached GPU
n_ctx=n_ctx,
flash_attn=flash_attn,
verbose=False,
)
return _complete(llm, messages, grammar, temperature, max_tokens)
class LlamaRuntime:
def __init__(
self,
model_path: str,
n_ctx: int = 4096,
n_gpu_layers: int = 0,
temperature: float = 0.8,
max_tokens: int = 220,
seed: int | None = None,
verbose: bool = False,
):
from llama_cpp import Llama # imported here so the core engine stays dep-free
self.temperature = temperature
self.max_tokens = max_tokens
self._llm = Llama(
model_path=model_path,
n_ctx=n_ctx,
n_gpu_layers=n_gpu_layers,
seed=seed if seed is not None else -1,
verbose=verbose,
)
def chat(self, messages: list[dict], grammar: str) -> str:
"""Generate one grammar-constrained NPC turn and return the raw text."""
return _complete(self._llm, messages, grammar, self.temperature, self.max_tokens)
class ZeroGpuLlamaRuntime:
"""llama.cpp on HF Spaces ZeroGPU: a thin wrapper over the module-level
`@spaces.GPU` entrypoint `_zerogpu_chat`.
The decorated function must be registered at startup (see the note above
`_zerogpu_chat`), so it lives at module scope rather than being built here.
This class just holds the per-Space config and forwards each turn to it.
A real GPU is attached only for the duration of the decorated call, so the
`Llama` object is constructed per turn with all layers offloaded. Slower per
turn (model reload), but correct -- and identical in output to the persistent
runtime, since the full prompt is rebuilt each turn upstream.
"""
# The @spaces.GPU duration is set at decoration time from SECOND_DEGREE_GPU_DURATION
# (see _gpu_duration / app.py), not here -- this class only forwards per-turn config.
def __init__(
self,
model_path: str,
n_ctx: int = 4096,
temperature: float = 0.8,
max_tokens: int = 220,
flash_attn: bool = True,
):
self.model_path = model_path
self.n_ctx = n_ctx
self.temperature = temperature
self.max_tokens = max_tokens
self.flash_attn = flash_attn
def chat(self, messages: list[dict], grammar: str) -> str:
return _zerogpu_chat(
self.model_path,
messages,
grammar,
self.n_ctx,
self.temperature,
self.max_tokens,
self.flash_attn,
)