agharsallah commited on
Commit Β·
0f11b49
1
Parent(s): 6d180a1
feat: Implement llama.cpp backend for local inference with GGUF models
Browse files- docs/adr/0032-llama-cpp-local-backend.md +71 -0
- docs/architecture/model-routing.md +35 -0
- src/models/inference.py +7 -1
- src/models/llamacpp_catalogue.py +227 -0
- src/models/llamacpp_server.py +212 -0
- tests/test_inference_backends.py +5 -3
- tests/test_llamacpp_backend.py +154 -0
docs/adr/0032-llama-cpp-local-backend.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ADR-0032: A Third Inference Backend β Local llama.cpp (GGUF)
|
| 2 |
+
|
| 3 |
+
## Status
|
| 4 |
+
|
| 5 |
+
Accepted (extends ADR-0024 *second inference backend / unified registry*, amends
|
| 6 |
+
ADR-0015 *LiteLLM gateway*, ADR-0022 *per-agent explicit model binding*)
|
| 7 |
+
|
| 8 |
+
## Context
|
| 9 |
+
|
| 10 |
+
The engine had two live backends: vLLM endpoints we deploy on Modal (ADR-0015/0019)
|
| 11 |
+
and Hugging Face's serverless router (ADR-0024). Both run *somewhere else* β Modal needs
|
| 12 |
+
warmed GPUs, HF needs a token and a provider that serves the model. There was no way to
|
| 13 |
+
run a cast **entirely on the operator's own machine**, with no account, no token, and no
|
| 14 |
+
network after the first download.
|
| 15 |
+
|
| 16 |
+
llama.cpp's `llama-server` is exactly that: it loads a quantized **GGUF** model, runs it
|
| 17 |
+
on whatever hardware is present (Apple Metal, NVIDIA CUDA, or CPU), and exposes an
|
| 18 |
+
**OpenAI-compatible** API on `/v1`. Because it speaks the same REST surface as Modal/HF,
|
| 19 |
+
it slots into the existing LiteLLM gateway with no new transport code β the same seam
|
| 20 |
+
ADR-0024 was designed to leave open ("adding a third backend later touches only
|
| 21 |
+
`inference._BACKENDS`").
|
| 22 |
+
|
| 23 |
+
This also stacks hackathon lanes from one local server: the **Llama Champion** badge
|
| 24 |
+
(a real llama.cpp runtime in the cast), the **NVIDIA Nemotron Quest** (Nemotron 3 Nano
|
| 25 |
+
4B), and the **OpenBMB** track (MiniCPM 4.1 8B) β plus a JetBrains Mellum 2 thinking
|
| 26 |
+
model on the balanced tier. Every model stays within the β€32B "small minds" rule, and the
|
| 27 |
+
4B Nemotron honours the β€4B Tiny-Titan band.
|
| 28 |
+
|
| 29 |
+
## Decision
|
| 30 |
+
|
| 31 |
+
**A third backend = one more catalogue + a registry entry.** Add
|
| 32 |
+
`src/models/llamacpp_catalogue.py`, stdlib-only and offline-safe like its siblings, listing
|
| 33 |
+
the GGUF models with both engine-facing fields (`key` / `profile` / `params_b` /
|
| 34 |
+
`served_id`) and serving fields (`hf_repo` / `quant` / `ctx_size` / sampling /
|
| 35 |
+
`flash_attn` / `reasoning`). Its `binding_for()` yields the LiteLLM custom-endpoint form
|
| 36 |
+
`model = openai/<served_id>`, `base_url = $LLAMACPP_BASE_URL` (default
|
| 37 |
+
`http://127.0.0.1:8080/v1`), `api_key = $LLAMACPP_API_KEY` (a placeholder β llama-server
|
| 38 |
+
ignores it). Register it in `inference._BACKENDS` under the prefix `llamacpp`; qualified
|
| 39 |
+
keys are `llamacpp:<slug>` (e.g. `llamacpp:nemotron-3-nano-4b`). Nothing above the registry
|
| 40 |
+
changes β the router, the config loader's `endpoint:` expansion, the live/offline gate, and
|
| 41 |
+
the Lab picker all derive from the faΓ§ade.
|
| 42 |
+
|
| 43 |
+
**The serving side is a separate, pure-where-it-matters launcher.** Add
|
| 44 |
+
`src/models/llamacpp_server.py`. `detect_accelerator(platform, probe)` returns
|
| 45 |
+
`metal` on macOS, `cuda` when `nvidia-smi` reports a GPU, else `cpu`;
|
| 46 |
+
`build_command(model, accelerator, β¦)` assembles the `llama-server` argv β pulling the
|
| 47 |
+
model by its `-hf` spec (downloaded on first run), serving it under `--alias <key>` so the
|
| 48 |
+
running server reports the stable id the engine binds to, and offloading **every layer to
|
| 49 |
+
the GPU** (`-ngl 999`) when one is present, omitting the flag on CPU. Both take their
|
| 50 |
+
environment as arguments so the GPU/CPU branches are testable with no GPU and no binary.
|
| 51 |
+
The `__main__` CLI launches a model by key and prints the matching `LLAMACPP_BASE_URL`
|
| 52 |
+
export.
|
| 53 |
+
|
| 54 |
+
**Opt-in by base URL, not a token.** A local server needs no auth, so the live/offline
|
| 55 |
+
gate (`has_credentials`) keys on `LLAMACPP_BASE_URL` being *set* β the launcher sets it,
|
| 56 |
+
or you export it to point at an already-running or remote server. With it unset the
|
| 57 |
+
backend never claims to be live, so the deterministic stub still owns the no-config demo.
|
| 58 |
+
|
| 59 |
+
## Consequences
|
| 60 |
+
|
| 61 |
+
- A cast can run fully local: `uv run python -m src.models.llamacpp_server
|
| 62 |
+
nemotron-3-nano-4b`, export the printed URL, and the engine routes to it through the
|
| 63 |
+
unchanged LiteLLM transport β no account, no token, GPU used automatically when present.
|
| 64 |
+
- Llama Champion + Nemotron + OpenBMB lanes are reachable from one server; the catalogue
|
| 65 |
+
is plain data, so adding a GGUF or retuning a tier is a one-line `LlamaCppModel(...)` edit.
|
| 66 |
+
- Backward compatible by construction: bare keys still mean Modal, and the `llamacpp`
|
| 67 |
+
prefix is new β existing config, manifests, and the green test baseline are unaffected.
|
| 68 |
+
- The engine still never names a vendor on a hot path: routing is by qualified key through
|
| 69 |
+
one faΓ§ade; the GGUF/quant churn is hidden behind `--alias <key>`.
|
| 70 |
+
- llama-server and the GGUF download are operator-side, never a Python dependency β the
|
| 71 |
+
offline stub remains the default with no binary, no network, and extras uninstalled.
|
docs/architecture/model-routing.md
CHANGED
|
@@ -77,6 +77,38 @@ Profiles map to the OpenAI-compatible vLLM endpoints served on Modal
|
|
| 77 |
The LiteLLM model string for an OpenAI-compatible custom endpoint is
|
| 78 |
`openai/<served_model_id>` with `api_base` pointing at the endpoint's `/v1` URL.
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
### Real cost β Governor
|
| 81 |
|
| 82 |
LiteLLM prices each call (`response._hidden_params["response_cost"]`, falling back
|
|
@@ -150,6 +182,9 @@ everything on the big model.
|
|
| 150 |
- `src/core/registry.py` β `Registry.from_world()` (a UI/LLM-composed run on the same path)
|
| 151 |
- `src/models/litellm_provider.py` β `LiteLLMProvider` (live transport, real cost)
|
| 152 |
- `src/models/modal_catalogue.py` β engine view of the catalogue (key β binding)
|
|
|
|
|
|
|
|
|
|
| 153 |
- `src/core/manifest.py` β `resolve_model()` (env β catalogue default)
|
| 154 |
- `src/core/registry.py` β `build_router()`, `_resolve_model_endpoints()`, `_expand_env()`
|
| 155 |
- `src/models/provider.py` β `ModelProvider.last_usage`, `estimate_tokens()`
|
|
|
|
| 77 |
The LiteLLM model string for an OpenAI-compatible custom endpoint is
|
| 78 |
`openai/<served_model_id>` with `api_base` pointing at the endpoint's `/v1` URL.
|
| 79 |
|
| 80 |
+
### Backends: Modal Β· Hugging Face Β· llama.cpp
|
| 81 |
+
|
| 82 |
+
A *backend* is just a catalogue + a binding rule, unified behind one registry
|
| 83 |
+
(`src/models/inference.py`, ADR-0024). Models are named by a **backend-qualified
|
| 84 |
+
key** `"<backend>:<raw>"`; a bare key means Modal, so all existing config keeps
|
| 85 |
+
working. Three backends ship today:
|
| 86 |
+
|
| 87 |
+
| Backend | Prefix | Where it runs | Opt-in |
|
| 88 |
+
|---|---|---|---|
|
| 89 |
+
| Modal | *(bare)* | vLLM endpoints you deploy on Modal GPUs | `MODAL_WORKSPACE` / `MODAL_LLM_BASE_URL` |
|
| 90 |
+
| Hugging Face | `hf:` | serverless Inference Providers router | `HF_TOKEN` |
|
| 91 |
+
| llama.cpp | `llamacpp:` | **local** GGUF via `llama-server`, GPU when present | `LLAMACPP_BASE_URL` |
|
| 92 |
+
|
| 93 |
+
The llama.cpp backend (ADR-0032) runs a cast fully on your own machine. Launch a
|
| 94 |
+
model and export the URL it prints:
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
uv run python -m src.models.llamacpp_server nemotron-3-nano-4b # GPU auto-detected
|
| 98 |
+
export LLAMACPP_BASE_URL=http://127.0.0.1:8080/v1
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
The launcher detects an accelerator β Apple Metal on macOS, NVIDIA CUDA via
|
| 102 |
+
`nvidia-smi`, else CPU β and offloads every layer to the GPU (`-ngl 999`) when one
|
| 103 |
+
is present. `llama-server` downloads the GGUF on first run (`-hf`) and serves it
|
| 104 |
+
under `--alias <key>`, so the engine binds to a stable id (`openai/<key>`) through
|
| 105 |
+
the same LiteLLM transport. Bind a tier to a local model with a qualified key:
|
| 106 |
+
|
| 107 |
+
```yaml
|
| 108 |
+
profiles:
|
| 109 |
+
tiny: { endpoint: "llamacpp:nemotron-3-nano-4b", temperature: 0.7, max_tokens: 192 }
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
### Real cost β Governor
|
| 113 |
|
| 114 |
LiteLLM prices each call (`response._hidden_params["response_cost"]`, falling back
|
|
|
|
| 182 |
- `src/core/registry.py` β `Registry.from_world()` (a UI/LLM-composed run on the same path)
|
| 183 |
- `src/models/litellm_provider.py` β `LiteLLMProvider` (live transport, real cost)
|
| 184 |
- `src/models/modal_catalogue.py` β engine view of the catalogue (key β binding)
|
| 185 |
+
- `src/models/inference.py` β unified backend registry (Modal Β· HF Β· llama.cpp); qualified keys
|
| 186 |
+
- `src/models/llamacpp_catalogue.py` β local GGUF catalogue (key β binding)
|
| 187 |
+
- `src/models/llamacpp_server.py` β `llama-server` launcher: GPU detection + command building
|
| 188 |
- `src/core/manifest.py` β `resolve_model()` (env β catalogue default)
|
| 189 |
- `src/core/registry.py` β `build_router()`, `_resolve_model_endpoints()`, `_expand_env()`
|
| 190 |
- `src/models/provider.py` β `ModelProvider.last_usage`, `estimate_tokens()`
|
src/models/inference.py
CHANGED
|
@@ -24,7 +24,7 @@ from __future__ import annotations
|
|
| 24 |
import os
|
| 25 |
from dataclasses import dataclass
|
| 26 |
|
| 27 |
-
from src.models import hf_catalogue, modal_catalogue
|
| 28 |
|
| 29 |
# Separator between a backend prefix and the backend-local key. A raw HF repo id can
|
| 30 |
# contain ``/`` but never a leading ``<backend>:`` prefix, so a single split is safe.
|
|
@@ -55,6 +55,12 @@ _BACKENDS: dict[str, Backend] = {
|
|
| 55 |
blurb="serverless Inference Providers β many small models, just a token",
|
| 56 |
catalogue=hf_catalogue,
|
| 57 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
}
|
| 59 |
|
| 60 |
DEFAULT_BACKEND = "modal"
|
|
|
|
| 24 |
import os
|
| 25 |
from dataclasses import dataclass
|
| 26 |
|
| 27 |
+
from src.models import hf_catalogue, llamacpp_catalogue, modal_catalogue
|
| 28 |
|
| 29 |
# Separator between a backend prefix and the backend-local key. A raw HF repo id can
|
| 30 |
# contain ``/`` but never a leading ``<backend>:`` prefix, so a single split is safe.
|
|
|
|
| 55 |
blurb="serverless Inference Providers β many small models, just a token",
|
| 56 |
catalogue=hf_catalogue,
|
| 57 |
),
|
| 58 |
+
"llamacpp": Backend(
|
| 59 |
+
key="llamacpp",
|
| 60 |
+
label="llama.cpp",
|
| 61 |
+
blurb="local GGUF models you run yourself β llama-server, GPU when present",
|
| 62 |
+
catalogue=llamacpp_catalogue,
|
| 63 |
+
),
|
| 64 |
}
|
| 65 |
|
| 66 |
DEFAULT_BACKEND = "modal"
|
src/models/llamacpp_catalogue.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""llama.cpp local-inference catalogue β the third backend, next to Modal and HF.
|
| 2 |
+
|
| 3 |
+
Where ``modal/catalogue.py`` describes models the project deploys itself (vLLM on
|
| 4 |
+
Modal GPUs) and ``hf_catalogue.py`` describes models reachable on Hugging Face's
|
| 5 |
+
serverless router, this module describes **GGUF models you run on your own machine**
|
| 6 |
+
through ``llama-server`` β llama.cpp's OpenAI-compatible HTTP server. It is the
|
| 7 |
+
"Llama Champion" lane: a real llama.cpp runtime in the cast, and the same data also
|
| 8 |
+
carries the **NVIDIA Nemotron** and **OpenBMB MiniCPM** small models, so one local
|
| 9 |
+
server can qualify several sponsor tracks at once.
|
| 10 |
+
|
| 11 |
+
Like the other catalogues this file is **stdlib-only** and reaches no network: it is
|
| 12 |
+
pure data plus string building, so the engine and the Lab picker can read it offline.
|
| 13 |
+
The engine never imports a vendor SDK from here β calls route through the same LiteLLM
|
| 14 |
+
gateway as the Modal/HF paths, using the OpenAI-compatible custom-endpoint form
|
| 15 |
+
``openai/<served_id>`` + ``api_base`` (the local server's ``/v1`` URL). The *serving*
|
| 16 |
+
side β picking a GGUF, detecting a GPU, and launching ``llama-server`` β lives in the
|
| 17 |
+
sibling :mod:`src.models.llamacpp_server`; this module only describes the models and
|
| 18 |
+
how to reach a running server.
|
| 19 |
+
|
| 20 |
+
Tier mapping mirrors the four logical profiles the cast routes by:
|
| 21 |
+
``tiny`` β€4B Β· ``fast`` β€8B Β· ``balanced`` β€13B Β· ``strong`` β€32B. Every model stays
|
| 22 |
+
within the project's β€32B "small minds" rule; ``tiny`` honours the Tiny-Titan β€4B band.
|
| 23 |
+
|
| 24 |
+
Add a model = append one :class:`LlamaCppModel`. Nothing downstream needs editing β
|
| 25 |
+
the unified backend registry (:mod:`src.models.inference`), the router, the Lab picker,
|
| 26 |
+
and the launcher all derive from this data.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import os
|
| 32 |
+
from dataclasses import dataclass
|
| 33 |
+
|
| 34 |
+
# The local llama.cpp server's OpenAI-compatible base URL. llama-server's default bind
|
| 35 |
+
# is 127.0.0.1:8080; the launcher prints the matching ``LLAMACPP_BASE_URL`` export so the
|
| 36 |
+
# engine and the server agree. Overridable for a remote box or a non-default port.
|
| 37 |
+
DEFAULT_BASE_URL = "http://127.0.0.1:8080/v1"
|
| 38 |
+
|
| 39 |
+
# Base-URL env var (the opt-in seam: set this and the backend goes live). A llama.cpp
|
| 40 |
+
# server needs no real token, but LiteLLM/OpenAI clients require *some* bearer string β
|
| 41 |
+
# ``LLAMACPP_API_KEY`` overrides the harmless default below.
|
| 42 |
+
_BASE_URL_ENV = "LLAMACPP_BASE_URL"
|
| 43 |
+
_API_KEY_ENV = "LLAMACPP_API_KEY"
|
| 44 |
+
_DEFAULT_API_KEY = "llama.cpp" # any non-empty string; llama-server ignores its value
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass(frozen=True)
|
| 48 |
+
class LlamaCppModel:
|
| 49 |
+
"""One GGUF model servable locally through ``llama-server``.
|
| 50 |
+
|
| 51 |
+
Engine-facing fields (``key`` / ``profile`` / ``params_b`` / ``served_id``) mirror
|
| 52 |
+
the other catalogues so the registry treats all three backends identically. The
|
| 53 |
+
serving fields (``hf_repo`` / ``quant`` / ``gguf_file`` / ``ctx_size`` / sampling /
|
| 54 |
+
``flash_attn`` / ``reasoning``) are read only by :mod:`src.models.llamacpp_server`
|
| 55 |
+
when it assembles the ``llama-server`` command β the engine never touches them.
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
# Identity / engine-facing
|
| 59 |
+
key: str # stable slug + served-model id, e.g. "nemotron-3-nano-4b"
|
| 60 |
+
hf_repo: str # Hugging Face GGUF repo, e.g. "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF"
|
| 61 |
+
profile: str | None = None # default tier (tiny/fast/balanced/strong) or None
|
| 62 |
+
params_b: float | None = None # total params in billions (docs / Tiny-Titan checks)
|
| 63 |
+
source: str = "llama.cpp" # friendly family/org label for the picker
|
| 64 |
+
|
| 65 |
+
# Serving / launcher-facing
|
| 66 |
+
quant: str | None = "Q4_K_M" # GGUF quant tag for the ``-hf repo:QUANT`` form; None
|
| 67 |
+
# when the quant is already baked into ``hf_repo``.
|
| 68 |
+
gguf_file: str | None = None # explicit GGUF filename (for ``-m``/download); optional
|
| 69 |
+
ctx_size: int = 8192 # default --ctx-size; 0 means "use the model's trained context"
|
| 70 |
+
temperature: float = 0.7
|
| 71 |
+
top_p: float = 0.95
|
| 72 |
+
top_k: int = 40
|
| 73 |
+
flash_attn: bool = True # --flash-attn (-fa): faster + lower memory where supported
|
| 74 |
+
reasoning: bool = False # a "thinking" model β budget more tokens; nudge sampling
|
| 75 |
+
|
| 76 |
+
@property
|
| 77 |
+
def served_model_id(self) -> str:
|
| 78 |
+
"""Model id clients pass. We launch with ``--alias <key>`` so the running server
|
| 79 |
+
reports this exact id, keeping the binding's ``openai/<id>`` stable across the
|
| 80 |
+
repo/quant churn of GGUF names."""
|
| 81 |
+
return self.key
|
| 82 |
+
|
| 83 |
+
@property
|
| 84 |
+
def hf_spec(self) -> str:
|
| 85 |
+
"""The argument for ``llama-server -hf`` β ``repo`` or ``repo:QUANT``.
|
| 86 |
+
|
| 87 |
+
Some repos bake the quant into the repo name (e.g. JetBrains' ``β¦-Q4_K_M``); for
|
| 88 |
+
those ``quant`` is None and the bare repo is used. Others publish many quants in
|
| 89 |
+
one repo and need the ``:QUANT`` selector.
|
| 90 |
+
"""
|
| 91 |
+
return f"{self.hf_repo}:{self.quant}" if self.quant else self.hf_repo
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# --- The catalogue: small GGUF models, one per default tier ---------------------------
|
| 95 |
+
# A deliberate spread across sponsor families so a single local server fills the cast and
|
| 96 |
+
# stacks prize lanes (Llama Champion + Nemotron + OpenBMB). Plain data β retuning a tier
|
| 97 |
+
# or adding a quant is a one-line edit.
|
| 98 |
+
|
| 99 |
+
LLAMACPP_MODELS: tuple[LlamaCppModel, ...] = (
|
| 100 |
+
# NVIDIA Nemotron 3 Nano 4B β tiny tier, β€4B Tiny-Titan band. Repo publishes several
|
| 101 |
+
# quants, so the quant is selected via the ``:Q4_K_M`` form.
|
| 102 |
+
LlamaCppModel(
|
| 103 |
+
key="nemotron-3-nano-4b",
|
| 104 |
+
hf_repo="nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF",
|
| 105 |
+
profile="tiny",
|
| 106 |
+
params_b=4.0,
|
| 107 |
+
source="NVIDIA Nemotron",
|
| 108 |
+
quant="Q4_K_M",
|
| 109 |
+
temperature=0.7,
|
| 110 |
+
),
|
| 111 |
+
# OpenBMB MiniCPM 4.1 8B β fast tier (β€8B). Multi-quant repo β ``:Q4_K_M``.
|
| 112 |
+
LlamaCppModel(
|
| 113 |
+
key="minicpm-4-1-8b",
|
| 114 |
+
hf_repo="openbmb/MiniCPM4.1-8B-GGUF",
|
| 115 |
+
profile="fast",
|
| 116 |
+
params_b=8.0,
|
| 117 |
+
source="OpenBMB MiniCPM",
|
| 118 |
+
quant="Q4_K_M",
|
| 119 |
+
gguf_file="MiniCPM4.1-8B-Q4_K_M.gguf",
|
| 120 |
+
temperature=0.8,
|
| 121 |
+
),
|
| 122 |
+
# JetBrains Mellum 2 12B-A2.5B β balanced tier (β€13B; MoE, ~2.5B active). A "thinking"
|
| 123 |
+
# model: budget more tokens and use its recommended sampling. The quant is baked into
|
| 124 |
+
# the repo name, so ``quant`` is None (the bare ``-hf`` repo is used).
|
| 125 |
+
LlamaCppModel(
|
| 126 |
+
key="mellum2-12b-thinking",
|
| 127 |
+
hf_repo="JetBrains/Mellum2-12B-A2.5B-Thinking-GGUF-Q4_K_M",
|
| 128 |
+
profile="balanced",
|
| 129 |
+
params_b=12.0,
|
| 130 |
+
source="JetBrains Mellum",
|
| 131 |
+
quant=None,
|
| 132 |
+
gguf_file="Mellum2-12B-A2.5B-Thinking-Q4_K_M.gguf",
|
| 133 |
+
ctx_size=16384,
|
| 134 |
+
temperature=0.6,
|
| 135 |
+
top_p=0.95,
|
| 136 |
+
top_k=20,
|
| 137 |
+
reasoning=True,
|
| 138 |
+
),
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# --- engine-facing read view (mirrors modal_catalogue / hf_catalogue dict shape) ------
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _build_entry(m: LlamaCppModel) -> dict:
|
| 146 |
+
"""One model as a plain dict, shaped like ``modal_catalogue.entries()``."""
|
| 147 |
+
return {
|
| 148 |
+
"key": m.key,
|
| 149 |
+
"provider": m.source,
|
| 150 |
+
"app": "llama.cpp",
|
| 151 |
+
"endpoint_name": m.key,
|
| 152 |
+
"served_model_id": m.served_model_id,
|
| 153 |
+
"profile": m.profile,
|
| 154 |
+
"params_b": m.params_b,
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# Built once at import (the catalogue is static): callers that mutate copy first.
|
| 159 |
+
_ENTRIES: tuple[dict, ...] = tuple(_build_entry(m) for m in LLAMACPP_MODELS)
|
| 160 |
+
_ENTRY_BY_KEY: dict[str, dict] = {e["key"]: e for e in _ENTRIES}
|
| 161 |
+
_MODEL_BY_KEY: dict[str, LlamaCppModel] = {m.key: m for m in LLAMACPP_MODELS}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def entries() -> list[dict]:
|
| 165 |
+
"""Every local model as a plain dict, shaped like the other catalogues:
|
| 166 |
+
|
| 167 |
+
``{key, provider, app, endpoint_name, served_model_id, profile, params_b}`` β so the
|
| 168 |
+
unified registry and the Lab picker treat all three backends identically.
|
| 169 |
+
"""
|
| 170 |
+
return list(_ENTRIES)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def entry_by_key(key: str) -> dict | None:
|
| 174 |
+
"""The catalogue entry whose key is *key*, or None."""
|
| 175 |
+
return _ENTRY_BY_KEY.get(key)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def model_by_key(key: str) -> LlamaCppModel | None:
|
| 179 |
+
"""The full :class:`LlamaCppModel` for *key* (serving fields included), or None.
|
| 180 |
+
|
| 181 |
+
The launcher uses this; the engine path only needs :func:`binding_for`.
|
| 182 |
+
"""
|
| 183 |
+
return _MODEL_BY_KEY.get(key)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def default_key_for_profile(profile: str) -> str | None:
|
| 187 |
+
"""The key of the model tagged for *profile* (first match), or None."""
|
| 188 |
+
return next((m.key for m in LLAMACPP_MODELS if m.profile == profile), None)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def base_url(env: dict[str, str] | None = None) -> str:
|
| 192 |
+
"""The configured local server base URL, or the llama-server default."""
|
| 193 |
+
source = os.environ if env is None else env
|
| 194 |
+
return source.get(_BASE_URL_ENV, "").strip() or DEFAULT_BASE_URL
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def has_credentials(env: dict[str, str] | None = None) -> bool:
|
| 198 |
+
"""True when the llama.cpp backend is opted in β an explicit ``LLAMACPP_BASE_URL``.
|
| 199 |
+
|
| 200 |
+
Unlike the hosted backends there is no token to gate on: a local ``llama-server``
|
| 201 |
+
needs no auth. We gate on the base URL being *set* so the backend never silently
|
| 202 |
+
claims to be live when nothing is running β the launcher sets this for you, or you
|
| 203 |
+
export it by hand to point at an already-running (or remote) server.
|
| 204 |
+
"""
|
| 205 |
+
source = os.environ if env is None else env
|
| 206 |
+
return bool(source.get(_BASE_URL_ENV, "").strip())
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def binding_for(key: str, env: dict[str, str] | None = None) -> dict:
|
| 210 |
+
"""Resolve a catalogue *key* into a concrete profile binding.
|
| 211 |
+
|
| 212 |
+
Returns ``{"model", "base_url", "api_key"}`` where ``model`` is the LiteLLM
|
| 213 |
+
OpenAI-compatible string ``openai/<served_id>``, ``base_url`` is ``LLAMACPP_BASE_URL``
|
| 214 |
+
(or the llama-server default), and ``api_key`` is ``LLAMACPP_API_KEY`` (or a harmless
|
| 215 |
+
placeholder β llama-server ignores it). Raises ``KeyError`` for an unknown key.
|
| 216 |
+
"""
|
| 217 |
+
source = os.environ if env is None else env
|
| 218 |
+
model = _MODEL_BY_KEY.get(key)
|
| 219 |
+
if model is None:
|
| 220 |
+
known = sorted(_MODEL_BY_KEY)
|
| 221 |
+
raise KeyError(f"unknown llama.cpp model {key!r}; known: {known}")
|
| 222 |
+
api_key = source.get(_API_KEY_ENV, "").strip() or _DEFAULT_API_KEY
|
| 223 |
+
return {
|
| 224 |
+
"model": f"openai/{model.served_model_id}",
|
| 225 |
+
"base_url": base_url(source),
|
| 226 |
+
"api_key": api_key,
|
| 227 |
+
}
|
src/models/llamacpp_server.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Launch a local ``llama-server`` for a catalogue model β GPU when one is present.
|
| 2 |
+
|
| 3 |
+
This is the *serving* side of the llama.cpp backend (the read/binding side lives in
|
| 4 |
+
:mod:`src.models.llamacpp_catalogue`). It assembles the ``llama-server`` command for a
|
| 5 |
+
GGUF model, detects an accelerator (Apple Metal on macOS, NVIDIA CUDA elsewhere) and
|
| 6 |
+
offloads every layer to it when found, and otherwise serves on CPU β so the same command
|
| 7 |
+
works on a laptop and a GPU box. ``llama-server`` downloads the GGUF on first run via its
|
| 8 |
+
``-hf`` flag and exposes an OpenAI-compatible API on ``/v1``, which the engine reaches
|
| 9 |
+
through the same LiteLLM gateway as the Modal/HF backends.
|
| 10 |
+
|
| 11 |
+
Usage::
|
| 12 |
+
|
| 13 |
+
# launch the tiny-tier Nemotron (GPU auto-detected), then export the URL it prints
|
| 14 |
+
uv run python -m src.models.llamacpp_server nemotron-3-nano-4b
|
| 15 |
+
export LLAMACPP_BASE_URL=http://127.0.0.1:8080/v1
|
| 16 |
+
|
| 17 |
+
uv run python -m src.models.llamacpp_server --list # show available models
|
| 18 |
+
uv run python -m src.models.llamacpp_server minicpm-4-1-8b --cpu --print-only
|
| 19 |
+
|
| 20 |
+
``build_command`` and ``detect_accelerator`` are pure and take their inputs as arguments
|
| 21 |
+
(platform string, a probe callable) so tests can exercise the GPU/CPU branches without a
|
| 22 |
+
GPU or the ``llama-server`` binary present.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import argparse
|
| 28 |
+
import os
|
| 29 |
+
import shutil
|
| 30 |
+
import subprocess
|
| 31 |
+
import sys
|
| 32 |
+
from collections.abc import Callable
|
| 33 |
+
|
| 34 |
+
from src.models import llamacpp_catalogue
|
| 35 |
+
from src.models.llamacpp_catalogue import LlamaCppModel
|
| 36 |
+
|
| 37 |
+
# Default bind. 127.0.0.1 keeps the server local; pass --host 0.0.0.0 to expose it (e.g.
|
| 38 |
+
# a remote GPU box). The port matches llama-server's own default so the catalogue's
|
| 39 |
+
# DEFAULT_BASE_URL lines up with a no-flag launch.
|
| 40 |
+
DEFAULT_HOST = "127.0.0.1"
|
| 41 |
+
DEFAULT_PORT = 8080
|
| 42 |
+
DEFAULT_BINARY = "llama-server"
|
| 43 |
+
|
| 44 |
+
# Offload-all sentinel: llama.cpp treats a large -ngl as "every layer on the GPU".
|
| 45 |
+
_OFFLOAD_ALL_LAYERS = 999
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def detect_accelerator(
|
| 49 |
+
platform: str | None = None,
|
| 50 |
+
probe: Callable[[], bool] | None = None,
|
| 51 |
+
) -> str:
|
| 52 |
+
"""Return the accelerator to offload to: ``"metal"``, ``"cuda"``, or ``"cpu"``.
|
| 53 |
+
|
| 54 |
+
macOS (``darwin``) ships llama.cpp with Metal, so Apple Silicon always offloads.
|
| 55 |
+
Elsewhere we offload only when an NVIDIA GPU is visible β probed with ``nvidia-smi``
|
| 56 |
+
by default (injectable so tests don't depend on the host). Anything else β CPU.
|
| 57 |
+
"""
|
| 58 |
+
plat = platform if platform is not None else sys.platform
|
| 59 |
+
if plat == "darwin":
|
| 60 |
+
return "metal"
|
| 61 |
+
has_cuda = probe() if probe is not None else _nvidia_smi_present()
|
| 62 |
+
return "cuda" if has_cuda else "cpu"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _nvidia_smi_present() -> bool:
|
| 66 |
+
"""True when ``nvidia-smi`` exists and exits cleanly (a usable NVIDIA GPU)."""
|
| 67 |
+
if shutil.which("nvidia-smi") is None:
|
| 68 |
+
return False
|
| 69 |
+
try:
|
| 70 |
+
result = subprocess.run(
|
| 71 |
+
["nvidia-smi"],
|
| 72 |
+
capture_output=True,
|
| 73 |
+
timeout=10,
|
| 74 |
+
check=False,
|
| 75 |
+
)
|
| 76 |
+
except (OSError, subprocess.SubprocessError): # pragma: no cover - host-dependent
|
| 77 |
+
return False
|
| 78 |
+
return result.returncode == 0
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def gpu_layers(accelerator: str) -> int:
|
| 82 |
+
"""How many layers to offload for *accelerator*: all on a GPU, none on CPU."""
|
| 83 |
+
return _OFFLOAD_ALL_LAYERS if accelerator in ("metal", "cuda") else 0
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def build_command(
|
| 87 |
+
model: LlamaCppModel,
|
| 88 |
+
*,
|
| 89 |
+
accelerator: str,
|
| 90 |
+
host: str = DEFAULT_HOST,
|
| 91 |
+
port: int = DEFAULT_PORT,
|
| 92 |
+
ctx_size: int | None = None,
|
| 93 |
+
binary: str = DEFAULT_BINARY,
|
| 94 |
+
) -> list[str]:
|
| 95 |
+
"""Assemble the ``llama-server`` argv for *model*. Returned as a list so the caller
|
| 96 |
+
launches with ``subprocess`` and no shell (no quoting pitfalls).
|
| 97 |
+
|
| 98 |
+
The model is pulled by its ``-hf`` spec (downloaded on first run) and served under
|
| 99 |
+
``--alias <key>`` so the running server reports the stable id the engine binds to.
|
| 100 |
+
On a GPU (``metal``/``cuda``) every layer is offloaded (``-ngl 999``); on CPU the
|
| 101 |
+
flag is omitted. Sampling defaults and flash-attention come from the model's
|
| 102 |
+
catalogue entry; ``ctx_size`` overrides the per-model context window when given.
|
| 103 |
+
"""
|
| 104 |
+
ctx = model.ctx_size if ctx_size is None else ctx_size
|
| 105 |
+
cmd: list[str] = [
|
| 106 |
+
binary,
|
| 107 |
+
"-hf",
|
| 108 |
+
model.hf_spec,
|
| 109 |
+
"--alias",
|
| 110 |
+
model.key,
|
| 111 |
+
"--host",
|
| 112 |
+
host,
|
| 113 |
+
"--port",
|
| 114 |
+
str(port),
|
| 115 |
+
"--ctx-size",
|
| 116 |
+
str(ctx),
|
| 117 |
+
"--temp",
|
| 118 |
+
str(model.temperature),
|
| 119 |
+
"--top-p",
|
| 120 |
+
str(model.top_p),
|
| 121 |
+
"--top-k",
|
| 122 |
+
str(model.top_k),
|
| 123 |
+
]
|
| 124 |
+
layers = gpu_layers(accelerator)
|
| 125 |
+
if layers:
|
| 126 |
+
cmd += ["-ngl", str(layers)]
|
| 127 |
+
if model.flash_attn:
|
| 128 |
+
cmd += ["--flash-attn", "on"]
|
| 129 |
+
return cmd
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def base_url_for(host: str, port: int) -> str:
|
| 133 |
+
"""The OpenAI-compatible URL clients should use for a server bound to *host:port*.
|
| 134 |
+
|
| 135 |
+
A server bound to ``0.0.0.0`` listens on every interface but is reached locally via
|
| 136 |
+
the loopback address, so we advertise ``127.0.0.1`` in that case.
|
| 137 |
+
"""
|
| 138 |
+
reachable = "127.0.0.1" if host in ("0.0.0.0", "::") else host
|
| 139 |
+
return f"http://{reachable}:{port}/v1"
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _format_models() -> str:
|
| 143 |
+
lines = ["Available llama.cpp models (key β repo Β· tier Β· params):"]
|
| 144 |
+
for m in llamacpp_catalogue.LLAMACPP_MODELS:
|
| 145 |
+
tier = m.profile or "β"
|
| 146 |
+
params = f"{m.params_b:g}B" if m.params_b else "?"
|
| 147 |
+
lines.append(f" {m.key:<24} {m.hf_spec} Β· {tier:<8} Β· {params}")
|
| 148 |
+
return "\n".join(lines)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def main(argv: list[str] | None = None) -> int:
|
| 152 |
+
parser = argparse.ArgumentParser(
|
| 153 |
+
prog="python -m src.models.llamacpp_server",
|
| 154 |
+
description="Launch a local llama-server for a catalogue model (GPU auto-detected).",
|
| 155 |
+
)
|
| 156 |
+
parser.add_argument("key", nargs="?", help="catalogue model key (see --list)")
|
| 157 |
+
parser.add_argument("--list", action="store_true", help="list available models and exit")
|
| 158 |
+
parser.add_argument("--host", default=DEFAULT_HOST, help=f"bind host (default {DEFAULT_HOST})")
|
| 159 |
+
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"bind port (default {DEFAULT_PORT})")
|
| 160 |
+
parser.add_argument("--ctx-size", type=int, default=None, help="override the model's context window")
|
| 161 |
+
parser.add_argument("--cpu", action="store_true", help="force CPU (skip GPU offload)")
|
| 162 |
+
parser.add_argument("--binary", default=DEFAULT_BINARY, help=f"llama-server binary (default {DEFAULT_BINARY})")
|
| 163 |
+
parser.add_argument("--print-only", action="store_true", help="print the command and export line, do not launch")
|
| 164 |
+
args = parser.parse_args(argv)
|
| 165 |
+
|
| 166 |
+
if args.list or not args.key:
|
| 167 |
+
print(_format_models())
|
| 168 |
+
return 0 if args.list else 2
|
| 169 |
+
|
| 170 |
+
model = llamacpp_catalogue.model_by_key(args.key)
|
| 171 |
+
if model is None:
|
| 172 |
+
print(f"unknown model {args.key!r}.\n\n{_format_models()}", file=sys.stderr)
|
| 173 |
+
return 2
|
| 174 |
+
|
| 175 |
+
accelerator = "cpu" if args.cpu else detect_accelerator()
|
| 176 |
+
cmd = build_command(
|
| 177 |
+
model,
|
| 178 |
+
accelerator=accelerator,
|
| 179 |
+
host=args.host,
|
| 180 |
+
port=args.port,
|
| 181 |
+
ctx_size=args.ctx_size,
|
| 182 |
+
binary=args.binary,
|
| 183 |
+
)
|
| 184 |
+
url = base_url_for(args.host, args.port)
|
| 185 |
+
|
| 186 |
+
where = {"metal": "Apple Metal GPU", "cuda": "NVIDIA GPU", "cpu": "CPU"}[accelerator]
|
| 187 |
+
print(f"βΆ {model.key} ({model.hf_spec}) on {where}")
|
| 188 |
+
print(f" {' '.join(cmd)}")
|
| 189 |
+
print(f"\nPoint the engine at it:\n export {llamacpp_catalogue._BASE_URL_ENV}={url}\n")
|
| 190 |
+
|
| 191 |
+
if args.print_only:
|
| 192 |
+
return 0
|
| 193 |
+
|
| 194 |
+
if shutil.which(args.binary) is None:
|
| 195 |
+
print(
|
| 196 |
+
f"'{args.binary}' not found on PATH. Install llama.cpp "
|
| 197 |
+
"(https://github.com/ggml-org/llama.cpp) or pass --binary /path/to/llama-server.",
|
| 198 |
+
file=sys.stderr,
|
| 199 |
+
)
|
| 200 |
+
return 127
|
| 201 |
+
|
| 202 |
+
# Export the URL into this process's children too, so a wrapper that launches the
|
| 203 |
+
# app in the same shell session sees it; the printed line covers the manual case.
|
| 204 |
+
os.environ[llamacpp_catalogue._BASE_URL_ENV] = url
|
| 205 |
+
try:
|
| 206 |
+
return subprocess.call(cmd)
|
| 207 |
+
except KeyboardInterrupt: # pragma: no cover - interactive
|
| 208 |
+
return 0
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
if __name__ == "__main__": # pragma: no cover - CLI entry
|
| 212 |
+
raise SystemExit(main())
|
tests/test_inference_backends.py
CHANGED
|
@@ -38,10 +38,12 @@ def test_entries_are_tagged_and_qualified():
|
|
| 38 |
assert modal_keys == {e["key"] for e in modal_catalogue.entries()}
|
| 39 |
assert all(k.startswith("hf:") for k in hf_keys)
|
| 40 |
assert modal_keys.isdisjoint(hf_keys)
|
| 41 |
-
# The unqualified call returns
|
| 42 |
everything = inference.entries()
|
| 43 |
-
assert {e["backend"] for e in everything}
|
| 44 |
-
assert len(everything) == len(
|
|
|
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
def test_entry_by_key_round_trips_both_backends():
|
|
|
|
| 38 |
assert modal_keys == {e["key"] for e in modal_catalogue.entries()}
|
| 39 |
assert all(k.startswith("hf:") for k in hf_keys)
|
| 40 |
assert modal_keys.isdisjoint(hf_keys)
|
| 41 |
+
# The unqualified call returns every backend's models, each tagged with its backend.
|
| 42 |
everything = inference.entries()
|
| 43 |
+
assert {"modal", "hf"} <= {e["backend"] for e in everything}
|
| 44 |
+
assert len(everything) == len(inference.entries("modal")) + len(inference.entries("hf")) + len(
|
| 45 |
+
inference.entries("llamacpp")
|
| 46 |
+
)
|
| 47 |
|
| 48 |
|
| 49 |
def test_entry_by_key_round_trips_both_backends():
|
tests/test_llamacpp_backend.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the llama.cpp local backend β catalogue, registry integration, launcher.
|
| 2 |
+
|
| 3 |
+
llama.cpp is the third inference backend (next to Modal and Hugging Face): GGUF models
|
| 4 |
+
served locally by ``llama-server`` behind an OpenAI-compatible API. These tests cover the
|
| 5 |
+
read/binding side (catalogue + unified registry) and the serving side (the launcher's
|
| 6 |
+
pure command-building and GPU detection) without a GPU or the binary present β the
|
| 7 |
+
launcher's pure functions take platform/probe as arguments precisely so they're testable.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
|
| 14 |
+
from src.models import inference, llamacpp_catalogue, llamacpp_server
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ββ catalogue ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_catalogue_covers_three_sponsor_tiers():
|
| 21 |
+
by_profile = {m.profile: m for m in llamacpp_catalogue.LLAMACPP_MODELS}
|
| 22 |
+
# Nemotron (tiny β€4B), MiniCPM (fast β€8B), Mellum (balanced β€13B) β the three lanes.
|
| 23 |
+
assert by_profile["tiny"].params_b <= 4
|
| 24 |
+
assert by_profile["fast"].params_b <= 8
|
| 25 |
+
assert by_profile["balanced"].params_b <= 13
|
| 26 |
+
# Every model stays within the β€32B "small minds" rule.
|
| 27 |
+
assert all(m.params_b <= 32 for m in llamacpp_catalogue.LLAMACPP_MODELS)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_hf_spec_appends_quant_only_when_not_baked_into_repo():
|
| 31 |
+
nemotron = llamacpp_catalogue.model_by_key("nemotron-3-nano-4b")
|
| 32 |
+
mellum = llamacpp_catalogue.model_by_key("mellum2-12b-thinking")
|
| 33 |
+
# Multi-quant repo β the quant is selected with the ``:QUANT`` form.
|
| 34 |
+
assert nemotron.hf_spec == "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF:Q4_K_M"
|
| 35 |
+
# Quant already baked into the repo name β bare repo, no ``:QUANT`` suffix.
|
| 36 |
+
assert mellum.quant is None
|
| 37 |
+
assert mellum.hf_spec == "JetBrains/Mellum2-12B-A2.5B-Thinking-GGUF-Q4_K_M"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_served_id_is_the_stable_key_not_the_gguf_name():
|
| 41 |
+
# The launcher serves under --alias <key>, so the engine binds to a stable id even as
|
| 42 |
+
# GGUF repo/quant names churn.
|
| 43 |
+
m = llamacpp_catalogue.model_by_key("minicpm-4-1-8b")
|
| 44 |
+
assert m.served_model_id == "minicpm-4-1-8b"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_binding_uses_local_url_and_placeholder_key_by_default():
|
| 48 |
+
binding = llamacpp_catalogue.binding_for("nemotron-3-nano-4b", env={})
|
| 49 |
+
assert binding["model"] == "openai/nemotron-3-nano-4b"
|
| 50 |
+
assert binding["base_url"] == llamacpp_catalogue.DEFAULT_BASE_URL
|
| 51 |
+
# llama-server ignores the token but OpenAI clients require a non-empty one.
|
| 52 |
+
assert binding["api_key"]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_binding_honours_env_overrides():
|
| 56 |
+
env = {"LLAMACPP_BASE_URL": "http://gpu-box:9000/v1", "LLAMACPP_API_KEY": "sekret"}
|
| 57 |
+
binding = llamacpp_catalogue.binding_for("minicpm-4-1-8b", env=env)
|
| 58 |
+
assert binding["base_url"] == "http://gpu-box:9000/v1"
|
| 59 |
+
assert binding["api_key"] == "sekret"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_binding_unknown_key_raises():
|
| 63 |
+
with pytest.raises(KeyError):
|
| 64 |
+
llamacpp_catalogue.binding_for("does-not-exist", env={})
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_has_credentials_gates_on_explicit_base_url():
|
| 68 |
+
# No silent "live": the backend is opted in only when the URL is set (the launcher
|
| 69 |
+
# sets it, or you export it to point at a running/remote server).
|
| 70 |
+
assert llamacpp_catalogue.has_credentials(env={}) is False
|
| 71 |
+
assert llamacpp_catalogue.has_credentials(env={"LLAMACPP_BASE_URL": "http://x/v1"}) is True
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ββ unified registry integration ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_registered_as_third_backend():
|
| 78 |
+
assert "llamacpp" in {b.key for b in inference.backends()}
|
| 79 |
+
keys = {e["key"] for e in inference.entries("llamacpp")}
|
| 80 |
+
assert all(k.startswith("llamacpp:") for k in keys)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_registry_dispatches_binding_and_availability():
|
| 84 |
+
key = inference.default_key_for_profile("tiny", "llamacpp")
|
| 85 |
+
assert key == "llamacpp:nemotron-3-nano-4b"
|
| 86 |
+
binding = inference.binding_for(key, env={"LLAMACPP_BASE_URL": "http://127.0.0.1:8080/v1"})
|
| 87 |
+
assert binding["base_url"] == "http://127.0.0.1:8080/v1"
|
| 88 |
+
assert inference.backend_available("llamacpp", env={"LLAMACPP_BASE_URL": "http://x/v1"}) is True
|
| 89 |
+
assert inference.backend_available("llamacpp", env={}) is False
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_configured_backends_includes_llamacpp_when_url_set():
|
| 93 |
+
configured = inference.configured_backends(env={"LLAMACPP_BASE_URL": "http://x/v1"})
|
| 94 |
+
assert "llamacpp" in configured
|
| 95 |
+
assert inference.configured_backends(env={}) == []
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ββ launcher: GPU detection ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_detect_accelerator_metal_on_macos():
|
| 102 |
+
assert llamacpp_server.detect_accelerator(platform="darwin") == "metal"
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_detect_accelerator_cuda_when_gpu_present():
|
| 106 |
+
assert llamacpp_server.detect_accelerator(platform="linux", probe=lambda: True) == "cuda"
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_detect_accelerator_cpu_when_no_gpu():
|
| 110 |
+
assert llamacpp_server.detect_accelerator(platform="linux", probe=lambda: False) == "cpu"
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_gpu_layers_offloads_all_on_gpu_none_on_cpu():
|
| 114 |
+
assert llamacpp_server.gpu_layers("metal") == 999
|
| 115 |
+
assert llamacpp_server.gpu_layers("cuda") == 999
|
| 116 |
+
assert llamacpp_server.gpu_layers("cpu") == 0
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ββ launcher: command building βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_build_command_offloads_layers_on_gpu():
|
| 123 |
+
model = llamacpp_catalogue.model_by_key("nemotron-3-nano-4b")
|
| 124 |
+
cmd = llamacpp_server.build_command(model, accelerator="cuda")
|
| 125 |
+
assert cmd[0] == "llama-server"
|
| 126 |
+
assert "-hf" in cmd and model.hf_spec in cmd
|
| 127 |
+
assert cmd[cmd.index("--alias") + 1] == "nemotron-3-nano-4b"
|
| 128 |
+
assert "-ngl" in cmd and cmd[cmd.index("-ngl") + 1] == "999"
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_build_command_omits_offload_on_cpu():
|
| 132 |
+
model = llamacpp_catalogue.model_by_key("nemotron-3-nano-4b")
|
| 133 |
+
cmd = llamacpp_server.build_command(model, accelerator="cpu")
|
| 134 |
+
assert "-ngl" not in cmd
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_build_command_carries_model_sampling_and_ctx():
|
| 138 |
+
model = llamacpp_catalogue.model_by_key("mellum2-12b-thinking")
|
| 139 |
+
cmd = llamacpp_server.build_command(model, accelerator="metal")
|
| 140 |
+
assert cmd[cmd.index("--temp") + 1] == "0.6"
|
| 141 |
+
assert cmd[cmd.index("--top-k") + 1] == "20"
|
| 142 |
+
assert cmd[cmd.index("--ctx-size") + 1] == "16384"
|
| 143 |
+
assert "--flash-attn" in cmd
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def test_build_command_ctx_override_wins():
|
| 147 |
+
model = llamacpp_catalogue.model_by_key("mellum2-12b-thinking")
|
| 148 |
+
cmd = llamacpp_server.build_command(model, accelerator="cpu", ctx_size=2048)
|
| 149 |
+
assert cmd[cmd.index("--ctx-size") + 1] == "2048"
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def test_base_url_for_advertises_loopback_when_bound_to_all_interfaces():
|
| 153 |
+
assert llamacpp_server.base_url_for("0.0.0.0", 8080) == "http://127.0.0.1:8080/v1"
|
| 154 |
+
assert llamacpp_server.base_url_for("127.0.0.1", 9000) == "http://127.0.0.1:9000/v1"
|