MSGEncrypted commited on
Commit
300911c
·
1 Parent(s): 9a7964b

app inference gradio config models

Browse files
apps/gradio-space/src/gradio_space/app.py CHANGED
@@ -101,8 +101,6 @@ Part of the [Build Small Hackathon](https://huggingface.co/build-small-hackathon
101
  """
102
  )
103
 
104
- model_key = gr.State(_app_config.active_model)
105
-
106
  if _app_config.allow_model_switch and len(_app_config.models) > 1:
107
  model_dropdown = gr.Dropdown(
108
  choices=_app_config.model_choices(),
@@ -116,10 +114,6 @@ Part of the [Build Small Hackathon](https://huggingface.co/build-small-hackathon
116
  fn=model_status,
117
  inputs=model_dropdown,
118
  outputs=status,
119
- ).then(
120
- fn=lambda key: key,
121
- inputs=model_dropdown,
122
- outputs=model_key,
123
  )
124
 
125
  gr.ChatInterface(
 
101
  """
102
  )
103
 
 
 
104
  if _app_config.allow_model_switch and len(_app_config.models) > 1:
105
  model_dropdown = gr.Dropdown(
106
  choices=_app_config.model_choices(),
 
114
  fn=model_status,
115
  inputs=model_dropdown,
116
  outputs=status,
 
 
 
 
117
  )
118
 
