diff --git a/AGENTS.md b/AGENTS.md index 86cf15a..dedb9cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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"` diff --git a/config.py b/config.py index fd560d6..5d116b1 100644 --- a/config.py +++ b/config.py @@ -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" diff --git a/gpu.py b/gpu.py index 7268e6d..b1795d8 100644 --- a/gpu.py +++ b/gpu.py @@ -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} diff --git a/hardware.py b/hardware.py index 67b3b4e..7d6f873 100644 --- a/hardware.py +++ b/hardware.py @@ -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 = [] diff --git a/node_agent/agent.py b/node_agent/agent.py index 20ce706..6d2ca40 100644 --- a/node_agent/agent.py +++ b/node_agent/agent.py @@ -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 diff --git a/tests/test_gpu.py b/tests/test_gpu.py new file mode 100644 index 0000000..6a9dc6e --- /dev/null +++ b/tests/test_gpu.py @@ -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 diff --git a/tests/test_hardware.py b/tests/test_hardware.py index 3941c67..dcb8d4f 100644 --- a/tests/test_hardware.py +++ b/tests/test_hardware.py @@ -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