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.
179 lines
7.6 KiB
Python
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')
|