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 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:** keeps the **OpenBMB / MiniCPM** track (MiniCPM4.1-8B is in the
98
- local catalogue), the **Tiny Titan** lane (Qwen2.5-3B-Instruct at the `tiny` tier is
99
- ≀4B), and the **Community Choice** on-device-inference story for the HF Space demo.
 
 
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
- - The single catalogue entry tagged as a tier default is `Qwen/Qwen2.5-3B-Instruct`
121
- (`tiny`). Additional models (MiniCPM4.1-8B, Qwen2.5-7B-Instruct) are in the catalogue
122
- but untagged; ZeroGPU quota pressure justifies keeping the default footprint minimal.
 
 
 
 
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:Qwen/Qwen2.5-3B-Instruct` | Qwen 2.5 3B | **tiny default** β€” latency + quota guardrail |
114
- | `local:openbmb/MiniCPM4.1-8B` | MiniCPM 4.1 8B | alternate; OpenBMB prize lane |
115
- | `local:Qwen/Qwen2.5-7B-Instruct` | Qwen 2.5 7B | alternate |
 
 
 
 
 
 
 
116
 
117
  Bind a tier to a local model with a qualified key:
118
 
119
  ```yaml
120
  profiles:
121
- tiny: { endpoint: "local:Qwen/Qwen2.5-3B-Instruct", temperature: 0.7, max_tokens: 192 }
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. A full multi-agent show makes many sequential
35
- calls, so the catalogue deliberately tags **one tiny model** as the only tier default:
36
- with no per-tier override the whole cast routes to it (see ``lab._default_model_key``),
37
- keeping a live show inside the daily budget. On a dedicated GPU there is no such cap, but
38
- the tiny default still keeps first-token latency low. Larger alternates are listed
39
- untagged β€” a cast can pin them, but they are never the silent default.
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: small transformers instruct models -------------------------------
88
- # One tiny model is tagged as the cast-wide default (low latency + ZeroGPU-quota-friendly,
89
- # see the module docstring); the rest are untagged alternates a cast can pin. Plain data:
90
- # swapping the default or adding a sponsor family is a one-line edit.
 
 
 
 
 
 
 
 
91
 
92
  LOCAL_MODELS: tuple[LocalModel, ...] = (
93
- # Tiny tier (≀4B, Tiny-Titan band) β€” the cast-wide default. Small, fast, and a
94
- # reliable chat template, so a full show stays low-latency (and well inside the free
95
- # ZeroGPU GPU/day budget).
 
96
  LocalModel(
97
- repo_id="Qwen/Qwen2.5-3B-Instruct",
98
  profile="tiny",
99
- params_b=3.0,
100
- source="Qwen",
 
101
  ),
102
- # OpenBMB MiniCPM 4.1 8B (fast tier) β€” keeps the OpenBMB lane on the in-process path.
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
- # Qwen 7B (fast tier) β€” a slightly larger alternate for a single specialist seat (e.g.
112
- # the Judge) when the hardware/quota allows. Untagged, so never the silent default.
 
 
 
 
 
 
 
 
 
 
113
  LocalModel(
114
- repo_id="Qwen/Qwen2.5-7B-Instruct",
115
- params_b=7.0,
116
- source="Qwen",
 
 
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
- from transformers import AutoModelForCausalLM, AutoTokenizer
 
79
 
 
 
 
80
  tokenizer = AutoTokenizer.from_pretrained(repo_id, trust_remote_code=trust_remote_code)
81
  try:
82
- model = AutoModelForCausalLM.from_pretrained(
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 = AutoModelForCausalLM.from_pretrained(
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. ``"Qwen/Qwen2.5-3B-Instruct"``) β€”
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 test_only_tiny_is_a_tier_default_and_sizes_stay_small():
28
- # Exactly one tier default (tiny) so the whole cast routes to it unless a seat is
29
- # pinned β€” the latency/ZeroGPU-quota guardrail. Every model honours the ≀32B rule.
30
- tagged = [m for m in local_catalogue.LOCAL_MODELS if m.profile is not None]
31
- assert [m.profile for m in tagged] == ["tiny"]
 
32
  assert all(m.params_b is None or m.params_b <= 32 for m in local_catalogue.LOCAL_MODELS)
33
- tiny = local_catalogue.model_by_key(local_catalogue.default_key_for_profile("tiny"))
34
- assert tiny is not None and tiny.params_b <= 4 # Tiny-Titan band
35
 
36
 
37
- def test_only_tiny_has_a_default_other_tiers_fall_through():
38
- assert local_catalogue.default_key_for_profile("tiny") is not None
39
- for tier in ("fast", "balanced", "strong"):
40
- assert local_catalogue.default_key_for_profile(tier) is None
 
 
 
41
 
42
 
43
  def test_model_by_key_carries_trust_remote_code():
44
- # MiniCPM ships custom modelling code; Qwen does not; an off-catalogue id is unknown.
 
 
45
  assert local_catalogue.model_by_key("openbmb/MiniCPM4.1-8B").trust_remote_code is True
46
- assert local_catalogue.model_by_key("Qwen/Qwen2.5-3B-Instruct").trust_remote_code is False
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("Qwen/Qwen2.5-3B-Instruct")
54
- assert binding["model"] == "Qwen/Qwen2.5-3B-Instruct"
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:Qwen/Qwen2.5-3B-Instruct"
107
  binding = inference.binding_for(key)
108
- assert binding["model"] == "Qwen/Qwen2.5-3B-Instruct"
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:Qwen/Qwen2.5-3B-Instruct")
128
  assert isinstance(provider, LocalTransformersProvider)
129
- assert provider.model == "Qwen/Qwen2.5-3B-Instruct"
130
- assert provider.model_id == "Qwen/Qwen2.5-3B-Instruct"
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:Qwen/Qwen2.5-3B-Instruct")
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="Qwen/Qwen2.5-3B-Instruct")
147
- assert provider.model_id == "Qwen/Qwen2.5-3B-Instruct"
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="Qwen/Qwen2.5-3B-Instruct")._trust_remote_code() is False
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