← back to Exo
worker: add EXO_MODELS_PATH for pre-downloaded model directories
42da58c297adf9d4998c6d71ad99b4afc6466ade · 2026-02-20 16:34:02 +0000 · Jake Hillion
Users with pre-existing model files (e.g. on shared NFS mounts or from
prior downloads) had no way to point exo at those directories without
going through the download coordinator. EXO_MODELS_DIR only moves the
download target directory, it doesn't support read-only search paths.
Added EXO_MODELS_PATH environment variable as a colon-separated list of
directories to search for models. When the worker's plan loop encounters
a DownloadModel task, it checks these directories first and emits a
synthetic DownloadCompleted event if found, bypassing the download
coordinator entirely. The runner's build_model_path also checks these
directories first so the correct path is used during model loading.
This keeps the existing event sourcing state machine unchanged — the
DownloadCompleted event propagates naturally through the system, so
_load_model and all downstream logic work without modification.
Test plan:
- `s1@s1s-Mac-Studio ~ % EXO_LIBP2P_NAMESPACE=jake EXO_MODELS_PATH="/Volumes/Definitely Leo's SSD" nix --extra-experimental-features 'nix-command flakes' run github:exo-explore/exo/f2babbc2f742357d97dc177619fec062ef545be4`
- Started mlx-community/Qwen3-Coder-Next-4bit - it's present on the disk
and it worked.
- Renamed one safetensor of mlx-community/Qwen3-Coder-Next-4bit on the
disk. It then started the download locally, as expected.
Files touched
M src/exo/download/download_utils.pyM src/exo/shared/constants.pyM src/exo/worker/main.py
Diff
commit 42da58c297adf9d4998c6d71ad99b4afc6466ade
Author: Jake Hillion <jake@hillion.co.uk>
Date: Fri Feb 20 16:34:02 2026 +0000
worker: add EXO_MODELS_PATH for pre-downloaded model directories
Users with pre-existing model files (e.g. on shared NFS mounts or from
prior downloads) had no way to point exo at those directories without
going through the download coordinator. EXO_MODELS_DIR only moves the
download target directory, it doesn't support read-only search paths.
Added EXO_MODELS_PATH environment variable as a colon-separated list of
directories to search for models. When the worker's plan loop encounters
a DownloadModel task, it checks these directories first and emits a
synthetic DownloadCompleted event if found, bypassing the download
coordinator entirely. The runner's build_model_path also checks these
directories first so the correct path is used during model loading.
This keeps the existing event sourcing state machine unchanged — the
DownloadCompleted event propagates naturally through the system, so
_load_model and all downstream logic work without modification.
Test plan:
- `s1@s1s-Mac-Studio ~ % EXO_LIBP2P_NAMESPACE=jake EXO_MODELS_PATH="/Volumes/Definitely Leo's SSD" nix --extra-experimental-features 'nix-command flakes' run github:exo-explore/exo/f2babbc2f742357d97dc177619fec062ef545be4`
- Started mlx-community/Qwen3-Coder-Next-4bit - it's present on the disk
and it worked.
- Renamed one safetensor of mlx-community/Qwen3-Coder-Next-4bit on the
disk. It then started the download locally, as expected.
---
src/exo/download/download_utils.py | 145 +++++++++++++++++++++++--------------
src/exo/shared/constants.py | 9 +++
src/exo/worker/main.py | 50 ++++++++++---
3 files changed, 136 insertions(+), 68 deletions(-)
diff --git a/src/exo/download/download_utils.py b/src/exo/download/download_utils.py
index a9da4cc5..dde07018 100644
--- a/src/exo/download/download_utils.py
+++ b/src/exo/download/download_utils.py
@@ -20,7 +20,6 @@ from huggingface_hub import (
)
from loguru import logger
from pydantic import (
- DirectoryPath,
TypeAdapter,
)
@@ -31,7 +30,7 @@ from exo.download.huggingface_utils import (
get_hf_endpoint,
get_hf_token,
)
-from exo.shared.constants import EXO_MODELS_DIR
+from exo.shared.constants import EXO_MODELS_DIR, EXO_MODELS_PATH
from exo.shared.models.model_cards import ModelTask
from exo.shared.types.common import ModelId
from exo.shared.types.memory import Memory
@@ -111,7 +110,27 @@ def map_repo_download_progress_to_download_progress_data(
)
-def build_model_path(model_id: ModelId) -> DirectoryPath:
+def resolve_model_in_path(model_id: ModelId) -> Path | None:
+ """Search EXO_MODELS_PATH directories for a pre-existing model.
+
+ Checks each directory for the normalized name (org--model). A candidate
+ is only returned if ``is_model_directory_complete`` confirms all weight
+ files are present.
+ """
+ if EXO_MODELS_PATH is None:
+ return None
+ normalized = model_id.normalize()
+ for search_dir in EXO_MODELS_PATH:
+ candidate = search_dir / normalized
+ if candidate.is_dir() and is_model_directory_complete(candidate):
+ return candidate
+ return None
+
+
+def build_model_path(model_id: ModelId) -> Path:
+ found = resolve_model_in_path(model_id)
+ if found is not None:
+ return found
return EXO_MODELS_DIR / model_id.normalize()
@@ -158,6 +177,72 @@ async def seed_models(seed_dir: str | Path):
logger.error(traceback.format_exc())
+def _scan_model_directory(
+ model_dir: Path, recursive: bool = False
+) -> list[FileListEntry] | None:
+ """Scan a local model directory and build a file list.
+
+ Requires at least one ``*.safetensors.index.json``. Every weight file
+ referenced by the index that is missing on disk gets ``size=None``.
+ """
+ index_files = list(model_dir.glob("**/*.safetensors.index.json"))
+ if not index_files:
+ return None
+
+ entries_by_path: dict[str, FileListEntry] = {}
+
+ if recursive:
+ for dirpath, _, filenames in os.walk(model_dir):
+ for filename in filenames:
+ if filename.endswith(".partial"):
+ continue
+ full_path = Path(dirpath) / filename
+ rel_path = str(full_path.relative_to(model_dir))
+ entries_by_path[rel_path] = FileListEntry(
+ type="file",
+ path=rel_path,
+ size=full_path.stat().st_size,
+ )
+ else:
+ for item in model_dir.iterdir():
+ if item.is_file() and not item.name.endswith(".partial"):
+ entries_by_path[item.name] = FileListEntry(
+ type="file",
+ path=item.name,
+ size=item.stat().st_size,
+ )
+
+ # Add expected weight files from index that haven't been downloaded yet
+ for index_file in index_files:
+ try:
+ index_data = ModelSafetensorsIndex.model_validate_json(
+ index_file.read_text()
+ )
+ relative_dir = index_file.parent.relative_to(model_dir)
+ for filename in set(index_data.weight_map.values()):
+ rel_path = (
+ str(relative_dir / filename)
+ if relative_dir != Path(".")
+ else filename
+ )
+ if rel_path not in entries_by_path:
+ entries_by_path[rel_path] = FileListEntry(
+ type="file",
+ path=rel_path,
+ size=None,
+ )
+ except Exception:
+ continue
+
+ return list(entries_by_path.values())
+
+
+def is_model_directory_complete(model_dir: Path) -> bool:
+ """Check if a model directory contains all required weight files."""
+ file_list = _scan_model_directory(model_dir, recursive=True)
+ return file_list is not None and all(f.size is not None for f in file_list)
+
+
async def _build_file_list_from_local_directory(
model_id: ModelId,
recursive: bool = False,
@@ -172,59 +257,7 @@ async def _build_file_list_from_local_directory(
if not await aios.path.exists(model_dir):
return None
- def _scan() -> list[FileListEntry] | None:
- index_files = list(model_dir.glob("**/*.safetensors.index.json"))
- if not index_files:
- return None
-
- entries_by_path: dict[str, FileListEntry] = {}
-
- if recursive:
- for dirpath, _, filenames in os.walk(model_dir):
- for filename in filenames:
- if filename.endswith(".partial"):
- continue
- full_path = Path(dirpath) / filename
- rel_path = str(full_path.relative_to(model_dir))
- entries_by_path[rel_path] = FileListEntry(
- type="file",
- path=rel_path,
- size=full_path.stat().st_size,
- )
- else:
- for item in model_dir.iterdir():
- if item.is_file() and not item.name.endswith(".partial"):
- entries_by_path[item.name] = FileListEntry(
- type="file",
- path=item.name,
- size=item.stat().st_size,
- )
-
- # Add expected weight files from index that haven't been downloaded yet
- for index_file in index_files:
- try:
- index_data = ModelSafetensorsIndex.model_validate_json(
- index_file.read_text()
- )
- relative_dir = index_file.parent.relative_to(model_dir)
- for filename in set(index_data.weight_map.values()):
- rel_path = (
- str(relative_dir / filename)
- if relative_dir != Path(".")
- else filename
- )
- if rel_path not in entries_by_path:
- entries_by_path[rel_path] = FileListEntry(
- type="file",
- path=rel_path,
- size=None,
- )
- except Exception:
- continue
-
- return list(entries_by_path.values())
-
- file_list = await asyncio.to_thread(_scan)
+ file_list = await asyncio.to_thread(_scan_model_directory, model_dir, recursive)
if not file_list:
return None
return file_list
diff --git a/src/exo/shared/constants.py b/src/exo/shared/constants.py
index 78781209..795b1b84 100644
--- a/src/exo/shared/constants.py
+++ b/src/exo/shared/constants.py
@@ -33,6 +33,15 @@ EXO_MODELS_DIR = (
if _EXO_MODELS_DIR_ENV is None
else Path.home() / _EXO_MODELS_DIR_ENV
)
+
+# Read-only search path for pre-downloaded models (colon-separated directories)
+_EXO_MODELS_PATH_ENV = os.environ.get("EXO_MODELS_PATH", None)
+EXO_MODELS_PATH: tuple[Path, ...] | None = (
+ tuple(Path(p).expanduser() for p in _EXO_MODELS_PATH_ENV.split(":") if p)
+ if _EXO_MODELS_PATH_ENV is not None
+ else None
+)
+
_RESOURCES_DIR_ENV = os.environ.get("EXO_RESOURCES_DIR", None)
RESOURCES_DIR = (
find_resources() if _RESOURCES_DIR_ENV is None else Path.home() / _RESOURCES_DIR_ENV
diff --git a/src/exo/worker/main.py b/src/exo/worker/main.py
index 7782ce2d..b80716c5 100644
--- a/src/exo/worker/main.py
+++ b/src/exo/worker/main.py
@@ -7,6 +7,7 @@ from anyio import CancelScope, create_task_group, fail_after
from anyio.abc import TaskGroup
from loguru import logger
+from exo.download.download_utils import resolve_model_in_path
from exo.shared.apply import apply
from exo.shared.models.model_cards import ModelId
from exo.shared.types.api import ImageEditsTaskParams
@@ -24,6 +25,7 @@ from exo.shared.types.events import (
IndexedEvent,
InputChunkReceived,
LocalForwarderEvent,
+ NodeDownloadProgress,
NodeGatheredInfo,
TaskCreated,
TaskStatusUpdated,
@@ -42,6 +44,7 @@ from exo.shared.types.tasks import (
TaskStatus,
)
from exo.shared.types.topology import Connection, SocketConnection
+from exo.shared.types.worker.downloads import DownloadCompleted
from exo.shared.types.worker.runners import RunnerId
from exo.utils.channels import Receiver, Sender, channel
from exo.utils.event_buffer import OrderedBuffer
@@ -212,20 +215,43 @@ class Worker:
model_id = shard.model_card.model_id
self._download_backoff.record_attempt(model_id)
- await self.download_command_sender.send(
- ForwarderDownloadCommand(
- origin=self._system_id,
- command=StartDownload(
- target_node_id=self.node_id,
- shard_metadata=shard,
- ),
+ found_path = resolve_model_in_path(model_id)
+ if found_path is not None:
+ logger.info(
+ f"Model {model_id} found in EXO_MODELS_PATH at {found_path}"
)
- )
- await self.event_sender.send(
- TaskStatusUpdated(
- task_id=task.task_id, task_status=TaskStatus.Running
+ await self.event_sender.send(
+ NodeDownloadProgress(
+ download_progress=DownloadCompleted(
+ node_id=self.node_id,
+ shard_metadata=shard,
+ model_directory=str(found_path),
+ total=shard.model_card.storage_size,
+ )
+ )
+ )
+ await self.event_sender.send(
+ TaskStatusUpdated(
+ task_id=task.task_id,
+ task_status=TaskStatus.Complete,
+ )
+ )
+ else:
+ await self.download_command_sender.send(
+ ForwarderDownloadCommand(
+ origin=self._system_id,
+ command=StartDownload(
+ target_node_id=self.node_id,
+ shard_metadata=shard,
+ ),
+ )
+ )
+ await self.event_sender.send(
+ TaskStatusUpdated(
+ task_id=task.task_id,
+ task_status=TaskStatus.Running,
+ )
)
- )
case Shutdown(runner_id=runner_id):
runner = self.runners.pop(runner_id)
try:
← 6b5a7059 fix: immediate cancel check after prefill completes (#1575)
·
back to Exo
·
model-cards: add missing metadata for Qwen3 Coder Next varia 71e48c0f →