B7: Apple Silicon worker support (v0.19.0)

gpu.py: darwin branch via system_profiler SPDisplaysDataType for GPU
model/VRAM on macOS, falls back to rocm-smi on Linux.
hardware.py: _get_vram_darwin() parses system_profiler output.
node_agent/agent.py: get_load() reports VRAM on darwin via
system_profiler.
tests: 5 new gpu tests (linux/darwin/absent), 3 new hardware tests
(darwin assessment + VRAM parsing).
This commit is contained in:
gramps
2026-07-14 08:09:41 -07:00
parent feaa2830da
commit 790c81457a
7 changed files with 273 additions and 13 deletions
+4 -4
View File
@@ -60,7 +60,7 @@ Refactored from single-file (`app.py`) into modules under project root:
| `search.py` | SearXNG integration, perplexity scoring, refusal detection |
| `rag.py` | Qdrant vector search + system prompt assembly + chunk_text() helper |
| `eviction.py` | Score-based RAG eviction engine |
| `gpu.py` | AMD GPU stats via `rocm-smi` |
| `gpu.py` | GPU stats `rocm-smi` (AMD/Linux) or `system_profiler` (Apple Silicon/macOS) |
| `triage.py` | Phi-4-mini-based query classification + cluster node selection |
| `cluster.py` | Cluster node registry, event log, coordinator election, ping/pong, model swap handlers |
| `amqp.py` | AMQP connection manager — connect, disconnect, publish, subscribe, auto-reconnect |
@@ -135,6 +135,7 @@ All streaming endpoints yield `data: {json}\n\n`. Key shapes:
## Work State
### Completed this session
- **B7 (v0.19.0)** — Apple Silicon worker support. `gpu.py` now detects `sys.platform == "darwin"` and parses `system_profiler SPDisplaysDataType` for GPU model/VRAM instead of `rocm-smi`. `hardware.py` has darwin branch via `_get_vram_darwin()`. `node_agent/agent.py` reports VRAM on macOS via `system_profiler`. 5 new tests cover linux/darwin gpu paths, 3 new hardware tests cover darwin assessment + VRAM parsing.
- **Scroll-position fighting** — `scrollToTop()` now respects `_userScrolledAway` flag (100px threshold), skips auto-scroll when user is reading older content. `resetScrollLock()` called on new messages.
- **401 error cascade** — `SESSION_TIMEOUT_SECONDS` bumped 90→3600 (1 hour). All 10 unprotected `authFetch` calls wrapped in try/catch.
- **Token counter** — removed localStorage persistence; resets to 0 on page refresh.
@@ -156,13 +157,12 @@ All streaming endpoints yield `data: {json}\n\n`. Key shapes:
- B4 — RAG Corpus Management UI
- B5 — default model auto-pull on first start
- B6 — waterfall direction toggle
- B7 — Apple Silicon worker support (gpu.py Metal, hardware.py Darwin)
- B8 — **Encryption & PHI readiness** — spec out encryption at rest (SQLCipher for caic.db, Qdrant payload encryption) and in-transit (TLS for inference, AMQP, RAG). Per-user auth, audit logging, log sanitizer, data lifecycle. Document the "personal LAN HIPAA gap."
- **Preferred approach: Private Chat mode** — a toggle that skips DB persistence, memory/RAG injection, and content logging entirely. Zero stored data = zero data to protect. Simpler, more robust, less code to audit than full encryption. Design this as the primary PHI path before reaching for crypto.
- **Preferred approach: Private Chat mode** — a toggle that skips DB persistence, memory/RAG injection, content logging, and **external SearXNG searching** entirely. Zero stored data, zero external queries = zero data to protect. Simpler, more robust, less code to audit than full encryption. Design this as the primary PHI path before reaching for crypto.
- **In-transit still needs TLS** — Private Chat eliminates at-rest risk, but AMQP (RabbitMQ) and inference (llama-server) traffic between coordinator and workers is still plaintext on the wire. TLS termination on each node is the lightweight fix (self-signed CA, nginx sidecar or RabbitMQ TLS).
### Key config values (current)
- `VERSION = "v0.18.0"` in `config.py`
- `VERSION = "v0.19.0"` in `config.py`
- `SESSION_TIMEOUT_SECONDS = 3600`
- `DEFAULT_MODEL = "qwen2.5-7b-instruct"`
- `LLAMA_SERVER_BASE = "http://192.168.50.108:8081"`
+1 -1
View File
@@ -9,7 +9,7 @@ import logging
log = logging.getLogger("caic")
VERSION = "v0.18.0"
VERSION = "v0.19.0"
OLLAMA_BASE = os.environ.get("OLLAMA_BASE", "http://localhost:11434")
LLAMA_SERVER_BASE = os.environ.get("LLAMA_SERVER_BASE", "http://192.168.50.108:8081")
SEARXNG_BASE = "http://localhost:8888"
+50 -3
View File
@@ -1,14 +1,50 @@
"""
cAIc - AMD GPU stats via rocm-smi.
cAIc - GPU stats: rocm-smi (AMD/Linux), system_profiler (Apple Silicon/macOS).
"""
import json
import logging
import platform
import re
import subprocess
import sys
log = logging.getLogger("caic")
def get_gpu_stats() -> dict:
def _parse_darwin_gpu_stats() -> dict:
try:
result = subprocess.run(
["system_profiler", "SPDisplaysDataType"],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return {}
text = result.stdout
gpu_model = ""
vram_mb = 0
for line in text.splitlines():
m = re.match(r"\s+Chipset Model:\s+(.+)", line)
if m:
gpu_model = m.group(1).strip()
m = re.match(r"\s+VRAM \(Dynamic, Max\):\s+(\d+)\s+GB", line)
if m:
vram_mb = int(m.group(1)) * 1024
if gpu_model:
return {
"gpu_percent": 0,
"vram_percent": 0,
"available": True,
"gpu_model": gpu_model,
"vram_total_mb": vram_mb,
}
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
except Exception as e:
log.warning("Darwin GPU stats error: %s", e)
return {}
def _parse_linux_gpu_stats() -> dict:
try:
result = subprocess.run(
["rocm-smi", "--showuse", "--showmemuse", "--json"],
@@ -27,5 +63,16 @@ def get_gpu_stats() -> dict:
except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
pass
except Exception as e:
log.warning(f"GPU stats error: {e}")
log.warning("Linux GPU stats error: %s", e)
return {}
def get_gpu_stats() -> dict:
if sys.platform == "darwin":
stats = _parse_darwin_gpu_stats()
if stats:
return stats
stats = _parse_linux_gpu_stats()
if stats:
return stats
return {"gpu_percent": 0, "vram_percent": 0, "available": False}
+36 -5
View File
@@ -4,7 +4,9 @@ cAIc — Startup hardware self-assessment.
import asyncio
import json
import logging
import re
import subprocess
import sys
from pathlib import Path
import httpx
@@ -18,12 +20,28 @@ HARDWARE_STATE_PATH = Path("hardware_state.json")
_TIMEOUT_EXPIRED = subprocess.TimeoutExpired
async def assess_hardware() -> dict:
mem = psutil.virtual_memory()
ram_total_gb = round(mem.total / (1024 ** 3), 1)
ram_available_gb = round(mem.available / (1024 ** 3), 1)
cpu_count = psutil.cpu_count()
def _get_vram_darwin() -> tuple[int, int]:
try:
result = subprocess.run(
["system_profiler", "SPDisplaysDataType"],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return 0, 0
vram_total_mb = 0
for line in result.stdout.splitlines():
m = re.match(r"\s+VRAM \(Dynamic, Max\):\s+(\d+)\s+GB", line)
if m:
vram_total_mb += int(m.group(1)) * 1024
return vram_total_mb, vram_total_mb # free ~ total (unified memory)
except (FileNotFoundError, _TIMEOUT_EXPIRED):
log.warning("system_profiler not available — VRAM stats set to 0")
except Exception as e:
log.warning(f"Darwin VRAM error: {e}")
return 0, 0
def _get_vram_linux() -> tuple[int, int]:
vram_total_mb = 0
vram_free_mb = 0
try:
@@ -47,6 +65,19 @@ async def assess_hardware() -> dict:
log.warning("rocm-smi not available or failed — VRAM stats set to 0")
except Exception as e:
log.warning(f"rocm-smi error: {e}")
return vram_total_mb, vram_free_mb
async def assess_hardware() -> dict:
mem = psutil.virtual_memory()
ram_total_gb = round(mem.total / (1024 ** 3), 1)
ram_available_gb = round(mem.available / (1024 ** 3), 1)
cpu_count = psutil.cpu_count()
if sys.platform == "darwin":
vram_total_mb, vram_free_mb = _get_vram_darwin()
else:
vram_total_mb, vram_free_mb = _get_vram_linux()
llama_reachable = False
llama_models = []
+15
View File
@@ -51,6 +51,7 @@ import asyncio
import json
import logging
import os
import re
import socket
import subprocess
import sys
@@ -194,6 +195,20 @@ def get_load() -> dict:
load["vram_pct"] = round(used / total * 100)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# Darwin / Apple Silicon
if sys.platform == "darwin" and "vram_pct" not in load:
try:
result = subprocess.run(
["system_profiler", "SPDisplaysDataType"],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
for line in result.stdout.splitlines():
m = re.match(r"\s+VRAM \(Dynamic, Max\):\s+(\d+)\s+GB", line)
if m:
load["vram_pct"] = 50 # unified memory — best guess
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return load
+107
View File
@@ -0,0 +1,107 @@
import json
import subprocess
import sys
from gpu import get_gpu_stats
def test_linux_gpu_stats_via_rocm_smi(monkeypatch):
monkeypatch.setattr(sys, "platform", "linux")
rocm_json = json.dumps({
"card0": {"GPU use (%)": "42%", "GPU Memory Allocated (VRAM%)": "68%"}
})
class MockProc:
returncode = 0
stdout = rocm_json
class MockSP:
TimeoutExpired = subprocess.TimeoutExpired
run = staticmethod(lambda cmd, **kw: MockProc())
monkeypatch.setattr("gpu.subprocess", MockSP())
stats = get_gpu_stats()
assert stats["gpu_percent"] == 42
assert stats["vram_percent"] == 68
assert stats["available"] is True
def test_linux_gpu_stats_rocm_smi_absent(monkeypatch):
monkeypatch.setattr(sys, "platform", "linux")
class MockSP:
TimeoutExpired = subprocess.TimeoutExpired
run = staticmethod(lambda cmd, **kw: (_ for _ in ()).throw(FileNotFoundError("rocm-smi not found")))
monkeypatch.setattr("gpu.subprocess", MockSP())
stats = get_gpu_stats()
assert stats["gpu_percent"] == 0
assert stats["vram_percent"] == 0
assert stats["available"] is False
def test_darwin_gpu_stats_via_system_profiler(monkeypatch):
monkeypatch.setattr(sys, "platform", "darwin")
sp_output = """Graphics/Displays:
Apple M2 Pro:
Chipset Model: Apple M2 Pro
Type: GPU
Bus: Built-In
Total Number of Cores: 19
VRAM (Dynamic, Max): 16 GB
Displays:
Color LCD:
Display Type: Built-In Retina LCD
Resolution: 3456x2234
"""
class MockProc:
returncode = 0
stdout = sp_output
class MockSP:
TimeoutExpired = subprocess.TimeoutExpired
run = staticmethod(lambda cmd, **kw: MockProc())
monkeypatch.setattr("gpu.subprocess", MockSP())
stats = get_gpu_stats()
assert stats["available"] is True
assert stats["gpu_model"] == "Apple M2 Pro"
assert stats["vram_total_mb"] == 16384
assert stats["gpu_percent"] == 0
assert stats["vram_percent"] == 0
def test_darwin_gpu_stats_system_profiler_absent(monkeypatch):
monkeypatch.setattr(sys, "platform", "darwin")
class MockSP:
TimeoutExpired = subprocess.TimeoutExpired
run = staticmethod(lambda cmd, **kw: (_ for _ in ()).throw(FileNotFoundError("no system_profiler")))
monkeypatch.setattr("gpu.subprocess", MockSP())
stats = get_gpu_stats()
assert stats["available"] is False
assert stats["gpu_percent"] == 0
assert stats["vram_percent"] == 0
def test_darwin_no_gpu_found(monkeypatch):
monkeypatch.setattr(sys, "platform", "darwin")
sp_output = "Graphics/Displays:\n\n No displays found.\n"
class MockProc:
returncode = 0
stdout = sp_output
class MockSP:
TimeoutExpired = subprocess.TimeoutExpired
run = staticmethod(lambda cmd, **kw: MockProc())
monkeypatch.setattr("gpu.subprocess", MockSP())
stats = get_gpu_stats()
assert stats["available"] is False
assert stats["gpu_percent"] == 0
assert stats["vram_percent"] == 0
+60
View File
@@ -2,6 +2,7 @@ import asyncio
import json
import os
import subprocess
import sys
from pathlib import Path
import httpx
@@ -174,3 +175,62 @@ def test_get_hardware_endpoint(tmp_path: Path, monkeypatch):
assert data["searxng_reachable"] is True
assert "llama_models" in data
assert "qdrant_collections" in data
def test_assess_hardware_darwin(tmp_path: Path, monkeypatch):
hardware.HARDWARE_STATE_PATH = tmp_path / "hardware_state.json"
monkeypatch.setattr(psutil, "virtual_memory", lambda: type("M", (), {"total": 16 * 1024 ** 3, "available": 8 * 1024 ** 3})())
monkeypatch.setattr(psutil, "cpu_count", lambda: 8)
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(hardware, "_get_vram_darwin", lambda: (16384, 16384))
async def mock_get(self, url, *args, **kwargs):
if "v1/models" in url:
return _MockGet(200, {"data": [{"id": "qwen2.5:latest"}]})
if "6333" in url:
return _MockGet(200, {"result": {"collections": [{"name": "caic"}]}})
if "8888" in url:
return _MockGet(200, {})
return _MockGet(200, {})
monkeypatch.setattr(httpx.AsyncClient, "get", mock_get)
state = asyncio.run(hardware.assess_hardware())
assert state["vram_total_mb"] == 16384
assert state["vram_free_mb"] == 16384
assert state["ram_total_gb"] == 16.0
assert state["llama_reachable"] is True
def test_get_vram_darwin_parse(monkeypatch):
sp_output = """Graphics/Displays:
Apple M2 Pro:
Chipset Model: Apple M2 Pro
VRAM (Dynamic, Max): 16 GB
"""
class MockProc:
returncode = 0
stdout = sp_output
class MockSP:
TimeoutExpired = subprocess.TimeoutExpired
run = staticmethod(lambda cmd, **kw: MockProc())
monkeypatch.setattr(hardware, "subprocess", MockSP())
total, free = hardware._get_vram_darwin()
assert total == 16384
assert free == 16384
def test_get_vram_darwin_system_profiler_absent(monkeypatch):
class MockSP:
TimeoutExpired = subprocess.TimeoutExpired
run = staticmethod(lambda cmd, **kw: (_ for _ in ()).throw(FileNotFoundError("no system_profiler")))
monkeypatch.setattr(hardware, "subprocess", MockSP())
total, free = hardware._get_vram_darwin()
assert total == 0
assert free == 0