119
  gr.ChatInterface(
libs/inference/src/inference/__init__.py CHANGED
@@ -1,3 +1,12 @@
1
- from inference.factory import get_backend
 
2
 
3
- __all__ = ["get_backend"]
 
 
 
 
 
 
 
 
 
1
+ from inference.config import AppConfig, ModelConfig, get_app_config, get_model_config, load_app_config
2
+ from inference.factory import get_backend, reset_backend
3
 
4
+ __all__ = [
5
+ "AppConfig",
6
+ "ModelConfig",
7
+ "get_app_config",
8
+ "get_backend",
9
+ "get_model_config",
10
+ "load_app_config",
11
+ "reset_backend",
12
+ ]
libs/inference/src/inference/config.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Central model preset and app configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, replace
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ BackendName = Literal["llama_cpp", "transformers"]
11
+
12
+ DEFAULT_PRESET_KEY = "qwen3b-gguf"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ModelConfig:
17
+ """Single model preset used by inference backends and the Gradio UI."""
18
+
19
+ key: str
20
+ label: str
21
+ backend: BackendName
22
+ model_repo: str | None = None
23
+ model_file: str | None = None
24
+ model_path: str | None = None
25
+ model_id: str | None = None
26
+ trust_remote_code: bool = False
27
+ n_ctx: int = 4096
28
+ n_gpu_layers: int = 0
29
+ max_tokens: int = 512
30
+ temperature: float = 0.7
31
+
32
+ def cache_key(self) -> tuple[Any, ...]:
33
+ return (
34
+ self.backend,
35
+ self.model_repo,
36
+ self.model_file,
37
+ self.model_path,
38
+ self.model_id,
39
+ self.trust_remote_code,
40
+ self.n_ctx,
41
+ self.n_gpu_layers,
42
+ )
43
+
44
+ def summary(self) -> str:
45
+ if self.backend == "llama_cpp":
46
+ source = self.model_path or f"{self.model_repo}/{self.model_file}"
47
+ return f"{self.label} · llama.cpp · {source}"
48
+ return f"{self.label} · transformers · {self.model_id}"
49
+
50
+ def resolve_paths(self, base_dir: Path) -> ModelConfig:
51
+ updates: dict[str, Any] = {}
52
+
53
+ if self.model_path:
54
+ path = Path(self.model_path)
55
+ if not path.is_absolute():
56
+ updates["model_path"] = str((base_dir / path).resolve())
57
+
58
+ if self.model_id and self.model_id.startswith(("./", "../")):
59
+ updates["model_id"] = str((base_dir / self.model_id).resolve())
60
+
61
+ return replace(self, **updates) if updates else self
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class AppConfig:
66
+ """Runtime app configuration for dev and Hugging Face Space."""
67
+
68
+ active_model: str
69
+ models: dict[str, ModelConfig]
70
+ allow_model_switch: bool = False
71
+ model_cache_dir: str | None = None
72
+ presets_path: Path | None = None
73
+
74
+ def get_model(self, key: str | None = None) -> ModelConfig:
75
+ model_key = key or self.active_model
76
+ if model_key not in self.models:
77
+ known = ", ".join(sorted(self.models))
78
+ raise KeyError(f"Unknown model preset {model_key!r}. Known presets: {known}")
79
+ return self.models[model_key]
80
+
81
+ @property
82
+ def active(self) -> ModelConfig:
83
+ return self.get_model(self.active_model)
84
+
85
+ def model_choices(self) -> list[tuple[str, str]]:
86
+ return [(model.label, model.key) for model in self.models.values()]
87
+
88
+
89
+ def _builtin_presets() -> dict[str, ModelConfig]:
90
+ return {
91
+ DEFAULT_PRESET_KEY: ModelConfig(
92
+ key=DEFAULT_PRESET_KEY,
93
+ label="Qwen 2.5 3B Instruct (GGUF)",
94
+ backend="llama_cpp",
95
+ model_repo="Qwen/Qwen2.5-3B-Instruct-GGUF",
96
+ model_file="qwen2.5-3b-instruct-q4_k_m.gguf",
97
+ ),
98
+ "minicpm5-1b": ModelConfig(
99
+ key="minicpm5-1b",
100
+ label="MiniCPM5 1B (Transformers)",
101
+ backend="transformers",
102
+ model_id="openbmb/MiniCPM5-1B",
103
+ trust_remote_code=True,
104
+ ),
105
+ "gemma-merged-local": ModelConfig(
106
+ key="gemma-merged-local",
107
+ label="Fine-tuned merged model (local)",
108
+ backend="transformers",
109
+ model_id="./gemma_merged_model",
110
+ ),
111
+ }
112
+
113
+
114
+ def _find_presets_path() -> Path | None:
115
+ env_path = os.environ.get("MODEL_PRESETS_PATH")
116
+ if env_path:
117
+ path = Path(env_path)
118
+ if path.is_file():
119
+ return path.resolve()
120
+
121
+ for base in (Path.cwd(), *Path.cwd().parents):
122
+ candidate = base / "models.yaml"
123
+ if candidate.is_file():
124
+ return candidate.resolve()
125
+ return None
126
+
127
+
128
+ def _repo_root_for(presets_path: Path | None) -> Path:
129
+ if presets_path is not None:
130
+ return presets_path.parent
131
+ app_root = os.environ.get("APP_ROOT")
132
+ if app_root:
133
+ return Path(app_root).resolve()
134
+ return Path.cwd().resolve()
135
+
136
+
137
+ def _parse_model_entry(key: str, raw: dict[str, Any]) -> ModelConfig:
138
+ backend = str(raw.get("backend", "llama_cpp")).lower()
139
+ if backend not in ("llama_cpp", "transformers"):
140
+ raise ValueError(f"Preset {key!r}: backend must be llama_cpp or transformers")
141
+
142
+ return ModelConfig(
143
+ key=key,
144
+ label=str(raw.get("label", key)),
145
+ backend=backend, # type: ignore[arg-type]
146
+ model_repo=raw.get("model_repo"),
147
+ model_file=raw.get("model_file"),
148
+ model_path=raw.get("model_path"),
149
+ model_id=raw.get("model_id"),
150
+ trust_remote_code=bool(raw.get("trust_remote_code", False)),
151
+ n_ctx=int(raw.get("n_ctx", 4096)),
152
+ n_gpu_layers=int(raw.get("n_gpu_layers", 0)),
153
+ max_tokens=int(raw.get("max_tokens", 512)),
154
+ temperature=float(raw.get("temperature", 0.7)),
155
+ )
156
+
157
+
158
+ def _load_presets_from_yaml(path: Path) -> tuple[dict[str, Any], dict[str, ModelConfig]]:
159
+ try:
160
+ import yaml
161
+ except ImportError as exc:
162
+ raise ImportError(
163
+ "Loading models.yaml requires PyYAML. Install with: uv add --package inference pyyaml"
164
+ ) from exc
165
+
166
+ data = yaml.safe_load(path.read_text()) or {}
167
+ defaults = data.get("defaults", {})
168
+ raw_models = data.get("models", {})
169
+ if not isinstance(raw_models, dict) or not raw_models:
170
+ raise ValueError(f"{path}: expected non-empty top-level 'models' mapping")
171
+
172
+ models = {key: _parse_model_entry(key, value) for key, value in raw_models.items()}
173
+ return defaults, models
174
+
175
+
176
+ def _apply_legacy_env_overrides(model: ModelConfig) -> ModelConfig:
177
+ """Keep single-model .env workflow working alongside preset keys."""
178
+
179
+ updates: dict[str, Any] = {}
180
+
181
+ backend = os.environ.get("INFERENCE_BACKEND")
182
+ if backend:
183
+ updates["backend"] = backend.lower()
184
+
185
+ for field, env_name in (
186
+ ("model_repo", "MODEL_REPO"),
187
+ ("model_file", "MODEL_FILE"),
188
+ ("model_path", "MODEL_PATH"),
189
+ ("model_id", "MODEL_ID"),
190
+ ):
191
+ value = os.environ.get(env_name)
192
+ if value:
193
+ updates[field] = value
194
+
195
+ if os.environ.get("TRUST_REMOTE_CODE", "").lower() in {"1", "true", "yes"}:
196
+ updates["trust_remote_code"] = True
197
+
198
+ for field, env_name in (
199
+ ("n_ctx", "N_CTX"),
200
+ ("n_gpu_layers", "N_GPU_LAYERS"),
201
+ ("max_tokens", "MAX_TOKENS"),
202
+ ):
203
+ value = os.environ.get(env_name)
204
+ if value is not None and value != "":
205
+ updates[field] = int(value)
206
+
207
+ temperature = os.environ.get("TEMPERATURE")
208
+ if temperature is not None and temperature != "":
209
+ updates["temperature"] = float(temperature)
210
+
211
+ return replace(model, **updates) if updates else model
212
+
213
+
214
+ def load_app_config() -> AppConfig:
215
+ presets_path = _find_presets_path()
216
+ repo_root = _repo_root_for(presets_path)
217
+
218
+ if presets_path is None:
219
+ defaults: dict[str, Any] = {}
220
+ models = _builtin_presets()
221
+ else:
222
+ defaults, models = _load_presets_from_yaml(presets_path)
223
+
224
+ active_model = os.environ.get("ACTIVE_MODEL") or defaults.get(
225
+ "active_model", DEFAULT_PRESET_KEY
226
+ )
227
+ if active_model not in models:
228
+ active_model = next(iter(models))
229
+
230
+ allow_model_switch = os.environ.get("ALLOW_MODEL_SWITCH")
231
+ if allow_model_switch is None:
232
+ allow_switch = bool(defaults.get("allow_model_switch", False))
233
+ else:
234
+ allow_switch = allow_model_switch.lower() in {"1", "true", "yes"}
235
+
236
+ cache_dir = os.environ.get("MODEL_CACHE_DIR") or defaults.get("model_cache_dir")
237
+
238
+ resolved_models = {
239
+ key: model.resolve_paths(repo_root) for key, model in models.items()
240
+ }
241
+
242
+ has_legacy_override = any(
243
+ os.environ.get(name)
244
+ for name in (
245
+ "INFERENCE_BACKEND",
246
+ "MODEL_REPO",
247
+ "MODEL_FILE",
248
+ "MODEL_PATH",
249
+ "MODEL_ID",
250
+ "N_CTX",
251
+ "N_GPU_LAYERS",
252
+ )
253
+ )
254
+ if has_legacy_override:
255
+ resolved_models[active_model] = _apply_legacy_env_overrides(
256
+ resolved_models[active_model]
257
+ )
258
+
259
+ return AppConfig(
260
+ active_model=active_model,
261
+ models=resolved_models,
262
+ allow_model_switch=allow_switch,
263
+ model_cache_dir=cache_dir,
264
+ presets_path=presets_path,
265
+ )
266
+
267
+
268
+ _app_config: AppConfig | None = None
269
+
270
+
271
+ def get_app_config(reload: bool = False) -> AppConfig:
272
+ global _app_config
273
+ if _app_config is None or reload:
274
+ _app_config = load_app_config()
275
+ return _app_config
276
+
277
+
278
+ def get_model_config(key: str | None = None) -> ModelConfig:
279
+ config = get_app_config()
280
+ model_key = key or config.active_model
281
+ return config.get_model(model_key)
libs/inference/src/inference/factory.py CHANGED
@@ -1,23 +1,40 @@
1
- import os
2
- from functools import lru_cache
3
-
4
  from inference.base import InferenceBackend
 
5
  from inference.llama_cpp import LlamaCppBackend
6
 
 
 
7
 
8
- @lru_cache(maxsize=1)
9
- def get_backend() -> InferenceBackend:
10
- backend_name = os.environ.get("INFERENCE_BACKEND", "llama_cpp").lower()
11
 
12
- if backend_name == "llama_cpp":
13
- return LlamaCppBackend()
 
14
 
15
- if backend_name == "transformers":
16
  from inference.transformers import TransformersBackend
17
 
18
- return TransformersBackend()
19
 
20
  raise ValueError(
21
- f"Unknown INFERENCE_BACKEND={backend_name!r}. "
22
  "Expected 'llama_cpp' or 'transformers'."
23
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from inference.base import InferenceBackend
2
+ from inference.config import ModelConfig, get_model_config
3
  from inference.llama_cpp import LlamaCppBackend
4
 
5
+ _backend: InferenceBackend | None = None
6
+ _backend_key: tuple | None = None
7
 
 
 
 
8
 
9
+ def _create_backend(config: ModelConfig) -> InferenceBackend:
10
+ if config.backend == "llama_cpp":
11
+ return LlamaCppBackend(config)
12
 
13
+ if config.backend == "transformers":
14
  from inference.transformers import TransformersBackend
15
 
16
+ return TransformersBackend(config)
17
 
18
  raise ValueError(
19
+ f"Unknown backend {config.backend!r} for preset {config.key!r}. "
20
  "Expected 'llama_cpp' or 'transformers'."
21
  )
22
+
23
+
24
+ def get_backend(model_key: str | None = None) -> InferenceBackend:
25
+ global _backend, _backend_key
26
+
27
+ config = get_model_config(model_key)
28
+ cache_key = config.cache_key()
29
+
30
+ if _backend is None or _backend_key != cache_key:
31
+ _backend = _create_backend(config)
32
+ _backend_key = cache_key
33
+
34
+ return _backend
35
+
36
+
37
+ def reset_backend() -> None:
38
+ global _backend, _backend_key
39
+ _backend = None
40
+ _backend_key = None
libs/inference/src/inference/llama_cpp.py CHANGED
@@ -4,31 +4,32 @@ from pathlib import Path
4
  from huggingface_hub import hf_hub_download
5
  from llama_cpp import Llama
6
 
7
-
8
- DEFAULT_MODEL_REPO = "Qwen/Qwen2.5-3B-Instruct-GGUF"
9
- DEFAULT_MODEL_FILE = "qwen2.5-3b-instruct-q4_k_m.gguf"
10
 
11
 
12
  class LlamaCppBackend:
13
- def __init__(self) -> None:
 
14
  self._model: Llama | None = None
15
  self._model_path: str | None = None
16
 
17
  def _resolve_model_path(self) -> str:
18
- model_path = os.environ.get("MODEL_PATH")
19
- if model_path:
20
- path = Path(model_path)
21
  if not path.exists():
22
- raise FileNotFoundError(f"MODEL_PATH does not exist: {model_path}")
23
  return str(path)
24
 
25
- model_repo = os.environ.get("MODEL_REPO", DEFAULT_MODEL_REPO)
26
- model_file = os.environ.get("MODEL_FILE", DEFAULT_MODEL_FILE)
 
 
 
27
  cache_dir = os.environ.get("MODEL_CACHE_DIR")
28
 
29
  return hf_hub_download(
30
- repo_id=model_repo,
31
- filename=model_file,
32
  cache_dir=cache_dir,
33
  )
34
 
@@ -37,13 +38,11 @@ class LlamaCppBackend:
37
  return
38
 
39
  self._model_path = self._resolve_model_path()
40
- n_ctx = int(os.environ.get("N_CTX", "4096"))
41
- n_gpu_layers = int(os.environ.get("N_GPU_LAYERS", "0"))
42
 
43
  self._model = Llama(
44
  model_path=self._model_path,
45
- n_ctx=n_ctx,
46
- n_gpu_layers=n_gpu_layers,
47
  verbose=False,
48
  )
49
 
@@ -51,16 +50,16 @@ class LlamaCppBackend:
51
  self,
52
  prompt: str,
53
  *,
54
- max_tokens: int = 512,
55
- temperature: float = 0.7,
56
  ) -> str:
57
  self.load()
58
  assert self._model is not None
59
 
60
  result = self._model(
61
  prompt,
62
- max_tokens=max_tokens,
63
- temperature=temperature,
64
  echo=False,
65
  )
66
  return result["choices"][0]["text"].strip()
@@ -69,15 +68,15 @@ class LlamaCppBackend:
69
  self,
70
  messages: list[dict[str, str]],
71
  *,
72
- max_tokens: int = 512,
73
- temperature: float = 0.7,
74
  ) -> str:
