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 ADDED
@@ -0,0 +1,3 @@
1
+ """llm-ctl: CLI tool to manage local LLM models."""
2
+
3
+ __version__ = "0.1.0"
llm_ctl/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from llm_ctl.cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ llm-ctl = llm_ctl.cli:cli
@@ -0,0 +1 @@
1
+ llm_ctl