agharsallah commited on
Commit Β·
7a13e4e
1
Parent(s): 97f641c
feat: Update model routing and local provider to support multi-sponsor tiers and auto-class resolution
Browse files- docs/adr/0033-local-inproc-transformers-backend.md +12 -6
- docs/architecture/model-routing.md +15 -7
- src/models/__init__.py +0 -1
- src/models/local_catalogue.py +50 -25
- src/models/local_provider.py +26 -9
- tests/test_local_backend.py +46 -24
docs/adr/0033-local-inproc-transformers-backend.md
CHANGED
|
@@ -94,9 +94,11 @@ backend stays inactive and the deterministic stub owns the no-config demo path.
|
|
| 94 |
- No new Python dependencies β `torch` and `transformers` are already transitive deps.
|
| 95 |
- The parent-process cache means each ZeroGPU call after the first is weight-load-free
|
| 96 |
within a session.
|
| 97 |
-
- **Prize-lane impact:**
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
| 100 |
|
| 101 |
**Negative / Risks:**
|
| 102 |
- **Llama Champion badge is explicitly dropped.** No llama.cpp runtime in the cast means
|
|
@@ -117,9 +119,13 @@ backend stays inactive and the deterministic stub owns the no-config demo path.
|
|
| 117 |
- `llamacpp_catalogue.py` and `llamacpp_server.py` are deleted; `app.py`'s
|
| 118 |
`gpu_selftest` `@spaces.GPU` guard is retained β it detects ZeroGPU availability at
|
| 119 |
startup and is unrelated to the inference path.
|
| 120 |
-
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
- Tests live in `tests/test_local_backend.py`. All 676 tests pass; the capability-gate
|
| 124 |
logic is fully covered without a GPU or torch import in test processes.
|
| 125 |
|
|
|
|
| 94 |
- No new Python dependencies β `torch` and `transformers` are already transitive deps.
|
| 95 |
- The parent-process cache means each ZeroGPU call after the first is weight-load-free
|
| 96 |
within a session.
|
| 97 |
+
- **Prize-lane impact:** one sponsor family per tier, so a single in-process cast spans
|
| 98 |
+
four tracks at once β **NVIDIA Nemotron** (`tiny`, also the **Tiny Titan** β€4B lane via
|
| 99 |
+
Nemotron-3-Nano-4B), **OpenBMB / MiniCPM** (`fast`), **Cohere / Aya** (`balanced`), and
|
| 100 |
+
**JetBrains / Mellum** (`strong`) β plus the **Community Choice** on-device-inference
|
| 101 |
+
story for the HF Space demo.
|
| 102 |
|
| 103 |
**Negative / Risks:**
|
| 104 |
- **Llama Champion badge is explicitly dropped.** No llama.cpp runtime in the cast means
|
|
|
|
| 119 |
- `llamacpp_catalogue.py` and `llamacpp_server.py` are deleted; `app.py`'s
|
| 120 |
`gpu_selftest` `@spaces.GPU` guard is retained β it detects ZeroGPU availability at
|
| 121 |
startup and is unrelated to the inference path.
|
| 122 |
+
- Each tier is tagged with a distinct sponsor model (NVIDIA Nemotron-3-Nano-4B-BF16 Β·
|
| 123 |
+
OpenBMB MiniCPM4.1-8B Β· Cohere Aya-Expanse-8B Β· JetBrains Mellum2-12B-A2.5B-Instruct), so
|
| 124 |
+
a cross-sponsor cast runs on the Space's own GPU. This trades ZeroGPU quota/RAM headroom
|
| 125 |
+
(several multi-GB loads per show) for multi-track coverage; the `tiny` model is listed
|
| 126 |
+
first so any untagged fallback lands on the cheapest tier. Two deployment notes: Aya is a
|
| 127 |
+
**gated** repo (needs licence acceptance + `HF_TOKEN`), and Mellum loads via
|
| 128 |
+
`AutoModelForMultimodalLM` (a per-model `auto_class` on `LocalModel`).
|
| 129 |
- Tests live in `tests/test_local_backend.py`. All 676 tests pass; the capability-gate
|
| 130 |
logic is fully covered without a GPU or torch import in test processes.
|
| 131 |
|
docs/architecture/model-routing.md
CHANGED
|
@@ -106,19 +106,27 @@ Off ZeroGPU and without `LOCAL_INFERENCE=1`, `@spaces.GPU` is a no-op and the
|
|
| 106 |
engine falls back to the deterministic stub β so the demo is always reproducible on
|
| 107 |
CPU-only hosts. Pick "Local GPU" in the Lab's backend radio to opt in per run.
|
| 108 |
|
| 109 |
-
Available models (all β€32B; select via `local:<repo_id>`)
|
|
|
|
| 110 |
|
| 111 |
-
| Key | Model | Notes |
|
| 112 |
-
|---|---|---|
|
| 113 |
-
| `local:
|
| 114 |
-
| `local:openbmb/MiniCPM4.1-8B` | MiniCPM 4.1 8B |
|
| 115 |
-
| `local:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
Bind a tier to a local model with a qualified key:
|
| 118 |
|
| 119 |
```yaml
|
| 120 |
profiles:
|
| 121 |
-
tiny: { endpoint: "local:
|
| 122 |
```
|
| 123 |
|
| 124 |
### Real cost β Governor
|
|
|
|
| 106 |
engine falls back to the deterministic stub β so the demo is always reproducible on
|
| 107 |
CPU-only hosts. Pick "Local GPU" in the Lab's backend radio to opt in per run.
|
| 108 |
|
| 109 |
+
Available models (all β€32B; select via `local:<repo_id>`). One sponsor family per tier, so
|
| 110 |
+
a single cast spans four sponsors at once (the multi-track strategy):
|
| 111 |
|
| 112 |
+
| Key | Model | Tier | Notes |
|
| 113 |
+
|---|---|---|---|
|
| 114 |
+
| `local:nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` | Nemotron Nano 4B | **tiny** | NVIDIA lane; Tiny-Titan β€4B band; `trust_remote_code`; Mamba-2 hybrid (BF16, not GGUF) |
|
| 115 |
+
| `local:openbmb/MiniCPM4.1-8B` | MiniCPM 4.1 8B | fast | OpenBMB lane; `trust_remote_code` |
|
| 116 |
+
| `local:CohereLabs/aya-expanse-8b` | Aya Expanse 8B | balanced | Cohere lane; **gated repo** β needs licence acceptance + `HF_TOKEN` |
|
| 117 |
+
| `local:JetBrains/Mellum2-12B-A2.5B-Instruct` | Mellum 2 (12B MoE, ~2.5B active) | strong | JetBrains lane; loads via `AutoModelForMultimodalLM` |
|
| 118 |
+
|
| 119 |
+
Each tier is tagged, so a cast's `fast`/`balanced`/`strong` seats route to different sponsor
|
| 120 |
+
models. That cross-sponsor cast loads several multi-GB models per show β heavy on the free
|
| 121 |
+
ZeroGPU ~5-min/day budget and host RAM; on a dedicated GPU there is no cap. For a
|
| 122 |
+
quota-light demo, pin the whole cast to the tiny default. The tiny model is listed first, so
|
| 123 |
+
any untagged fallback also lands on the cheapest tier.
|
| 124 |
|
| 125 |
Bind a tier to a local model with a qualified key:
|
| 126 |
|
| 127 |
```yaml
|
| 128 |
profiles:
|
| 129 |
+
tiny: { endpoint: "local:nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16", temperature: 0.7, max_tokens: 192 }
|
| 130 |
```
|
| 131 |
|
| 132 |
### Real cost β Governor
|
src/models/__init__.py
CHANGED
|
@@ -1,2 +1 @@
|
|
| 1 |
"""Model provider adapters."""
|
| 2 |
-
|
|
|
|
| 1 |
"""Model provider adapters."""
|
|
|
src/models/local_catalogue.py
CHANGED
|
@@ -31,12 +31,12 @@ append one :class:`LocalModel`. Every model stays within the β€32B "small minds
|
|
| 31 |
the ``tiny`` default honours the Tiny-Titan β€4B band.
|
| 32 |
|
| 33 |
**Quota note (ZeroGPU only).** Free ZeroGPU grants ~5 minutes of GPU/day (2 for anonymous
|
| 34 |
-
visitors), billed per ``@spaces.GPU`` call.
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
"""
|
| 41 |
|
| 42 |
from __future__ import annotations
|
|
@@ -65,7 +65,10 @@ class LocalModel:
|
|
| 65 |
``transformers``). ``profile`` is the tier this model is the default casting for, or
|
| 66 |
None for an alternate the cast can still pin explicitly. ``source`` is a friendly
|
| 67 |
family/org label for the picker. ``trust_remote_code`` is forwarded to
|
| 68 |
-
``from_pretrained`` for repos that ship custom modelling code (e.g. MiniCPM).
|
|
|
|
|
|
|
|
|
|
| 69 |
"""
|
| 70 |
|
| 71 |
repo_id: str
|
|
@@ -73,6 +76,7 @@ class LocalModel:
|
|
| 73 |
params_b: float | None = None
|
| 74 |
source: str = "Hugging Face"
|
| 75 |
trust_remote_code: bool = False
|
|
|
|
| 76 |
|
| 77 |
@property
|
| 78 |
def key(self) -> str:
|
|
@@ -84,36 +88,57 @@ class LocalModel:
|
|
| 84 |
return self.repo_id
|
| 85 |
|
| 86 |
|
| 87 |
-
# --- The catalogue:
|
| 88 |
-
#
|
| 89 |
-
#
|
| 90 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
LOCAL_MODELS: tuple[LocalModel, ...] = (
|
| 93 |
-
# Tiny tier (β€4B, Tiny-Titan band) β the cast-wide default.
|
| 94 |
-
#
|
| 95 |
-
#
|
|
|
|
| 96 |
LocalModel(
|
| 97 |
-
repo_id="
|
| 98 |
profile="tiny",
|
| 99 |
-
params_b=
|
| 100 |
-
source="
|
|
|
|
| 101 |
),
|
| 102 |
-
# OpenBMB MiniCPM 4.1 8B
|
| 103 |
-
# Ships custom modelling code, so trust_remote_code is required. An alternate, not the
|
| 104 |
-
# default: a cast can pin it, but the tiny model above drives a show by default.
|
| 105 |
LocalModel(
|
| 106 |
repo_id="openbmb/MiniCPM4.1-8B",
|
|
|
|
| 107 |
params_b=8.0,
|
| 108 |
source="OpenBMB MiniCPM",
|
| 109 |
trust_remote_code=True,
|
| 110 |
),
|
| 111 |
-
#
|
| 112 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
LocalModel(
|
| 114 |
-
repo_id="
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
| 117 |
),
|
| 118 |
)
|
| 119 |
|
|
|
|
| 31 |
the ``tiny`` default honours the Tiny-Titan β€4B band.
|
| 32 |
|
| 33 |
**Quota note (ZeroGPU only).** Free ZeroGPU grants ~5 minutes of GPU/day (2 for anonymous
|
| 34 |
+
visitors), billed per ``@spaces.GPU`` call. Each tier maps to a *different* sponsor model
|
| 35 |
+
(see ``LOCAL_MODELS``), so a cross-sponsor cast loads several multi-GB models per show β
|
| 36 |
+
heavy on that daily budget and on host RAM. A dedicated-GPU Space has no such cap; for a
|
| 37 |
+
quota-light demo, pin the whole cast to the tiny default in the Lab (one model, low
|
| 38 |
+
latency). The tiny model is listed first, so any untagged fallback (see
|
| 39 |
+
``lab._default_model_key``) also lands on the cheapest tier.
|
| 40 |
"""
|
| 41 |
|
| 42 |
from __future__ import annotations
|
|
|
|
| 65 |
``transformers``). ``profile`` is the tier this model is the default casting for, or
|
| 66 |
None for an alternate the cast can still pin explicitly. ``source`` is a friendly
|
| 67 |
family/org label for the picker. ``trust_remote_code`` is forwarded to
|
| 68 |
+
``from_pretrained`` for repos that ship custom modelling code (e.g. MiniCPM, Nemotron).
|
| 69 |
+
``auto_class`` is the ``transformers`` auto-class the provider loads the repo with β
|
| 70 |
+
``AutoModelForCausalLM`` for an ordinary LM, overridden where a model card calls for a
|
| 71 |
+
different one (e.g. JetBrains Mellum loads with ``AutoModelForMultimodalLM``).
|
| 72 |
"""
|
| 73 |
|
| 74 |
repo_id: str
|
|
|
|
| 76 |
params_b: float | None = None
|
| 77 |
source: str = "Hugging Face"
|
| 78 |
trust_remote_code: bool = False
|
| 79 |
+
auto_class: str = "AutoModelForCausalLM"
|
| 80 |
|
| 81 |
@property
|
| 82 |
def key(self) -> str:
|
|
|
|
| 88 |
return self.repo_id
|
| 89 |
|
| 90 |
|
| 91 |
+
# --- The catalogue: one sponsor model per tier ---------------------------------------
|
| 92 |
+
# Each tier is tagged with a distinct sponsor family, so a single cast legitimately spans
|
| 93 |
+
# four sponsors at once (NVIDIA Β· OpenBMB Β· Cohere Β· JetBrains) β the multi-track prize
|
| 94 |
+
# strategy run on the Space's own GPU, no endpoint to deploy. Every model honours the β€32B
|
| 95 |
+
# "small minds" rule and the tiny default keeps the Tiny-Titan β€4B band. Plain data:
|
| 96 |
+
# swapping a tier's model is a one-line edit.
|
| 97 |
+
#
|
| 98 |
+
# ZeroGPU cost: a cross-sponsor cast loads several multi-GB models per show (a download on
|
| 99 |
+
# first use, then a hostβdevice copy per turn), which is heavy on the free ~5-min/day GPU
|
| 100 |
+
# quota and on host RAM. A dedicated-GPU Space has no such cap; for a quota-light demo, pin
|
| 101 |
+
# the whole cast to the tiny default in the Lab. The first entry is the tiny default, so any
|
| 102 |
+
# untagged fallback also lands on the cheapest model.
|
| 103 |
|
| 104 |
LOCAL_MODELS: tuple[LocalModel, ...] = (
|
| 105 |
+
# Tiny tier (β€4B, Tiny-Titan band) β the cast-wide fallback default. NVIDIA Nemotron
|
| 106 |
+
# Nano is a Mamba-2/Transformer hybrid; load the BF16 (safetensors) sibling, not the
|
| 107 |
+
# GGUF, since the in-process path runs transformers. Ships custom modelling code, so
|
| 108 |
+
# trust_remote_code is required.
|
| 109 |
LocalModel(
|
| 110 |
+
repo_id="nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16",
|
| 111 |
profile="tiny",
|
| 112 |
+
params_b=4.0,
|
| 113 |
+
source="NVIDIA Nemotron",
|
| 114 |
+
trust_remote_code=True,
|
| 115 |
),
|
| 116 |
+
# Fast tier β OpenBMB MiniCPM 4.1 8B. Ships custom modelling code (trust_remote_code).
|
|
|
|
|
|
|
| 117 |
LocalModel(
|
| 118 |
repo_id="openbmb/MiniCPM4.1-8B",
|
| 119 |
+
profile="fast",
|
| 120 |
params_b=8.0,
|
| 121 |
source="OpenBMB MiniCPM",
|
| 122 |
trust_remote_code=True,
|
| 123 |
),
|
| 124 |
+
# Balanced tier β Cohere Labs Aya Expanse 8B (Command family, native transformers arch).
|
| 125 |
+
# NOTE: this repo is *gated* β the Space's HF account must accept its licence and an
|
| 126 |
+
# HF_TOKEN must be present for the weights to download.
|
| 127 |
+
LocalModel(
|
| 128 |
+
repo_id="CohereLabs/aya-expanse-8b",
|
| 129 |
+
profile="balanced",
|
| 130 |
+
params_b=8.0,
|
| 131 |
+
source="Cohere Labs Aya",
|
| 132 |
+
),
|
| 133 |
+
# Strong tier β JetBrains Mellum 2 (12B MoE, ~2.5B active). The Instruct variant (a
|
| 134 |
+
# post-trained assistant with a chat template), not the Base completion model. Its card
|
| 135 |
+
# loads it with AutoModelForMultimodalLM, so we pin that auto-class.
|
| 136 |
LocalModel(
|
| 137 |
+
repo_id="JetBrains/Mellum2-12B-A2.5B-Instruct",
|
| 138 |
+
profile="strong",
|
| 139 |
+
params_b=12.0,
|
| 140 |
+
source="JetBrains Mellum",
|
| 141 |
+
auto_class="AutoModelForMultimodalLM",
|
| 142 |
),
|
| 143 |
)
|
| 144 |
|
src/models/local_provider.py
CHANGED
|
@@ -50,7 +50,7 @@ from src.models.provider import ModelProvider, estimate_tokens, model_error
|
|
| 50 |
_LOADED: dict[str, tuple] = {}
|
| 51 |
|
| 52 |
|
| 53 |
-
def _ensure_loaded(repo_id: str, trust_remote_code: bool) -> tuple:
|
| 54 |
"""Load (once, cached) the tokenizer + model for *repo_id* **on CPU**.
|
| 55 |
|
| 56 |
Called from :meth:`LocalTransformersProvider.complete` in the parent process to warm
|
|
@@ -75,15 +75,19 @@ def _ensure_loaded(repo_id: str, trust_remote_code: bool) -> tuple:
|
|
| 75 |
"""
|
| 76 |
if repo_id in _LOADED:
|
| 77 |
return _LOADED[repo_id]
|
| 78 |
-
|
|
|
|
| 79 |
|
|
|
|
|
|
|
|
|
|
| 80 |
tokenizer = AutoTokenizer.from_pretrained(repo_id, trust_remote_code=trust_remote_code)
|
| 81 |
try:
|
| 82 |
-
model =
|
| 83 |
repo_id, dtype="auto", low_cpu_mem_usage=False, trust_remote_code=trust_remote_code
|
| 84 |
)
|
| 85 |
except TypeError: # pragma: no cover - older transformers use the torch_dtype kwarg name
|
| 86 |
-
model =
|
| 87 |
repo_id, torch_dtype="auto", low_cpu_mem_usage=False, trust_remote_code=trust_remote_code
|
| 88 |
)
|
| 89 |
# Re-tie the output head to the (materialized) input embeddings so no parameter is left
|
|
@@ -94,7 +98,7 @@ def _ensure_loaded(repo_id: str, trust_remote_code: bool) -> tuple:
|
|
| 94 |
return _LOADED[repo_id]
|
| 95 |
|
| 96 |
|
| 97 |
-
def _gpu_duration(repo_id, trust_remote_code, system, prompt, max_new_tokens, temperature, top_p) -> int:
|
| 98 |
"""Dynamic ``@spaces.GPU`` duration (seconds) for one generation.
|
| 99 |
|
| 100 |
Scales with the token budget and stays short so the Space keeps high queue priority on
|
|
@@ -105,7 +109,7 @@ def _gpu_duration(repo_id, trust_remote_code, system, prompt, max_new_tokens, te
|
|
| 105 |
|
| 106 |
|
| 107 |
@spaces.GPU(duration=_gpu_duration)
|
| 108 |
-
def _generate(repo_id, trust_remote_code, system, prompt, max_new_tokens, temperature, top_p):
|
| 109 |
"""Run one chat completion on the GPU; return ``(text, prompt_tokens, completion_tokens)``.
|
| 110 |
|
| 111 |
Module-level and decorated so ZeroGPU registers it and grants a GPU for the call. The
|
|
@@ -118,7 +122,7 @@ def _generate(repo_id, trust_remote_code, system, prompt, max_new_tokens, temper
|
|
| 118 |
"""
|
| 119 |
import torch
|
| 120 |
|
| 121 |
-
tokenizer, model = _ensure_loaded(repo_id, trust_remote_code)
|
| 122 |
if torch.cuda.is_available():
|
| 123 |
model = model.to("cuda")
|
| 124 |
device = next(model.parameters()).device
|
|
@@ -151,7 +155,7 @@ def _generate(repo_id, trust_remote_code, system, prompt, max_new_tokens, temper
|
|
| 151 |
class LocalTransformersProvider(ModelProvider):
|
| 152 |
"""Serve one logical profile by running a ``transformers`` model on the host GPU.
|
| 153 |
|
| 154 |
-
``model`` is the bare ``transformers`` repo id (e.g. ``"
|
| 155 |
the same string :func:`src.models.local_catalogue.binding_for` returns. Decoding
|
| 156 |
(``temperature`` / ``top_p`` / ``max_tokens``) comes from the router's per-profile
|
| 157 |
spec. ``trust_remote_code`` is resolved from the catalogue for the repo (default
|
|
@@ -177,11 +181,12 @@ class LocalTransformersProvider(ModelProvider):
|
|
| 177 |
# Warm the weights in the PARENT first so the forked @spaces.GPU call
|
| 178 |
# inherits them (see module docstring); this is a cache hit after the
|
| 179 |
# first use of this model in the process.
|
| 180 |
-
_ensure_loaded(self.model, self._trust_remote_code())
|
| 181 |
system = OpenAICompatProvider._system_for_role(role)
|
| 182 |
text, prompt_tokens, completion_tokens = _generate(
|
| 183 |
self.model,
|
| 184 |
self._trust_remote_code(),
|
|
|
|
| 185 |
system,
|
| 186 |
prompt,
|
| 187 |
self.max_tokens,
|
|
@@ -209,6 +214,18 @@ class LocalTransformersProvider(ModelProvider):
|
|
| 209 |
entry = local_catalogue.model_by_key(self.model)
|
| 210 |
return bool(entry.trust_remote_code) if entry is not None else False
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
def _record_usage(self, prompt_tokens: int, completion_tokens: int, prompt: str, text: str) -> None:
|
| 213 |
# Generation returns exact token counts; fall back to an estimate only if a count
|
| 214 |
# came back as zero (e.g. an empty decode), so the Governor always sees a budget hit.
|
|
|
|
| 50 |
_LOADED: dict[str, tuple] = {}
|
| 51 |
|
| 52 |
|
| 53 |
+
def _ensure_loaded(repo_id: str, trust_remote_code: bool, auto_class: str = "AutoModelForCausalLM") -> tuple:
|
| 54 |
"""Load (once, cached) the tokenizer + model for *repo_id* **on CPU**.
|
| 55 |
|
| 56 |
Called from :meth:`LocalTransformersProvider.complete` in the parent process to warm
|
|
|
|
| 75 |
"""
|
| 76 |
if repo_id in _LOADED:
|
| 77 |
return _LOADED[repo_id]
|
| 78 |
+
import transformers
|
| 79 |
+
from transformers import AutoTokenizer
|
| 80 |
|
| 81 |
+
# The auto-class is per-model (most are AutoModelForCausalLM; some cards call for another,
|
| 82 |
+
# e.g. Mellum's AutoModelForMultimodalLM) β resolve it by name off the transformers module.
|
| 83 |
+
model_cls = getattr(transformers, auto_class)
|
| 84 |
tokenizer = AutoTokenizer.from_pretrained(repo_id, trust_remote_code=trust_remote_code)
|
| 85 |
try:
|
| 86 |
+
model = model_cls.from_pretrained(
|
| 87 |
repo_id, dtype="auto", low_cpu_mem_usage=False, trust_remote_code=trust_remote_code
|
| 88 |
)
|
| 89 |
except TypeError: # pragma: no cover - older transformers use the torch_dtype kwarg name
|
| 90 |
+
model = model_cls.from_pretrained(
|
| 91 |
repo_id, torch_dtype="auto", low_cpu_mem_usage=False, trust_remote_code=trust_remote_code
|
| 92 |
)
|
| 93 |
# Re-tie the output head to the (materialized) input embeddings so no parameter is left
|
|
|
|
| 98 |
return _LOADED[repo_id]
|
| 99 |
|
| 100 |
|
| 101 |
+
def _gpu_duration(repo_id, trust_remote_code, auto_class, system, prompt, max_new_tokens, temperature, top_p) -> int:
|
| 102 |
"""Dynamic ``@spaces.GPU`` duration (seconds) for one generation.
|
| 103 |
|
| 104 |
Scales with the token budget and stays short so the Space keeps high queue priority on
|
|
|
|
| 109 |
|
| 110 |
|
| 111 |
@spaces.GPU(duration=_gpu_duration)
|
| 112 |
+
def _generate(repo_id, trust_remote_code, auto_class, system, prompt, max_new_tokens, temperature, top_p):
|
| 113 |
"""Run one chat completion on the GPU; return ``(text, prompt_tokens, completion_tokens)``.
|
| 114 |
|
| 115 |
Module-level and decorated so ZeroGPU registers it and grants a GPU for the call. The
|
|
|
|
| 122 |
"""
|
| 123 |
import torch
|
| 124 |
|
| 125 |
+
tokenizer, model = _ensure_loaded(repo_id, trust_remote_code, auto_class)
|
| 126 |
if torch.cuda.is_available():
|
| 127 |
model = model.to("cuda")
|
| 128 |
device = next(model.parameters()).device
|
|
|
|
| 155 |
class LocalTransformersProvider(ModelProvider):
|
| 156 |
"""Serve one logical profile by running a ``transformers`` model on the host GPU.
|
| 157 |
|
| 158 |
+
``model`` is the bare ``transformers`` repo id (e.g. ``"openbmb/MiniCPM4.1-8B"``) β
|
| 159 |
the same string :func:`src.models.local_catalogue.binding_for` returns. Decoding
|
| 160 |
(``temperature`` / ``top_p`` / ``max_tokens``) comes from the router's per-profile
|
| 161 |
spec. ``trust_remote_code`` is resolved from the catalogue for the repo (default
|
|
|
|
| 181 |
# Warm the weights in the PARENT first so the forked @spaces.GPU call
|
| 182 |
# inherits them (see module docstring); this is a cache hit after the
|
| 183 |
# first use of this model in the process.
|
| 184 |
+
_ensure_loaded(self.model, self._trust_remote_code(), self._auto_class())
|
| 185 |
system = OpenAICompatProvider._system_for_role(role)
|
| 186 |
text, prompt_tokens, completion_tokens = _generate(
|
| 187 |
self.model,
|
| 188 |
self._trust_remote_code(),
|
| 189 |
+
self._auto_class(),
|
| 190 |
system,
|
| 191 |
prompt,
|
| 192 |
self.max_tokens,
|
|
|
|
| 214 |
entry = local_catalogue.model_by_key(self.model)
|
| 215 |
return bool(entry.trust_remote_code) if entry is not None else False
|
| 216 |
|
| 217 |
+
def _auto_class(self) -> str:
|
| 218 |
+
"""The ``transformers`` auto-class to load this repo with (from the catalogue).
|
| 219 |
+
|
| 220 |
+
Most models load with ``AutoModelForCausalLM``; a few cards call for another (e.g.
|
| 221 |
+
JetBrains Mellum β ``AutoModelForMultimodalLM``). An off-catalogue id defaults to
|
| 222 |
+
``AutoModelForCausalLM`` β the ordinary case for a hand-pinned chat model.
|
| 223 |
+
"""
|
| 224 |
+
from src.models import local_catalogue
|
| 225 |
+
|
| 226 |
+
entry = local_catalogue.model_by_key(self.model)
|
| 227 |
+
return entry.auto_class if entry is not None else "AutoModelForCausalLM"
|
| 228 |
+
|
| 229 |
def _record_usage(self, prompt_tokens: int, completion_tokens: int, prompt: str, text: str) -> None:
|
| 230 |
# Generation returns exact token counts; fall back to an estimate only if a count
|
| 231 |
# came back as zero (e.g. an empty decode), so the Governor always sees a budget hit.
|
tests/test_local_backend.py
CHANGED
|
@@ -24,34 +24,46 @@ from src.models.router import ModelRouter
|
|
| 24 |
# ββ catalogue βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
|
| 26 |
|
| 27 |
-
def
|
| 28 |
-
#
|
| 29 |
-
#
|
| 30 |
-
|
| 31 |
-
|
|
|
|
| 32 |
assert all(m.params_b is None or m.params_b <= 32 for m in local_catalogue.LOCAL_MODELS)
|
| 33 |
-
|
| 34 |
-
assert
|
| 35 |
|
| 36 |
|
| 37 |
-
def
|
| 38 |
-
assert local_catalogue.default_key_for_profile("tiny")
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
def test_model_by_key_carries_trust_remote_code():
|
| 44 |
-
# MiniCPM
|
|
|
|
|
|
|
| 45 |
assert local_catalogue.model_by_key("openbmb/MiniCPM4.1-8B").trust_remote_code is True
|
| 46 |
-
assert local_catalogue.model_by_key("
|
| 47 |
assert local_catalogue.model_by_key("does/not-exist") is None
|
| 48 |
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
def test_binding_is_a_bare_repo_id_with_no_endpoint():
|
| 51 |
# In-process: the binding carries the raw transformers repo id (no openai/ prefix) and
|
| 52 |
# neither a base_url nor an api_key β the router builds the in-process provider from it.
|
| 53 |
-
binding = local_catalogue.binding_for("
|
| 54 |
-
assert binding["model"] == "
|
| 55 |
assert binding["base_url"] == ""
|
| 56 |
assert binding["api_key"] == ""
|
| 57 |
|
|
@@ -103,9 +115,9 @@ def test_local_backend_is_registered_and_qualified():
|
|
| 103 |
|
| 104 |
def test_registry_default_and_binding_round_trip():
|
| 105 |
key = inference.default_key_for_profile("tiny", "local")
|
| 106 |
-
assert key == "local:
|
| 107 |
binding = inference.binding_for(key)
|
| 108 |
-
assert binding["model"] == "
|
| 109 |
assert binding["base_url"] == ""
|
| 110 |
|
| 111 |
|
|
@@ -124,15 +136,15 @@ def test_router_dispatches_local_key_to_in_process_provider():
|
|
| 124 |
# A live router resolving a local: key must build the in-process provider (not LiteLLM),
|
| 125 |
# bound to the bare repo id. Construction only β no GPU is touched.
|
| 126 |
router = ModelRouter(offline=False)
|
| 127 |
-
provider = router.for_profile("local:
|
| 128 |
assert isinstance(provider, LocalTransformersProvider)
|
| 129 |
-
assert provider.model == "
|
| 130 |
-
assert provider.model_id == "
|
| 131 |
|
| 132 |
|
| 133 |
def test_catalogue_spec_tags_local_kind_and_others_litellm():
|
| 134 |
router = ModelRouter(offline=False)
|
| 135 |
-
local_spec = router._catalogue_spec("local:
|
| 136 |
assert local_spec is not None and local_spec.kind == "local"
|
| 137 |
# An HF key resolves through the same path but stays on the HTTP transport.
|
| 138 |
hf_spec = router._catalogue_spec("hf:katanemo/Arch-Router-1.5B")
|
|
@@ -143,8 +155,8 @@ def test_catalogue_spec_tags_local_kind_and_others_litellm():
|
|
| 143 |
|
| 144 |
|
| 145 |
def test_provider_reports_model_id_and_zeroed_usage_before_any_call():
|
| 146 |
-
provider = LocalTransformersProvider(model="
|
| 147 |
-
assert provider.model_id == "
|
| 148 |
assert provider.last_usage == {} # no call yet β matches the sibling providers
|
| 149 |
provider._zero_usage()
|
| 150 |
assert provider.last_usage == {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
|
@@ -152,11 +164,21 @@ def test_provider_reports_model_id_and_zeroed_usage_before_any_call():
|
|
| 152 |
|
| 153 |
def test_provider_resolves_trust_remote_code_from_catalogue():
|
| 154 |
assert LocalTransformersProvider(model="openbmb/MiniCPM4.1-8B")._trust_remote_code() is True
|
| 155 |
-
assert LocalTransformersProvider(model="
|
| 156 |
# An off-catalogue repo defaults to the safe choice.
|
| 157 |
assert LocalTransformersProvider(model="some/random-repo")._trust_remote_code() is False
|
| 158 |
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
# ββ ZeroGPU contract: CUDA only inside @spaces.GPU, never in the parent βββββββββββββββ
|
| 161 |
# Regression guard for the production crash "Low-level CUDA init (torch._C._cuda_init)
|
| 162 |
# reached β¦ ZeroGPU's emulation did not intercept": the parent process gets no GPU, so any
|
|
|
|
| 24 |
# ββ catalogue βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
|
| 26 |
|
| 27 |
+
def test_one_sponsor_model_per_tier_and_sizes_stay_small():
|
| 28 |
+
# Each tier maps to a *distinct* sponsor model (the multi-track cast), so one show spans
|
| 29 |
+
# NVIDIA Β· OpenBMB Β· Cohere Β· JetBrains. Every model honours the β€32B rule and the tiny
|
| 30 |
+
# default keeps the Tiny-Titan β€4B band.
|
| 31 |
+
tagged = {m.profile: m for m in local_catalogue.LOCAL_MODELS if m.profile is not None}
|
| 32 |
+
assert set(tagged) == {"tiny", "fast", "balanced", "strong"}
|
| 33 |
assert all(m.params_b is None or m.params_b <= 32 for m in local_catalogue.LOCAL_MODELS)
|
| 34 |
+
assert tagged["tiny"].params_b <= 4 # Tiny-Titan band
|
| 35 |
+
assert len({m.source for m in tagged.values()}) == 4 # four sponsor families
|
| 36 |
|
| 37 |
|
| 38 |
+
def test_every_tier_resolves_to_its_sponsor_model():
|
| 39 |
+
assert local_catalogue.default_key_for_profile("tiny") == "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
|
| 40 |
+
assert local_catalogue.default_key_for_profile("fast") == "openbmb/MiniCPM4.1-8B"
|
| 41 |
+
assert local_catalogue.default_key_for_profile("balanced") == "CohereLabs/aya-expanse-8b"
|
| 42 |
+
assert local_catalogue.default_key_for_profile("strong") == "JetBrains/Mellum2-12B-A2.5B-Instruct"
|
| 43 |
+
# the tiny model is listed first, so an untagged/unknown tier falls back to the cheapest.
|
| 44 |
+
assert local_catalogue.LOCAL_MODELS[0].profile == "tiny"
|
| 45 |
|
| 46 |
|
| 47 |
def test_model_by_key_carries_trust_remote_code():
|
| 48 |
+
# Nemotron + MiniCPM ship custom modelling code; Aya (native Command arch) does not; an
|
| 49 |
+
# off-catalogue id is unknown.
|
| 50 |
+
assert local_catalogue.model_by_key("nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16").trust_remote_code is True
|
| 51 |
assert local_catalogue.model_by_key("openbmb/MiniCPM4.1-8B").trust_remote_code is True
|
| 52 |
+
assert local_catalogue.model_by_key("CohereLabs/aya-expanse-8b").trust_remote_code is False
|
| 53 |
assert local_catalogue.model_by_key("does/not-exist") is None
|
| 54 |
|
| 55 |
|
| 56 |
+
def test_model_by_key_carries_auto_class():
|
| 57 |
+
# Mellum's card loads it with AutoModelForMultimodalLM; the rest use the default class.
|
| 58 |
+
assert local_catalogue.model_by_key("JetBrains/Mellum2-12B-A2.5B-Instruct").auto_class == "AutoModelForMultimodalLM"
|
| 59 |
+
assert local_catalogue.model_by_key("openbmb/MiniCPM4.1-8B").auto_class == "AutoModelForCausalLM"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
def test_binding_is_a_bare_repo_id_with_no_endpoint():
|
| 63 |
# In-process: the binding carries the raw transformers repo id (no openai/ prefix) and
|
| 64 |
# neither a base_url nor an api_key β the router builds the in-process provider from it.
|
| 65 |
+
binding = local_catalogue.binding_for("nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16")
|
| 66 |
+
assert binding["model"] == "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
|
| 67 |
assert binding["base_url"] == ""
|
| 68 |
assert binding["api_key"] == ""
|
| 69 |
|
|
|
|
| 115 |
|
| 116 |
def test_registry_default_and_binding_round_trip():
|
| 117 |
key = inference.default_key_for_profile("tiny", "local")
|
| 118 |
+
assert key == "local:nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
|
| 119 |
binding = inference.binding_for(key)
|
| 120 |
+
assert binding["model"] == "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
|
| 121 |
assert binding["base_url"] == ""
|
| 122 |
|
| 123 |
|
|
|
|
| 136 |
# A live router resolving a local: key must build the in-process provider (not LiteLLM),
|
| 137 |
# bound to the bare repo id. Construction only β no GPU is touched.
|
| 138 |
router = ModelRouter(offline=False)
|
| 139 |
+
provider = router.for_profile("local:nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16")
|
| 140 |
assert isinstance(provider, LocalTransformersProvider)
|
| 141 |
+
assert provider.model == "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
|
| 142 |
+
assert provider.model_id == "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
|
| 143 |
|
| 144 |
|
| 145 |
def test_catalogue_spec_tags_local_kind_and_others_litellm():
|
| 146 |
router = ModelRouter(offline=False)
|
| 147 |
+
local_spec = router._catalogue_spec("local:nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16")
|
| 148 |
assert local_spec is not None and local_spec.kind == "local"
|
| 149 |
# An HF key resolves through the same path but stays on the HTTP transport.
|
| 150 |
hf_spec = router._catalogue_spec("hf:katanemo/Arch-Router-1.5B")
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
def test_provider_reports_model_id_and_zeroed_usage_before_any_call():
|
| 158 |
+
provider = LocalTransformersProvider(model="nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16")
|
| 159 |
+
assert provider.model_id == "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
|
| 160 |
assert provider.last_usage == {} # no call yet β matches the sibling providers
|
| 161 |
provider._zero_usage()
|
| 162 |
assert provider.last_usage == {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
|
|
|
| 164 |
|
| 165 |
def test_provider_resolves_trust_remote_code_from_catalogue():
|
| 166 |
assert LocalTransformersProvider(model="openbmb/MiniCPM4.1-8B")._trust_remote_code() is True
|
| 167 |
+
assert LocalTransformersProvider(model="CohereLabs/aya-expanse-8b")._trust_remote_code() is False
|
| 168 |
# An off-catalogue repo defaults to the safe choice.
|
| 169 |
assert LocalTransformersProvider(model="some/random-repo")._trust_remote_code() is False
|
| 170 |
|
| 171 |
|
| 172 |
+
def test_provider_resolves_auto_class_from_catalogue():
|
| 173 |
+
# Mellum loads with a non-default auto-class; ordinary and off-catalogue repos use CausalLM.
|
| 174 |
+
assert (
|
| 175 |
+
LocalTransformersProvider(model="JetBrains/Mellum2-12B-A2.5B-Instruct")._auto_class()
|
| 176 |
+
== "AutoModelForMultimodalLM"
|
| 177 |
+
)
|
| 178 |
+
assert LocalTransformersProvider(model="openbmb/MiniCPM4.1-8B")._auto_class() == "AutoModelForCausalLM"
|
| 179 |
+
assert LocalTransformersProvider(model="some/random-repo")._auto_class() == "AutoModelForCausalLM"
|
| 180 |
+
|
| 181 |
+
|
| 182 |
# ββ ZeroGPU contract: CUDA only inside @spaces.GPU, never in the parent βββββββββββββββ
|
| 183 |
# Regression guard for the production crash "Low-level CUDA init (torch._C._cuda_init)
|
| 184 |
# reached β¦ ZeroGPU's emulation did not intercept": the parent process gets no GPU, so any
|