75
  self.load()
76
  assert self._model is not None
77
 
78
  result = self._model.create_chat_completion(
79
  messages=messages,
80
- max_tokens=max_tokens,
81
- temperature=temperature,
82
  )
83
  return result["choices"][0]["message"]["content"].strip()
 
4
  from huggingface_hub import hf_hub_download
5
  from llama_cpp import Llama
6
 
7
+ from inference.config import ModelConfig
 
 
8
 
9
 
10
  class LlamaCppBackend:
11
+ def __init__(self, config: ModelConfig) -> None:
12
+ self._config = config
13
  self._model: Llama | None = None
14
  self._model_path: str | None = None
15
 
16
  def _resolve_model_path(self) -> str:
17
+ if self._config.model_path:
18
+ path = Path(self._config.model_path)
 
19
  if not path.exists():
20
+ raise FileNotFoundError(f"MODEL_PATH does not exist: {self._config.model_path}")
21
  return str(path)
22
 
23
+ if not self._config.model_repo or not self._config.model_file:
24
+ raise ValueError(
25
+ f"Preset {self._config.key!r} requires model_repo and model_file for llama_cpp"
26
+ )
27
+
28
  cache_dir = os.environ.get("MODEL_CACHE_DIR")
29
 
30
  return hf_hub_download(
31
+ repo_id=self._config.model_repo,
32
+ filename=self._config.model_file,
33
  cache_dir=cache_dir,
34
  )
