local-llm-ctl 0.1.0__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.
- llm_ctl/__init__.py +3 -0
- llm_ctl/__main__.py +4 -0
- llm_ctl/cli.py +294 -0
- llm_ctl/config.py +82 -0
- llm_ctl/models.py +247 -0
- llm_ctl/server.py +263 -0
- llm_ctl/utils.py +68 -0
- local_llm_ctl-0.1.0.dist-info/METADATA +9 -0
- local_llm_ctl-0.1.0.dist-info/RECORD +12 -0
- local_llm_ctl-0.1.0.dist-info/WHEEL +5 -0
- local_llm_ctl-0.1.0.dist-info/entry_points.txt +2 -0
- local_llm_ctl-0.1.0.dist-info/top_level.txt +1 -0
llm_ctl/__init__.py
ADDED
llm_ctl/__main__.py
ADDED
llm_ctl/cli.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""CLI entry point for llm-ctl."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from .config import ConfigManager, VALID_ROLES, CONFIG_PATH
|
|
12
|
+
from .models import discover_models, find_model_by_id, register_model, unregister_model
|
|
13
|
+
from .server import ServerManager, StateFile
|
|
14
|
+
from .utils import is_port_available, find_next_available_port
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group()
|
|
18
|
+
@click.version_option(version="0.1.0", prog_name="llm-ctl")
|
|
19
|
+
def cli():
|
|
20
|
+
"""Manage local LLM models and role-based server lifecycle."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@cli.command("list")
|
|
25
|
+
def list_models():
|
|
26
|
+
"""List all available local models."""
|
|
27
|
+
models = discover_models()
|
|
28
|
+
if not models:
|
|
29
|
+
click.echo("No models found.")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
click.echo(f"\n{'Model ID':<55} {'Type':<6} {'Path'}")
|
|
33
|
+
click.echo("-" * 100)
|
|
34
|
+
for model in sorted(models, key=lambda m: m["model_id"]):
|
|
35
|
+
path_display = model["path"]
|
|
36
|
+
if len(path_display) > 40:
|
|
37
|
+
path_display = "..." + path_display[-37:]
|
|
38
|
+
click.echo(f"{model['model_id']:<55} {model['type']:<6} {path_display}")
|
|
39
|
+
|
|
40
|
+
click.echo(f"\nTotal: {len(models)} model(s)")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@cli.command()
|
|
44
|
+
@click.argument("role")
|
|
45
|
+
@click.argument("model_id")
|
|
46
|
+
@click.option("--port", "-p", type=int, default=None, help="Override auto-assigned port")
|
|
47
|
+
@click.option("--type", "-t", "model_type", type=click.Choice(["mlx", "gguf"]), default=None,
|
|
48
|
+
help="Override auto-detected model type")
|
|
49
|
+
def register(role, model_id, port, model_type):
|
|
50
|
+
"""Register a model to a role.
|
|
51
|
+
|
|
52
|
+
ROLE: planner, reviewer, engineer, or assistant
|
|
53
|
+
|
|
54
|
+
MODEL_ID: HuggingFace model ID (e.g., lmstudio-community/Qwen3.6-27B-MLX-6bit)
|
|
55
|
+
"""
|
|
56
|
+
if role not in VALID_ROLES:
|
|
57
|
+
click.echo(f"Error: Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}")
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
config = ConfigManager()
|
|
62
|
+
discovered = discover_models()
|
|
63
|
+
model = find_model_by_id(model_id, discovered)
|
|
64
|
+
if not model:
|
|
65
|
+
click.echo(f"Error: Model not found: {model_id}")
|
|
66
|
+
click.echo("Run 'llm-ctl list' to see available models.")
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
# Check if role is already registered
|
|
70
|
+
existing = config.get_role(role)
|
|
71
|
+
if existing:
|
|
72
|
+
click.echo(f"Role '{role}' is already registered to: {existing['model_id']}")
|
|
73
|
+
if not click.confirm("Unregister and re-register?"):
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
# Check for shared-model warning
|
|
77
|
+
all_roles = config.get_all_roles()
|
|
78
|
+
for other_role, other_config in all_roles.items():
|
|
79
|
+
if other_role == role:
|
|
80
|
+
continue
|
|
81
|
+
if other_config.get("model_id") == model_id:
|
|
82
|
+
click.echo(
|
|
83
|
+
f"Warning: Role '{other_role}' is already using '{model_id}'. "
|
|
84
|
+
f"Each role runs a separate server process, which means duplicate "
|
|
85
|
+
f"GPU memory usage. Consider using --all with a single role or "
|
|
86
|
+
f"assigning different models."
|
|
87
|
+
)
|
|
88
|
+
break
|
|
89
|
+
|
|
90
|
+
# Use user-specified type or auto-detected type
|
|
91
|
+
effective_type = model_type if model_type else model["type"]
|
|
92
|
+
|
|
93
|
+
# Auto-assign or use provided port
|
|
94
|
+
used_ports = {r.get("port") for r in all_roles.values() if r.get("port") and r.get("model_id") != model_id}
|
|
95
|
+
if port:
|
|
96
|
+
if not is_port_available(port):
|
|
97
|
+
click.echo(f"Error: Port {port} is not available.")
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
else:
|
|
100
|
+
base_port = config.get_base_port()
|
|
101
|
+
port = find_next_available_port(start=base_port, exclude=used_ports)
|
|
102
|
+
|
|
103
|
+
role_config = {
|
|
104
|
+
"model_id": model["model_id"],
|
|
105
|
+
"path": model["path"],
|
|
106
|
+
"type": effective_type,
|
|
107
|
+
"port": port,
|
|
108
|
+
}
|
|
109
|
+
config.register_role(role, role_config)
|
|
110
|
+
click.echo(f"Registered '{model_id}' to role '{role}' on port {port} (type: {effective_type})")
|
|
111
|
+
|
|
112
|
+
except ValueError as e:
|
|
113
|
+
click.echo(f"Error: {e}")
|
|
114
|
+
sys.exit(1)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@cli.command()
|
|
118
|
+
@click.argument("role")
|
|
119
|
+
def unregister(role):
|
|
120
|
+
"""Unregister a model from a role."""
|
|
121
|
+
if role not in VALID_ROLES:
|
|
122
|
+
click.echo(f"Error: Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}")
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
|
|
125
|
+
config = ConfigManager()
|
|
126
|
+
if not config.get_role(role):
|
|
127
|
+
click.echo(f"Role '{role}' is not registered.")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
# Stop server if running
|
|
131
|
+
server = ServerManager()
|
|
132
|
+
server.stop(role)
|
|
133
|
+
|
|
134
|
+
config.unregister_role(role)
|
|
135
|
+
click.echo(f"Unregistered role '{role}'")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@cli.command()
|
|
139
|
+
@click.argument("role", required=False)
|
|
140
|
+
@click.option("--all", "-a", "all_roles", is_flag=True, help="Start all registered role servers")
|
|
141
|
+
@click.option("--timeout", "-t", type=int, default=None, help="Override startup timeout in seconds")
|
|
142
|
+
def start(role, all_roles, timeout):
|
|
143
|
+
"""Start the server for a role, or all roles with --all."""
|
|
144
|
+
# Guard against --all + positional role conflict
|
|
145
|
+
if all_roles and role:
|
|
146
|
+
click.echo("Error: Cannot specify both --all and a role.")
|
|
147
|
+
sys.exit(1)
|
|
148
|
+
|
|
149
|
+
server = ServerManager()
|
|
150
|
+
if timeout:
|
|
151
|
+
server.STARTUP_TIMEOUT = timeout
|
|
152
|
+
config = ConfigManager()
|
|
153
|
+
|
|
154
|
+
if all_roles:
|
|
155
|
+
results = server.start_all()
|
|
156
|
+
for r in results:
|
|
157
|
+
status_icon = "\u2713" if r["status"] == "started" else "\u2717"
|
|
158
|
+
detail = f"port {r.get('info', {}).get('port', '?')}" if r["status"] == "started" else r.get("error", "")
|
|
159
|
+
click.echo(f" {status_icon} {r['role']}: {r['status']} {detail}")
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
if not role:
|
|
163
|
+
click.echo("Error: Specify a role or use --all.")
|
|
164
|
+
click.echo(f"Available roles: {', '.join(sorted(VALID_ROLES))}")
|
|
165
|
+
sys.exit(1)
|
|
166
|
+
|
|
167
|
+
if role not in VALID_ROLES:
|
|
168
|
+
click.echo(f"Error: Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}")
|
|
169
|
+
sys.exit(1)
|
|
170
|
+
|
|
171
|
+
role_config = config.get_role(role)
|
|
172
|
+
if not role_config:
|
|
173
|
+
click.echo(f"Error: Role '{role}' is not registered. Run 'llm-ctl register {role} <model_id>'")
|
|
174
|
+
sys.exit(1)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
state = server.start(role, role_config)
|
|
178
|
+
click.echo(f"Started {role} server (PID {state['pid']}, port {state['port']})")
|
|
179
|
+
click.echo(f"Log: {state.get('log_file', 'N/A')}")
|
|
180
|
+
except RuntimeError as e:
|
|
181
|
+
click.echo(f"Error: {e}")
|
|
182
|
+
sys.exit(1)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@cli.command()
|
|
186
|
+
@click.argument("role", required=False)
|
|
187
|
+
@click.option("--all", "-a", "all_roles", is_flag=True, help="Stop all running servers")
|
|
188
|
+
def stop(role, all_roles):
|
|
189
|
+
"""Stop the server for a role, or all roles with --all."""
|
|
190
|
+
# Guard against --all + positional role conflict
|
|
191
|
+
if all_roles and role:
|
|
192
|
+
click.echo("Error: Cannot specify both --all and a role.")
|
|
193
|
+
sys.exit(1)
|
|
194
|
+
|
|
195
|
+
server = ServerManager()
|
|
196
|
+
|
|
197
|
+
if all_roles:
|
|
198
|
+
results = server.stop_all()
|
|
199
|
+
for r in results:
|
|
200
|
+
click.echo(f" {r['role']}: {r['status']}")
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
if not role:
|
|
204
|
+
click.echo("Error: Specify a role or use --all.")
|
|
205
|
+
sys.exit(1)
|
|
206
|
+
|
|
207
|
+
stopped = server.stop(role)
|
|
208
|
+
if stopped:
|
|
209
|
+
click.echo(f"Stopped {role} server")
|
|
210
|
+
else:
|
|
211
|
+
click.echo(f"Role '{role}' server is not running")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@cli.command()
|
|
215
|
+
def status():
|
|
216
|
+
"""Show status of all registered roles and their servers."""
|
|
217
|
+
config = ConfigManager()
|
|
218
|
+
server = ServerManager()
|
|
219
|
+
|
|
220
|
+
roles = config.get_all_roles()
|
|
221
|
+
if not roles:
|
|
222
|
+
click.echo("No roles registered. Run 'llm-ctl register <role> <model_id>'")
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
statuses = server.get_all_status()
|
|
226
|
+
|
|
227
|
+
click.echo(f"\n{'Role':<12} {'Model':<50} {'Type':<6} {'Port':<6} {'Status':<10} {'PID'}")
|
|
228
|
+
click.echo("-" * 100)
|
|
229
|
+
for s in statuses:
|
|
230
|
+
running = "running" if s["running"] else "stopped"
|
|
231
|
+
pid = str(s["pid"]) if s["pid"] else "-"
|
|
232
|
+
model_id = s.get("model_id", "-")
|
|
233
|
+
if len(model_id) > 48:
|
|
234
|
+
model_id = "..." + model_id[-45:]
|
|
235
|
+
click.echo(f"{s['role']:<12} {model_id:<50} {s.get('type', '-'):6} {str(s.get('port', '-')):<6} {running:<10} {pid}")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@cli.command()
|
|
239
|
+
@click.argument("model_id")
|
|
240
|
+
@click.option("--cache-dir", default=None, help="Custom HuggingFace cache directory")
|
|
241
|
+
def download(model_id, cache_dir):
|
|
242
|
+
"""Download a model from HuggingFace.
|
|
243
|
+
|
|
244
|
+
MODEL_ID: HuggingFace model ID (e.g., mlx-community/Qwen3.6-27B-4bit)
|
|
245
|
+
"""
|
|
246
|
+
try:
|
|
247
|
+
from huggingface_hub import snapshot_download
|
|
248
|
+
except ImportError:
|
|
249
|
+
click.echo("Error: huggingface-hub is not installed. Run: pip install huggingface-hub")
|
|
250
|
+
sys.exit(1)
|
|
251
|
+
|
|
252
|
+
click.echo(f"Downloading {model_id}...")
|
|
253
|
+
try:
|
|
254
|
+
if cache_dir:
|
|
255
|
+
cachedir = cache_dir
|
|
256
|
+
else:
|
|
257
|
+
cachedir = str(Path.home() / "models" / "huggingface" / "hub")
|
|
258
|
+
|
|
259
|
+
path = snapshot_download(
|
|
260
|
+
repo_id=model_id,
|
|
261
|
+
cache_dir=cachedir,
|
|
262
|
+
)
|
|
263
|
+
click.echo(f"Downloaded to: {path}")
|
|
264
|
+
except Exception as e:
|
|
265
|
+
click.echo(f"Error downloading model: {e}")
|
|
266
|
+
sys.exit(1)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@cli.command()
|
|
270
|
+
@click.argument("action", required=False, default="show")
|
|
271
|
+
def config(action):
|
|
272
|
+
"""View or edit the configuration file.
|
|
273
|
+
|
|
274
|
+
ACTION: show (default) or edit (opens in $EDITOR)
|
|
275
|
+
"""
|
|
276
|
+
cfg = ConfigManager()
|
|
277
|
+
|
|
278
|
+
if action == "show":
|
|
279
|
+
data = cfg.load()
|
|
280
|
+
click.echo(json.dumps(data, indent=2))
|
|
281
|
+
elif action == "edit":
|
|
282
|
+
editor = os.environ.get("EDITOR", "vi")
|
|
283
|
+
# Ensure file exists
|
|
284
|
+
if not cfg.config_path.exists():
|
|
285
|
+
cfg.save(cfg.load())
|
|
286
|
+
subprocess.call([editor, str(cfg.config_path)])
|
|
287
|
+
click.echo(f"Config file: {cfg.config_path}")
|
|
288
|
+
else:
|
|
289
|
+
click.echo(f"Unknown action: {action}. Use 'show' or 'edit'.")
|
|
290
|
+
sys.exit(1)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
if __name__ == "__main__":
|
|
294
|
+
cli()
|
llm_ctl/config.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Configuration management for llm-ctl."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
DEFAULT_CONFIG: Dict[str, Any] = {
|
|
9
|
+
"roles": {},
|
|
10
|
+
"defaults": {
|
|
11
|
+
"base_port": 8080,
|
|
12
|
+
"models_dirs": [
|
|
13
|
+
os.path.expanduser("~/models/huggingface/hub"),
|
|
14
|
+
os.path.expanduser("~/models"),
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
VALID_ROLES = {"planner", "reviewer", "engineer", "assistant"}
|
|
20
|
+
|
|
21
|
+
CONFIG_DIR = Path.home() / ".config" / "llm-ctl"
|
|
22
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConfigManager:
|
|
26
|
+
def __init__(self, config_path: Optional[str] = None):
|
|
27
|
+
self.config_path = Path(config_path) if config_path else CONFIG_PATH
|
|
28
|
+
|
|
29
|
+
def load(self) -> Dict[str, Any]:
|
|
30
|
+
if not self.config_path.exists():
|
|
31
|
+
return self._default_config()
|
|
32
|
+
with open(self.config_path, "r") as f:
|
|
33
|
+
data = json.load(f)
|
|
34
|
+
# Merge with defaults to ensure new keys appear
|
|
35
|
+
merged = json.loads(json.dumps(DEFAULT_CONFIG))
|
|
36
|
+
merged["roles"].update(data.get("roles", {}))
|
|
37
|
+
if "defaults" in data:
|
|
38
|
+
merged["defaults"].update(data["defaults"])
|
|
39
|
+
return merged
|
|
40
|
+
|
|
41
|
+
def save(self, config: Dict[str, Any]) -> None:
|
|
42
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
with open(self.config_path, "w") as f:
|
|
44
|
+
json.dump(config, f, indent=2)
|
|
45
|
+
f.write("\n")
|
|
46
|
+
|
|
47
|
+
def register_role(self, role: str, role_config: Dict[str, Any]) -> None:
|
|
48
|
+
if role not in VALID_ROLES:
|
|
49
|
+
raise ValueError(f"Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}")
|
|
50
|
+
config = self.load()
|
|
51
|
+
config["roles"][role] = role_config
|
|
52
|
+
self.save(config)
|
|
53
|
+
|
|
54
|
+
def unregister_role(self, role: str) -> None:
|
|
55
|
+
config = self.load()
|
|
56
|
+
if role in config["roles"]:
|
|
57
|
+
del config["roles"][role]
|
|
58
|
+
self.save(config)
|
|
59
|
+
|
|
60
|
+
def get_role(self, role: str) -> Optional[Dict[str, Any]]:
|
|
61
|
+
config = self.load()
|
|
62
|
+
return config["roles"].get(role)
|
|
63
|
+
|
|
64
|
+
def get_all_roles(self) -> Dict[str, Any]:
|
|
65
|
+
config = self.load()
|
|
66
|
+
return config["roles"]
|
|
67
|
+
|
|
68
|
+
def get_base_port(self) -> int:
|
|
69
|
+
config = self.load()
|
|
70
|
+
return config["defaults"].get("base_port", 8080)
|
|
71
|
+
|
|
72
|
+
def get_models_dirs(self) -> list:
|
|
73
|
+
config = self.load()
|
|
74
|
+
return config["defaults"].get("models_dirs", DEFAULT_CONFIG["defaults"]["models_dirs"])
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _default_config() -> Dict[str, Any]:
|
|
78
|
+
return json.loads(json.dumps(DEFAULT_CONFIG))
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def path(self) -> Path:
|
|
82
|
+
return self.config_path
|
llm_ctl/models.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Model discovery and management for llm-ctl."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
7
|
+
|
|
8
|
+
from huggingface_hub.constants import HF_HUB_CACHE as HF_DEFAULT_CACHE
|
|
9
|
+
|
|
10
|
+
from .config import ConfigManager
|
|
11
|
+
from .utils import expand_path, find_next_available_port
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_hf_cache_dir(dir_name: str) -> Tuple[str, str]:
|
|
15
|
+
"""Parse a HuggingFace cache directory name into (user, repo).
|
|
16
|
+
|
|
17
|
+
Format: models--<user>--<repo>
|
|
18
|
+
"""
|
|
19
|
+
parts = dir_name.split("--")
|
|
20
|
+
if len(parts) < 3 or parts[0] != "models":
|
|
21
|
+
raise ValueError(f"Invalid HF cache directory name: {dir_name}")
|
|
22
|
+
# Rejoin in case repo name contains '--'
|
|
23
|
+
user = parts[1]
|
|
24
|
+
repo = "--".join(parts[2:])
|
|
25
|
+
return user, repo
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolve_hf_model_path(cache_dir: Path) -> Optional[str]:
|
|
29
|
+
"""Resolve the actual model path from a HF cache directory.
|
|
30
|
+
|
|
31
|
+
Follows: cache_dir/refs/main → snapshots/<commit_hash>
|
|
32
|
+
"""
|
|
33
|
+
refs_file = cache_dir / "refs" / "main"
|
|
34
|
+
if not refs_file.exists():
|
|
35
|
+
# Try other common refs
|
|
36
|
+
for ref_name in ["head", "main"]:
|
|
37
|
+
ref = cache_dir / "refs" / ref_name
|
|
38
|
+
if ref.exists():
|
|
39
|
+
refs_file = ref
|
|
40
|
+
break
|
|
41
|
+
else:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
commit_hash = refs_file.read_text().strip()
|
|
45
|
+
snapshots_dir = cache_dir / "snapshots" / commit_hash
|
|
46
|
+
if snapshots_dir.is_dir():
|
|
47
|
+
return str(snapshots_dir)
|
|
48
|
+
|
|
49
|
+
# Fallback: find any snapshot directory
|
|
50
|
+
snapshots_base = cache_dir / "snapshots"
|
|
51
|
+
if snapshots_base.is_dir():
|
|
52
|
+
for d in sorted(snapshots_base.iterdir()):
|
|
53
|
+
if d.is_dir():
|
|
54
|
+
return str(d)
|
|
55
|
+
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_mlx_model(model_path: str) -> bool:
|
|
60
|
+
"""Check if a model directory contains MLX-specific model files.
|
|
61
|
+
|
|
62
|
+
Checks for MLX-specific indicators rather than generic HF files:
|
|
63
|
+
- model.mlxfmt.json (MLX format descriptor)
|
|
64
|
+
- weights.npz (MLX native weight format)
|
|
65
|
+
- *.safetensors files with MLX architecture in config
|
|
66
|
+
"""
|
|
67
|
+
path = Path(model_path)
|
|
68
|
+
# MLX-specific files — these are NOT used by standard transformers models
|
|
69
|
+
mlx_indicators = [
|
|
70
|
+
path / "model.mlxfmt.json",
|
|
71
|
+
path / "weights.npz",
|
|
72
|
+
]
|
|
73
|
+
if any(f.exists() for f in mlx_indicators):
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
# Check for safetensors + MLX architecture in config.json
|
|
77
|
+
has_safetensors = any(path.glob("*.safetensors")) or (path / "model.safetensors.index.json").exists()
|
|
78
|
+
if has_safetensors:
|
|
79
|
+
config_file = path / "config.json"
|
|
80
|
+
if config_file.exists():
|
|
81
|
+
try:
|
|
82
|
+
with open(config_file) as f:
|
|
83
|
+
config = json.load(f)
|
|
84
|
+
arch = config.get("architectures", [])
|
|
85
|
+
if any("MLP" in a or "MLX" in a for a in arch):
|
|
86
|
+
return True
|
|
87
|
+
except (json.JSONDecodeError, IOError):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def detect_model_type(model_path: str) -> str:
|
|
94
|
+
"""Detect whether a model is MLX or GGUF."""
|
|
95
|
+
path = Path(model_path)
|
|
96
|
+
if path.suffix.lower() == ".gguf":
|
|
97
|
+
return "gguf"
|
|
98
|
+
if is_mlx_model(model_path):
|
|
99
|
+
return "mlx"
|
|
100
|
+
return "unknown"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_hf_cache_dir(dir_path: Path) -> bool:
|
|
104
|
+
"""Check if a directory is a HuggingFace hub cache directory.
|
|
105
|
+
|
|
106
|
+
Uses the huggingface_hub constant for the default cache location,
|
|
107
|
+
and falls back to checking for models--* subdirectories.
|
|
108
|
+
"""
|
|
109
|
+
# Check if this is the known HF cache dir (or a subdirectory of it)
|
|
110
|
+
expanded_hf_cache = Path(expand_path(HF_DEFAULT_CACHE))
|
|
111
|
+
try:
|
|
112
|
+
dir_path.resolve().relative_to(expanded_hf_cache.resolve())
|
|
113
|
+
return True
|
|
114
|
+
except ValueError:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
# Check if the dir itself contains models--* entries
|
|
118
|
+
if any(
|
|
119
|
+
d.is_dir() and d.name.startswith("models--")
|
|
120
|
+
for d in dir_path.iterdir() if d.is_dir()
|
|
121
|
+
):
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def scan_hf_cache(hf_cache_dir: str) -> List[Dict]:
|
|
128
|
+
"""Scan the HuggingFace cache directory for available models."""
|
|
129
|
+
models = []
|
|
130
|
+
cache_path = Path(expand_path(hf_cache_dir))
|
|
131
|
+
if not cache_path.is_dir():
|
|
132
|
+
return models
|
|
133
|
+
|
|
134
|
+
for entry in cache_path.iterdir():
|
|
135
|
+
if not entry.is_dir() or entry.name.startswith("."):
|
|
136
|
+
continue
|
|
137
|
+
try:
|
|
138
|
+
user, repo = parse_hf_cache_dir(entry.name)
|
|
139
|
+
except ValueError:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
model_path = resolve_hf_model_path(entry)
|
|
143
|
+
if not model_path:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
model_type = detect_model_type(model_path)
|
|
147
|
+
if model_type == "unknown":
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
models.append({
|
|
151
|
+
"model_id": f"{user}/{repo}",
|
|
152
|
+
"path": model_path,
|
|
153
|
+
"type": model_type,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
return models
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def scan_gguf_models(gguf_dir: str) -> List[Dict]:
|
|
160
|
+
"""Scan a directory for GGUF model files."""
|
|
161
|
+
models = []
|
|
162
|
+
dir_path = Path(expand_path(gguf_dir))
|
|
163
|
+
if not dir_path.is_dir():
|
|
164
|
+
return models
|
|
165
|
+
|
|
166
|
+
for gguf_file in dir_path.glob("*.gguf"):
|
|
167
|
+
model_id = f"local/{gguf_file.name}"
|
|
168
|
+
models.append({
|
|
169
|
+
"model_id": model_id,
|
|
170
|
+
"path": str(gguf_file),
|
|
171
|
+
"type": "gguf",
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
return models
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def discover_models(models_dirs: Optional[List[str]] = None) -> List[Dict]:
|
|
178
|
+
"""Discover all available local models."""
|
|
179
|
+
config = ConfigManager()
|
|
180
|
+
if models_dirs is None:
|
|
181
|
+
models_dirs = config.get_models_dirs()
|
|
182
|
+
|
|
183
|
+
all_models = []
|
|
184
|
+
for models_dir in models_dirs:
|
|
185
|
+
expanded = expand_path(models_dir)
|
|
186
|
+
dir_path = Path(expanded)
|
|
187
|
+
if not dir_path.is_dir():
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
# Use robust HF cache detection instead of hardcoded "hub" check
|
|
191
|
+
if is_hf_cache_dir(dir_path):
|
|
192
|
+
all_models.extend(scan_hf_cache(expanded))
|
|
193
|
+
else:
|
|
194
|
+
# Scan for GGUF files
|
|
195
|
+
all_models.extend(scan_gguf_models(expanded))
|
|
196
|
+
|
|
197
|
+
# Deduplicate by path
|
|
198
|
+
seen_paths = set()
|
|
199
|
+
unique_models = []
|
|
200
|
+
for model in all_models:
|
|
201
|
+
if model["path"] not in seen_paths:
|
|
202
|
+
seen_paths.add(model["path"])
|
|
203
|
+
unique_models.append(model)
|
|
204
|
+
|
|
205
|
+
return unique_models
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def find_model_by_id(model_id: str, discovered: Optional[List[Dict]] = None) -> Optional[Dict]:
|
|
209
|
+
"""Find a model by its model_id from discovered models."""
|
|
210
|
+
if discovered is None:
|
|
211
|
+
discovered = discover_models()
|
|
212
|
+
for model in discovered:
|
|
213
|
+
if model["model_id"] == model_id:
|
|
214
|
+
return model
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def register_model(role: str, model_id: str) -> Dict:
|
|
219
|
+
"""Register a model to a role in the config.
|
|
220
|
+
|
|
221
|
+
Returns the role config that was saved.
|
|
222
|
+
"""
|
|
223
|
+
discovered = discover_models()
|
|
224
|
+
model = find_model_by_id(model_id, discovered)
|
|
225
|
+
if not model:
|
|
226
|
+
raise ValueError(f"Model not found: {model_id}. Run 'llm-ctl list' to see available models.")
|
|
227
|
+
|
|
228
|
+
config = ConfigManager()
|
|
229
|
+
# Auto-assign port using the consolidated utility function
|
|
230
|
+
used_ports = {r.get("port") for r in config.get_all_roles().values() if r.get("port")}
|
|
231
|
+
base_port = config.get_base_port()
|
|
232
|
+
port = find_next_available_port(start=base_port, exclude=used_ports)
|
|
233
|
+
|
|
234
|
+
role_config = {
|
|
235
|
+
"model_id": model["model_id"],
|
|
236
|
+
"path": model["path"],
|
|
237
|
+
"type": model["type"],
|
|
238
|
+
"port": port,
|
|
239
|
+
}
|
|
240
|
+
config.register_role(role, role_config)
|
|
241
|
+
return role_config
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def unregister_model(role: str) -> None:
|
|
245
|
+
"""Unregister a model from a role."""
|
|
246
|
+
config = ConfigManager()
|
|
247
|
+
config.unregister_role(role)
|
llm_ctl/server.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Server lifecycle management for llm-ctl."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from .config import ConfigManager
|
|
13
|
+
from .utils import is_process_running, wait_for_port_ready, expand_path
|
|
14
|
+
|
|
15
|
+
STATE_DIR = Path.home() / ".config" / "llm-ctl"
|
|
16
|
+
STATE_PATH = STATE_DIR / "state.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StateFile:
|
|
20
|
+
"""Manages the runtime state file tracking running servers."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, state_path: Optional[str] = None):
|
|
23
|
+
self.state_path = Path(state_path) if state_path else STATE_PATH
|
|
24
|
+
|
|
25
|
+
def load(self) -> Dict[str, Any]:
|
|
26
|
+
if not self.state_path.exists():
|
|
27
|
+
return {}
|
|
28
|
+
try:
|
|
29
|
+
with open(self.state_path, "r") as f:
|
|
30
|
+
return json.load(f)
|
|
31
|
+
except (json.JSONDecodeError, IOError):
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
def save(self, state: Dict[str, Any]) -> None:
|
|
35
|
+
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
with open(self.state_path, "w") as f:
|
|
37
|
+
json.dump(state, f, indent=2)
|
|
38
|
+
f.write("\n")
|
|
39
|
+
|
|
40
|
+
def save_role(self, role: str, role_state: Dict[str, Any]) -> None:
|
|
41
|
+
state = self.load()
|
|
42
|
+
state[role] = role_state
|
|
43
|
+
self.save(state)
|
|
44
|
+
|
|
45
|
+
def remove_role(self, role: str) -> None:
|
|
46
|
+
state = self.load()
|
|
47
|
+
state.pop(role, None)
|
|
48
|
+
self.save(state)
|
|
49
|
+
|
|
50
|
+
def get_role(self, role: str) -> Optional[Dict[str, Any]]:
|
|
51
|
+
state = self.load()
|
|
52
|
+
return state.get(role)
|
|
53
|
+
|
|
54
|
+
def cleanup_stale(self) -> List[str]:
|
|
55
|
+
"""Remove entries for processes that are no longer running. Returns cleaned role names."""
|
|
56
|
+
state = self.load()
|
|
57
|
+
cleaned = []
|
|
58
|
+
for role, info in list(state.items()):
|
|
59
|
+
pid = info.get("pid")
|
|
60
|
+
if pid and not is_process_running(pid):
|
|
61
|
+
del state[role]
|
|
62
|
+
cleaned.append(role)
|
|
63
|
+
if cleaned:
|
|
64
|
+
self.save(state)
|
|
65
|
+
return cleaned
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ServerManager:
|
|
69
|
+
"""Manages starting, stopping, and checking LLM inference servers."""
|
|
70
|
+
|
|
71
|
+
HOST = "127.0.0.1"
|
|
72
|
+
STARTUP_TIMEOUT = 60 # seconds to wait for server to be ready
|
|
73
|
+
|
|
74
|
+
def __init__(self, state_file: Optional[StateFile] = None):
|
|
75
|
+
self.state = state_file or StateFile()
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _build_mlx_command(model_path: str, host: str, port: int) -> List[str]:
|
|
79
|
+
"""Build the command to start an MLX model server."""
|
|
80
|
+
return [
|
|
81
|
+
"mlx_lm", "serve", model_path,
|
|
82
|
+
"--host", host,
|
|
83
|
+
"--port", str(port),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _build_gguf_command(model_path: str, host: str, port: int) -> List[str]:
|
|
88
|
+
"""Build the command to start a GGUF model server via llama-server."""
|
|
89
|
+
return [
|
|
90
|
+
"llama-server",
|
|
91
|
+
"--model", model_path,
|
|
92
|
+
"--host", host,
|
|
93
|
+
"--port", str(port),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
def start(self, role: str, role_config: Dict[str, Any]) -> Dict[str, Any]:
|
|
97
|
+
"""Start the server for a given role.
|
|
98
|
+
|
|
99
|
+
Returns the role state info.
|
|
100
|
+
"""
|
|
101
|
+
# Check if already running
|
|
102
|
+
existing = self.state.get_role(role)
|
|
103
|
+
if existing and is_process_running(existing.get("pid", 0)):
|
|
104
|
+
return existing
|
|
105
|
+
|
|
106
|
+
# Clean up stale entry if exists
|
|
107
|
+
if existing:
|
|
108
|
+
self.state.remove_role(role)
|
|
109
|
+
|
|
110
|
+
model_type = role_config.get("type", "mlx")
|
|
111
|
+
model_path = role_config.get("path", "")
|
|
112
|
+
port = role_config.get("port", 8080)
|
|
113
|
+
|
|
114
|
+
if model_type == "mlx":
|
|
115
|
+
cmd = self._build_mlx_command(model_path, self.HOST, port)
|
|
116
|
+
elif model_type == "gguf":
|
|
117
|
+
cmd = self._build_gguf_command(model_path, self.HOST, port)
|
|
118
|
+
else:
|
|
119
|
+
raise ValueError(f"Unsupported model type: {model_type}")
|
|
120
|
+
|
|
121
|
+
# Check that the command exists
|
|
122
|
+
cmd_name = cmd[0]
|
|
123
|
+
if not self._command_exists(cmd_name):
|
|
124
|
+
raise RuntimeError(
|
|
125
|
+
f"Command '{cmd_name}' not found in PATH. "
|
|
126
|
+
f"Make sure {'mlx_lm' if model_type == 'mlx' else 'llama.cpp'} is installed."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Start the process
|
|
130
|
+
# Redirect output to log file
|
|
131
|
+
log_dir = Path.home() / ".config" / "llm-ctl" / "logs"
|
|
132
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
log_file = log_dir / f"{role}.log"
|
|
134
|
+
|
|
135
|
+
# Open log file — do NOT use a context manager. The child process inherits
|
|
136
|
+
# the file descriptor, and closing it would send SIGPIPE to the child,
|
|
137
|
+
# killing the server before it finishes starting.
|
|
138
|
+
log_fh = open(log_file, "w")
|
|
139
|
+
process = subprocess.Popen(
|
|
140
|
+
cmd,
|
|
141
|
+
stdout=log_fh,
|
|
142
|
+
stderr=subprocess.STDOUT,
|
|
143
|
+
start_new_session=True,
|
|
144
|
+
)
|
|
145
|
+
# log_fh is intentionally left open — the OS will clean up the fd when
|
|
146
|
+
# both the parent (this Python process) and child (the server) exit.
|
|
147
|
+
|
|
148
|
+
# Wait for server to be ready
|
|
149
|
+
ready = wait_for_port_ready(port, self.HOST, timeout=self.STARTUP_TIMEOUT)
|
|
150
|
+
if not ready:
|
|
151
|
+
# Server didn't start in time — kill it
|
|
152
|
+
try:
|
|
153
|
+
os.kill(process.pid, signal.SIGTERM)
|
|
154
|
+
except OSError:
|
|
155
|
+
pass
|
|
156
|
+
raise RuntimeError(
|
|
157
|
+
f"Server for role '{role}' failed to start within {self.STARTUP_TIMEOUT}s. "
|
|
158
|
+
f"Check {log_file} for details."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
role_state = {
|
|
162
|
+
"pid": process.pid,
|
|
163
|
+
"port": port,
|
|
164
|
+
"command": " ".join(cmd),
|
|
165
|
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
166
|
+
"log_file": str(log_file),
|
|
167
|
+
}
|
|
168
|
+
self.state.save_role(role, role_state)
|
|
169
|
+
return role_state
|
|
170
|
+
|
|
171
|
+
def stop(self, role: str) -> bool:
|
|
172
|
+
"""Stop the server for a given role. Returns True if a server was stopped."""
|
|
173
|
+
role_state = self.state.get_role(role)
|
|
174
|
+
if not role_state:
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
pid = role_state.get("pid")
|
|
178
|
+
if pid and is_process_running(pid):
|
|
179
|
+
try:
|
|
180
|
+
os.kill(pid, signal.SIGTERM)
|
|
181
|
+
# Wait up to 10 seconds for graceful shutdown
|
|
182
|
+
for _ in range(20):
|
|
183
|
+
if not is_process_running(pid):
|
|
184
|
+
break
|
|
185
|
+
time.sleep(0.5)
|
|
186
|
+
# Force kill if still running
|
|
187
|
+
if is_process_running(pid):
|
|
188
|
+
os.kill(pid, signal.SIGKILL)
|
|
189
|
+
except OSError:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
self.state.remove_role(role)
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
def status(self, role: str) -> Dict[str, Any]:
|
|
196
|
+
"""Get the status of a server for a given role."""
|
|
197
|
+
role_state = self.state.get_role(role)
|
|
198
|
+
if not role_state:
|
|
199
|
+
return {
|
|
200
|
+
"role": role,
|
|
201
|
+
"running": False,
|
|
202
|
+
"pid": None,
|
|
203
|
+
"port": None,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
pid = role_state.get("pid")
|
|
207
|
+
running = is_process_running(pid) if pid else False
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
"role": role,
|
|
211
|
+
"running": running,
|
|
212
|
+
"pid": pid,
|
|
213
|
+
"port": role_state.get("port"),
|
|
214
|
+
"started_at": role_state.get("started_at"),
|
|
215
|
+
"command": role_state.get("command"),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
def start_all(self) -> List[Dict[str, Any]]:
|
|
219
|
+
"""Start servers for all registered roles."""
|
|
220
|
+
config = ConfigManager()
|
|
221
|
+
roles = config.get_all_roles()
|
|
222
|
+
results = []
|
|
223
|
+
for role, role_config in roles.items():
|
|
224
|
+
try:
|
|
225
|
+
state = self.start(role, role_config)
|
|
226
|
+
results.append({"role": role, "status": "started", "info": state})
|
|
227
|
+
except Exception as e:
|
|
228
|
+
results.append({"role": role, "status": "error", "error": str(e)})
|
|
229
|
+
return results
|
|
230
|
+
|
|
231
|
+
def stop_all(self) -> List[Dict[str, Any]]:
|
|
232
|
+
"""Stop all running servers."""
|
|
233
|
+
config = ConfigManager()
|
|
234
|
+
roles = config.get_all_roles()
|
|
235
|
+
results = []
|
|
236
|
+
for role in roles:
|
|
237
|
+
try:
|
|
238
|
+
stopped = self.stop(role)
|
|
239
|
+
results.append({"role": role, "status": "stopped" if stopped else "not running"})
|
|
240
|
+
except Exception as e:
|
|
241
|
+
results.append({"role": role, "status": "error", "error": str(e)})
|
|
242
|
+
return results
|
|
243
|
+
|
|
244
|
+
def get_all_status(self) -> List[Dict[str, Any]]:
|
|
245
|
+
"""Get status of all registered role servers."""
|
|
246
|
+
# Clean up stale entries first
|
|
247
|
+
self.state.cleanup_stale()
|
|
248
|
+
|
|
249
|
+
config = ConfigManager()
|
|
250
|
+
roles = config.get_all_roles()
|
|
251
|
+
status_list = []
|
|
252
|
+
for role, role_config in roles.items():
|
|
253
|
+
status = self.status(role)
|
|
254
|
+
status["model_id"] = role_config.get("model_id", "unknown")
|
|
255
|
+
status["type"] = role_config.get("type", "unknown")
|
|
256
|
+
status_list.append(status)
|
|
257
|
+
return status_list
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def _command_exists(cmd: str) -> bool:
|
|
261
|
+
"""Check if a command exists in PATH."""
|
|
262
|
+
import shutil
|
|
263
|
+
return shutil.which(cmd) is not None
|
llm_ctl/utils.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Utility functions for llm-ctl."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import socket
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Set
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def expand_path(path_str: str) -> str:
|
|
12
|
+
"""Expand ~ and environment variables in a path string."""
|
|
13
|
+
return os.path.expandvars(os.path.expanduser(path_str))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_process_running(pid: int) -> bool:
|
|
17
|
+
"""Check if a process with the given PID is running."""
|
|
18
|
+
try:
|
|
19
|
+
os.kill(pid, 0)
|
|
20
|
+
return True
|
|
21
|
+
except (OSError, ProcessLookupError):
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_port_available(port: int, host: str = "127.0.0.1") -> bool:
|
|
26
|
+
"""Check if a TCP port is available (not in use)."""
|
|
27
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
28
|
+
try:
|
|
29
|
+
s.bind((host, port))
|
|
30
|
+
return True
|
|
31
|
+
except OSError:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def find_next_available_port(start: int = 8080, host: str = "127.0.0.1", max_attempts: int = 100, exclude: Optional[Set[int]] = None) -> int:
|
|
36
|
+
"""Find the next available port starting from the given port.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
start: First port to try.
|
|
40
|
+
host: Host to bind against for availability check.
|
|
41
|
+
max_attempts: Maximum number of ports to try.
|
|
42
|
+
exclude: Set of port numbers to skip even if available (e.g., already assigned to other roles).
|
|
43
|
+
"""
|
|
44
|
+
if exclude is None:
|
|
45
|
+
exclude = set()
|
|
46
|
+
for port in range(start, start + max_attempts):
|
|
47
|
+
if port in exclude:
|
|
48
|
+
continue
|
|
49
|
+
if is_port_available(port, host):
|
|
50
|
+
return port
|
|
51
|
+
raise RuntimeError(f"No available port found in range {start}-{start + max_attempts}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def wait_for_port_ready(port: int, host: str = "127.0.0.1", timeout: int = 30, interval: float = 0.5) -> bool:
|
|
55
|
+
"""Wait for a port to become reachable (server is listening). Returns True if the port accepts connections.
|
|
56
|
+
|
|
57
|
+
This checks whether something is actively listening on the port — the opposite of
|
|
58
|
+
is_port_available(), which checks whether a port is free to bind.
|
|
59
|
+
"""
|
|
60
|
+
deadline = time.time() + timeout
|
|
61
|
+
while time.time() < deadline:
|
|
62
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
63
|
+
s.settimeout(1)
|
|
64
|
+
result = s.connect_ex((host, port))
|
|
65
|
+
if result == 0:
|
|
66
|
+
return True
|
|
67
|
+
time.sleep(interval)
|
|
68
|
+
return False
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: local-llm-ctl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool to manage local LLM models and role-based server lifecycle
|
|
5
|
+
Author: Renato Rozas
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Requires-Dist: click>=8.0
|
|
9
|
+
Requires-Dist: huggingface-hub>=0.20
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
llm_ctl/__init__.py,sha256=xzx3cLsIxXqVDud175hlXASsCz3P9Y4OMUmOHb61yaA,75
|
|
2
|
+
llm_ctl/__main__.py,sha256=W7V4Qx4-94fFwoGz-6BTIsCkRb61umaHG7JVMK7BveY,66
|
|
3
|
+
llm_ctl/cli.py,sha256=IA33ZH1vrKMUd59Tx5EzBtrs4PrXRoqkeqGyTzC2yTo,9824
|
|
4
|
+
llm_ctl/config.py,sha256=9BDgL8zqn4fOQ-WXcs_sPidfNeIYVZfPG5mipFvrF8s,2628
|
|
5
|
+
llm_ctl/models.py,sha256=5XvHDmTAYtZo4HZeQ8CtgVDA0MU9j_fMI2E352tSIyk,7556
|
|
6
|
+
llm_ctl/server.py,sha256=A24dg5frSs_Lv2LgK9dJfp8oV89hN4U1JHy73U5d1vc,9097
|
|
7
|
+
llm_ctl/utils.py,sha256=Ukq3x-W4RaEHxQBgR72B5fqFiVzoxPAVAR82jSBF9bg,2344
|
|
8
|
+
local_llm_ctl-0.1.0.dist-info/METADATA,sha256=vNBonm1ID7-fwqWDvTVYY7Hi6U8rTwJr4shy2EjaTeU,265
|
|
9
|
+
local_llm_ctl-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
10
|
+
local_llm_ctl-0.1.0.dist-info/entry_points.txt,sha256=L0XwIOEPQgVV9ZP1_F8YSLwTP9ojQOvRkVGoW9tcvw0,44
|
|
11
|
+
local_llm_ctl-0.1.0.dist-info/top_level.txt,sha256=ZbmQR11Vu7Fb8ywia7XEIo0wmA_81Dxm4N4m2Ly4eZs,8
|
|
12
|
+
local_llm_ctl-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
llm_ctl
|