diff --git a/solstone/apps/settings/install_copy.py b/solstone/apps/settings/install_copy.py index 9bd052101..f2afac701 100644 --- a/solstone/apps/settings/install_copy.py +++ b/solstone/apps/settings/install_copy.py @@ -21,6 +21,39 @@ INSTALL_BUTTON_INSTALL = "Install" INSTALL_BUTTON_INSTALLING = "Installing…" INSTALL_BUTTON_RETRY = "Try again" +LOCAL_REQUIREMENTS_TEMPLATE = ( + "Needs {ram_gb} GB available memory and a {download_size} download. " + "One local VLM handles both vision and thinking, so this does not double " + "the memory requirement." +) +LOCAL_DETECTED_MEMORY_TEMPLATE = "{available_gb} GB available memory detected." +LOCAL_DETECTED_MEMORY_UNKNOWN = "Available memory could not be detected." +LOCAL_PATHS_FRAMING = ( + "Hosted key: light path, fastest setup. Local model: maximum-privacy path, " + "heavier install and hardware needs, with model work staying on this machine." +) +LOCAL_EXPERIMENTAL_NOTE = "Local is experimental and may need hands-on setup or tuning." +LOCAL_RECOVERY_NO_HOSTED_KEY = ( + "Add a hosted key to use the light path now; you can revisit local after " + "memory or hardware changes." +) +LOCAL_RECOVERY_HOSTED_KEY_SET = ( + "Your hosted key already covers generate and cogitate; keep using the light " + "path and revisit local after memory or hardware changes." +) +LOCAL_MEMORY_WARNING_LOW_TEMPLATE = ( + "Available memory is below {ram_gb} GB. The local model can still install, " + "but the rest of the system may be slow or unstable while it runs." +) +LOCAL_MEMORY_WARNING_UNKNOWN = ( + "Available memory could not be verified. Setup can continue, but local " + "performance may depend on what else is running." +) +LOCAL_MLX_MEMORY_WARNING_UNKNOWN = ( + "Available memory could not be verified. Setup can continue, but this Mac " + "may still need more free memory when the local model starts." +) + __all__ = [ "INSTALL_PHASE_IDLE", @@ -36,4 +69,14 @@ __all__ = [ "INSTALL_BUTTON_INSTALL", "INSTALL_BUTTON_INSTALLING", "INSTALL_BUTTON_RETRY", + "LOCAL_REQUIREMENTS_TEMPLATE", + "LOCAL_DETECTED_MEMORY_TEMPLATE", + "LOCAL_DETECTED_MEMORY_UNKNOWN", + "LOCAL_PATHS_FRAMING", + "LOCAL_EXPERIMENTAL_NOTE", + "LOCAL_RECOVERY_NO_HOSTED_KEY", + "LOCAL_RECOVERY_HOSTED_KEY_SET", + "LOCAL_MEMORY_WARNING_LOW_TEMPLATE", + "LOCAL_MEMORY_WARNING_UNKNOWN", + "LOCAL_MLX_MEMORY_WARNING_UNKNOWN", ] diff --git a/solstone/apps/settings/local_bootstrap.py b/solstone/apps/settings/local_bootstrap.py index 4d220cc1b..73d58d15d 100644 --- a/solstone/apps/settings/local_bootstrap.py +++ b/solstone/apps/settings/local_bootstrap.py @@ -8,10 +8,14 @@ from __future__ import annotations import logging import sys import threading +from pathlib import Path -import psutil - -from solstone.apps.settings.install_copy import INSTALL_FAILED_NO_PROGRESS +from solstone.apps.settings.install_copy import ( + INSTALL_FAILED_NO_PROGRESS, + LOCAL_MEMORY_WARNING_LOW_TEMPLATE, + LOCAL_MEMORY_WARNING_UNKNOWN, + LOCAL_MLX_MEMORY_WARNING_UNKNOWN, +) from solstone.think.models import LOCAL_MODEL, QWEN_35_9B from solstone.think.providers import local_install, mlx_install from solstone.think.providers.install_state import ( @@ -25,16 +29,25 @@ from solstone.think.providers.install_state import ( ) from solstone.think.providers.local import ( LOCAL_MODEL_SPECS, + LocalModelSpec, LocalProviderError, normalize_model_id, ) +from solstone.think.providers.memory import ( + MLX_AVAILABLE_FLOOR_BYTES, + assess_memory, + free_bytes, + gb, + gb_label, + read_total_bytes, +) logger = logging.getLogger(__name__) _INSTALL_THREADS: dict[str, threading.Thread] = {} _INSTALL_PROGRESS: dict[str, tuple[int | None, int | None]] = {} _INSTALL_LOCK = threading.Lock() -_MLX_MODEL_LABEL = "qwen 3.5 9B VLM — 16 GB" +_MLX_MODEL_LABEL = f"qwen 3.5 9B VLM — {gb_label(MLX_AVAILABLE_FLOOR_BYTES)} GB" class LocalBootstrapUnavailableError(RuntimeError): @@ -78,8 +91,8 @@ def list_local_models() -> list[dict[str, object]]: { "name": spec.name, "label": _MLX_MODEL_LABEL, - "min_ram_gb": spec.min_ram_bytes // 1024**3, - "size_bytes": None, + "min_ram_gb": MLX_AVAILABLE_FLOOR_BYTES // 1024**3, + "size_bytes": spec.size_bytes, } ] return [ @@ -118,32 +131,44 @@ def _platform_supported() -> tuple[bool, str]: return True, "" -def get_availability_payload(model: str) -> dict[str, bool | float | int | str]: +def _download_bytes_for_local_spec(spec: LocalModelSpec) -> int: + return int(spec.size_bytes + (spec.mmproj_size_bytes or 0)) + + +def get_availability_payload(model: str) -> dict[str, bool | float | int | str | None]: """Return the local provider availability payload used by Settings.""" model_id = _resolve_model_id(model) if _is_mlx_backend(): spec = mlx_install.resolve_model_spec(model_id) readiness = mlx_install.inspect_readiness(model_id) - total_memory_bytes = int(psutil.virtual_memory().total) - min_ram_gb = spec.min_ram_bytes // 1024**3 - available = all( - readiness[key] - for key in ( - "platform_supported", - "package_available", - "ram_sufficient", - "model_installed", - ) + memory_verdict = assess_memory( + MLX_AVAILABLE_FLOOR_BYTES, block_below_floor=True + ) + total_memory_bytes = read_total_bytes() + min_ram_gb = MLX_AVAILABLE_FLOOR_BYTES // 1024**3 + memory_blocked = memory_verdict.severity == "blocked" + available = bool( + readiness["platform_supported"] + and readiness["package_available"] + and not memory_blocked + and readiness["model_installed"] + ) + warning = ( + LOCAL_MLX_MEMORY_WARNING_UNKNOWN + if memory_verdict.severity == "warning" + else "" ) if not readiness["platform_supported"]: reason = "requires Apple Silicon macOS" - elif not readiness["package_available"]: - reason = "mlx-vlm runtime is not installed" - elif not readiness["ram_sufficient"]: + elif memory_blocked: + assert memory_verdict.available_bytes is not None reason = ( - f"insufficient RAM (need {min_ram_gb} GB, " - f"have {total_memory_bytes // 1024**3} GB)" + "insufficient RAM " + f"(need {gb_label(memory_verdict.required_bytes)} GB available, " + f"have {gb_label(memory_verdict.available_bytes)} GB available)" ) + elif not readiness["package_available"]: + reason = "mlx-vlm runtime is not installed" elif not readiness["model_installed"]: reason = "local model files are not installed" else: @@ -151,30 +176,34 @@ def get_availability_payload(model: str) -> dict[str, bool | float | int | str]: return { "model": readiness["model_id"], "platform_supported": readiness["platform_supported"], - "total_memory_gb": round(total_memory_bytes / 1024**3, 1), + "total_memory_gb": gb(total_memory_bytes), + "available_memory_gb": gb(memory_verdict.available_bytes), "min_ram_gb": min_ram_gb, "binary_present": readiness["package_available"], "model_present": readiness["model_installed"], "available": available, "reason": reason, + "warning": warning, + "download_bytes": spec.size_bytes, } spec = LOCAL_MODEL_SPECS[model_id] binary_present = check_binary_present() model_present = check_model_present(model_id) platform_supported, reason = _platform_supported() - total_memory_bytes = int(psutil.virtual_memory().total) - total_memory_gb = round(total_memory_bytes / 1024**3, 1) - ram_sufficient = total_memory_bytes >= spec.min_ram_bytes + total_memory_gb = gb(read_total_bytes()) + memory_verdict = assess_memory(spec.min_ram_bytes, block_below_floor=False) + warning = "" + if memory_verdict.severity == "warning": + if memory_verdict.available_bytes is None: + warning = LOCAL_MEMORY_WARNING_UNKNOWN + else: + warning = LOCAL_MEMORY_WARNING_LOW_TEMPLATE.format( + ram_gb=spec.min_ram_bytes // 1024**3 + ) if not platform_supported: available = False - elif not ram_sufficient: - available = False - reason = ( - f"insufficient RAM (need {spec.min_ram_bytes // 1024**3} GB, " - f"have {int(total_memory_bytes / 1024**3)} GB)" - ) else: available = binary_present and model_present if not binary_present: @@ -188,11 +217,14 @@ def get_availability_payload(model: str) -> dict[str, bool | float | int | str]: "model": model_id, "platform_supported": platform_supported, "total_memory_gb": total_memory_gb, + "available_memory_gb": gb(memory_verdict.available_bytes), "min_ram_gb": spec.min_ram_bytes // 1024**3, "binary_present": binary_present, "model_present": model_present, "available": available, "reason": reason, + "warning": warning, + "download_bytes": _download_bytes_for_local_spec(spec), } @@ -297,6 +329,10 @@ def start_bootstrap(model: str) -> tuple[dict[str, str], int]: if status["install_state"] in IN_FLIGHT_STATES: return {"install_state": status["install_state"]}, 200 + disk_reason = _disk_blocked_reason(availability) + if disk_reason: + raise LocalBootstrapUnavailableError(disk_reason) + try: worker = ( _mlx_bootstrap_worker if _is_mlx_backend() else _run_bootstrap_worker @@ -329,17 +365,38 @@ def start_bootstrap(model: str) -> tuple[dict[str, str], int]: return {"install_state": "downloading"}, 202 -def _blocked_reason(availability: dict[str, bool | float | int | str]) -> str: +def _blocked_reason(availability: dict[str, bool | float | int | str | None]) -> str: if not availability["platform_supported"]: return str(availability["reason"]) reason = str(availability["reason"]) if reason.startswith("insufficient RAM"): return reason + if reason.startswith("insufficient disk"): + return reason if _is_mlx_backend() and reason == "mlx-vlm runtime is not installed": return reason return "" +def _disk_target() -> Path: + if _is_mlx_backend(): + return Path(mlx_install.constants.HF_HUB_CACHE) + return local_install.cache_root() + + +def _disk_blocked_reason( + availability: dict[str, bool | float | int | str | None], +) -> str: + need = int(availability["download_bytes"] or 0) + free = free_bytes(_disk_target()) + if free >= need: + return "" + return ( + "insufficient disk space " + f"(need {gb_label(need)} GB, have {gb_label(free)} GB free)" + ) + + def _mlx_bootstrap_worker(model: str) -> None: current_thread = threading.current_thread() try: diff --git a/solstone/apps/settings/tests/test_install_copy.py b/solstone/apps/settings/tests/test_install_copy.py new file mode 100644 index 000000000..5b17e70a7 --- /dev/null +++ b/solstone/apps/settings/tests/test_install_copy.py @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import re + +from solstone.apps.settings import install_copy + +NEW_LOCAL_COPY = ( + "LOCAL_REQUIREMENTS_TEMPLATE", + "LOCAL_DETECTED_MEMORY_TEMPLATE", + "LOCAL_DETECTED_MEMORY_UNKNOWN", + "LOCAL_PATHS_FRAMING", + "LOCAL_EXPERIMENTAL_NOTE", + "LOCAL_RECOVERY_NO_HOSTED_KEY", + "LOCAL_RECOVERY_HOSTED_KEY_SET", + "LOCAL_MEMORY_WARNING_LOW_TEMPLATE", + "LOCAL_MEMORY_WARNING_UNKNOWN", + "LOCAL_MLX_MEMORY_WARNING_UNKNOWN", +) +BANNED_OWNER_TERMS = ( + "capture", + "watch", + "record", + "monitor", + "track", + "collect", +) + + +def test_new_local_install_copy_is_exported_and_populated() -> None: + for name in NEW_LOCAL_COPY: + assert name in install_copy.__all__ + assert getattr(install_copy, name) + + assert "{ram_gb}" in install_copy.LOCAL_REQUIREMENTS_TEMPLATE + assert "{download_size}" in install_copy.LOCAL_REQUIREMENTS_TEMPLATE + assert "{available_gb}" in install_copy.LOCAL_DETECTED_MEMORY_TEMPLATE + assert "{ram_gb}" in install_copy.LOCAL_MEMORY_WARNING_LOW_TEMPLATE + + +def test_new_local_install_copy_avoids_banned_owner_terms() -> None: + combined = "\n".join(getattr(install_copy, name) for name in NEW_LOCAL_COPY) + + for term in BANNED_OWNER_TERMS: + assert re.search(rf"\b{term}\b", combined, re.IGNORECASE) is None diff --git a/solstone/apps/settings/tests/test_local_bootstrap_routes.py b/solstone/apps/settings/tests/test_local_bootstrap_routes.py index fa3bc8503..36afbb2f2 100644 --- a/solstone/apps/settings/tests/test_local_bootstrap_routes.py +++ b/solstone/apps/settings/tests/test_local_bootstrap_routes.py @@ -6,6 +6,7 @@ from __future__ import annotations import importlib import threading from datetime import datetime, timedelta, timezone +from types import SimpleNamespace import pytest @@ -13,6 +14,7 @@ from solstone.apps.settings import local_bootstrap from solstone.apps.settings.install_copy import INSTALL_FAILED_NO_PROGRESS from solstone.convey import create_app from solstone.think.models import LOCAL_MODEL, QWEN_35_9B +from solstone.think.providers import memory from solstone.think.providers.install_state import ( InstallState, InstallStatus, @@ -137,9 +139,9 @@ def test_local_availability_payload_exact_shape(settings_env, monkeypatch): monkeypatch.setattr(local_bootstrap, "check_model_present", lambda _model: True) monkeypatch.setattr(local_bootstrap, "_platform_supported", lambda: (True, "")) monkeypatch.setattr( - local_bootstrap.psutil, + memory.psutil, "virtual_memory", - lambda: type("VMem", (), {"total": 32 * 1024**3})(), + lambda: SimpleNamespace(available=32 * 1024**3, total=32 * 1024**3), ) client = _client(journal_path) @@ -151,21 +153,30 @@ def test_local_availability_payload_exact_shape(settings_env, monkeypatch): "model", "platform_supported", "total_memory_gb", + "available_memory_gb", "min_ram_gb", "binary_present", "model_present", "available", "reason", + "warning", + "download_bytes", } assert payload == { "model": LOCAL_MODEL, "platform_supported": True, "total_memory_gb": 32.0, + "available_memory_gb": 32.0, "min_ram_gb": 8, "binary_present": True, "model_present": True, "available": True, "reason": "", + "warning": "", + "download_bytes": ( + LOCAL_MODEL_SPECS[LOCAL_MODEL].size_bytes + + (LOCAL_MODEL_SPECS[LOCAL_MODEL].mmproj_size_bytes or 0) + ), } @@ -178,9 +189,9 @@ def test_mlx_availability_payload_exact_shape(settings_env, monkeypatch): lambda _model: _mlx_readiness(), ) monkeypatch.setattr( - local_bootstrap.psutil, + memory.psutil, "virtual_memory", - lambda: type("VMem", (), {"total": 32 * 1024**3})(), + lambda: SimpleNamespace(available=32 * 1024**3, total=32 * 1024**3), ) payload = local_bootstrap.get_availability_payload(QWEN_35_9B) @@ -189,21 +200,27 @@ def test_mlx_availability_payload_exact_shape(settings_env, monkeypatch): "model", "platform_supported", "total_memory_gb", + "available_memory_gb", "min_ram_gb", "binary_present", "model_present", "available", "reason", + "warning", + "download_bytes", } assert payload == { "model": QWEN_35_9B, "platform_supported": True, "total_memory_gb": 32.0, - "min_ram_gb": 16, + "available_memory_gb": 32.0, + "min_ram_gb": 13, "binary_present": True, "model_present": True, "available": True, "reason": "", + "warning": "", + "download_bytes": 10453446077, } @@ -235,9 +252,9 @@ def test_mlx_models_route_returns_settings_shape(settings_env, monkeypatch): assert response.get_json() == [ { "name": QWEN_35_9B, - "label": "qwen 3.5 9B VLM — 16 GB", - "min_ram_gb": 16, - "size_bytes": None, + "label": "qwen 3.5 9B VLM — 13 GB", + "min_ram_gb": 13, + "size_bytes": 10453446077, }, ] @@ -251,9 +268,9 @@ def test_mlx_availability_accepts_first_fetch_alias(settings_env, monkeypatch): lambda _model: _mlx_readiness(), ) monkeypatch.setattr( - local_bootstrap.psutil, + memory.psutil, "virtual_memory", - lambda: type("VMem", (), {"total": 32 * 1024**3})(), + lambda: SimpleNamespace(available=32 * 1024**3, total=32 * 1024**3), ) client = _client(journal_path) @@ -263,6 +280,53 @@ def test_mlx_availability_accepts_first_fetch_alias(settings_env, monkeypatch): assert response.get_json()["model"] == QWEN_35_9B +def test_mlx_availability_blocks_below_available_floor(settings_env, monkeypatch): + settings_env(_settings_config()) + monkeypatch.setattr(local_bootstrap, "_is_mlx_backend", lambda: True) + monkeypatch.setattr( + local_bootstrap.mlx_install, + "inspect_readiness", + lambda _model: _mlx_readiness(), + ) + monkeypatch.setattr( + memory.psutil, + "virtual_memory", + lambda: SimpleNamespace(available=12 * 1024**3, total=32 * 1024**3), + ) + + payload = local_bootstrap.get_availability_payload(QWEN_35_9B) + + assert payload["available"] is False + assert payload["min_ram_gb"] == 13 + assert payload["available_memory_gb"] == 12.0 + assert str(payload["reason"]).startswith("insufficient RAM") + assert payload["warning"] == "" + + +def test_local_availability_warns_but_does_not_block_on_low_memory( + settings_env, monkeypatch +): + journal_path, _config = settings_env(_settings_config()) + monkeypatch.setattr(local_bootstrap, "check_binary_present", lambda: True) + monkeypatch.setattr(local_bootstrap, "check_model_present", lambda _model: True) + monkeypatch.setattr(local_bootstrap, "_platform_supported", lambda: (True, "")) + monkeypatch.setattr( + memory.psutil, + "virtual_memory", + lambda: SimpleNamespace(available=1 * 1024**3, total=32 * 1024**3), + ) + client = _client(journal_path) + + response = client.get("/app/settings/api/local/availability") + + assert response.status_code == 200 + payload = response.get_json() + assert payload["available"] is True + assert payload["reason"] == "" + assert payload["warning"].startswith("Available memory is below 8 GB") + assert payload["available_memory_gb"] == 1.0 + + @pytest.mark.parametrize( ("method", "path"), [ @@ -374,8 +438,17 @@ def test_start_bootstrap_payload_for_canonical_states( "reason": "local runtime is not installed", "binary_present": False, "model_present": False, + "download_bytes": ( + LOCAL_MODEL_SPECS[LOCAL_MODEL].size_bytes + + (LOCAL_MODEL_SPECS[LOCAL_MODEL].mmproj_size_bytes or 0) + ), }, ) + monkeypatch.setattr( + memory.shutil, + "disk_usage", + lambda _path: SimpleNamespace(free=100 * 1024**3), + ) _FakeThread.init_count = 0 _FakeThread.start_count = 0 monkeypatch.setattr(local_bootstrap.threading, "Thread", _FakeThread) @@ -386,6 +459,68 @@ def test_start_bootstrap_payload_for_canonical_states( ) +def test_start_bootstrap_low_memory_warning_does_not_block(settings_env, monkeypatch): + settings_env(_settings_config()) + _write_local_status("idle") + monkeypatch.setattr(local_bootstrap, "check_binary_present", lambda: False) + monkeypatch.setattr(local_bootstrap, "check_model_present", lambda _model: False) + monkeypatch.setattr(local_bootstrap, "_platform_supported", lambda: (True, "")) + monkeypatch.setattr( + memory.psutil, + "virtual_memory", + lambda: SimpleNamespace(available=1 * 1024**3, total=32 * 1024**3), + ) + monkeypatch.setattr( + memory.shutil, + "disk_usage", + lambda _path: SimpleNamespace(free=100 * 1024**3), + ) + _FakeThread.init_count = 0 + _FakeThread.start_count = 0 + monkeypatch.setattr(local_bootstrap.threading, "Thread", _FakeThread) + + assert local_bootstrap.start_bootstrap(LOCAL_MODEL) == ( + {"install_state": "downloading"}, + 202, + ) + assert _FakeThread.start_count == 1 + + +def test_start_bootstrap_insufficient_disk_blocks_before_worker( + settings_env, monkeypatch +): + settings_env(_settings_config()) + _write_local_status("idle") + monkeypatch.setattr( + local_bootstrap, + "get_availability_payload", + lambda _model: { + "platform_supported": True, + "reason": "local runtime is not installed", + "binary_present": False, + "model_present": False, + "download_bytes": 4 * 1024**3, + }, + ) + monkeypatch.setattr( + memory.shutil, + "disk_usage", + lambda _path: SimpleNamespace(free=1 * 1024**3), + ) + _FakeThread.init_count = 0 + _FakeThread.start_count = 0 + monkeypatch.setattr(local_bootstrap.threading, "Thread", _FakeThread) + + with pytest.raises( + local_bootstrap.LocalBootstrapUnavailableError, match="insufficient disk" + ): + local_bootstrap.start_bootstrap(LOCAL_MODEL) + + assert _FakeThread.init_count == 0 + status = read_install_status(scope="bundled", name="local") + assert status["install_state"] == "idle" + + def test_local_bootstrap_status_returns_canonical_shape(settings_env): journal_path, _config = settings_env(_settings_config()) _write_local_status("downloading", last_progress_at=_fresh_progress_iso()) @@ -452,9 +587,9 @@ def test_mlx_availability_ignores_install_state_when_snapshot_missing( ), ) monkeypatch.setattr( - local_bootstrap.psutil, + memory.psutil, "virtual_memory", - lambda: type("VMem", (), {"total": 32 * 1024**3})(), + lambda: SimpleNamespace(available=32 * 1024**3, total=32 * 1024**3), ) payload = local_bootstrap.get_availability_payload(QWEN_35_9B) @@ -519,9 +654,9 @@ def test_local_bootstrap_migrates_preexisting_install_without_worker( ) monkeypatch.setattr(local_bootstrap, "_platform_supported", lambda: (True, "")) monkeypatch.setattr( - local_bootstrap.psutil, + memory.psutil, "virtual_memory", - lambda: type("VMem", (), {"total": 32 * 1024**3})(), + lambda: SimpleNamespace(available=32 * 1024**3, total=32 * 1024**3), ) monkeypatch.setattr( local_bootstrap.threading, @@ -548,16 +683,24 @@ def test_mlx_start_bootstrap_dispatches_to_mlx_worker(settings_env, monkeypatch) "model": QWEN_35_9B, "platform_supported": True, "total_memory_gb": 32.0, - "min_ram_gb": 16, + "available_memory_gb": 32.0, + "min_ram_gb": 13, "binary_present": True, "model_present": False, "available": False, "reason": "local model files are not installed", + "warning": "", + "download_bytes": 10453446077, }, ) _FakeThread.init_count = 0 _FakeThread.start_count = 0 _FakeThread.targets = [] + monkeypatch.setattr( + memory.shutil, + "disk_usage", + lambda _path: SimpleNamespace(free=100 * 1024**3), + ) monkeypatch.setattr(local_bootstrap.threading, "Thread", _FakeThread) assert local_bootstrap.start_bootstrap(QWEN_35_9B) == ( diff --git a/solstone/apps/settings/tests/test_workspace_html.py b/solstone/apps/settings/tests/test_workspace_html.py index 14fbfa410..d2596770c 100644 --- a/solstone/apps/settings/tests/test_workspace_html.py +++ b/solstone/apps/settings/tests/test_workspace_html.py @@ -62,6 +62,7 @@ def test_workspace_unified_provider_panel_replaces_install_regions(): assert "function startLocalBootstrap()" in text assert "function renderProvidersPanel(data)" in text assert "function providerCardMeta(state, kind, availability)" in text + assert "function providerCardMetaLine(state, kind, availability)" in text assert "function runProviderAction(providerId, action)" in text assert "async function pollProvidersPanel()" in text assert "function providerCardOverflow(state, kind)" in text @@ -103,8 +104,27 @@ def test_workspace_unified_provider_panel_has_byte_and_blocked_state_paths(): assert "formatMlxBytes(receivedBytes)" in text assert "formatMlxBytes(totalBytes)" in text assert "function localMlxBlockedReason(state, availability)" in text + assert "providerCardMetaLine(state, kind, availability)" in text + assert "INSTALL_COPY.LOCAL_REQUIREMENTS_TEMPLATE" in text + assert "INSTALL_COPY.LOCAL_DETECTED_MEMORY_TEMPLATE" in text + assert "INSTALL_COPY.LOCAL_DETECTED_MEMORY_UNKNOWN" in text + assert "INSTALL_COPY.LOCAL_PATHS_FRAMING" in text + assert "INSTALL_COPY.LOCAL_EXPERIMENTAL_NOTE" in text + assert "INSTALL_COPY.LOCAL_RECOVERY_HOSTED_KEY_SET" in text + assert "INSTALL_COPY.LOCAL_RECOVERY_NO_HOSTED_KEY" in text + assert ( + "!!(configData?.env?.GOOGLE_API_KEY || configData?.runtime_env?.GOOGLE_API_KEY)" + ) in text assert "'local runtime is not installed'" in text assert "'local model files are not installed'" in text + match = re.search( + r"const installableReasons = \[(?P.*?)\];", + text, + re.DOTALL, + ) + assert match is not None + assert "insufficient RAM" not in match.group("body") + assert "insufficient disk" not in match.group("body") def test_workspace_cogitate_key_guidance_strings_present(): diff --git a/solstone/apps/settings/workspace.html b/solstone/apps/settings/workspace.html index 05077d9a7..46f5dd1b0 100644 --- a/solstone/apps/settings/workspace.html +++ b/solstone/apps/settings/workspace.html @@ -5297,7 +5297,7 @@ function renderProviderCard(providerId, state, kind, availability) { header.append(title, pills); card.appendChild(header); - const metaLine = providerCardMetaLine(state, kind); + const metaLine = providerCardMetaLine(state, kind, availability); if (metaLine) { const details = document.createElement('div'); details.className = 'provider-card__meta'; @@ -5337,8 +5337,48 @@ function renderProviderCard(providerId, state, kind, availability) { return card; } -function providerCardMetaLine(state, kind) { - return null; +function providerCardMetaLine(state, kind, availability) { + if (!['local', 'local-mlx'].includes(kind) || !availability) return null; + const block = document.createElement('div'); + block.className = 'provider-card__resource-meta'; + const requirement = installCopyTemplate(INSTALL_COPY.LOCAL_REQUIREMENTS_TEMPLATE, { + ram_gb: availability.min_ram_gb, + download_size: formatMlxBytes(availability.download_bytes), + }); + appendProviderMetaLine(block, requirement); + if (availability.available_memory_gb == null) { + appendProviderMetaLine(block, INSTALL_COPY.LOCAL_DETECTED_MEMORY_UNKNOWN); + } else { + appendProviderMetaLine( + block, + installCopyTemplate(INSTALL_COPY.LOCAL_DETECTED_MEMORY_TEMPLATE, { + available_gb: availability.available_memory_gb, + }) + ); + } + appendProviderMetaLine(block, INSTALL_COPY.LOCAL_PATHS_FRAMING); + appendProviderMetaLine(block, INSTALL_COPY.LOCAL_EXPERIMENTAL_NOTE); + if (localMlxBlockedReason(state, availability)) { + const hasHostedKey = !!(configData?.env?.GOOGLE_API_KEY || configData?.runtime_env?.GOOGLE_API_KEY); + appendProviderMetaLine( + block, + hasHostedKey ? INSTALL_COPY.LOCAL_RECOVERY_HOSTED_KEY_SET : INSTALL_COPY.LOCAL_RECOVERY_NO_HOSTED_KEY + ); + } + return block; +} + +function appendProviderMetaLine(parent, text) { + const line = document.createElement('div'); + line.textContent = text; + parent.appendChild(line); +} + +function installCopyTemplate(template, values) { + return template.replace(/\{([a-z_]+)\}/g, (match, key) => { + if (Object.prototype.hasOwnProperty.call(values, key)) return String(values[key]); + return match; + }); } function localMlxBlockedReason(state, availability) { diff --git a/solstone/think/providers/local.py b/solstone/think/providers/local.py index 8df80ba80..a8fecad6b 100644 --- a/solstone/think/providers/local.py +++ b/solstone/think/providers/local.py @@ -41,6 +41,7 @@ class LocalModelSpec: min_ram_bytes: int mmproj_filename: str | None = None mmproj_sha256: str | None = None + mmproj_size_bytes: int | None = None LOCAL_MODEL_SPECS: dict[str, LocalModelSpec] = { @@ -54,6 +55,7 @@ LOCAL_MODEL_SPECS: dict[str, LocalModelSpec] = { min_ram_bytes=8 * 1024**3, mmproj_filename="mmproj-F16.gguf", mmproj_sha256="cd88edcf8d031894960bb0c9c5b9b7e1fea6ebee02b9f7ce925a00d12891f864", + mmproj_size_bytes=672423616, ), } diff --git a/solstone/think/providers/local_install.py b/solstone/think/providers/local_install.py index 82ebec6b9..6a3f9aac1 100644 --- a/solstone/think/providers/local_install.py +++ b/solstone/think/providers/local_install.py @@ -30,10 +30,10 @@ from solstone.think.providers.install_state import ( ) from solstone.think.providers.local import ( LOCAL_MODEL_SPECS, - LocalModelSpec, LocalProviderError, normalize_model_id, ) +from solstone.think.providers.memory import assess_memory from solstone.think.utils import get_journal LOCAL_PROVIDER_NAME = "local" @@ -389,15 +389,6 @@ def install_local(model_id: str = LOCAL_MODEL) -> dict[str, Any]: return install_model(model_id) -def _ram_sufficient(spec: LocalModelSpec) -> bool: - try: - import psutil - - return int(psutil.virtual_memory().total) >= spec.min_ram_bytes - except Exception: - return True - - def inspect_readiness(model_id: str | None = None) -> dict[str, Any]: config = read_journal_config() record = config.get("providers", {}).get("bundled", {}).get(LOCAL_PROVIDER_NAME, {}) @@ -432,14 +423,14 @@ def inspect_readiness(model_id: str | None = None) -> dict[str, Any]: selected_model ) mmproj_installed = resolved_mmproj is None or resolved_mmproj.exists() - ram_sufficient = _ram_sufficient(spec) + memory_verdict = assess_memory(spec.min_ram_bytes, block_below_floor=False) return { "install_state": status["install_state"], "binary_installed": binary_path.exists() and os.access(binary_path, os.X_OK), "model_installed": gguf_path.exists() and mmproj_installed, "gguf_installed": gguf_path.exists(), "mmproj_installed": mmproj_installed, - "ram_sufficient": ram_sufficient, + "ram_sufficient": memory_verdict.severity != "blocked", "binary_path": str(binary_path), "model_path": str(gguf_path), "mmproj_path": str(resolved_mmproj) if resolved_mmproj is not None else None, @@ -451,11 +442,6 @@ def inspect_readiness(model_id: str | None = None) -> dict[str, Any]: def ensure_artifacts_installed(model_id: str) -> tuple[Path, Path, Path | None]: selected_model = normalize_model_id(model_id) readiness = inspect_readiness(selected_model) - if not readiness["ram_sufficient"]: - raise LocalProviderError( - "ram_insufficient", - "This computer does not have enough memory for the selected local model.", - ) if not readiness["binary_installed"]: raise LocalProviderError("binary_missing", "Local runtime is not installed.") if not readiness["model_installed"]: diff --git a/solstone/think/providers/memory.py b/solstone/think/providers/memory.py new file mode 100644 index 000000000..60a9b91c9 --- /dev/null +++ b/solstone/think/providers/memory.py @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Memory and disk helpers for bundled local provider readiness.""" + +from __future__ import annotations + +import shutil +from dataclasses import dataclass +from pathlib import Path + +import psutil + +MLX_SINGLE_VLM_RESIDENT_BYTES = int(12.5 * 1024**3) +MLX_HEADROOM_BYTES = int(0.5 * 1024**3) +MLX_AVAILABLE_FLOOR_BYTES = MLX_SINGLE_VLM_RESIDENT_BYTES + MLX_HEADROOM_BYTES + + +@dataclass(frozen=True) +class MemoryVerdict: + available_bytes: int | None + required_bytes: int + severity: str + + +def read_available_bytes() -> int | None: + """Return available memory bytes, or None when detection is unreliable.""" + try: + memory = psutil.virtual_memory() + available = int(memory.available) + total = int(memory.total) + except Exception: + return None + if available <= 0 or total <= 0 or available > total: + return None + return available + + +def read_total_bytes() -> int | None: + """Return total memory bytes for legacy display-only payloads.""" + try: + total = int(psutil.virtual_memory().total) + except Exception: + return None + return total if total > 0 else None + + +def assess_memory(required_bytes: int, *, block_below_floor: bool) -> MemoryVerdict: + available = read_available_bytes() + if available is None: + severity = "warning" + elif available >= required_bytes: + severity = "ok" + elif block_below_floor: + severity = "blocked" + else: + severity = "warning" + return MemoryVerdict( + available_bytes=available, + required_bytes=required_bytes, + severity=severity, + ) + + +def gb(value: int | None) -> float | None: + if value is None: + return None + return round(value / 1024**3, 1) + + +def gb_label(value: int) -> str: + value_gb = gb(value) + assert value_gb is not None + return f"{value_gb:g}" + + +def free_bytes(target: Path) -> int: + usage_root = target + while not usage_root.exists() and usage_root != usage_root.parent: + usage_root = usage_root.parent + return int(shutil.disk_usage(usage_root).free) + + +__all__ = [ + "MLX_AVAILABLE_FLOOR_BYTES", + "MLX_HEADROOM_BYTES", + "MLX_SINGLE_VLM_RESIDENT_BYTES", + "MemoryVerdict", + "assess_memory", + "free_bytes", + "gb", + "gb_label", + "read_available_bytes", + "read_total_bytes", +] diff --git a/solstone/think/providers/mlx_install.py b/solstone/think/providers/mlx_install.py index cf9e5b6c1..0af2080ba 100644 --- a/solstone/think/providers/mlx_install.py +++ b/solstone/think/providers/mlx_install.py @@ -16,7 +16,6 @@ from pathlib import Path from typing import Any import huggingface_hub -import psutil from huggingface_hub import constants from huggingface_hub.file_download import repo_folder_name @@ -28,6 +27,11 @@ from solstone.think.providers.install_state import ( transition_state, write_install_status, ) +from solstone.think.providers.memory import ( + MLX_AVAILABLE_FLOOR_BYTES, + assess_memory, + gb_label, +) MLX_SOFT_TOKEN_BUDGET = 1120 _GEMMA4_MIN_POSITION_EMBEDDING_SIZE = 10240 @@ -49,7 +53,7 @@ class MLXModelSpec: name: str repo: str revision: str - min_ram_bytes: int + size_bytes: int _MLX_MODEL_REGISTRY: dict[str, MLXModelSpec] = { @@ -57,13 +61,15 @@ _MLX_MODEL_REGISTRY: dict[str, MLXModelSpec] = { name=QWEN_35_9B, repo="mlx-community/Qwen3.5-9B-MLX-8bit", revision="84f7c2deea248d8df56240f88102def51c7ed5d6", - min_ram_bytes=16 * 1024**3, + size_bytes=10453446077, ), GEMMA4_26B_A4B_4BIT: MLXModelSpec( name=GEMMA4_26B_A4B_4BIT, repo="mlx-community/gemma-4-26b-a4b-it-4bit", revision="efbeee6e582ebfd06abc9d65e90839c4b5d2116b", - min_ram_bytes=24 * 1024**3, + # Per-model RAM floors are future work tied to hardware-keyed model + # selection; today's active VLM floor is shared. + size_bytes=15641241224, ), } @@ -115,12 +121,13 @@ def is_mlx_available_for_model(spec: MLXModelSpec) -> tuple[bool, str]: ok, reason = _check_platform_and_package() if not ok: return ok, reason - total_ram = psutil.virtual_memory().total - if total_ram < spec.min_ram_bytes: + verdict = assess_memory(MLX_AVAILABLE_FLOOR_BYTES, block_below_floor=True) + if verdict.severity == "blocked": + assert verdict.available_bytes is not None return False, ( f"insufficient RAM for {spec.name} " - f"(need {spec.min_ram_bytes // 1024**3} GB, " - f"have {total_ram // 1024**3} GB)" + f"(need {gb_label(verdict.required_bytes)} GB available, " + f"have {gb_label(verdict.available_bytes)} GB available)" ) return True, "" @@ -380,13 +387,13 @@ def inspect_readiness(model_id: str | None = None) -> dict[str, Any]: spec = resolve_model_spec(str(selected_model)) status = _read_status() presence = _artifact_presence(spec) - total_ram = psutil.virtual_memory().total + verdict = assess_memory(MLX_AVAILABLE_FLOOR_BYTES, block_below_floor=True) return { "install_state": status["install_state"], "model_installed": presence["model_installed"], "snapshot_installed": presence["snapshot_installed"], "variant_installed": presence["variant_installed"], - "ram_sufficient": total_ram >= spec.min_ram_bytes, + "ram_sufficient": verdict.severity != "blocked", "platform_supported": is_mlx_platform_supported(), "package_available": _check_platform_and_package()[0], "model_id": spec.name, diff --git a/solstone/think/providers/state.py b/solstone/think/providers/state.py index 4583a83c3..eec6f6e4b 100644 --- a/solstone/think/providers/state.py +++ b/solstone/think/providers/state.py @@ -268,9 +268,8 @@ def local_status_dict() -> dict: readiness = local_install.inspect_readiness() binary_installed = bool(readiness["binary_installed"]) model_installed = bool(readiness["model_installed"]) - ram_sufficient = bool(readiness["ram_sufficient"]) selected = is_local_provider_needed() - configured = binary_installed and model_installed and ram_sufficient + configured = binary_installed and model_installed if not selected: return { @@ -289,8 +288,6 @@ def local_status_dict() -> dict: issues.append("binary_missing") if not model_installed: issues.append("model_missing") - if not ram_sufficient: - issues.append("ram_insufficient") if configured and not server_healthy: runnable, detail = local_install.probe_binary_runnable(readiness["binary_path"]) if runnable: @@ -408,17 +405,6 @@ def _local_readiness_for_provider( readiness = local_install.inspect_readiness(selected_model) model_id = str(readiness.get("model_id") or selected_model) - if not readiness["ram_sufficient"]: - return _state( - provider, - interface, - "blocked", - "ram_insufficient", - model=model_id, - message=str(readiness.get("install_error") or "") or None, - source="local_install", - ) - if readiness["install_state"] in IN_FLIGHT_STATES: return _state( provider, diff --git a/tests/test_local.py b/tests/test_local.py index c1cb7a4bf..0898e1d05 100644 --- a/tests/test_local.py +++ b/tests/test_local.py @@ -47,6 +47,7 @@ def test_local_model_specs(): spec.mmproj_sha256 == "cd88edcf8d031894960bb0c9c5b9b7e1fea6ebee02b9f7ce925a00d12891f864" ) + assert spec.mmproj_size_bytes == 672423616 def test_local_provider_defaults_and_registry(): @@ -451,12 +452,38 @@ def test_local_provider_status_carries_install_hint_substring(monkeypatch): assert status["issues"] == [ "binary_missing", "model_missing", - "ram_insufficient", "run `journal install-provider local`", ] assert any("journal install-provider local" in issue for issue in status["issues"]) +def test_build_provider_status_local_configured_ignores_ram_flag(monkeypatch): + from solstone.think.providers import build_provider_status + + _select_local_provider(monkeypatch) + monkeypatch.setattr( + "solstone.think.providers.local_install.inspect_readiness", + lambda: { + "binary_installed": True, + "model_installed": True, + "ram_sufficient": False, + "binary_path": "/fake/llama-server", + }, + ) + monkeypatch.setattr( + "solstone.think.providers.local_server.is_healthy", lambda: True + ) + + status = build_provider_status( + [{"name": "local", "label": "Local (on-device)", "env_key": ""}] + )["local"] + + assert status["configured"] is True + assert status["generate_ready"] is True + assert status["cogitate_ready"] is True + assert status["issues"] == [] + + def test_local_server_connect_returns_healthy_service(monkeypatch): from solstone.think.providers import local_server diff --git a/tests/test_local_install.py b/tests/test_local_install.py index 690f38ccc..da65a9958 100644 --- a/tests/test_local_install.py +++ b/tests/test_local_install.py @@ -9,12 +9,13 @@ import tarfile import time from dataclasses import replace from pathlib import Path +from types import SimpleNamespace import pytest from solstone.think.journal_config import read_journal_config from solstone.think.models import LOCAL_MODEL -from solstone.think.providers import local_install +from solstone.think.providers import local_install, memory from solstone.think.providers.install_state import read_install_status from solstone.think.providers.local import LOCAL_MODEL_SPECS @@ -311,6 +312,55 @@ def test_ensure_artifacts_installed_returns_binary_gguf_and_optional_mmproj( ) +def test_ensure_artifacts_installed_ignores_low_memory_when_artifacts_exist( + tmp_path, monkeypatch +): + binary = tmp_path / "llama-server" + gguf = tmp_path / "model.gguf" + monkeypatch.setattr( + local_install, + "inspect_readiness", + lambda model_id: { + "binary_installed": True, + "model_installed": True, + "ram_sufficient": False, + "binary_path": str(binary), + "model_path": str(gguf), + "mmproj_path": None, + }, + ) + + assert local_install.ensure_artifacts_installed(LOCAL_MODEL) == ( + binary, + gguf, + None, + ) + + +def test_inspect_readiness_reports_ram_sufficient_for_low_or_unknown_memory( + tmp_path, monkeypatch +): + _init_journal(tmp_path, monkeypatch) + monkeypatch.setattr( + memory.psutil, + "virtual_memory", + lambda: SimpleNamespace(available=1 * 1024**3, total=16 * 1024**3), + ) + + readiness = local_install.inspect_readiness(LOCAL_MODEL) + + assert readiness["ram_sufficient"] is True + + def raise_memory_error(): + raise RuntimeError("psutil failed") + + monkeypatch.setattr(memory.psutil, "virtual_memory", raise_memory_error) + + readiness = local_install.inspect_readiness(LOCAL_MODEL) + + assert readiness["ram_sufficient"] is True + + def test_inspect_readiness_ignores_stale_model_path_after_model_change( tmp_path, monkeypatch ): diff --git a/tests/test_mlx_install.py b/tests/test_mlx_install.py index 3bb48116b..829ce3892 100644 --- a/tests/test_mlx_install.py +++ b/tests/test_mlx_install.py @@ -15,7 +15,7 @@ from huggingface_hub import RepoFile from solstone.think.journal_config import read_journal_config from solstone.think.models import GEMMA4_26B_A4B_4BIT, QWEN_35_9B -from solstone.think.providers import mlx_install +from solstone.think.providers import memory, mlx_install from solstone.think.providers.install_state import read_install_status @@ -41,9 +41,9 @@ def _local_slot() -> dict: def _allow_install(monkeypatch: pytest.MonkeyPatch, *, ram_gb: int = 64) -> None: monkeypatch.setattr(mlx_install, "_check_platform_and_package", lambda: (True, "")) monkeypatch.setattr( - mlx_install.psutil, + memory.psutil, "virtual_memory", - lambda: SimpleNamespace(total=ram_gb * 1024**3), + lambda: SimpleNamespace(available=ram_gb * 1024**3, total=64 * 1024**3), ) @@ -113,6 +113,68 @@ def test_default_model_and_registry_contents() -> None: mlx_install._MLX_MODEL_REGISTRY[GEMMA4_26B_A4B_4BIT].repo == "mlx-community/gemma-4-26b-a4b-it-4bit" ) + assert mlx_install._MLX_MODEL_REGISTRY[QWEN_35_9B].size_bytes == 10453446077 + assert ( + mlx_install._MLX_MODEL_REGISTRY[GEMMA4_26B_A4B_4BIT].size_bytes == 15641241224 + ) + + +def test_is_mlx_available_uses_available_floor(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(mlx_install, "_check_platform_and_package", lambda: (True, "")) + spec = mlx_install.resolve_model_spec(QWEN_35_9B) + monkeypatch.setattr( + memory.psutil, + "virtual_memory", + lambda: SimpleNamespace( + available=memory.MLX_AVAILABLE_FLOOR_BYTES, + total=64 * 1024**3, + ), + ) + + assert mlx_install.is_mlx_available_for_model(spec) == (True, "") + + monkeypatch.setattr( + memory.psutil, + "virtual_memory", + lambda: SimpleNamespace( + available=memory.MLX_AVAILABLE_FLOOR_BYTES - 1, + total=64 * 1024**3, + ), + ) + + ok, reason = mlx_install.is_mlx_available_for_model(spec) + assert ok is False + assert reason.startswith("insufficient RAM") + + +def test_inspect_readiness_ram_sufficient_matches_available_floor( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _init_journal(tmp_path, monkeypatch) + monkeypatch.setattr(mlx_install, "_check_platform_and_package", lambda: (True, "")) + monkeypatch.setattr(mlx_install, "is_mlx_platform_supported", lambda: True) + monkeypatch.setattr( + memory.psutil, + "virtual_memory", + lambda: SimpleNamespace( + available=memory.MLX_AVAILABLE_FLOOR_BYTES, + total=64 * 1024**3, + ), + ) + + assert mlx_install.inspect_readiness(QWEN_35_9B)["ram_sufficient"] is True + + monkeypatch.setattr( + memory.psutil, + "virtual_memory", + lambda: SimpleNamespace( + available=memory.MLX_AVAILABLE_FLOOR_BYTES - 1, + total=64 * 1024**3, + ), + ) + + readiness = mlx_install.inspect_readiness(QWEN_35_9B) + assert readiness["ram_sufficient"] is False def test_install_local_mlx_writes_canonical_sequence( diff --git a/tests/test_provider_memory.py b/tests/test_provider_memory.py new file mode 100644 index 000000000..f1fccc3d9 --- /dev/null +++ b/tests/test_provider_memory.py @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from solstone.think.providers import memory + + +def test_mlx_available_floor_constants_fit_single_vlm_footprint() -> None: + assert memory.MLX_SINGLE_VLM_RESIDENT_BYTES == int(12.5 * 1024**3) + assert memory.MLX_HEADROOM_BYTES == int(0.5 * 1024**3) + assert memory.MLX_AVAILABLE_FLOOR_BYTES == 13 * 1024**3 + assert memory.MLX_AVAILABLE_FLOOR_BYTES < 2 * memory.MLX_SINGLE_VLM_RESIDENT_BYTES + + +def test_assess_memory_blocks_or_warns_below_floor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + required = memory.MLX_AVAILABLE_FLOOR_BYTES + monkeypatch.setattr(memory, "read_available_bytes", lambda: required) + + verdict = memory.assess_memory(required, block_below_floor=True) + + assert verdict.severity == "ok" + assert verdict.available_bytes == required + + monkeypatch.setattr(memory, "read_available_bytes", lambda: required - 1) + + assert memory.assess_memory(required, block_below_floor=True).severity == "blocked" + assert memory.assess_memory(required, block_below_floor=False).severity == "warning" + + +def test_assess_memory_warns_when_detection_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(memory, "read_available_bytes", lambda: None) + + verdict = memory.assess_memory(8 * 1024**3, block_below_floor=True) + + assert verdict.available_bytes is None + assert verdict.severity == "warning" + + +def test_read_available_bytes_returns_none_when_psutil_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fail(): + raise RuntimeError("no memory data") + + monkeypatch.setattr(memory.psutil, "virtual_memory", fail) + + assert memory.read_available_bytes() is None + + +@pytest.mark.parametrize( + "payload", + [ + SimpleNamespace(available=0, total=100), + SimpleNamespace(available=-1, total=100), + SimpleNamespace(available=101, total=100), + ], +) +def test_read_available_bytes_rejects_nonsense( + monkeypatch: pytest.MonkeyPatch, + payload: SimpleNamespace, +) -> None: + monkeypatch.setattr(memory.psutil, "virtual_memory", lambda: payload) + + assert memory.read_available_bytes() is None diff --git a/tests/test_provider_state.py b/tests/test_provider_state.py index c300fd704..d65e12825 100644 --- a/tests/test_provider_state.py +++ b/tests/test_provider_state.py @@ -187,18 +187,25 @@ def test_local_readiness_installing(monkeypatch): assert provider_state.source == "local_install" -def test_local_readiness_ram_insufficient(monkeypatch): +def test_local_readiness_uses_normal_ready_state_for_non_blocking_memory( + monkeypatch, +): monkeypatch.setattr( local_install, "inspect_readiness", - lambda _model=None: _readiness(ram=False), + lambda _model=None: _readiness(ram=True), + ) + monkeypatch.setattr( + local_server, + "probe_state", + lambda: (local_server.STATE_READY, None), ) provider_state = state.readiness_for_provider("local", "generate") - assert provider_state.status == "blocked" - assert provider_state.reason_code == "ram_insufficient" - assert provider_state.source == "local_install" + assert provider_state.status == "ready" + assert provider_state.reason_code is None + assert provider_state.source == "local_server" def test_local_readiness_loading(monkeypatch): diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py index 98c1256a2..b49a6b655 100644 --- a/tests/test_supervisor.py +++ b/tests/test_supervisor.py @@ -1726,6 +1726,28 @@ def test_start_local_server_skips_when_mlx_not_installed_on_darwin(monkeypatch): launch.assert_not_called() +def test_start_local_server_skips_when_mlx_memory_blocked_on_darwin(monkeypatch): + mod = importlib.import_module("solstone.think.supervisor") + from solstone.think.providers import mlx_install + + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr( + mlx_install, + "inspect_readiness", + lambda: { + "platform_supported": True, + "package_available": True, + "ram_sufficient": False, + "model_installed": True, + }, + ) + launch = MagicMock() + monkeypatch.setattr(mod, "_launch_process", launch) + + assert mod.start_local_server() is None + launch.assert_not_called() + + def test_start_local_server_launches_llama_server_key_and_cmd( tmp_path, monkeypatch, capsys ):