35
 
 
38
  return
39
 
40
  self._model_path = self._resolve_model_path()
 
 
41
 
42
  self._model = Llama(
43
  model_path=self._model_path,
44
+ n_ctx=self._config.n_ctx,
45
+ n_gpu_layers=self._config.n_gpu_layers,
46
  verbose=False,
47
  )
48
 
 
50
  self,
51
  prompt: str,
52
  *,
53
+ max_tokens: int | None = None,
54
+ temperature: float | None = None,
55
  ) -> str:
56
  self.load()
57
  assert self._model is not None
58
 
59
  result = self._model(
60
  prompt,
61
+ max_tokens=max_tokens or self._config.max_tokens,
62
+ temperature=temperature if temperature is not None else self._config.temperature,
63
  echo=False,
64
  )
65
  return result["choices"][0]["text"].strip()
 
68
  self,
69
  messages: list[dict[str, str]],
70
  *,
71
+ max_tokens: int | None = None,
72
+ temperature: float | None = None,
73
  ) -> str:
74
  self.load()
75
  assert self._model is not None
76
 
77
  result = self._model.create_chat_completion(
78
  messages=messages,
79
+ max_tokens=max_tokens or self._config.max_tokens,
80
+ temperature=temperature if temperature is not None else self._config.temperature,
81
  )
