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 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 both backends' models, each tagged with its backend.
42
  everything = inference.entries()
43
- assert {e["backend"] for e in everything} == {"modal", "hf"}
44
- assert len(everything) == len(modal_keys) + len(hf_keys)
 
 
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"