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.
@@ -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