lllm2 0.2.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.
lllm2/__init__.py ADDED
File without changes
lllm2/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
lllm2/app.py ADDED
@@ -0,0 +1,299 @@
1
+ import argparse
2
+ import fcntl
3
+ import json
4
+ import secrets
5
+ import signal
6
+ import socket
7
+ import threading
8
+ import time
9
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10
+ from pathlib import Path
11
+ from urllib.parse import urlparse
12
+ from . import config, downloads
13
+ from .bench import Bench, WORKLOADS
14
+ from .discovery import CATALOG, engines, hardware, probe
15
+ from .engine import Cancelled, Engine
16
+ from .settings import Settings, capabilities, launch_args
17
+ from .store import Store
18
+ from .defaults import starting_defaults
19
+ from .recommendations import promotion_provenance, saved_qualifications
20
+ from .launch import installed_models, choose_launch
21
+
22
+
23
+ class App:
24
+ def __init__(self):
25
+ self.store = Store()
26
+ self.engine = Engine()
27
+ self.bench = Bench(self.engine,self.store)
28
+ self.token = secrets.token_urlsafe(32)
29
+ self.start_requests = {}
30
+
31
+ def default_key(self,s):
32
+ return str(Path(s.model).expanduser().resolve()) + '|' + s.backend
33
+
34
+ def action(self,path,data):
35
+ if path == '/api/files':
36
+ requested = data.get('directory')
37
+ directory = Path(requested).expanduser() if requested else config.MODELS_DIR
38
+ if not requested and not directory.is_dir():
39
+ directory = Path.home()
40
+ directory = directory.resolve(strict=True)
41
+ if not directory.is_dir():
42
+ raise ValueError('Choose a directory.')
43
+ entries = []
44
+ for child in directory.iterdir():
45
+ try:
46
+ is_dir = child.is_dir()
47
+ if is_dir or (child.is_file() and child.suffix.lower() == '.gguf'):
48
+ entries.append(dict(name=child.name, path=str(child), directory=is_dir,
49
+ size=None if is_dir else child.stat().st_size))
50
+ except OSError:
51
+ continue
52
+ entries.sort(key=lambda e: (not e['directory'], e['name'].casefold()))
53
+ return dict(directory=str(directory), parent=str(directory.parent), entries=entries,
54
+ home=str(Path.home()), models=str(config.MODELS_DIR))
55
+ if path == '/api/discover':
56
+ return dict(models=installed_models(),engines=engines(),catalog=CATALOG)
57
+ if path == '/api/launch/select':
58
+ return choose_launch(data.get('model', ''), data.get('engine', ''),
59
+ data.get('backend', ''), data.get('device', ''))
60
+ if path == '/api/launch/check':
61
+ s = Settings.parse(data['settings'])
62
+ error = None
63
+ try:
64
+ if not hardware()['gpus']:
65
+ raise ValueError('No NVIDIA GPU detected. Check GPU availability before starting.')
66
+ launch_args(s, config.ENGINE_PORT)
67
+ except (OSError, ValueError) as e:
68
+ error = str(e)
69
+ return dict(valid=error is None, error=error,
70
+ saved_exists=self.store.get('default', self.default_key(s)) is not None)
71
+ if path == '/api/result/preview':
72
+ r = self.store.get('result', data['result_id'])
73
+ if not r or r['status'] != 'complete' or not r['samples']:
74
+ raise ValueError('Choose a completed result with samples.')
75
+ if r.get('measurement_mode') == 'warm-conversation' or r.get('quality_status') == 'failed':
76
+ raise ValueError('This result cannot be used as a general measured configuration.')
77
+ s = Settings.parse(r['settings'])
78
+ if data.get('use_context'):
79
+ if not r.get('recommended_context'):
80
+ raise ValueError('This result has no successful context probe.')
81
+ s.context = r['recommended_context'] * s.slots
82
+ return dict(settings=s.dict(), source='Experiment result · for next launch',
83
+ notes=['Review before starting or saving. Saved preferences are unchanged.'],
84
+ evidence=promotion_provenance(r, s, data.get('use_context')),
85
+ result_id=r['id'], use_context=bool(data.get('use_context')))
86
+ if path == '/api/capabilities':
87
+ s = Settings.parse(data['settings'])
88
+ return dict(features=capabilities(s),engine={k:v for k,v in probe(s.engine).items() if k != 'help'})
89
+ if path == '/api/default/resolve':
90
+ s = Settings.parse(data['settings'])
91
+ source = data.get('source','auto')
92
+ if source not in ['auto','saved','built-in']:
93
+ raise ValueError('Unknown defaults source.')
94
+ saved = self.store.get('default',self.default_key(s))
95
+ if saved and source != 'built-in':
96
+ resolved = Settings.parse(saved)
97
+ resolved.engine, resolved.device = s.engine, s.device
98
+ provenance = self.store.get('default-evidence', self.default_key(s))
99
+ if not provenance or provenance.get('settings') != saved:
100
+ return dict(settings=resolved.dict(), source='Saved defaults · origin unknown',
101
+ notes=['Legacy saved settings preserved; no measurement provenance was recorded.'])
102
+ notes = ['Saved preferences take precedence over built-in recommendations.']
103
+ if provenance['kind'] == 'benchmark':
104
+ notes.append(provenance['note'])
105
+ notes.extend(saved_qualifications(provenance, resolved))
106
+ if provenance['context']['used_headroom_estimate']:
107
+ notes.append('Saved context uses a headroom estimate, not an observed successful allocation.')
108
+ return dict(settings=resolved.dict(), source='Saved defaults · ' +
109
+ ('historical benchmark evidence · qualified' if provenance['kind'] == 'benchmark' else 'manual preferences'),
110
+ notes=notes, evidence=provenance if provenance['kind'] == 'benchmark' else None)
111
+ if source == 'saved':
112
+ raise ValueError('No saved settings for this model and backend yet. Load a completed experiment into Launch or edit the draft, then choose “Save my settings”.')
113
+ return starting_defaults(s)
114
+ if path == '/api/default/load':
115
+ s = Settings.parse(data['settings'])
116
+ return self.store.get('default',self.default_key(s))
117
+ if path == '/api/default/save':
118
+ provenance = None
119
+ if data.get('result_id'):
120
+ r = self.store.get('result',data['result_id'])
121
+ if not r or r['status'] != 'complete' or not r['samples']:
122
+ raise ValueError('Only a completed measured configuration can be promoted.')
123
+ if r.get('measurement_mode') == 'warm-conversation':
124
+ raise ValueError('Warm conversation results cannot be promoted as a general cold baseline. Save launch preferences manually if desired.')
125
+ if r.get('quality_status') == 'failed':
126
+ raise ValueError('Source adherence failed; this result cannot be promoted as a measured baseline.')
127
+ s = Settings.parse(r['settings'])
128
+ if data.get('use_context'):
129
+ if not r['recommended_context']:
130
+ raise ValueError('This run has no successful context probe.')
131
+ s.context = r['recommended_context'] * s.slots
132
+ provenance = promotion_provenance(r, s, data.get('use_context'))
133
+ else:
134
+ s = Settings.parse(data['settings'])
135
+ launch_args(s,config.ENGINE_PORT)
136
+ self.store.put('default',self.default_key(s),s.dict())
137
+ self.store.put('default-evidence', self.default_key(s),
138
+ provenance or dict(kind='manual', settings=s.dict()))
139
+ return s.dict()
140
+ if path == '/api/benchmark':
141
+ return self.bench.submit(data)
142
+ if path in ['/api/cancel','/api/stop']:
143
+ with self.bench.lock:
144
+ self.bench.cancel.set()
145
+ self.engine.stop()
146
+ if not self.bench.active:
147
+ self.bench.progress = dict(status='stopped', kind='launch')
148
+ return dict(ok=True)
149
+ if path == '/api/start':
150
+ s = Settings.parse(data['settings'])
151
+ request_id = data.get('request_id')
152
+ if request_id is not None and (not isinstance(request_id, str) or not 1 <= len(request_id) <= 128):
153
+ raise ValueError('Invalid start request identity.')
154
+ launch_args(s,config.ENGINE_PORT)
155
+ with self.bench.lock:
156
+ if request_id in self.start_requests:
157
+ if self.start_requests[request_id] != s.dict():
158
+ raise ValueError('Start request identity already used for different settings.')
159
+ return dict(ok=True)
160
+ if self.bench.active:
161
+ raise ValueError('Wait for the current operation or cancel it first.')
162
+ running = self.engine.state()
163
+ if running.get('ready') and running['settings'] == s.dict():
164
+ return dict(ok=True)
165
+ if running['running']:
166
+ if data.get('replace_running') is not True or data.get('expected_pid') != running['pid']:
167
+ raise ValueError('A model is running or has changed. Refresh status and explicitly switch or restart it.')
168
+ self.bench.active = True
169
+ self.bench.cancel.clear()
170
+ if request_id:
171
+ self.start_requests[request_id] = s.dict()
172
+ if len(self.start_requests) > 128:
173
+ del self.start_requests[next(iter(self.start_requests))]
174
+ self.bench.progress = dict(kind='launch', status='starting', phase='Loading model',
175
+ started_at=time.time(), settings=s.dict(), request_id=request_id)
176
+ def start():
177
+ try:
178
+ self.engine.start(s,self.bench.cancel)
179
+ self.bench.update(status='serving',phase='Ready')
180
+ except Cancelled:
181
+ self.bench.update(status='cancelled')
182
+ except Exception as e:
183
+ self.bench.update(status='failed',error=str(e))
184
+ finally:
185
+ with self.bench.lock:
186
+ self.bench.active = False
187
+ threading.Thread(target=start,daemon=True).start()
188
+ return dict(ok=True)
189
+ if path == '/api/download':
190
+ entry = next((e for e in CATALOG if e['id']==data['id']),None)
191
+ if entry is None:
192
+ raise ValueError('Unknown catalogue model.')
193
+ return downloads.start(entry).as_dict()
194
+ if path == '/api/download/cancel':
195
+ return dict(cancelled=downloads.cancel(data['id']))
196
+ raise ValueError('Unknown action')
197
+
198
+
199
+ def serve(host='127.0.0.1', port=8082):
200
+ config.STATE_DIR.mkdir(parents=True,exist_ok=True)
201
+ lock = (config.STATE_DIR/'panel.lock').open('w')
202
+ try:
203
+ fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
204
+ except BlockingIOError as error:
205
+ lock.close()
206
+ raise RuntimeError('Another lllm2 panel owns this state directory.') from error
207
+ app = App()
208
+ hostnames = {'127.0.0.1','localhost',socket.gethostname().lower(),socket.getfqdn().lower()}
209
+ allowed_hosts = {f'{name}:{port}' for name in hostnames}
210
+
211
+ class Handler(BaseHTTPRequestHandler):
212
+ def send(self,status,body,kind='application/json'):
213
+ raw = json.dumps(body).encode() if kind == 'application/json' else body
214
+ self.send_response(status)
215
+ self.send_header('Content-Type',kind)
216
+ self.send_header('Content-Length',str(len(raw)))
217
+ self.send_header('Cache-Control','no-store')
218
+ self.send_header('X-Content-Type-Options','nosniff')
219
+ self.end_headers()
220
+ try:
221
+ self.wfile.write(raw)
222
+ except (BrokenPipeError,ConnectionResetError):
223
+ pass
224
+
225
+ def valid_host(self):
226
+ # The accepted socket identifies the local interface used by this
227
+ # request, including LAN addresses on multi-interface workstations.
228
+ local_host = f'{self.connection.getsockname()[0]}:{port}'
229
+ if self.headers.get('Host','').lower() not in allowed_hosts | {local_host}:
230
+ self.send(403,dict(error='Use this workstation’s panel address or hostname.'))
231
+ return False
232
+ return True
233
+
234
+ def do_GET(self):
235
+ if not self.valid_host():
236
+ return
237
+ path = urlparse(self.path).path
238
+ if path == '/':
239
+ self.send(200,Path(__file__).with_name('static').joinpath('index.html').read_bytes(),'text/html; charset=utf-8')
240
+ elif path == '/static/panel.js':
241
+ self.send(200,Path(__file__).with_name('static').joinpath('panel.js').read_bytes(),'text/javascript; charset=utf-8')
242
+ elif path == '/static/panel.css':
243
+ self.send(200,Path(__file__).with_name('static').joinpath('panel.css').read_bytes(),'text/css; charset=utf-8')
244
+ elif path == '/api/status':
245
+ self.send(200,dict(token=app.token,engine=app.engine.state(),job=app.bench.snapshot(),hardware=hardware(),
246
+ downloads=downloads.all_downloads(),endpoint=app.engine.base+'/v1',
247
+ paths=dict(models=str(config.MODELS_DIR),engines=[str(p) for p in config.ENGINE_ROOTS]),workloads=WORKLOADS))
248
+ elif path in ['/api/results','/api/results/export']:
249
+ rows = app.store.list('summary' if path == '/api/results' else 'result')
250
+ self.send(200,rows)
251
+ else:
252
+ self.send(404,dict(error='Not found'))
253
+
254
+ def do_POST(self):
255
+ if not self.valid_host():
256
+ return
257
+ origin = self.headers.get('Origin')
258
+ if self.headers.get('X-LLLM2-Token') != app.token or (origin and origin.lower() != f'http://{self.headers.get("Host","").lower()}'):
259
+ self.send(403,dict(error='Reload the panel to renew its session.'))
260
+ return
261
+ try:
262
+ size = int(self.headers.get('Content-Length','0'))
263
+ if not 0<size<=2_000_000:
264
+ raise ValueError('Invalid request size')
265
+ data = json.loads(self.rfile.read(size))
266
+ self.send(200,app.action(urlparse(self.path).path,data))
267
+ except (ValueError,KeyError,TypeError,OSError) as e:
268
+ self.send(400,dict(error=str(e)))
269
+ except Exception as e:
270
+ app.engine.log('Panel error: ' + str(e))
271
+ self.send(500,dict(error=str(e)))
272
+
273
+ def log_message(self,*args):
274
+ pass
275
+
276
+ server = ThreadingHTTPServer((host,port),Handler)
277
+ def shutdown(*_):
278
+ threading.Thread(target=server.shutdown,daemon=True).start()
279
+ signal.signal(signal.SIGTERM,shutdown)
280
+ signal.signal(signal.SIGINT,shutdown)
281
+ print(f'lllm2: http://127.0.0.1:{port}',flush=True)
282
+ if host != '127.0.0.1':
283
+ print(f'LAN panel: http://{socket.gethostname() if host == "0.0.0.0" else host}:{port} (listening on {host})',flush=True)
284
+ print('LAN access has no login or TLS: anyone who can reach this port can control the workbench. Use only on a trusted network.',flush=True)
285
+ try:
286
+ server.serve_forever()
287
+ finally:
288
+ app.bench.cancel.set()
289
+ app.engine.stop()
290
+ server.server_close()
291
+ lock.close()
292
+
293
+
294
+ def main(argv=None):
295
+ parser = argparse.ArgumentParser(description='lllm2 local LLM workbench')
296
+ parser.add_argument('--port',type=int,default=8082)
297
+ parser.add_argument('--host',default='127.0.0.1',help='IPv4 bind address (default: localhost; use 0.0.0.0 for trusted-LAN access without authentication or TLS)')
298
+ args = parser.parse_args(argv)
299
+ serve(args.host, args.port)