From 6077ceb27d078eff390213a076b424323d7bd6d7 Mon Sep 17 00:00:00 2001 From: Deepesh Sonar <18deepnar@gmail.com> Date: Mon, 31 Aug 2026 23:10:53 +0530 Subject: [PATCH 1/2] fix(gpt4all): discover existing model directory (#30) --- src/modeldock/adapters/runtimes/gpt4all.py | 23 +++++++++--- src/modeldock/adapters/runtimes/registry.py | 4 +++ src/modeldock/common/config.py | 9 +++++ src/modeldock/core/manager.py | 14 +++++++- tests/unit/test_config.py | 12 +++++++ tests/unit/test_gpt4all_runtime.py | 39 +++++++++++++++++++++ 6 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_gpt4all_runtime.py diff --git a/src/modeldock/adapters/runtimes/gpt4all.py b/src/modeldock/adapters/runtimes/gpt4all.py index f96c7d1..5a8ce03 100644 --- a/src/modeldock/adapters/runtimes/gpt4all.py +++ b/src/modeldock/adapters/runtimes/gpt4all.py @@ -1,7 +1,12 @@ -"""GPT4All runtime adapter (planned). Implements RuntimePort; not yet shipped.""" +"""GPT4All local model-directory discovery. + +The adapter deliberately stops at discovery for issue #30. It does not download +or load models until the remaining GPT4All runtime work is implemented. +""" from __future__ import annotations +from pathlib import Path from typing import Any, List from modeldock.adapters.runtimes.base import BaseRuntime @@ -11,15 +16,25 @@ class Gpt4AllRuntime(BaseRuntime): - """Planned runtime adapter for GPT4All.""" + """Discover models already present in a GPT4All models directory.""" backend: RuntimeBackend = RuntimeBackend.GPT4ALL + def __init__(self, models_dir: Path | None = None) -> None: + super().__init__() + self._models_dir = models_dir or Path.home() / ".cache" / "gpt4all" + def _check_available(self) -> bool: - return False + return self._models_dir.is_dir() def list_installed(self) -> List[ModelRef]: - raise RuntimeUnavailableError("gpt4all", hint="Adapter planned, not shipped.") + if not self._models_dir.is_dir(): + return [] + refs: List[ModelRef] = [] + for path in sorted(self._models_dir.iterdir()): + if path.is_file() and path.suffix.lower() in {".gguf", ".bin"}: + refs.append(ModelRef.parse(path.stem)) + return refs def _do_pull(self, ref: ModelRef, progress: Any) -> PullResult: raise RuntimeUnavailableError("gpt4all", hint="Adapter planned, not shipped.") diff --git a/src/modeldock/adapters/runtimes/registry.py b/src/modeldock/adapters/runtimes/registry.py index bd662c9..2d14ea8 100644 --- a/src/modeldock/adapters/runtimes/registry.py +++ b/src/modeldock/adapters/runtimes/registry.py @@ -6,6 +6,7 @@ from __future__ import annotations +from pathlib import Path from typing import Any, Callable, Dict, List, cast from modeldock.common.logging import get_logger @@ -66,6 +67,7 @@ def get( backend: RuntimeBackend, host: str | None = None, gpu_layers: int | None = None, + models_dir: Path | None = None, ) -> RuntimePort: """Return a runtime instance for backend (entry points win). @@ -88,6 +90,8 @@ def get( clear() if gpu_layers is not None and hasattr(runtime, "_gpu_layers"): runtime._gpu_layers = gpu_layers + if models_dir is not None and hasattr(runtime, "_models_dir"): + runtime._models_dir = models_dir return runtime @staticmethod diff --git a/src/modeldock/common/config.py b/src/modeldock/common/config.py index 93c91f6..6ca4121 100644 --- a/src/modeldock/common/config.py +++ b/src/modeldock/common/config.py @@ -46,6 +46,7 @@ class Settings(BaseModel): ollama_host: Optional[str] = None lmstudio_host: Optional[str] = None llamacpp_gpu_layers: Optional[int] = None + gpt4all_models_dir: Optional[Path] = None config_path: Optional[Path] = None @field_validator("log_level") @@ -103,6 +104,9 @@ def to_env_overrides(self) -> Dict[str, str]: f"{_ENV_PREFIX}CATALOG_SOURCE": self.catalog_source, f"{_ENV_PREFIX}AUTO_INSTALL": str(self.auto_install).lower(), f"{_ENV_PREFIX}CACHE_DIR": str(self.cache_dir), + f"{_ENV_PREFIX}GPT4ALL_MODELS_DIR": "" + if self.gpt4all_models_dir is None + else str(self.gpt4all_models_dir), } @@ -181,6 +185,10 @@ def _apply_mapping(settings: Settings, data: Dict[str, Any], source: str = "conf if "llamacpp_gpu_layers" in data: raw = data["llamacpp_gpu_layers"] _safe_set(settings, "llamacpp_gpu_layers", raw if raw not in (None, "") else None, source) + if "gpt4all_models_dir" in data: + raw = data["gpt4all_models_dir"] + resolved_path = Path(str(raw)) if raw not in (None, "") else None + _safe_set(settings, "gpt4all_models_dir", resolved_path, source) def load_settings( @@ -228,6 +236,7 @@ def load_settings( f"{_ENV_PREFIX}OLLAMA_HOST": "ollama_host", f"{_ENV_PREFIX}LMSTUDIO_HOST": "lmstudio_host", f"{_ENV_PREFIX}LLAMACPP_GPU_LAYERS": "llamacpp_gpu_layers", + f"{_ENV_PREFIX}GPT4ALL_MODELS_DIR": "gpt4all_models_dir", } env_data = { field_name: os.environ[env_key] diff --git a/src/modeldock/core/manager.py b/src/modeldock/core/manager.py index f65521c..107adca 100644 --- a/src/modeldock/core/manager.py +++ b/src/modeldock/core/manager.py @@ -164,6 +164,11 @@ def _resolve_backend_catalog(self, cfg: Settings) -> Optional[RegistryPort]: RuntimeBackend.LLAMACPP: "llamacpp_gpu_layers", } + #: Config field holding the local model directory for file-based runtimes. + _MODELS_DIR_SETTING_FOR = { + RuntimeBackend.GPT4ALL: "gpt4all_models_dir", + } + def _resolve_runtime(self, backend: RuntimeBackend, cfg: Settings) -> RuntimePort: # Apply the configured host at construction time so it always takes # effect (clients are built lazily and cached per instance). Backends @@ -173,8 +178,15 @@ def _resolve_runtime(self, backend: RuntimeBackend, cfg: Settings) -> RuntimePor host = getattr(cfg, host_field, None) if host_field else None gpu_layers_field = self._GPU_LAYERS_SETTING_FOR.get(backend) gpu_layers = getattr(cfg, gpu_layers_field, None) if gpu_layers_field else None + models_dir_field = self._MODELS_DIR_SETTING_FOR.get(backend) + models_dir = getattr(cfg, models_dir_field, None) if models_dir_field else None try: - runtime = self._runtime_registry.get(backend, host=host, gpu_layers=gpu_layers) + runtime = self._runtime_registry.get( + backend, + host=host, + gpu_layers=gpu_layers, + models_dir=models_dir, + ) except KeyError as exc: raise RuntimeUnavailableError(backend.value) from exc if not runtime.is_available(): diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 69e4585..9f50f3a 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -33,6 +33,10 @@ def test_settings_llamacpp_gpu_layers_defaults_to_none() -> None: assert Settings().llamacpp_gpu_layers is None +def test_settings_gpt4all_models_dir_defaults_to_none() -> None: + assert Settings().gpt4all_models_dir is None + + def test_settings_llamacpp_gpu_layers_accepts_non_negative_int() -> None: assert Settings(llamacpp_gpu_layers=35).llamacpp_gpu_layers == 35 @@ -89,6 +93,14 @@ def test_load_settings_llamacpp_gpu_layers_invalid_env_falls_back( assert s.llamacpp_gpu_layers is None +def test_load_settings_gpt4all_models_dir_env( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + models_dir = tmp_path / "gpt4all" + monkeypatch.setenv("MODELDOCK_GPT4ALL_MODELS_DIR", str(models_dir)) + assert load_settings().gpt4all_models_dir == models_dir + + def test_default_cache_dir_override(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("MODELDOCK_CACHE_DIR", str(tmp_path)) assert default_cache_dir() == tmp_path diff --git a/tests/unit/test_gpt4all_runtime.py b/tests/unit/test_gpt4all_runtime.py new file mode 100644 index 0000000..915be33 --- /dev/null +++ b/tests/unit/test_gpt4all_runtime.py @@ -0,0 +1,39 @@ +"""Unit tests for GPT4All local model discovery (issue #30).""" + +from __future__ import annotations + +from pathlib import Path + +from modeldock.adapters.runtimes.gpt4all import Gpt4AllRuntime +from modeldock.adapters.runtimes.registry import RuntimeRegistry +from modeldock.domain.model import RuntimeBackend + + +def test_list_installed_registers_supported_model_files(tmp_path: Path) -> None: + (tmp_path / "Phi-3-mini.Q4_0.gguf").touch() + (tmp_path / "legacy-model.bin").touch() + (tmp_path / "notes.txt").touch() + (tmp_path / "nested").mkdir() + (tmp_path / "nested" / "ignored.gguf").touch() + + runtime = Gpt4AllRuntime(models_dir=tmp_path) + + assert runtime.is_available() is True + assert [ref.name for ref in runtime.list_installed()] == [ + "Phi-3-mini.Q4_0", + "legacy-model", + ] + + +def test_missing_models_directory_is_unavailable_and_empty(tmp_path: Path) -> None: + runtime = Gpt4AllRuntime(models_dir=tmp_path / "missing") + + assert runtime.is_available() is False + assert runtime.list_installed() == [] + + +def test_registry_forwards_gpt4all_models_dir(tmp_path: Path) -> None: + runtime = RuntimeRegistry().get(RuntimeBackend.GPT4ALL, models_dir=tmp_path) + + assert isinstance(runtime, Gpt4AllRuntime) + assert runtime._models_dir == tmp_path From d2fa1ec1bc045836c3f7f15efc3c500705fbefcd Mon Sep 17 00:00:00 2001 From: Deepesh Sonar <18deepnar@gmail.com> Date: Tue, 1 Sep 2026 15:33:16 +0530 Subject: [PATCH 2/2] fix(gpt4all): make model discovery ordering deterministic --- src/modeldock/adapters/runtimes/gpt4all.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modeldock/adapters/runtimes/gpt4all.py b/src/modeldock/adapters/runtimes/gpt4all.py index 5a8ce03..19f1f8c 100644 --- a/src/modeldock/adapters/runtimes/gpt4all.py +++ b/src/modeldock/adapters/runtimes/gpt4all.py @@ -31,7 +31,7 @@ def list_installed(self) -> List[ModelRef]: if not self._models_dir.is_dir(): return [] refs: List[ModelRef] = [] - for path in sorted(self._models_dir.iterdir()): + for path in sorted(self._models_dir.iterdir(), key=lambda path: path.name): if path.is_file() and path.suffix.lower() in {".gguf", ".bin"}: refs.append(ModelRef.parse(path.stem)) return refs