Spaces:
Sleeping
Sleeping
Commit ·
300911c
1
Parent(s): 9a7964b
app inference gradio config models
Browse files- apps/gradio-space/src/gradio_space/app.py +0 -6
- libs/inference/src/inference/__init__.py +11 -2
- libs/inference/src/inference/config.py +281 -0
- libs/inference/src/inference/factory.py +28 -11
- libs/inference/src/inference/llama_cpp.py +23 -24
- libs/inference/src/inference/transformers.py +24 -18
- libs/inference/tests/test_config.py +76 -0
- scripts/download_model.py +32 -13
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.
|
|
|
|
| 2 |
|
| 3 |
-
__all__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 13 |
-
|
|
|
|
| 14 |
|
| 15 |
-
if
|
| 16 |
from inference.transformers import TransformersBackend
|
| 17 |
|
| 18 |
-
return TransformersBackend()
|
| 19 |
|
| 20 |
raise ValueError(
|
| 21 |
-
f"Unknown
|
| 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 |
-
|
| 19 |
-
|
| 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 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 55 |
-
temperature: float =
|
| 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 =
|
| 73 |
-
temperature: float =
|
| 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
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 41 |
-
temperature: float =
|
| 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=
|
| 53 |
-
temperature=
|
| 54 |
-
do_sample=
|
| 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 =
|
| 64 |
-
temperature: float =
|
| 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
|
| 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 |
-
"--
|
| 17 |
-
default=
|
| 18 |
-
help="
|
| 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=
|
| 37 |
-
filename=
|
| 38 |
local_dir=args.output_dir,
|
| 39 |
local_dir_use_symlinks=False,
|
| 40 |
)
|
|
|
|
| 41 |
print(f"Model ready at: {path}")
|
| 42 |
-
print(
|
|
|
|
| 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__":
|