82
  return result["choices"][0]["message"]["content"].strip()
libs/inference/src/inference/transformers.py CHANGED
@@ -1,10 +1,9 @@
1
- import os
2
-
3
- from inference.base import InferenceBackend
4
 
5
 
6
  class TransformersBackend:
7
- def __init__(self) -> None:
 
8
  self._model = None
9
  self._tokenizer = None
10
 
@@ -12,6 +11,11 @@ class TransformersBackend:
12
  if self._model is not None:
13
  return
14
 
 
 
 
 
 
15
  try:
16
  import torch
17
  from transformers import AutoModelForCausalLM, AutoTokenizer
@@ -21,14 +25,17 @@ class TransformersBackend:
21
  "Install with: uv sync --package inference --extra transformers"
22
  ) from exc
23
 
24
- model_id = os.environ.get("MODEL_ID", "Qwen/Qwen2.5-3B-Instruct")
25
  device = "cuda" if torch.cuda.is_available() else "cpu"
26
 
27
- self._tokenizer = AutoTokenizer.from_pretrained(model_id)
 
 
 
28
  self._model = AutoModelForCausalLM.from_pretrained(
29
- model_id,
30
  torch_dtype=torch.float16 if device == "cuda" else torch.float32,
31
  device_map="auto" if device == "cuda" else None,
 
32
  )
