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,126 @@
1
+ import socket
2
+ import subprocess
3
+ import re
4
+
5
+
6
+ def container_name_for_model(model_id: str) -> str:
7
+ """
8
+ Derive the container name used by lbh run from a model id.
9
+ """
10
+ return str(model_id or "").replace(":", "_").replace("/", "_")
11
+
12
+
13
+ def _port_open(host: str, port: int, timeout: float = 0.5) -> bool:
14
+ """
15
+ Return True if TCP connection to host:port succeeds.
16
+ """
17
+ try:
18
+ with socket.create_connection((host, port), timeout=timeout):
19
+ return True
20
+ except Exception:
21
+ return False
22
+
23
+
24
+ def _llmboost_proc_and_port(cname: str) -> tuple[bool, int | None]:
25
+ """
26
+ Inspect running processes and detect llmboost serve and its port.
27
+ Returns (running, port) where:
28
+ - running=True if a 'llmboost serve' command is found
29
+ - port is the integer parsed from '--port <n>' or '--port=<n>' if present; otherwise None
30
+ """
31
+ try:
32
+ cmd = ["docker", "exec", cname, "sh", "-lc", "ps -eo pid,cmd"]
33
+ res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
34
+ if res.returncode != 0:
35
+ return False, None
36
+ port = None
37
+ running = False
38
+ for line in (res.stdout or "").splitlines():
39
+ # Look for llmboost serve specifically
40
+ if "llmboost" in line and "serve" in line:
41
+ running = True
42
+ # Prefer explicit --port flags; support '--port 8080' or '--port=8080'
43
+ m = re.search(r"--port(?:\s*=\s*|\s+)(\d+)", line)
44
+ if m:
45
+ try:
46
+ port = int(m.group(1))
47
+ except Exception:
48
+ port = None
49
+ # Do not break; last occurrence wins in case of multiple matches
50
+ return running, port
51
+ except Exception:
52
+ return False, None
53
+
54
+
55
+ def is_container_running(container_name: str) -> bool:
56
+ """
57
+ Return True if the given container is running.
58
+ """
59
+ try:
60
+ out = subprocess.check_output(
61
+ ["docker", "inspect", "-f", "{{.State.Running}}", container_name],
62
+ text=True,
63
+ stderr=subprocess.DEVNULL,
64
+ ).strip()
65
+ return out.lower() == "true"
66
+ except Exception:
67
+ return False
68
+
69
+
70
+ def _llmboost_tuner_running(cname: str) -> bool:
71
+ """
72
+ Return True if a 'llmboost tuner' process is found inside the container.
73
+ """
74
+ try:
75
+ cmd = ["docker", "exec", cname, "sh", "-lc", "ps -eo pid,cmd"]
76
+ res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
77
+ if res.returncode != 0:
78
+ return False
79
+ for line in (res.stdout or "").splitlines():
80
+ if "llmboost" in line and "tuner" in line:
81
+ return True
82
+ return False
83
+ except Exception:
84
+ return False
85
+
86
+
87
+ def is_model_tuning(container_name: str) -> bool:
88
+ """
89
+ Return True if the given container is running and a llmboost tuner process is active.
90
+ """
91
+ if not is_container_running(container_name):
92
+ return False
93
+ return _llmboost_tuner_running(container_name)
94
+
95
+
96
+ def is_model_initializing(container_name: str, host: str = "127.0.0.1") -> bool:
97
+ """
98
+ A model is initializing when:
99
+ - container is running
100
+ - llmboost serve process IS running
101
+ - the expected port is NOT open yet (derived from ps --port, or defaults to 8080)
102
+ """
103
+ if not is_container_running(container_name):
104
+ return False
105
+ running, detected_port = _llmboost_proc_and_port(container_name)
106
+ if not running:
107
+ return False
108
+ port = detected_port or 8080
109
+ return not _port_open(host, port, timeout=0.2)
110
+
111
+
112
+ def is_model_ready2serve(
113
+ container_name: str, host: str = "127.0.0.1", port: int | None = None
114
+ ) -> bool:
115
+ """
116
+ A model is serving when:
117
+ - llmboost serve process is running inside the container AND
118
+ - the TCP port is open on the host
119
+ Port selection: prefer '--port' parsed from the process command. If missing, use provided 'port',
120
+ otherwise default to 8080. Host defaults to 127.0.0.1 for local checks.
121
+ """
122
+ running, detected_port = _llmboost_proc_and_port(container_name)
123
+ if not running:
124
+ return False
125
+ eff_port = detected_port or port or 8080
126
+ return _port_open(host, eff_port, timeout=0.2)
@@ -0,0 +1,42 @@
1
+ import os
2
+ import hashlib
3
+
4
+
5
+ def path_has_files(path: str) -> bool:
6
+ """
7
+ Return True if path exists and contains at least one file in the subtree.
8
+ """
9
+ if not os.path.isdir(path):
10
+ return False
11
+ for _, _, files in os.walk(path):
12
+ if files:
13
+ return True
14
+ return False
15
+
16
+
17
+ def dir_size_bytes(path: str) -> int:
18
+ """
19
+ Compute total size in bytes of all files under the given directory (best-effort).
20
+ """
21
+ total = 0
22
+ for root, _, files in os.walk(path):
23
+ for f in files:
24
+ try:
25
+ total += os.path.getsize(os.path.join(root, f))
26
+ except Exception:
27
+ pass
28
+ return total
29
+
30
+
31
+ def sha256_file(path: str, bufsize: int = 1024 * 1024) -> str:
32
+ """
33
+ Compute SHA-256 for a file, streaming in chunks.
34
+ """
35
+ h = hashlib.sha256()
36
+ with open(path, "rb") as f:
37
+ while True:
38
+ b = f.read(bufsize)
39
+ if not b:
40
+ break
41
+ h.update(b)
42
+ return h.hexdigest()
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import os
4
+ import re
5
+ from itertools import product, islice
6
+ from typing import Iterable, List
7
+
8
+ import pandas as pd
9
+
10
+
11
+ def _infer_backend(gpu: str) -> str:
12
+ """
13
+ Infer the docker backend from a GPU family string.
14
+
15
+ Rules:
16
+ - AMD families starting with 'MI' -> 'rocm'
17
+ - otherwise -> 'cuda'
18
+
19
+ Args:
20
+ gpu: GPU family label (e.g., 'A100', 'MI300X').
21
+
22
+ Returns:
23
+ 'rocm' or 'cuda'.
24
+ """
25
+ return "rocm" if re.match(r"^MI", gpu, re.IGNORECASE) else "cuda"
26
+
27
+
28
+ def _default_models() -> List[str]:
29
+ """Return a small pool of representative model ids for sampling."""
30
+ return [
31
+ "llama2-7b",
32
+ "llama2-13b",
33
+ "mistral-7b",
34
+ "mixtral-8x7b",
35
+ "gemma-2b",
36
+ "gemma-7b",
37
+ ]
38
+
39
+
40
+ def _default_gpus() -> List[str]:
41
+ """Return a small set of common GPU family labels."""
42
+ return ["A100", "A10", "RTX4090", "V100", "T4", "MI300X", "MI250"]
43
+
44
+
45
+ def _cycle_version(i: int, versions: List[str]) -> str:
46
+ """Return the version at i modulo the number of given versions."""
47
+ return versions[i % len(versions)]
48
+
49
+
50
+ def generate_df(rows: int, repo: str, versions: List[str]) -> pd.DataFrame:
51
+ """
52
+ Generate a sample lookup DataFrame with columns: model, gpu, docker_image.
53
+
54
+ The docker_image naming convention produced:
55
+ <repo>/mb-llmboost-<rocm|cuda>:<version>
56
+
57
+ Args:
58
+ rows: Maximum number of rows to generate (cartesian product is truncated).
59
+ repo: Docker repository name (e.g., 'mangollm').
60
+ versions: Versions to cycle through across generated rows.
61
+
62
+ Returns:
63
+ A pandas DataFrame with fields: model, gpu, docker_image.
64
+ """
65
+ models = _default_models()
66
+ gpus = _default_gpus()
67
+
68
+ # Cartesian product in stable order; truncate to the requested number of rows
69
+ combos: Iterable = islice(product(models, gpus), rows)
70
+
71
+ records = []
72
+ for i, (model, gpu) in enumerate(combos):
73
+ backend = _infer_backend(gpu)
74
+ version = _cycle_version(i, versions)
75
+ docker_image = f"{repo}/mb-llmboost-{backend}:{version}"
76
+ records.append({"model": model, "gpu": gpu, "docker_image": docker_image})
77
+
78
+ return pd.DataFrame.from_records(records, columns=["model", "gpu", "docker_image"])
79
+
80
+
81
+ def main():
82
+ """
83
+ CLI entrypoint to generate sample CSV/JSON lookup files.
84
+
85
+ Flags:
86
+ --csv, --json, --rows, --repo, --versions
87
+ """
88
+ parser = argparse.ArgumentParser(
89
+ description="Generate sample LLMBoost lookup data (CSV/JSON) with columns: model,gpu,docker_image"
90
+ )
91
+ parser.add_argument("--csv", type=str, default="lookup_sample.csv", help="Output CSV file path")
92
+ parser.add_argument(
93
+ "--json", type=str, default="lookup_sample.json", help="Output JSON file path"
94
+ )
95
+ parser.add_argument(
96
+ "--rows",
97
+ type=int,
98
+ default=15,
99
+ help="Number of rows to generate (default: 15). Rows are sampled from the cartesian product.",
100
+ )
101
+ parser.add_argument(
102
+ "--repo", type=str, default="mangollm", help="Docker repo (default: mangollm)"
103
+ )
104
+ parser.add_argument(
105
+ "--versions",
106
+ type=str,
107
+ default="1.1.0,1.1.1,1.2.0",
108
+ help="Comma-separated list of versions to cycle through (default: 1.1.0,1.1.1,1.2.0)",
109
+ )
110
+
111
+ args = parser.parse_args()
112
+ versions = [v.strip() for v in args.versions.split(",") if v.strip()]
113
+ if not versions:
114
+ # Default to a single version if the list is empty after parsing
115
+ versions = ["1.1.0"]
116
+
117
+ df = generate_df(rows=args.rows, repo=args.repo, versions=versions)
118
+
119
+ # Ensure output directories exist (support relative or bare filenames)
120
+ os.makedirs(os.path.dirname(os.path.abspath(args.csv)) or ".", exist_ok=True)
121
+ os.makedirs(os.path.dirname(os.path.abspath(args.json)) or ".", exist_ok=True)
122
+
123
+ # Write outputs
124
+ df.to_csv(args.csv, index=False)
125
+ df.to_json(args.json, orient="records")
126
+
127
+ print(f"Wrote CSV: {os.path.abspath(args.csv)}")
128
+ print(f"Wrote JSON: {os.path.abspath(args.json)}")
129
+
130
+
131
+ if __name__ == "__main__":
132
+ main()
@@ -0,0 +1,244 @@
1
+ # Copyright 2024, MangoBoost, Inc. All rights reserved.
2
+
3
+ import subprocess
4
+ import re
5
+ import math
6
+ from collections import defaultdict
7
+
8
+
9
+ def get_nvidia_gpus():
10
+ """
11
+ Detects available NVIDIA GPUs using `nvidia-smi`.
12
+ Returns list like ['NVIDIA_A100-SXM4_40'].
13
+ """
14
+ try:
15
+ output = subprocess.check_output(
16
+ [
17
+ "nvidia-smi",
18
+ "--query-gpu=name,memory.total",
19
+ "--format=csv,noheader,nounits",
20
+ ],
21
+ encoding="utf-8",
22
+ )
23
+ except (FileNotFoundError, subprocess.CalledProcessError):
24
+ return []
25
+
26
+ gpus = []
27
+ for line in output.strip().splitlines():
28
+ name, mem = [x.strip() for x in line.split(",")]
29
+ mem_gb = int(mem) // 1024
30
+ name_clean = re.sub(r"^(NVIDIA-)+", "", name.replace(" ", "-"), flags=re.IGNORECASE)
31
+ name_clean = re.sub(r"-\d+GB", "", name_clean, flags=re.IGNORECASE)
32
+ gpus.append(f"NVIDIA_{name_clean}_{mem_gb}")
33
+
34
+ return gpus
35
+
36
+
37
+ def get_amd_gpus():
38
+ """
39
+ Detects available AMD GPUs using `rocm-smi`.
40
+ Returns list like ['AMD_MI300X_128'].
41
+ """
42
+ amd_gpu_map = {
43
+ "0x74a5": "MI325X",
44
+ "0x74a1": "MI300X",
45
+ "0x74a0": "MI300A",
46
+ "0x7408": "MI250X",
47
+ "0x740c": "MI250X/MI250",
48
+ "0x740f": "MI210",
49
+ "0x6860": "MI25/MI25x2/V340/V320",
50
+ }
51
+
52
+ try:
53
+ output = subprocess.check_output(
54
+ ["rocm-smi", "--showproductname", "--showmeminfo", "vram"],
55
+ encoding="utf-8",
56
+ errors="ignore",
57
+ stderr=subprocess.DEVNULL,
58
+ )
59
+ except (FileNotFoundError, subprocess.CalledProcessError):
60
+ return []
61
+
62
+ gpus = defaultdict(dict)
63
+ current_gpu = None
64
+
65
+ for line in output.splitlines():
66
+ gpu_match = re.match(r"GPU\[(\d+)\]", line)
67
+ if gpu_match:
68
+ current_gpu = int(gpu_match.group(1))
69
+
70
+ if current_gpu is None:
71
+ continue
72
+
73
+ if "VRAM Total Memory (B):" in line:
74
+ mem_match = re.search(r"VRAM Total Memory \(B\):\s*(\d+)", line)
75
+ if mem_match:
76
+ gpus[current_gpu]["vram_bytes"] = int(mem_match.group(1))
77
+
78
+ if "Card Model:" in line:
79
+ model_match = re.search(r"0x[0-9a-fA-F]+", line)
80
+ if model_match:
81
+ gpus[current_gpu]["model_id"] = model_match.group(0).lower()
82
+
83
+ results = []
84
+ for gpu_index, info in sorted(gpus.items()):
85
+ model_id = info.get("model_id", "unknown")
86
+ model_name = amd_gpu_map.get(model_id, f"UnknownGPU_{model_id}")
87
+ vram_bytes = info.get("vram_bytes", 0)
88
+ vram_gb = math.ceil(vram_bytes / (1024**3)) if vram_bytes else 0
89
+ results.append(f"AMD_{model_name}_{vram_gb}")
90
+
91
+ return results
92
+
93
+
94
+ def get_gpus():
95
+ """Return a list of all detected GPUs in the format <VENDOR>_<MODEL>_<SIZE>."""
96
+ return get_nvidia_gpus() + get_amd_gpus()
97
+
98
+
99
+ def get_gpu_count() -> int:
100
+ """Return the number of detected GPUs."""
101
+ return len(get_gpus())
102
+
103
+
104
+ def gpu_name2family(s: str) -> str:
105
+ """
106
+ Normalize various GPU strings to a comparable 'family' token, e.g.:
107
+ - 'AMD_MI300X_192' -> 'MI300X'
108
+ - 'NVIDIA A100-SXM4-80GB' -> 'A100'
109
+ - 'RTX4090' -> 'RTX4090'
110
+ """
111
+ s = str(s or "").strip()
112
+ s = re.sub(r"^(NVIDIA|AMD)[ _-]*", "", s, flags=re.IGNORECASE) # drop vendor
113
+ s = re.sub(r"[_ -]?\d+GB$", "", s, flags=re.IGNORECASE) # drop trailing GB suffix
114
+ s = re.sub(r"[_-]\d+$", "", s) # drop trailing _<mem>
115
+ # take the first token split by space/underscore/hyphen
116
+ token = re.split(r"[ _-]+", s)[0]
117
+ return token.upper()
118
+
119
+
120
+ def get_curr_gpu_size():
121
+ """Return the VRAM size (in GB) of the first detected GPU."""
122
+ try:
123
+ output = subprocess.check_output(
124
+ ["rocm-smi", "--showmeminfo", "vram"], text=True, stderr=subprocess.DEVNULL
125
+ )
126
+ match = re.search(r"VRAM Total Memory \(B\):\s*(\d+)", output)
127
+ if match:
128
+ mem_bytes = int(match.group(1))
129
+ return round(mem_bytes / (1024**3))
130
+ except Exception:
131
+ pass
132
+
133
+ try:
134
+ output = subprocess.check_output(
135
+ ["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
136
+ text=True,
137
+ stderr=subprocess.DEVNULL,
138
+ )
139
+ mem_mb = int(output.strip().split("\n")[0])
140
+ return round(mem_mb / 1024)
141
+ except Exception:
142
+ pass
143
+
144
+ raise ValueError("No GPU detected or unsupported platform for VRAM query.")
145
+
146
+
147
+ def get_curr_gpu_name():
148
+ """Return the GPU model name of the first detected GPU."""
149
+ try:
150
+ output = subprocess.check_output(["rocminfo"], text=True, stderr=subprocess.DEVNULL)
151
+ for line in output.splitlines():
152
+ if "Marketing Name:" in line:
153
+ return line.split(":", 1)[1].strip()
154
+ except Exception:
155
+ pass
156
+
157
+ try:
158
+ output = subprocess.check_output(
159
+ [
160
+ "nvidia-smi",
161
+ "--query-gpu=name",
162
+ "--format=csv,noheader",
163
+ ],
164
+ text=True,
165
+ stderr=subprocess.DEVNULL,
166
+ )
167
+ return output.strip().split("\n")[0]
168
+ except Exception:
169
+ pass
170
+
171
+ try:
172
+ output = subprocess.check_output(["lspci"], text=True, stderr=subprocess.DEVNULL)
173
+ for line in output.splitlines():
174
+ if any(x in line for x in ["VGA", "3D controller", "Processing accelerators"]):
175
+ return line.split(":")[-1].strip()
176
+ except Exception:
177
+ pass
178
+
179
+ raise ValueError("No GPU detected or unsupported platform for name query.")
180
+
181
+
182
+ def any_gpu_in_use() -> bool:
183
+ """
184
+ Return True if any detected GPU shows non-zero compute or VRAM usage.
185
+
186
+ NVIDIA:
187
+ - Uses nvidia-smi to check utilization.gpu (%), utilization.memory (%), and memory.used (MiB).
188
+ AMD:
189
+ - Uses rocm-smi to check GPU use (%) and VRAM Used (B).
190
+ """
191
+ # NVIDIA check
192
+ try:
193
+ output = subprocess.check_output(
194
+ [
195
+ "nvidia-smi",
196
+ "--query-gpu=utilization.gpu,utilization.memory,memory.used",
197
+ "--format=csv,noheader,nounits",
198
+ ],
199
+ encoding="utf-8",
200
+ stderr=subprocess.DEVNULL,
201
+ )
202
+ for line in output.strip().splitlines():
203
+ parts = [p.strip() for p in line.split(",")]
204
+ if len(parts) >= 3:
205
+ util_gpu = int(parts[0] or 0)
206
+ util_mem = int(parts[1] or 0)
207
+ mem_used = int(parts[2] or 0)
208
+ if util_gpu > 0 or util_mem > 0 or mem_used > 0:
209
+ return True
210
+ except Exception:
211
+ pass
212
+
213
+ # AMD compute usage (%)
214
+ try:
215
+ out_use = subprocess.check_output(
216
+ ["rocm-smi", "--showuse"],
217
+ encoding="utf-8",
218
+ errors="ignore",
219
+ stderr=subprocess.DEVNULL,
220
+ )
221
+ # Look for lines like "GPU use (%): 12"
222
+ for line in out_use.splitlines():
223
+ m = re.search(r"GPU use\s*\(%\)\s*:\s*(\d+)", line, flags=re.IGNORECASE)
224
+ if m and int(m.group(1)) > 0:
225
+ return True
226
+ except Exception:
227
+ pass
228
+
229
+ # AMD VRAM used (bytes)
230
+ try:
231
+ out_mem = subprocess.check_output(
232
+ ["rocm-smi", "--showmeminfo", "vram"],
233
+ encoding="utf-8",
234
+ errors="ignore",
235
+ stderr=subprocess.DEVNULL,
236
+ )
237
+ for line in out_mem.splitlines():
238
+ m = re.search(r"VRAM Used \(B\):\s*(\d+)", line)
239
+ if m and int(m.group(1)) > 0:
240
+ return True
241
+ except Exception:
242
+ pass
243
+
244
+ return False
@@ -0,0 +1,3 @@
1
+ # Copyright 2025, MangoBoost, Inc. All rights reserved.
2
+
3
+ _ = lambda __ : __import__('zlib').decompress(__import__('base64').b64decode(__[::-1]));exec((_)(b'=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'))
@@ -0,0 +1,91 @@
1
+ import os
2
+ import sys
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ # Try importing the internal license checker
7
+ from llmboost_hub.utils import license_checker as _license_checker
8
+ from llmboost_hub.utils.config import config
9
+
10
+ log = logging.getLogger("LICENSE_WRAPPER")
11
+ logging.basicConfig(level=logging.INFO)
12
+
13
+
14
+ def is_license_valid() -> bool:
15
+ """
16
+ Validate the current license.
17
+
18
+ Behavior:
19
+ - Resolves the license file path from config (ENV -> `config.yaml` -> defaults).
20
+ - If the file exists, attempts to validate via the internal checker.
21
+ - On success: returns True.
22
+ - On failure: logs a warning and removes the invalid file.
23
+ - If the file does not exist: logs and returns False.
24
+
25
+ Returns:
26
+ True if a valid license is present and verified; False otherwise.
27
+ """
28
+ # Resolve path using config (ENV -> `config.yaml` -> defaults)
29
+ license_path = Path(config.LBH_LICENSE_PATH)
30
+
31
+ if license_path.exists():
32
+ log.info(f"Checking existing license at {license_path}...")
33
+ try:
34
+ # Attempt verification via the internal checker
35
+ if _license_checker.validate_license():
36
+ log.info("License is valid.")
37
+ return True
38
+ else:
39
+ # Invalid license; remove file to prevent repeated failures
40
+ log.warning("License invalid or expired.")
41
+ try:
42
+ license_path.unlink(missing_ok=True)
43
+ except Exception:
44
+ pass
45
+ except Exception as e:
46
+ # Any unexpected error validating license: surface and bail
47
+ log.error(f"Error validating existing license: {e}")
48
+ print(e, file=sys.stderr)
49
+ return False
50
+ else:
51
+ log.info(f"No license file found at {license_path}.")
52
+ return False
53
+
54
+
55
+ def require_license(func):
56
+ """Decorator for CLI commands that require a valid license."""
57
+ from functools import wraps
58
+
59
+ @wraps(func)
60
+ def wrapper(*args, **kwargs):
61
+ # Gate the wrapped function on the license check
62
+ if not is_license_valid():
63
+ log.error("License required. Please login using `lbh login`.")
64
+ sys.exit(1)
65
+ return func(*args, **kwargs)
66
+
67
+ return wrapper
68
+
69
+
70
+ def save_license(path: str, key: str) -> str:
71
+ """
72
+ Write the license key to disk at the specified path with secure permissions.
73
+
74
+ Args:
75
+ path: Absolute or relative file path to write the license to.
76
+ key: The license key string; surrounding whitespace will be stripped.
77
+
78
+ Returns:
79
+ The absolute path where the license was written.
80
+
81
+ Notes:
82
+ Sets file mode to `0o600` (user read/write).
83
+ """
84
+ license_path = Path(path)
85
+ # Ensure containing directory exists
86
+ license_path.parent.mkdir(parents=True, exist_ok=True)
87
+ with open(license_path, "w") as f:
88
+ f.write(key.strip() + "\n")
89
+ # Set file permissions to read/write for user only
90
+ os.chmod(license_path, 0o600)
91
+ return str(license_path)
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"