atlas-os/scripts/cluster.py
Chaib Aarab 301cc75de9 init: Atlas OS — open-source patterns + tools for solo founder LLM cluster
3 browser-native chat surfaces (atlas-chat / atlas-hud / atlas-command, no build step)
+ 11 Python automation scripts (cluster, tray, finance, reminders, autonomy loops)
+ 2 patterns documented (cluster-then-polish, anti-impulse R1-R10)
MIT licensed, anonymized from production stack.
2026-05-26 16:21:16 +02:00

179 lines
7.6 KiB
Python

#!/usr/bin/env python3
"""
cluster.py — multi-node Ollama orchestrator.
Three potential nodes:
- pc : localhost (this PC, GPU, 10 models, fast but laptop-up only)
- atlas-01 : <YOUR_VPS_IP>:11434 (Hetzner VPS, CPU, 5 models, always-on)
- met : <YOUR_PC_NODE_IP>:11434 (MET desktop, secondary capacity)
Routing strategy:
- For "always_on" tasks (overnight, scheduled, server-side): prefer atlas-01
- For "fast" tasks (interactive, jarvis, code completion): prefer pc
- For "embed": prefer pc (nomic on both, pc is faster)
- For "vision" (qwen2.5vl): pc only (atlas-01 doesn't have it)
- Fallback chain: if preferred unreachable → try next available node that has the model
Public API:
ask(prompt, task='general', node_pref=None, max_tokens=512) -> {ok, text, node, model, elapsed_s}
embed(text) -> {ok, vector, node, model}
nodes_status() -> [{name, reachable, latency_ms, models}]
pick_node(task, model=None) -> (node_name, model_name)
"""
from __future__ import annotations
import json, time, urllib.request, urllib.error, socket
NODES = {
'pc': {'host': '127.0.0.1', 'port': 11434, 'always_on': False, 'fast': True, 'has_gpu': True},
'atlas-01': {'host': '<YOUR_VPS_IP>', 'port': 11434, 'always_on': True, 'fast': False, 'has_gpu': False},
# FIX 2026-05-26: was <YOUR_PC_NODE_IP> (which is PC's own tailnet IP).
# MET-Desktop actually lives at <YOUR_DESKTOP_NODE_IP>.
'met': {'host': '<YOUR_DESKTOP_NODE_IP>','port': 11434, 'always_on': False, 'fast': True, 'has_gpu': False},
}
TASK_MODEL = {
'chat': 'hermes3:8b',
'voice': 'llama3.2:3b',
'fast': 'llama3.2:1b',
'code': 'qwen2.5-coder:7b',
'code_fast': 'qwen2.5-coder:1.5b',
'reason': 'phi3:medium',
'general': 'qwen2.5:7b',
'general_fast':'qwen2.5:1.5b',
'embed': 'nomic-embed-text',
'vision': 'qwen2.5vl:7b',
}
# Per-node model availability — populated lazily on first nodes_status() call.
_NODE_MODELS_CACHE = {'pc': None, 'atlas-01': None, 'met': None}
_CACHE_AT = {'pc': 0, 'atlas-01': 0, 'met': 0}
_CACHE_TTL = 300 # 5 min
def _http(url: str, body: dict | None = None, timeout=8):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method='POST' if body else 'GET')
if body: req.add_header('Content-Type', 'application/json')
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode())
def _check_node(name: str) -> dict:
"""Probe a node — returns {reachable, latency_ms, models, err?}."""
if name not in NODES: return {'reachable': False, 'err': 'unknown node'}
n = NODES[name]
t0 = time.time()
try:
# Fast TCP check first
with socket.create_connection((n['host'], n['port']), timeout=2): pass
# Then fetch tags
r = _http(f"http://{n['host']}:{n['port']}/api/tags", timeout=5)
models = [m['name'] for m in r.get('models', [])]
return {'reachable': True, 'latency_ms': int((time.time() - t0) * 1000), 'models': models}
except (socket.error, urllib.error.URLError, urllib.error.HTTPError) as e:
return {'reachable': False, 'latency_ms': int((time.time() - t0) * 1000), 'err': str(e)[:60]}
def nodes_status(force=False) -> dict:
"""Return per-node {reachable, models, latency_ms}. Cached 5 min."""
out = {}
now = time.time()
for name in NODES:
if not force and _NODE_MODELS_CACHE[name] is not None and now - _CACHE_AT[name] < _CACHE_TTL:
out[name] = _NODE_MODELS_CACHE[name]
else:
s = _check_node(name)
_NODE_MODELS_CACHE[name] = s
_CACHE_AT[name] = now
out[name] = s
return out
def pick_node(task: str = 'general', model: str | None = None, prefer: str | None = None) -> tuple[str, str] | None:
"""Decide (node, model) for a task. Returns None if no node available."""
if not model: model = TASK_MODEL.get(task, TASK_MODEL['general'])
status = nodes_status()
# Build preference order
order = []
if prefer and prefer in NODES: order.append(prefer)
# task-driven defaults
if task in ('embed',) or task.endswith('_fast') or task == 'fast' or task == 'voice':
order += ['pc', 'atlas-01', 'met']
elif task == 'vision':
order += ['pc'] # only PC has vision model
else:
order += ['pc', 'atlas-01', 'met']
# dedupe preserving order
seen = set(); order = [n for n in order if not (n in seen or seen.add(n))]
for name in order:
s = status.get(name, {})
if not s.get('reachable'): continue
models = s.get('models', [])
# exact match
if model in models: return (name, model)
# match-by-prefix (qwen2.5-coder:7b matches qwen2.5-coder:7b-instruct-q4_K_M etc.)
for m in models:
if m.startswith(model.split(':')[0]): return (name, m)
return None
def ask(prompt: str, task: str = 'general', node_pref: str | None = None,
max_tokens: int = 512, temperature: float = 0.4, timeout: int = 120) -> dict:
pick = pick_node(task, prefer=node_pref)
if not pick: return {'ok': False, 'err': 'no node has the required model'}
node, model = pick
n = NODES[node]
t0 = time.time()
try:
r = _http(f"http://{n['host']}:{n['port']}/api/generate", {
'model': model, 'prompt': prompt, 'stream': False,
'options': {'temperature': temperature, 'num_predict': max_tokens},
}, timeout=timeout)
return {'ok': True, 'text': r.get('response', '').strip(),
'node': node, 'model': model,
'elapsed_s': round(time.time() - t0, 2)}
except Exception as e:
return {'ok': False, 'err': str(e)[:120], 'node': node, 'model': model,
'elapsed_s': round(time.time() - t0, 2)}
def embed(text: str, node_pref: str | None = None) -> dict:
pick = pick_node('embed', prefer=node_pref)
if not pick: return {'ok': False, 'err': 'no embed model'}
node, model = pick
n = NODES[node]
t0 = time.time()
try:
r = _http(f"http://{n['host']}:{n['port']}/api/embeddings", {
'model': model, 'prompt': text,
}, timeout=30)
return {'ok': True, 'vector': r.get('embedding', []),
'node': node, 'model': model,
'elapsed_s': round(time.time() - t0, 2)}
except Exception as e:
return {'ok': False, 'err': str(e)[:120]}
if __name__ == '__main__':
import sys
cmd = sys.argv[1] if len(sys.argv) > 1 else 'status'
if cmd == 'status':
import io
try: sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
except: pass
s = nodes_status(force=True)
for name, info in s.items():
mark = 'OK ' if info.get('reachable') else 'FAIL'
mc = len(info.get('models', []))
lat = info.get('latency_ms', '?')
err = info.get('err', '')
print(f' {mark} {name:10s} {mc:>2d} models {lat:>4}ms {err}')
elif cmd == 'ask':
task = sys.argv[2] if len(sys.argv) > 2 else 'general'
prompt = ' '.join(sys.argv[3:]) or 'say OK only'
r = ask(prompt, task=task)
if r['ok']:
print(f'[{r["node"]} / {r["model"]} / {r["elapsed_s"]}s]')
print(r['text'])
else:
print('ERR:', r.get('err'))
elif cmd == 'pick':
task = sys.argv[2] if len(sys.argv) > 2 else 'general'
prefer = sys.argv[3] if len(sys.argv) > 3 else None
p = pick_node(task, prefer=prefer)
print(p or 'no node available')