33
  if device == "cpu":
34
  self._model.to(device)
@@ -37,8 +44,8 @@ class TransformersBackend:
37
  self,
38
  prompt: str,
39
  *,
40
- max_tokens: int = 512,
41
- temperature: float = 0.7,
42
  ) -> str:
43
  self.load()
44
  assert self._model is not None
@@ -46,12 +53,15 @@ class TransformersBackend:
46
 
47
  import torch
48
 
 
 
 
49
  inputs = self._tokenizer(prompt, return_tensors="pt").to(self._model.device)
50
  output = self._model.generate(
51
  **inputs,
52
- max_new_tokens=max_tokens,
53
- temperature=temperature,
54
- do_sample=temperature > 0,
55
  )
56
  generated = output[0][inputs["input_ids"].shape[-1] :]
57
  return self._tokenizer.decode(generated, skip_special_tokens=True).strip()
@@ -60,8 +70,8 @@ class TransformersBackend:
60
  self,
61
  messages: list[dict[str, str]],
62
  *,
63
- max_tokens: int = 512,
64
- temperature: float = 0.7,
65
  ) -> str:
66
  self.load()
67
  assert self._model is not None
@@ -83,7 +93,3 @@ class TransformersBackend:
83
  prompt = "\n".join(parts)
84
 
85
  return self.generate(prompt, max_tokens=max_tokens, temperature=temperature)
86
-
87
-
88
- # Satisfy static type checkers that expect InferenceBackend.
89
- _: InferenceBackend = TransformersBackend()
 
1
+ from inference.config import ModelConfig
 
 
2
 
3
 
4
  class TransformersBackend:
5
+ def __init__(self, config: ModelConfig) -> None:
6
+ self._config = config
7
  self._model = None
8
  self._tokenizer = None
9
 
 
11
  if self._model is not None:
12
  return
13
 
14
+ if not self._config.model_id:
15
+ raise ValueError(
16
+ f"Preset {self._config.key!r} requires model_id for transformers backend"
17
+ )
18
+
19
  try:
20
  import torch
21
  from transformers import AutoModelForCausalLM, AutoTokenizer
 
25
  "Install with: uv sync --package inference --extra transformers"
26
  ) from exc
27
 
 
28
  device = "cuda" if torch.cuda.is_available() else "cpu"
29
 
