llmboost-hub 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- llmboost_hub/cli.py +47 -0
- llmboost_hub/commands/attach.py +74 -0
- llmboost_hub/commands/chat.py +62 -0
- llmboost_hub/commands/completions.py +238 -0
- llmboost_hub/commands/list.py +283 -0
- llmboost_hub/commands/login.py +72 -0
- llmboost_hub/commands/prep.py +559 -0
- llmboost_hub/commands/run.py +486 -0
- llmboost_hub/commands/search.py +182 -0
- llmboost_hub/commands/serve.py +303 -0
- llmboost_hub/commands/status.py +34 -0
- llmboost_hub/commands/stop.py +59 -0
- llmboost_hub/commands/test_cmd.py +45 -0
- llmboost_hub/commands/tune.py +372 -0
- llmboost_hub/utils/config.py +220 -0
- llmboost_hub/utils/container_utils.py +126 -0
- llmboost_hub/utils/fs_utils.py +42 -0
- llmboost_hub/utils/generate_sample_lookup.py +132 -0
- llmboost_hub/utils/gpu_info.py +244 -0
- llmboost_hub/utils/license_checker.py +3 -0
- llmboost_hub/utils/license_wrapper.py +91 -0
- llmboost_hub/utils/llmboost_version.py +1 -0
- llmboost_hub/utils/lookup_cache.py +123 -0
- llmboost_hub/utils/model_utils.py +76 -0
- llmboost_hub/utils/signature.py +3 -0
- llmboost_hub-0.1.1.dist-info/METADATA +203 -0
- llmboost_hub-0.1.1.dist-info/RECORD +31 -0
- llmboost_hub-0.1.1.dist-info/WHEEL +5 -0
- llmboost_hub-0.1.1.dist-info/entry_points.txt +3 -0
- llmboost_hub-0.1.1.dist-info/licenses/LICENSE +16 -0
- llmboost_hub-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import shlex
|
|
5
|
+
import glob
|
|
6
|
+
import json
|
|
7
|
+
from typing import List
|
|
8
|
+
from llmboost_hub.utils import gpu_info
|
|
9
|
+
from llmboost_hub.utils.config import config
|
|
10
|
+
from llmboost_hub.commands.prep import do_prep
|
|
11
|
+
from llmboost_hub.commands.list import do_list
|
|
12
|
+
from llmboost_hub.utils.container_utils import container_name_for_model
|
|
13
|
+
from llmboost_hub.utils.model_utils import get_repo_from_model_id, get_model_name_from_model_id
|
|
14
|
+
from llmboost_hub.commands.stop import do_stop
|
|
15
|
+
from llmboost_hub.commands.completions import complete_model_names
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _image_exists_locally(image_name: str) -> bool:
|
|
19
|
+
"""
|
|
20
|
+
Return True if the image exists locally (matches repo or repo:tag).
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
image_name: Image reference to search for.
|
|
24
|
+
|
|
25
|
+
Notes:
|
|
26
|
+
Uses `docker images --format` and performs relaxed matching:
|
|
27
|
+
exact match, prefix match, or matching repository name without tag.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
out = subprocess.check_output(
|
|
31
|
+
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"], text=True
|
|
32
|
+
)
|
|
33
|
+
lines = [l.strip() for l in out.splitlines() if l.strip()]
|
|
34
|
+
# allow matching repo or repo:tag
|
|
35
|
+
for l in lines:
|
|
36
|
+
if l == image_name or l.startswith(image_name) or l.split(":")[0] == image_name:
|
|
37
|
+
return True
|
|
38
|
+
return False
|
|
39
|
+
except Exception:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _wait_for_running(container_name: str, timeout_sec: float = 10.0) -> bool:
|
|
44
|
+
"""
|
|
45
|
+
Poll docker inspect until the container is reported running or timeout.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
container_name: Target container name.
|
|
49
|
+
timeout_sec: Maximum seconds to wait.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
True if running within the timeout window; False otherwise.
|
|
53
|
+
"""
|
|
54
|
+
import time
|
|
55
|
+
|
|
56
|
+
deadline = time.time() + timeout_sec
|
|
57
|
+
while time.time() < deadline:
|
|
58
|
+
try:
|
|
59
|
+
out = subprocess.check_output(
|
|
60
|
+
["docker", "inspect", "-f", "{{.State.Running}}", container_name],
|
|
61
|
+
text=True,
|
|
62
|
+
stderr=subprocess.DEVNULL,
|
|
63
|
+
).strip()
|
|
64
|
+
if out.lower() == "true":
|
|
65
|
+
return True
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
time.sleep(0.2)
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _ensure_inference_db_symlink(cname: str, verbose: bool = False) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Ensure the container's inference DB points at the host DB (mounted).
|
|
75
|
+
|
|
76
|
+
Steps:
|
|
77
|
+
1) If host DB file (`config.LBH_TUNER_DB_PATH`) is missing, copy from container (`config.LLMBOOST_TUNER_DB_PATH`).
|
|
78
|
+
2) Back up container DB file (`config.LLMBOOST_TUNER_DB_PATH` -> `config.LLMBOOST_TUNER_DB_BACKUP_PATH`).
|
|
79
|
+
3) Symlink container DB -> mounted host DB inside container:
|
|
80
|
+
`config.LLMBOOST_TUNER_DB_PATH` -> `config.CONTAINER_LBH_HOME`/basename(`config.LBH_TUNER_DB_PATH`).
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
cname: Container name.
|
|
84
|
+
verbose: If True, echo docker exec steps.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ClickException: If host DB cannot be created or ensured.
|
|
88
|
+
"""
|
|
89
|
+
host_home = config.LBH_HOME
|
|
90
|
+
host_db = config.LBH_TUNER_DB_PATH
|
|
91
|
+
try:
|
|
92
|
+
os.makedirs(host_home, exist_ok=True)
|
|
93
|
+
if not os.path.exists(host_db):
|
|
94
|
+
if verbose:
|
|
95
|
+
click.echo(f"[run] Creating host inference DB at {host_db}...")
|
|
96
|
+
# Copy from container if exists
|
|
97
|
+
copy_cmd = [
|
|
98
|
+
"docker",
|
|
99
|
+
"cp",
|
|
100
|
+
f"{cname}:{config.LLMBOOST_TUNER_DB_PATH}",
|
|
101
|
+
host_db,
|
|
102
|
+
]
|
|
103
|
+
try:
|
|
104
|
+
subprocess.run(copy_cmd, check=True)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
raise click.ClickException(
|
|
107
|
+
f"Host inference DB '{host_db}' not found; and failed to copy from container.\n{e}"
|
|
108
|
+
)
|
|
109
|
+
except Exception as e:
|
|
110
|
+
raise click.ClickException(f"Failed to ensure host inference DB at '{host_db}'.\n{e}")
|
|
111
|
+
|
|
112
|
+
def _exec(cmd):
|
|
113
|
+
if verbose:
|
|
114
|
+
click.echo(f"[run] docker exec: {' '.join(cmd)}")
|
|
115
|
+
try:
|
|
116
|
+
subprocess.run(cmd, check=True)
|
|
117
|
+
except subprocess.CalledProcessError as e:
|
|
118
|
+
if verbose:
|
|
119
|
+
click.echo(f"[run] Warning: exec failed: {e}")
|
|
120
|
+
|
|
121
|
+
# Create data dir inside container
|
|
122
|
+
_exec(
|
|
123
|
+
[
|
|
124
|
+
"docker",
|
|
125
|
+
"exec",
|
|
126
|
+
cname,
|
|
127
|
+
"mkdir",
|
|
128
|
+
"-p",
|
|
129
|
+
f"{os.path.dirname(config.LLMBOOST_TUNER_DB_PATH)}",
|
|
130
|
+
]
|
|
131
|
+
)
|
|
132
|
+
# Backup DB inside container
|
|
133
|
+
_exec(
|
|
134
|
+
[
|
|
135
|
+
"docker",
|
|
136
|
+
"exec",
|
|
137
|
+
cname,
|
|
138
|
+
"sh",
|
|
139
|
+
"-lc",
|
|
140
|
+
f"cp {config.LLMBOOST_TUNER_DB_PATH} {config.LLMBOOST_TUNER_DB_BACKUP_PATH}",
|
|
141
|
+
]
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Symlink container DB -> host DB
|
|
145
|
+
_exec(
|
|
146
|
+
[
|
|
147
|
+
"docker",
|
|
148
|
+
"exec",
|
|
149
|
+
cname,
|
|
150
|
+
"sh",
|
|
151
|
+
"-lc",
|
|
152
|
+
f'ln -sfn {os.path.join(config.CONTAINER_LBH_HOME, f"{os.path.basename(config.LBH_TUNER_DB_PATH)}")} {config.LLMBOOST_TUNER_DB_PATH}',
|
|
153
|
+
]
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def do_run(
|
|
158
|
+
model: str,
|
|
159
|
+
lbh_workspace: str | None,
|
|
160
|
+
docker_args: tuple,
|
|
161
|
+
verbose: bool = False,
|
|
162
|
+
image: str | None = None,
|
|
163
|
+
model_path: str | None = None,
|
|
164
|
+
restart: bool = False,
|
|
165
|
+
) -> dict:
|
|
166
|
+
"""
|
|
167
|
+
Start a model container with recommended defaults (overridable via `docker_args`).
|
|
168
|
+
|
|
169
|
+
Behavior:
|
|
170
|
+
- Network/IPC/security defaults for ML workloads.
|
|
171
|
+
- Mounts:
|
|
172
|
+
* `config.LBH_HOME` -> `/llmboost_hub` (and sets `LBH_HOME`)
|
|
173
|
+
* `lbh_workspace` (or `config.LBH_WORKSPACE`) -> `/user_workspace` (workdir=`/workspace`)
|
|
174
|
+
* models: either `--model_path` or `config.LBH_HOME/models/<repo>` -> `config.LLMBOOST_MODELS_DIR`
|
|
175
|
+
- GPU flags:
|
|
176
|
+
* NVIDIA: `--gpus all`
|
|
177
|
+
* AMD: `--device /dev/dri` and `--device /dev/kfd`
|
|
178
|
+
- Detached keepalive (`tail -f /dev/null`) for later exec calls.
|
|
179
|
+
- Ensures inference DB symlink and license symlink inside container.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
model: Model identifier.
|
|
183
|
+
lbh_workspace: Optional workspace dir to mount.
|
|
184
|
+
docker_args: Extra docker args passed after `--`.
|
|
185
|
+
verbose: If True, echo docker command and exec steps.
|
|
186
|
+
image: Optional forced image (required when multiple images match).
|
|
187
|
+
model_path: Optional local HF model directory to mount directly.
|
|
188
|
+
restart: If True, restart the container if it is already running.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Dict: {returncode: int, container_name: str, command: list[str], error: str|None}
|
|
192
|
+
"""
|
|
193
|
+
lbh_home = config.LBH_HOME
|
|
194
|
+
workspace = lbh_workspace or config.LBH_WORKSPACE
|
|
195
|
+
os.makedirs(workspace, exist_ok=True)
|
|
196
|
+
|
|
197
|
+
# Restart if requested
|
|
198
|
+
if restart:
|
|
199
|
+
stop_res = do_stop(model, None, verbose=verbose)
|
|
200
|
+
if stop_res["returncode"] != 0:
|
|
201
|
+
msg = (stop_res.get("error") or "").lower()
|
|
202
|
+
if "not running" not in msg:
|
|
203
|
+
raise click.ClickException(
|
|
204
|
+
stop_res.get("error") or "Failed to stop existing container"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Resolve docker image
|
|
208
|
+
resolved_image = image
|
|
209
|
+
if not resolved_image:
|
|
210
|
+
matching = do_list(query=model, verbose=verbose)["lookup_df"]
|
|
211
|
+
if len(matching) == 0:
|
|
212
|
+
return {
|
|
213
|
+
"returncode": 1,
|
|
214
|
+
"container_name": "",
|
|
215
|
+
"command": [],
|
|
216
|
+
"error": f"No image found for model '{model}'. Use --image to specify explicitly.",
|
|
217
|
+
}
|
|
218
|
+
unique_images: List[str] = sorted(set(matching["docker_image"].astype(str).tolist()))
|
|
219
|
+
if len(unique_images) > 1:
|
|
220
|
+
return {
|
|
221
|
+
"returncode": 1,
|
|
222
|
+
"container_name": "",
|
|
223
|
+
"command": [],
|
|
224
|
+
"error": f"Multiple images found for model '{model}': {', '.join(unique_images)}. Use --image to choose.",
|
|
225
|
+
}
|
|
226
|
+
resolved_image = unique_images[0]
|
|
227
|
+
click.echo(f"Using image '{resolved_image}'.")
|
|
228
|
+
|
|
229
|
+
# Auto-prep missing model if configured
|
|
230
|
+
prep_needed_msg: str = ""
|
|
231
|
+
|
|
232
|
+
# if `--model_path` is given
|
|
233
|
+
if model_path:
|
|
234
|
+
# validate model path contains ('*config.json' file, AND contains '_name_or_path' or 'architectures' keys) AND ('vocab.json' or 'tokenizer_config.json' file)
|
|
235
|
+
|
|
236
|
+
transformed_model_path = os.path.abspath(os.path.expanduser(os.path.dirname(model_path)))
|
|
237
|
+
config_files = glob.glob(
|
|
238
|
+
os.path.join(transformed_model_path, "**", "*config.json"), recursive=True
|
|
239
|
+
)
|
|
240
|
+
if not config_files:
|
|
241
|
+
return {
|
|
242
|
+
"returncode": 1,
|
|
243
|
+
"container_name": "",
|
|
244
|
+
"command": [],
|
|
245
|
+
"error": f"Model path '{transformed_model_path}' does not contain any '*config.json' files.",
|
|
246
|
+
}
|
|
247
|
+
valid_config_found = False
|
|
248
|
+
for cfg_file in config_files:
|
|
249
|
+
try:
|
|
250
|
+
with open(cfg_file, "r") as f:
|
|
251
|
+
cfg = json.load(f)
|
|
252
|
+
if any(k in cfg for k in ["_name_or_path", "architectures"]):
|
|
253
|
+
# Check for vocab/tokenizer files
|
|
254
|
+
vocab_json = os.path.join(os.path.dirname(cfg_file), "vocab.json")
|
|
255
|
+
tokenizer_config_json = os.path.join(
|
|
256
|
+
os.path.dirname(cfg_file), "tokenizer_config.json"
|
|
257
|
+
)
|
|
258
|
+
if os.path.exists(vocab_json) or os.path.exists(tokenizer_config_json):
|
|
259
|
+
valid_config_found = True
|
|
260
|
+
break
|
|
261
|
+
except Exception:
|
|
262
|
+
continue
|
|
263
|
+
if not valid_config_found:
|
|
264
|
+
return {
|
|
265
|
+
"returncode": 1,
|
|
266
|
+
"container_name": "",
|
|
267
|
+
"command": [],
|
|
268
|
+
"error": f"Model path '{transformed_model_path}' does not appear to be a valid Hugging Face model directory, or is an unsupported model. Please ensure it contains a '*config.json' file with either '_name_or_path' or 'architectures' keys, and a 'vocab.json' or 'tokenizer_config.json' file.",
|
|
269
|
+
}
|
|
270
|
+
host_model_path: str = os.path.abspath(transformed_model_path) # path to model on host
|
|
271
|
+
else:
|
|
272
|
+
# Derive repo from full repo id (e.g., 'meta-llama/Llama-3.2-1B-Instruct' => 'meta-llama')
|
|
273
|
+
repo_name = get_repo_from_model_id(model)
|
|
274
|
+
model_name = get_model_name_from_model_id(model)
|
|
275
|
+
host_model_path: str = os.path.join(
|
|
276
|
+
config.LBH_MODELS, repo_name, model_name
|
|
277
|
+
) # path to model on host
|
|
278
|
+
if not os.path.exists(host_model_path):
|
|
279
|
+
prep_needed_msg += f"Model '{model}' does not exist locally. Please ensure model's 'Repo/Name' exactly matches the name on https://huggingface.co. Please login to huggingface-cli, then run '{__name__.split('.')[0]} prep {model}' or 'hf download {model} --local-dir {host_model_path}' to download the model."
|
|
280
|
+
|
|
281
|
+
container_model_path = os.path.join(
|
|
282
|
+
config.LLMBOOST_MODELS_DIR, get_model_name_from_model_id(model)
|
|
283
|
+
) # path to model inside container
|
|
284
|
+
click.echo(
|
|
285
|
+
f"Mounting model: {host_model_path} {f'<- {config.LLMBOOST_MODELS_DIR}' if verbose else ''}"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# GPU flags
|
|
289
|
+
gpu_flags: List[str] = []
|
|
290
|
+
if len(gpu_info.get_nvidia_gpus()) > 0:
|
|
291
|
+
gpu_flags = ["--gpus", "all"]
|
|
292
|
+
elif len(gpu_info.get_amd_gpus()) > 0:
|
|
293
|
+
gpu_flags = ["--device", "/dev/dri:/dev/dri", "--device", "/dev/kfd:/dev/kfd"]
|
|
294
|
+
|
|
295
|
+
# Keep the container alive so subsequent exec works reliably
|
|
296
|
+
keep_alive_flags: List[str] = ["tail", "-f", "/dev/null"]
|
|
297
|
+
|
|
298
|
+
container_name = container_name_for_model(model)
|
|
299
|
+
|
|
300
|
+
# Only accept docker args passed after `--`
|
|
301
|
+
extra = list(docker_args or ())
|
|
302
|
+
|
|
303
|
+
# Ensure image exists locally (pull as needed)
|
|
304
|
+
if not _image_exists_locally(resolved_image):
|
|
305
|
+
prep_needed_msg += f"Docker image '{resolved_image}' missing locally. Please login to docker (`docker login -u <username>`), then run '{__name__.split('.')[0]} prep {model}' (recommended) or 'docker pull {resolved_image}' to pull the image."
|
|
306
|
+
|
|
307
|
+
# Prep model, if missing and configured
|
|
308
|
+
if prep_needed_msg != "":
|
|
309
|
+
if config.LBH_AUTO_PREP:
|
|
310
|
+
prep_res = do_prep(model, verbose=verbose)
|
|
311
|
+
if prep_res["status"] == "error":
|
|
312
|
+
return {
|
|
313
|
+
"returncode": 1,
|
|
314
|
+
"container_name": "",
|
|
315
|
+
"command": [],
|
|
316
|
+
"error": prep_res["error"],
|
|
317
|
+
}
|
|
318
|
+
else:
|
|
319
|
+
return {
|
|
320
|
+
"returncode": 1,
|
|
321
|
+
"container_name": "",
|
|
322
|
+
"command": [],
|
|
323
|
+
"error": prep_needed_msg,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
# Base docker run with recommended defaults; start detached
|
|
327
|
+
docker_cmd = (
|
|
328
|
+
[
|
|
329
|
+
"docker",
|
|
330
|
+
"run",
|
|
331
|
+
"--rm",
|
|
332
|
+
"-d",
|
|
333
|
+
"--name",
|
|
334
|
+
container_name,
|
|
335
|
+
"--network",
|
|
336
|
+
"host",
|
|
337
|
+
"--group-add",
|
|
338
|
+
"video",
|
|
339
|
+
"--ipc",
|
|
340
|
+
"host",
|
|
341
|
+
"--cap-add",
|
|
342
|
+
"SYS_PTRACE",
|
|
343
|
+
"--security-opt",
|
|
344
|
+
"seccomp=unconfined",
|
|
345
|
+
"-v",
|
|
346
|
+
f"{host_model_path}:{container_model_path}",
|
|
347
|
+
"-v",
|
|
348
|
+
f"{lbh_home}:{config.CONTAINER_LBH_HOME}",
|
|
349
|
+
"-v",
|
|
350
|
+
f"{workspace}:{config.CONTAINER_USER_WORKSPACE}",
|
|
351
|
+
"-e",
|
|
352
|
+
f"LBH_HOME={config.CONTAINER_LBH_HOME}",
|
|
353
|
+
"-e",
|
|
354
|
+
f"HF_TOKEN={os.environ.get('HF_TOKEN', os.environ.get('HUGGINGFACE_TOKEN', ''))}",
|
|
355
|
+
"-w",
|
|
356
|
+
f"{config.LLMBOOST_WORKSPACE}",
|
|
357
|
+
]
|
|
358
|
+
+ gpu_flags
|
|
359
|
+
+ extra
|
|
360
|
+
+ [resolved_image]
|
|
361
|
+
+ keep_alive_flags
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
if verbose:
|
|
365
|
+
click.echo("[run] Executing Docker command:")
|
|
366
|
+
click.echo(" ".join(docker_cmd))
|
|
367
|
+
|
|
368
|
+
# Start container detached
|
|
369
|
+
try:
|
|
370
|
+
subprocess.run(docker_cmd, check=True)
|
|
371
|
+
except subprocess.CalledProcessError as e:
|
|
372
|
+
return {
|
|
373
|
+
"returncode": e.returncode,
|
|
374
|
+
"container_name": container_name,
|
|
375
|
+
"command": docker_cmd,
|
|
376
|
+
"error": f"Docker run failed (exit {e.returncode})",
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
# Wait until running
|
|
380
|
+
if not _wait_for_running(container_name, timeout_sec=15.0):
|
|
381
|
+
return {
|
|
382
|
+
"returncode": 1,
|
|
383
|
+
"container_name": container_name,
|
|
384
|
+
"command": docker_cmd,
|
|
385
|
+
"error": "Container failed to stay running after start.",
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
_ensure_inference_db_symlink(container_name, verbose=verbose)
|
|
389
|
+
|
|
390
|
+
# Symlink for license inside container
|
|
391
|
+
# - License: /llmboost_hub/license.skm <- /workspace/license.skm
|
|
392
|
+
def _exec(cmd):
|
|
393
|
+
if verbose:
|
|
394
|
+
click.echo(f"[run] docker exec: {' '.join(shlex.quote(x) for x in cmd)}")
|
|
395
|
+
try:
|
|
396
|
+
subprocess.run(cmd, check=True)
|
|
397
|
+
except subprocess.CalledProcessError as e:
|
|
398
|
+
if verbose:
|
|
399
|
+
click.echo(f"[run] Warning: exec failed: {e}")
|
|
400
|
+
|
|
401
|
+
_exec(
|
|
402
|
+
[
|
|
403
|
+
"docker",
|
|
404
|
+
"exec",
|
|
405
|
+
container_name,
|
|
406
|
+
"mkdir",
|
|
407
|
+
"-p",
|
|
408
|
+
config.LLMBOOST_WORKSPACE,
|
|
409
|
+
config.LLMBOOST_MODELS_DIR,
|
|
410
|
+
]
|
|
411
|
+
)
|
|
412
|
+
_exec(
|
|
413
|
+
[
|
|
414
|
+
"docker",
|
|
415
|
+
"exec",
|
|
416
|
+
container_name,
|
|
417
|
+
"ln",
|
|
418
|
+
"-sfn",
|
|
419
|
+
os.path.join(
|
|
420
|
+
config.CONTAINER_LBH_HOME,
|
|
421
|
+
os.path.basename(config.LBH_LICENSE_PATH),
|
|
422
|
+
),
|
|
423
|
+
config.LLMBOOST_LICENSE_PATH,
|
|
424
|
+
]
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# No interactive handling (by design)
|
|
428
|
+
return {
|
|
429
|
+
"returncode": 0,
|
|
430
|
+
"container_name": container_name,
|
|
431
|
+
"command": docker_cmd,
|
|
432
|
+
"error": None,
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@click.command(
|
|
437
|
+
context_settings={"ignore_unknown_options": True, "help_option_names": ["-h", "--help"]}
|
|
438
|
+
)
|
|
439
|
+
@click.argument("model", required=True, shell_complete=complete_model_names)
|
|
440
|
+
@click.option(
|
|
441
|
+
"--lbh-workspace", type=click.Path(), help="Override workspace path mounted inside container."
|
|
442
|
+
)
|
|
443
|
+
@click.option(
|
|
444
|
+
"-i",
|
|
445
|
+
"--image",
|
|
446
|
+
"forced_image",
|
|
447
|
+
type=str,
|
|
448
|
+
default=None,
|
|
449
|
+
help="Force a specific docker image (required when multiple images match the model).",
|
|
450
|
+
)
|
|
451
|
+
@click.option(
|
|
452
|
+
"-m",
|
|
453
|
+
"--model_path",
|
|
454
|
+
"model_path",
|
|
455
|
+
type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True),
|
|
456
|
+
default=None,
|
|
457
|
+
help=f"Local HF model directory to mount inside the container.",
|
|
458
|
+
)
|
|
459
|
+
@click.option(
|
|
460
|
+
"-r",
|
|
461
|
+
"--restart",
|
|
462
|
+
is_flag=True,
|
|
463
|
+
help="Restart the container if it is running before starting.",
|
|
464
|
+
)
|
|
465
|
+
@click.argument("docker_args", nargs=-1, type=click.UNPROCESSED)
|
|
466
|
+
@click.pass_context
|
|
467
|
+
def run(ctx: click.Context, model, lbh_workspace, forced_image, model_path, restart, docker_args):
|
|
468
|
+
"""
|
|
469
|
+
Run a model container with defaults; pass extra docker flags after `--`.
|
|
470
|
+
"""
|
|
471
|
+
verbose = ctx.obj.get("VERBOSE", False)
|
|
472
|
+
|
|
473
|
+
res = do_run(
|
|
474
|
+
model,
|
|
475
|
+
lbh_workspace,
|
|
476
|
+
docker_args,
|
|
477
|
+
verbose=verbose,
|
|
478
|
+
image=forced_image,
|
|
479
|
+
model_path=model_path,
|
|
480
|
+
restart=restart,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
if res["returncode"] != 0:
|
|
484
|
+
raise click.ClickException(res["error"] or "Docker run failed")
|
|
485
|
+
|
|
486
|
+
# No extra presentation needed on success
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import re
|
|
3
|
+
import requests
|
|
4
|
+
from typing import List, Dict
|
|
5
|
+
from llmboost_hub.commands.login import do_login
|
|
6
|
+
from llmboost_hub.utils.config import config
|
|
7
|
+
from llmboost_hub.utils import gpu_info
|
|
8
|
+
import tabulate
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from llmboost_hub.utils.lookup_cache import load_lookup_df
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _fetch_json(endpoint: str, params: Dict[str, str], verbose: bool = False) -> List[Dict]:
|
|
14
|
+
"""
|
|
15
|
+
Fetch a JSON payload from an endpoint with query params.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
endpoint: URL to query.
|
|
19
|
+
params: Mapping of query string params.
|
|
20
|
+
verbose: If True, echo the URL and params.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
A list of JSON objects.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
ClickException: On non-200 responses or invalid/malformed bodies.
|
|
27
|
+
"""
|
|
28
|
+
# Future support: keep unused for now
|
|
29
|
+
if verbose:
|
|
30
|
+
click.echo(f"Downloading JSON from {endpoint} with params {params}")
|
|
31
|
+
resp = requests.get(endpoint, params=params, timeout=10)
|
|
32
|
+
if resp.status_code != 200:
|
|
33
|
+
raise click.ClickException(f"Lookup failed ({resp.status_code}): {resp.text}")
|
|
34
|
+
try:
|
|
35
|
+
data = resp.json()
|
|
36
|
+
except ValueError:
|
|
37
|
+
raise click.ClickException("Lookup returned invalid JSON")
|
|
38
|
+
if isinstance(data, dict) and "results" in data:
|
|
39
|
+
data = data["results"]
|
|
40
|
+
if not isinstance(data, list):
|
|
41
|
+
raise click.ClickException("Unexpected lookup response format")
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fetch_from_remote(endpoint: str, query: str, verbose: bool = False) -> pd.DataFrame:
|
|
46
|
+
"""
|
|
47
|
+
Fetch lookup CSV (with cache and fallback) and normalize columns.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
endpoint: CSV endpoint.
|
|
51
|
+
query: Filter string passed to the loader (used by cache layer).
|
|
52
|
+
verbose: If True, echo loader activity.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
DataFrame with columns: model, gpu, docker_image (sorted by model,gpu).
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ClickException: When required columns are missing from the CSV.
|
|
59
|
+
"""
|
|
60
|
+
df = load_lookup_df(endpoint, query, verbose=verbose)
|
|
61
|
+
|
|
62
|
+
# Normalize column names
|
|
63
|
+
df.columns = [str(c).strip().lower() for c in df.columns]
|
|
64
|
+
required_cols = {"model", "gpu", "docker_image"}
|
|
65
|
+
if not required_cols.issubset(set(df.columns)):
|
|
66
|
+
missing = required_cols - set(df.columns)
|
|
67
|
+
raise click.ClickException(
|
|
68
|
+
f"Lookup CSV missing required columns: {', '.join(sorted(missing))}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# sort by model,gpu
|
|
72
|
+
df = df.sort_values(by=["model", "gpu"]).reset_index(drop=True)
|
|
73
|
+
df.index += 1 # user-friendly display index
|
|
74
|
+
|
|
75
|
+
return df[["model", "gpu", "docker_image"]]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def do_search(
|
|
79
|
+
query: str = r".*",
|
|
80
|
+
verbose: bool = False,
|
|
81
|
+
local_only: bool = False,
|
|
82
|
+
skip_cache_update: bool = False,
|
|
83
|
+
names_only: bool = False,
|
|
84
|
+
) -> pd.DataFrame:
|
|
85
|
+
"""
|
|
86
|
+
Search remote/locally-cached lookup and filter by query and local GPU families.
|
|
87
|
+
|
|
88
|
+
Behavior:
|
|
89
|
+
- local_only=True: skip license check and network; load only from local cache file.
|
|
90
|
+
- otherwise: attempt login/validation and fetch with cache fallback.
|
|
91
|
+
- Perform case-insensitive LIKE on 'model'.
|
|
92
|
+
- Filter rows to those matching detected GPU families.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
query: Regex pattern to filter 'model' column (case-insensitive).
|
|
96
|
+
verbose: If True, echo key steps.
|
|
97
|
+
local_only: Skip license check and remote fetch; read from local cache only.
|
|
98
|
+
skip_cache_update: Reserved for future use (cache policy is handled in loader).
|
|
99
|
+
names_only: If True, return only the 'model' column.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
DataFrame with columns: model, gpu, docker_image (possibly empty).
|
|
103
|
+
"""
|
|
104
|
+
if not local_only:
|
|
105
|
+
# Best-effort: try to ensure license; even on failure, loader may still use cache
|
|
106
|
+
do_login(license_file=None, verbose=verbose)
|
|
107
|
+
lookup_df = load_lookup_df(
|
|
108
|
+
config.LBH_LOOKUP_URL,
|
|
109
|
+
query,
|
|
110
|
+
verbose=verbose,
|
|
111
|
+
local_only=local_only,
|
|
112
|
+
skip_cache_update=skip_cache_update,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Filter by query (case-insensitive LIKE on 'model' field)
|
|
116
|
+
filtered_df = lookup_df[
|
|
117
|
+
lookup_df["model"].astype(str).str.contains(pat=query, regex=True, flags=re.IGNORECASE)
|
|
118
|
+
].reset_index(drop=True)
|
|
119
|
+
filtered_df.index += 1 # user-friendly display index
|
|
120
|
+
|
|
121
|
+
# GPU family filter
|
|
122
|
+
available_gpus = gpu_info.get_gpus()
|
|
123
|
+
local_families = {gpu_info.gpu_name2family(g) for g in available_gpus if g}
|
|
124
|
+
filtered_df = (
|
|
125
|
+
filtered_df.assign(_gpu_family=filtered_df["gpu"].apply(gpu_info.gpu_name2family))
|
|
126
|
+
.loc[lambda df: df["_gpu_family"].isin(local_families)]
|
|
127
|
+
.drop(columns=["_gpu_family"])
|
|
128
|
+
).reset_index(drop=True)
|
|
129
|
+
filtered_df.index += 1 # user-friendly display index
|
|
130
|
+
|
|
131
|
+
# Short-circuit: names only
|
|
132
|
+
if names_only:
|
|
133
|
+
return filtered_df[["model"]]
|
|
134
|
+
|
|
135
|
+
return filtered_df
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
139
|
+
@click.argument("query", type=str, required=True)
|
|
140
|
+
@click.option(
|
|
141
|
+
"--local-only",
|
|
142
|
+
is_flag=True,
|
|
143
|
+
help="Use only the local lookup cache (skip online fetch and license validation).",
|
|
144
|
+
)
|
|
145
|
+
@click.option(
|
|
146
|
+
"--skip-cache-update",
|
|
147
|
+
is_flag=True,
|
|
148
|
+
help="Fetch, but skip updating local cache. (not applicable with --local-only).",
|
|
149
|
+
)
|
|
150
|
+
@click.option(
|
|
151
|
+
"--names-only",
|
|
152
|
+
is_flag=True,
|
|
153
|
+
help="Return model names only.",
|
|
154
|
+
)
|
|
155
|
+
@click.pass_context
|
|
156
|
+
def search(ctx: click.Context, query, local_only, skip_cache_update, names_only):
|
|
157
|
+
"""
|
|
158
|
+
Search for models in the LLMBoost registry.
|
|
159
|
+
"""
|
|
160
|
+
verbose = ctx.obj.get("VERBOSE", False)
|
|
161
|
+
results_df = do_search(
|
|
162
|
+
query,
|
|
163
|
+
verbose=verbose,
|
|
164
|
+
local_only=local_only,
|
|
165
|
+
skip_cache_update=skip_cache_update,
|
|
166
|
+
names_only=names_only,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
click.echo(f"Found {len(results_df)} relevant images")
|
|
170
|
+
if results_df.empty:
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
# Present results via tabulate
|
|
174
|
+
click.echo(
|
|
175
|
+
tabulate.tabulate(
|
|
176
|
+
results_df.values.tolist(),
|
|
177
|
+
headers=list(results_df.columns),
|
|
178
|
+
showindex=list(results_df.index),
|
|
179
|
+
tablefmt="psql",
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
return results_df
|