local-llm-ctl 0.1.0__tar.gz
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.
- local_llm_ctl-0.1.0/MANIFEST.in +8 -0
- local_llm_ctl-0.1.0/PKG-INFO +9 -0
- local_llm_ctl-0.1.0/README.md +221 -0
- local_llm_ctl-0.1.0/pyproject.toml +27 -0
- local_llm_ctl-0.1.0/setup.cfg +4 -0
- local_llm_ctl-0.1.0/src/llm_ctl/__init__.py +3 -0
- local_llm_ctl-0.1.0/src/llm_ctl/__main__.py +4 -0
- local_llm_ctl-0.1.0/src/llm_ctl/cli.py +294 -0
- local_llm_ctl-0.1.0/src/llm_ctl/config.py +82 -0
- local_llm_ctl-0.1.0/src/llm_ctl/models.py +247 -0
- local_llm_ctl-0.1.0/src/llm_ctl/server.py +263 -0
- local_llm_ctl-0.1.0/src/llm_ctl/utils.py +68 -0
- local_llm_ctl-0.1.0/src/local_llm_ctl.egg-info/PKG-INFO +9 -0
- local_llm_ctl-0.1.0/src/local_llm_ctl.egg-info/SOURCES.txt +21 -0
- local_llm_ctl-0.1.0/src/local_llm_ctl.egg-info/dependency_links.txt +1 -0
- local_llm_ctl-0.1.0/src/local_llm_ctl.egg-info/entry_points.txt +2 -0
- local_llm_ctl-0.1.0/src/local_llm_ctl.egg-info/requires.txt +2 -0
- local_llm_ctl-0.1.0/src/local_llm_ctl.egg-info/top_level.txt +1 -0
- local_llm_ctl-0.1.0/tests/__init__.py +0 -0
- local_llm_ctl-0.1.0/tests/test_config.py +43 -0
- local_llm_ctl-0.1.0/tests/test_models.py +75 -0
- local_llm_ctl-0.1.0/tests/test_server.py +46 -0
- local_llm_ctl-0.1.0/tests/test_utils.py +34 -0
|
@@ -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,221 @@
|
|
|
1
|
+
# llm-ctl
|
|
2
|
+
|
|
3
|
+
A CLI tool to manage local LLM models and role-based server lifecycle.
|
|
4
|
+
|
|
5
|
+
Register models to roles (planner, reviewer, engineer, assistant), start/stop inference servers, and monitor their status — all from the terminal.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Model discovery** — Automatically scans HuggingFace cache and local GGUF files
|
|
10
|
+
- **Role-based management** — Assign models to planner, reviewer, engineer, or assistant roles
|
|
11
|
+
- **Server lifecycle** — Start, stop, and monitor inference servers with PID tracking
|
|
12
|
+
- **Auto port assignment** — Automatically finds available ports for each role
|
|
13
|
+
- **MLX & GGUF support** — Works with both Apple MLX and llama.cpp servers
|
|
14
|
+
- **State persistence** — Survives restarts via JSON state file
|
|
15
|
+
|
|
16
|
+
## Dependencies
|
|
17
|
+
|
|
18
|
+
### Required
|
|
19
|
+
|
|
20
|
+
- **Python 3.9+**
|
|
21
|
+
- **click** — CLI framework
|
|
22
|
+
- **huggingface-hub** — HuggingFace model management
|
|
23
|
+
|
|
24
|
+
### Server backend (one required)
|
|
25
|
+
|
|
26
|
+
You need at least one inference backend to serve models:
|
|
27
|
+
|
|
28
|
+
- **mlx-lm** — For serving MLX models on Apple Silicon (`mlx_lm serve`)
|
|
29
|
+
- **llama.cpp** — For serving GGUF models on any platform (`llama-server`)
|
|
30
|
+
|
|
31
|
+
Install one or both depending on your needs:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# MLX models (Apple Silicon Macs)
|
|
35
|
+
pip install mlx-lm
|
|
36
|
+
|
|
37
|
+
# GGUF models (any platform)
|
|
38
|
+
brew install llama.cpp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
### Quick install (recommended)
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install llm-ctl
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### From source
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
git clone https://github.com/renatorozas/llm-ctl.git
|
|
53
|
+
cd llm-ctl
|
|
54
|
+
python -m venv .venv
|
|
55
|
+
source .venv/bin/activate
|
|
56
|
+
pip install -e .
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### With uv
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
uv pip install llm-ctl
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### With pipx (system-wide, isolated)
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pipx install llm-ctl
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Setup
|
|
72
|
+
|
|
73
|
+
### 1. Install a server backend
|
|
74
|
+
|
|
75
|
+
For **MLX models** (Apple Silicon Macs):
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install mlx-lm
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
For **GGUF models** (any platform):
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# Install llama.cpp (follow instructions at https://github.com/ggerganov/llama.cpp)
|
|
85
|
+
# On macOS with Homebrew:
|
|
86
|
+
brew install llama.cpp
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 2. Download models
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Download from HuggingFace
|
|
93
|
+
llm-ctl download mlx-community/Qwen3.6-27B-4bit
|
|
94
|
+
|
|
95
|
+
# Or use models already in your HuggingFace cache
|
|
96
|
+
llm-ctl list
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 3. Register a role
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# Register a model to a role (auto-detects type and assigns a port)
|
|
103
|
+
llm-ctl register planner mlx-community/Qwen3.6-27B-4bit
|
|
104
|
+
|
|
105
|
+
# Override model type or port
|
|
106
|
+
llm-ctl register reviewer /path/to/model.gguf --type gguf --port 8081
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 4. Start servers
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# Start a single role
|
|
113
|
+
llm-ctl start planner
|
|
114
|
+
|
|
115
|
+
# Start all registered roles
|
|
116
|
+
llm-ctl start --all
|
|
117
|
+
|
|
118
|
+
# With custom timeout
|
|
119
|
+
llm-ctl start planner --timeout 120
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### 5. Check status
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
llm-ctl status
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 6. Stop servers
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
# Stop a single role
|
|
132
|
+
llm-ctl stop planner
|
|
133
|
+
|
|
134
|
+
# Stop all roles
|
|
135
|
+
llm-ctl stop --all
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Commands
|
|
139
|
+
|
|
140
|
+
| Command | Description |
|
|
141
|
+
|---------|-------------|
|
|
142
|
+
| `llm-ctl list` | List all available local models |
|
|
143
|
+
| `llm-ctl register <role> <model_id>` | Register a model to a role |
|
|
144
|
+
| `llm-ctl unregister <role>` | Unregister a model from a role |
|
|
145
|
+
| `llm-ctl start [role]` | Start server for a role (or `--all`) |
|
|
146
|
+
| `llm-ctl stop [role]` | Stop server for a role (or `--all`) |
|
|
147
|
+
| `llm-ctl status` | Show status of all registered roles |
|
|
148
|
+
| `llm-ctl config [show\|edit]` | View or edit the configuration file |
|
|
149
|
+
| `llm-ctl download <model_id>` | Download a model from HuggingFace |
|
|
150
|
+
|
|
151
|
+
## Configuration
|
|
152
|
+
|
|
153
|
+
Configuration is stored in `~/.config/llm-ctl/config.json`:
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"roles": {
|
|
158
|
+
"planner": {
|
|
159
|
+
"model_id": "mlx-community/Qwen3.6-27B-4bit",
|
|
160
|
+
"path": "/Users/renato/models/huggingface/hub/models--mlx-community--Qwen3.6-27B-4bit/snapshots/abc123",
|
|
161
|
+
"type": "mlx",
|
|
162
|
+
"port": 8080
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
"defaults": {
|
|
166
|
+
"base_port": 8080
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
View or edit the config:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
llm-ctl config show
|
|
175
|
+
llm-ctl config edit
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## State
|
|
179
|
+
|
|
180
|
+
Runtime state (PIDs, ports, start times) is stored in `~/.config/llm-ctl/state.json`. Stale entries are automatically cleaned up on status checks.
|
|
181
|
+
|
|
182
|
+
Server logs are written to `~/.config/llm-ctl/logs/<role>.log`.
|
|
183
|
+
|
|
184
|
+
## Roles
|
|
185
|
+
|
|
186
|
+
| Role | Description |
|
|
187
|
+
|------|-------------|
|
|
188
|
+
| `planner` | Planning and task decomposition |
|
|
189
|
+
| `reviewer` | Code review and quality checks |
|
|
190
|
+
| `engineer` | Implementation and coding |
|
|
191
|
+
| `assistant` | General assistance |
|
|
192
|
+
|
|
193
|
+
## Testing
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
python -m pytest tests/ -v
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Project Structure
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
llm-ctl/
|
|
203
|
+
├── src/llm_ctl/
|
|
204
|
+
│ ├── __init__.py
|
|
205
|
+
│ ├── __main__.py
|
|
206
|
+
│ ├── cli.py # Click CLI commands
|
|
207
|
+
│ ├── config.py # Configuration management
|
|
208
|
+
│ ├── models.py # Model discovery and management
|
|
209
|
+
│ ├── server.py # Server lifecycle management
|
|
210
|
+
│ └── utils.py # Utility functions
|
|
211
|
+
├── tests/
|
|
212
|
+
│ ├── test_config.py
|
|
213
|
+
│ ├── test_models.py
|
|
214
|
+
│ ├── test_server.py
|
|
215
|
+
│ └── test_utils.py
|
|
216
|
+
└── pyproject.toml
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## License
|
|
220
|
+
|
|
221
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "local-llm-ctl"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI tool to manage local LLM models and role-based server lifecycle"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [
|
|
12
|
+
{name = "Renato Rozas"}
|
|
13
|
+
]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"click>=8.0",
|
|
16
|
+
"huggingface-hub>=0.20",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
llm-ctl = "llm_ctl.cli:cli"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
24
|
+
exclude = ["*.egg-info"]
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.package-data]
|
|
27
|
+
llm_ctl = ["*.py"]
|
|
@@ -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()
|
|
@@ -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
|