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 | | `search.py` | SearXNG integration, perplexity scoring, refusal detection |
| `rag.py` | Qdrant vector search + system prompt assembly + chunk_text() helper | | `rag.py` | Qdrant vector search + system prompt assembly + chunk_text() helper |
| `eviction.py` | Score-based RAG eviction engine | | `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 | | `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 | | `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 | | `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 ## Work State
### Completed this session ### 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. - **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. - **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. - **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 - B4 — RAG Corpus Management UI
- B5 — default model auto-pull on first start - B5 — default model auto-pull on first start
- B6 — waterfall direction toggle - 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." - 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). - **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) ### Key config values (current)
- `VERSION = "v0.18.0"` in `config.py` - `VERSION = "v0.19.0"` in `config.py`
- `SESSION_TIMEOUT_SECONDS = 3600` - `SESSION_TIMEOUT_SECONDS = 3600`
- `DEFAULT_MODEL = "qwen2.5-7b-instruct"` - `DEFAULT_MODEL = "qwen2.5-7b-instruct"`
- `LLAMA_SERVER_BASE = "http://192.168.50.108:8081"` - `LLAMA_SERVER_BASE = "http://192.168.50.108:8081"`
+1 -1
View File
@@ -9,7 +9,7 @@ import logging
log = logging.getLogger("caic") log = logging.getLogger("caic")
VERSION = "v0.18.0" VERSION = "v0.19.0"
OLLAMA_BASE = os.environ.get("OLLAMA_BASE", "http://localhost:11434") 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") LLAMA_SERVER_BASE = os.environ.get("LLAMA_SERVER_BASE", "http://192.168.50.108:8081")
SEARXNG_BASE = "http://localhost:8888" 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 json
import logging import logging
import platform
import re
import subprocess import subprocess
import sys
log = logging.getLogger("caic") 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: try:
result = subprocess.run( result = subprocess.run(
["rocm-smi", "--showuse", "--showmemuse", "--json"], ["rocm-smi", "--showuse", "--showmemuse", "--json"],
@@ -27,5 +63,16 @@ def get_gpu_stats() -> dict:
except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError): except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
pass pass
except Exception as e: 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} 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 asyncio
import json import json
import logging import logging
import re
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
import httpx import httpx
@@ -18,12 +20,28 @@ HARDWARE_STATE_PATH = Path("hardware_state.json")
_TIMEOUT_EXPIRED = subprocess.TimeoutExpired _TIMEOUT_EXPIRED = subprocess.TimeoutExpired
async def assess_hardware() -> dict: def _get_vram_darwin() -> tuple[int, int]:
mem = psutil.virtual_memory() try:
ram_total_gb = round(mem.total / (1024 ** 3), 1) result = subprocess.run(
ram_available_gb = round(mem.available / (1024 ** 3), 1) ["system_profiler", "SPDisplaysDataType"],
cpu_count = psutil.cpu_count() 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_total_mb = 0
vram_free_mb = 0 vram_free_mb = 0
try: try:
@@ -47,6 +65,19 @@ async def assess_hardware() -> dict:
log.warning("rocm-smi not available or failed — VRAM stats set to 0") log.warning("rocm-smi not available or failed — VRAM stats set to 0")
except Exception as e: except Exception as e:
log.warning(f"rocm-smi error: {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_reachable = False
llama_models = [] llama_models = []
+15
View File
@@ -51,6 +51,7 @@ import asyncio
import json import json
import logging import logging
import os import os
import re
import socket import socket
import subprocess import subprocess
import sys import sys
@@ -194,6 +195,20 @@ def get_load() -> dict:
load["vram_pct"] = round(used / total * 100) load["vram_pct"] = round(used / total * 100)
except (FileNotFoundError, subprocess.TimeoutExpired): except (FileNotFoundError, subprocess.TimeoutExpired):
pass 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 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 json
import os import os
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
import httpx import httpx
@@ -174,3 +175,62 @@ def test_get_hardware_endpoint(tmp_path: Path, monkeypatch):
assert data["searxng_reachable"] is True assert data["searxng_reachable"] is True
assert "llama_models" in data assert "llama_models" in data
assert "qdrant_collections" 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