30
+ self._tokenizer = AutoTokenizer.from_pretrained(
31
+ self._config.model_id,
32
+ trust_remote_code=self._config.trust_remote_code,
33
+ )
34
  self._model = AutoModelForCausalLM.from_pretrained(
35
+ self._config.model_id,
36
  torch_dtype=torch.float16 if device == "cuda" else torch.float32,
37
  device_map="auto" if device == "cuda" else None,
38
+ trust_remote_code=self._config.trust_remote_code,
39
  )
40
  if device == "cpu":
41
  self._model.to(device)
 
44
  self,
45
  prompt: str,
46
  *,
47
+ max_tokens: int | None = None,
48
+ temperature: float | None = None,
49
  ) -> str:
50
  self.load()
51
  assert self._model is not None
 
53
 
54
  import torch
55
 
56
+ max_new_tokens = max_tokens or self._config.max_tokens
57
+ temp = self._config.temperature if temperature is None else temperature
58
+
59
  inputs = self._tokenizer(prompt, return_tensors="pt").to(self._model.device)
60
  output = self._model.generate(
61
  **inputs,
62
+ max_new_tokens=max_new_tokens,
63
+ temperature=temp,
64
+ do_sample=temp > 0,
65
  )
66
  generated = output[0][inputs["input_ids"].shape[-1] :]
67
  return self._tokenizer.decode(generated, skip_special_tokens=True).strip()
 
70
  self,
71
  messages: list[dict[str, str]],
72
  *,
73
+ max_tokens: int | None = None,
74
+ temperature: float | None = None,
75
  ) -> str:
76
  self.load()
77
  assert self._model is not None
 
93
  prompt = "\n".join(parts)
94
 
95
  return self.generate(prompt, max_tokens=max_tokens, temperature=temperature)
 
 
 
 
libs/inference/tests/test_config.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from inference.config import load_app_config
6
+
7
+
8
+ def test_load_app_config_from_models_yaml(tmp_path, monkeypatch):
9
+ presets = tmp_path / "models.yaml"
10
+ presets.write_text(
11
+ """
12
+ defaults:
13
+ active_model: demo
14
+ allow_model_switch: true
15
+ models:
16
+ demo:
17
+ label: Demo preset
18
+ backend: llama_cpp
19
+ model_repo: org/model-GGUF
20
+ model_file: demo.gguf
21
+ """
22
+ )
23
+ monkeypatch.chdir(tmp_path)
24
+ monkeypatch.delenv("ACTIVE_MODEL", raising=False)
25
+
26
+ config = load_app_config()
27
+
28
+ assert config.active_model == "demo"
29
+ assert config.allow_model_switch is True
30
+ assert config.get_model("demo").model_repo == "org/model-GGUF"
31
+
32
+
33
+ def test_legacy_env_overrides_active_preset(tmp_path, monkeypatch):
34
+ presets = tmp_path / "models.yaml"
35
+ presets.write_text(
36
+ """
37
+ defaults:
38
+ active_model: demo
39
+ models:
40
+ demo:
41
+ label: Demo
42
+ backend: llama_cpp
43
+ model_repo: org/original
44
+ model_file: original.gguf
45
+ """
46
+ )
47
+ monkeypatch.chdir(tmp_path)
48
+ monkeypatch.setenv("MODEL_REPO", "org/override")
49
+ monkeypatch.setenv("MODEL_FILE", "override.gguf")
50
+
51
+ model = load_app_config().get_model("demo")
52
+
53
+ assert model.model_repo == "org/override"
54
+ assert model.model_file == "override.gguf"
55
+
56
+
57
+ def test_resolve_relative_model_path(tmp_path, monkeypatch):
58
+ local_dir = tmp_path / "gemma_merged_model"
59
+ local_dir.mkdir()
60
+ presets = tmp_path / "models.yaml"
61
+ presets.write_text(
62
+ f"""
63
+ defaults:
64
+ active_model: local
65
+ models:
66
+ local:
67
+ label: Local merged
68
+ backend: transformers
69
+ model_id: ./{local_dir.name}
70
+ """
71
+ )
72
+ monkeypatch.chdir(tmp_path)
73
+
74
+ model = load_app_config().get_model("local")
75
+
76
+ assert model.model_id == str(local_dir.resolve())
scripts/download_model.py CHANGED
@@ -1,26 +1,22 @@
1
  #!/usr/bin/env python3
