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:
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user