2
- """Download the configured GGUF model from Hugging Face Hub for offline dev."""
3
 
4
  from __future__ import annotations
5
 
6
  import argparse
7
- import os
8
  from pathlib import Path
9
 
10
  from huggingface_hub import hf_hub_download
11
 
 
 
12
 
13
  def main() -> None:
14
  parser = argparse.ArgumentParser(description=__doc__)
15
  parser.add_argument(
16
- "--repo",
17
- default=os.environ.get("MODEL_REPO", "Qwen/Qwen2.5-3B-Instruct-GGUF"),
18
- help="Hugging Face repo containing the GGUF file",
19
- )
20
- parser.add_argument(
21
- "--file",
22
- default=os.environ.get("MODEL_FILE", "qwen2.5-3b-instruct-q4_k_m.gguf"),
23
- help="GGUF filename inside the repo",
24
  )
25
  parser.add_argument(
26
  "--output-dir",
@@ -30,16 +26,39 @@ def main() -> None:
30
  )
31
  args = parser.parse_args()
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  args.output_dir.mkdir(parents=True, exist_ok=True)
34
 
35
  path = hf_hub_download(
36
- repo_id=args.repo,
37
- filename=args.file,
38
  local_dir=args.output_dir,
39
  local_dir_use_symlinks=False,
40
  )
 
41
  print(f"Model ready at: {path}")
42
- print(f"Set MODEL_PATH={path} to use this file directly.")
 
43
 
44
 
45
  if __name__ == "__main__":
 
1
  #!/usr/bin/env python3
2
+ """Download a configured GGUF preset from Hugging Face Hub for offline dev."""
3
 
4
  from __future__ import annotations
5
 
6
  import argparse
 
7
  from pathlib import Path
8
 
9
  from huggingface_hub import hf_hub_download
10
 
11
+ from inference.config import get_app_config, get_model_config
12
+
13
 
14
  def main() -> None:
15
  parser = argparse.ArgumentParser(description=__doc__)
16
  parser.add_argument(
17
+ "--preset",
18
+ default=None,
19
+ help="Preset key from models.yaml (default: ACTIVE_MODEL or app default)",
 
 
 
 
 
20
  )
21
  parser.add_argument(
22
  "--output-dir",
 
26
  )
27
  args = parser.parse_args()
28
 
29
+ app_config = get_app_config()
30
+ preset_key = args.preset or app_config.active_model
31
+ model = get_model_config(preset_key)
32
+
33
+ if model.backend != "llama_cpp":
34
+ raise SystemExit(
35
+ f"Preset {preset_key!r} uses backend {model.backend!r}. "
36
+ "Only llama_cpp presets with model_repo/model_file can be downloaded."
37
+ )
38
+
39
+ if model.model_path:
40
+ path = Path(model.model_path)
41
+ if not path.exists():
42
+ raise SystemExit(f"Local MODEL_PATH does not exist: {model.model_path}")
43
+ print(f"Preset {preset_key!r} already points to local file: {path}")
44
+ print(f"Set MODEL_PATH={path} or update models.yaml model_path to use it directly.")
45
+ return
46
+
47
+ if not model.model_repo or not model.model_file:
48
+ raise SystemExit(f"Preset {preset_key!r} is missing model_repo/model_file.")
49
+
50
  args.output_dir.mkdir(parents=True, exist_ok=True)
51
 
52
  path = hf_hub_download(
53
+ repo_id=model.model_repo,
54
+ filename=model.model_file,
55
  local_dir=args.output_dir,
56
  local_dir_use_symlinks=False,
57
  )
58
+ print(f"Preset: {preset_key} ({model.label})")
59
  print(f"Model ready at: {path}")
60
+ print("Add to models.yaml under that preset:")
61
+ print(f" model_path: {Path(path).resolve()}")
62
 
63
 
64
  if __name__ == "__main__":