Skip to content

Commit 15f1b61

Browse files
authored
Rework model storage directory management (for external storage) (#1765)
## Motivation Replace confusing EXO_MODELS_DIR/EXO_MODELS_PATH with clearer multi-directory support, enabling automatic download spillover across volumes. ## Changes - EXO_MODELS_DIRS: colon-separated writable dirs (default always prepended, first with enough space wins) - EXO_MODELS_READ_ONLY_DIRS: colon-separated read-only dirs (protected from deletion) - select_download_dir(): picks writable dir by free space - resolve_existing_model(): unified lookup across all dirs - is_read_only_model_dir(): path-based read-only detection instead of hardcoded flag - Updated coordinator, worker, model cards, tests ## Why It Works Default dir always included so zero-config behavior is unchanged. Disk space checked at download time for automatic spillover. Read-only status derived from path, not hardcoded. ## Test Plan ### Manual Testing - No env vars set → identical behavior - EXO_MODELS_DIRS=/Volumes/SSD/models → downloads to external storage - EXO_MODELS_READ_ONLY_DIRS=/mnt/nfs → models found, deletion blocked ### Automated Testing - 4 new tests in test_xdg_paths.py (prepend, default-only, overlap, empty read-only) - Existing tests updated to patch new constants
1 parent 9034300 commit 15f1b61

16 files changed

Lines changed: 667 additions & 177 deletions

File tree

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -295,8 +295,9 @@ exo supports several environment variables for configuration:
295295

296296
| Variable | Description | Default |
297297
|----------|-------------|---------|
298-
| `EXO_MODELS_PATH` | Colon-separated paths to search for pre-downloaded models (e.g., on NFS mounts or shared storage) | None |
299-
| `EXO_MODELS_DIR` | Directory where exo downloads and stores models | `~/.local/share/exo/models` (Linux) or `~/.exo/models` (macOS) |
298+
| `EXO_DEFAULT_MODELS_DIR` | Default directory for model downloads and caches. Always first in the writable dirs list. | `~/.local/share/exo/models` (Linux) or `~/.exo/models` (macOS) |
299+
| `EXO_MODELS_DIRS` | Colon-separated additional writable directories for model downloads. Checked in order after the default; first with enough free space is used. | None |
300+
| `EXO_MODELS_READ_ONLY_DIRS` | Colon-separated read-only directories to search for pre-downloaded models (e.g., NFS mounts, shared storage). Models here cannot be deleted. | None |
300301
| `EXO_OFFLINE` | Run without internet connection (uses only local models) | `false` |
301302
| `EXO_ENABLE_IMAGE_MODELS` | Enable image model support | `false` |
302303
| `EXO_LIBP2P_NAMESPACE` | Custom namespace for cluster isolation | None |
@@ -306,8 +307,11 @@ exo supports several environment variables for configuration:
306307
**Example usage:**
307308

308309
```bash
309-
# Use pre-downloaded models from NFS mount
310-
EXO_MODELS_PATH=/mnt/nfs/models:/opt/ai-models uv run exo
310+
# Use pre-downloaded models from NFS mount (read-only)
311+
EXO_MODELS_READ_ONLY_DIRS=/mnt/nfs/models:/opt/ai-models uv run exo
312+
313+
# Download models to an external SSD (falls back to default dir if full)
314+
EXO_MODELS_DIRS=/Volumes/ExternalSSD/exo-models uv run exo
311315

312316
# Run in offline mode
313317
EXO_OFFLINE=true uv run exo

bench/harness.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ def run_planning_phase(
377377
f"have {avail // (1024**3)}GB. Use --danger-delete-downloads to free space."
378378
)
379379

380-
# Delete from smallest to largest (skip read-only models from EXO_MODELS_PATH)
380+
# Delete from smallest to largest (skip read-only models)
381381
completed = [
382382
(
383383
unwrap_instance(p["DownloadCompleted"]["shardMetadata"])["modelCard"][

src/exo/download/coordinator.py

Lines changed: 86 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
1+
from __future__ import annotations
2+
13
from dataclasses import dataclass, field
4+
from pathlib import Path
25

36
import anyio
4-
from anyio import current_time
7+
from anyio import current_time, to_thread
58
from loguru import logger
69

710
from exo.download.download_utils import (
811
RepoDownloadProgress,
912
delete_model,
13+
is_read_only_model_dir,
1014
map_repo_download_progress_to_download_progress_data,
11-
resolve_model_in_path,
15+
resolve_existing_model,
1216
)
1317
from exo.download.shard_downloader import ShardDownloader
14-
from exo.shared.constants import EXO_MODELS_DIR, EXO_MODELS_PATH
18+
from exo.shared.constants import EXO_DEFAULT_MODELS_DIR, EXO_MODELS_READ_ONLY_DIRS
1519
from exo.shared.models.model_cards import ModelId, get_model_cards
1620
from exo.shared.types.commands import (
1721
CancelDownload,
@@ -24,6 +28,7 @@
2428
Event,
2529
NodeDownloadProgress,
2630
)
31+
from exo.shared.types.memory import Memory
2732
from exo.shared.types.worker.downloads import (
2833
DownloadCompleted,
2934
DownloadFailed,
@@ -57,8 +62,23 @@ class DownloadCoordinator:
5762
def __post_init__(self) -> None:
5863
self.shard_downloader.on_progress(self._download_progress_callback)
5964

60-
def _model_dir(self, model_id: ModelId) -> str:
61-
return str(EXO_MODELS_DIR / model_id.normalize())
65+
@staticmethod
66+
def _default_model_dir(model_id: ModelId) -> str:
67+
return str(EXO_DEFAULT_MODELS_DIR / model_id.normalize())
68+
69+
def _completed_from_path(
70+
self,
71+
shard: ShardMetadata,
72+
found: Path,
73+
total: Memory,
74+
) -> DownloadCompleted:
75+
return DownloadCompleted(
76+
shard_metadata=shard,
77+
node_id=self.node_id,
78+
total=total,
79+
model_directory=str(found),
80+
read_only=is_read_only_model_dir(found),
81+
)
6282

6383
async def _download_progress_callback(
6484
self, callback_shard: ShardMetadata, progress: RepoDownloadProgress
@@ -67,12 +87,18 @@ async def _download_progress_callback(
6787
throttle_interval_secs = 1.0
6888

6989
if progress.status == "complete":
70-
completed = DownloadCompleted(
71-
shard_metadata=callback_shard,
72-
node_id=self.node_id,
73-
total=progress.total,
74-
model_directory=self._model_dir(model_id),
75-
)
90+
found = await to_thread.run_sync(resolve_existing_model, model_id)
91+
if found is not None:
92+
completed = self._completed_from_path(
93+
callback_shard, found, progress.total
94+
)
95+
else:
96+
completed = DownloadCompleted(
97+
shard_metadata=callback_shard,
98+
node_id=self.node_id,
99+
total=progress.total,
100+
model_directory=self._default_model_dir(model_id),
101+
)
76102
self.download_status[model_id] = completed
77103
await self.event_sender.send(
78104
NodeDownloadProgress(download_progress=completed)
@@ -89,7 +115,7 @@ async def _download_progress_callback(
89115
download_progress=map_repo_download_progress_to_download_progress_data(
90116
progress
91117
),
92-
model_directory=self._model_dir(model_id),
118+
model_directory=self._default_model_dir(model_id),
93119
)
94120
self.download_status[model_id] = ongoing
95121
await self.event_sender.send(
@@ -135,7 +161,7 @@ async def _cancel_download(self, model_id: ModelId) -> None:
135161
pending = DownloadPending(
136162
shard_metadata=current_status.shard_metadata,
137163
node_id=self.node_id,
138-
model_directory=self._model_dir(model_id),
164+
model_directory=self._default_model_dir(model_id),
139165
)
140166
self.download_status[model_id] = pending
141167
await self.event_sender.send(
@@ -154,18 +180,12 @@ async def _start_download(self, shard: ShardMetadata) -> None:
154180
)
155181
return
156182

157-
# Check EXO_MODELS_PATH for pre-downloaded models
158-
found_path = resolve_model_in_path(model_id)
183+
# Check all model directories for pre-existing complete models
184+
found_path = await to_thread.run_sync(resolve_existing_model, model_id)
159185
if found_path is not None:
160-
logger.info(
161-
f"DownloadCoordinator: Model {model_id} found in EXO_MODELS_PATH at {found_path}"
162-
)
163-
completed = DownloadCompleted(
164-
shard_metadata=shard,
165-
node_id=self.node_id,
166-
total=shard.model_card.storage_size,
167-
model_directory=str(found_path),
168-
read_only=True,
186+
logger.info(f"DownloadCoordinator: Model {model_id} found at {found_path}")
187+
completed = self._completed_from_path(
188+
shard, found_path, shard.model_card.storage_size
169189
)
170190
self.download_status[model_id] = completed
171191
await self.event_sender.send(
@@ -177,7 +197,7 @@ async def _start_download(self, shard: ShardMetadata) -> None:
177197
progress = DownloadPending(
178198
shard_metadata=shard,
179199
node_id=self.node_id,
180-
model_directory=self._model_dir(model_id),
200+
model_directory=self._default_model_dir(model_id),
181201
)
182202
self.download_status[model_id] = progress
183203
await self.event_sender.send(NodeDownloadProgress(download_progress=progress))
@@ -188,12 +208,18 @@ async def _start_download(self, shard: ShardMetadata) -> None:
188208
)
189209

190210
if initial_progress.status == "complete":
191-
completed = DownloadCompleted(
192-
shard_metadata=shard,
193-
node_id=self.node_id,
194-
total=initial_progress.total,
195-
model_directory=self._model_dir(model_id),
196-
)
211+
found = await to_thread.run_sync(resolve_existing_model, model_id)
212+
if found is not None:
213+
completed = self._completed_from_path(
214+
shard, found, initial_progress.total
215+
)
216+
else:
217+
completed = DownloadCompleted(
218+
shard_metadata=shard,
219+
node_id=self.node_id,
220+
total=initial_progress.total,
221+
model_directory=self._default_model_dir(model_id),
222+
)
197223
self.download_status[model_id] = completed
198224
await self.event_sender.send(
199225
NodeDownloadProgress(download_progress=completed)
@@ -208,7 +234,7 @@ async def _start_download(self, shard: ShardMetadata) -> None:
208234
shard_metadata=shard,
209235
node_id=self.node_id,
210236
error_message=f"Model files not found locally in offline mode: {model_id}",
211-
model_directory=self._model_dir(model_id),
237+
model_directory=self._default_model_dir(model_id),
212238
)
213239
self.download_status[model_id] = failed
214240
await self.event_sender.send(NodeDownloadProgress(download_progress=failed))
@@ -229,7 +255,7 @@ def _start_download_task(
229255
download_progress=map_repo_download_progress_to_download_progress_data(
230256
initial_progress
231257
),
232-
model_directory=self._model_dir(model_id),
258+
model_directory=self._default_model_dir(model_id),
233259
)
234260
self.download_status[model_id] = status
235261
self.event_sender.send_nowait(NodeDownloadProgress(download_progress=status))
@@ -244,7 +270,7 @@ async def download_wrapper(cancel_scope: anyio.CancelScope) -> None:
244270
shard_metadata=shard,
245271
node_id=self.node_id,
246272
error_message=str(e),
247-
model_directory=self._model_dir(model_id),
273+
model_directory=self._default_model_dir(model_id),
248274
)
249275
self.download_status[model_id] = failed
250276
await self.event_sender.send(
@@ -261,13 +287,11 @@ async def download_wrapper(cancel_scope: anyio.CancelScope) -> None:
261287
self.active_downloads[model_id] = scope
262288

263289
async def _delete_download(self, model_id: ModelId) -> None:
264-
# Protect read-only models (from EXO_MODELS_PATH) from deletion
290+
# Protect read-only models from deletion
265291
if model_id in self.download_status:
266292
current = self.download_status[model_id]
267293
if isinstance(current, DownloadCompleted) and current.read_only:
268-
logger.warning(
269-
f"Refusing to delete read-only model {model_id} (from EXO_MODELS_PATH)"
270-
)
294+
logger.warning(f"Refusing to delete read-only model {model_id}")
271295
return
272296

273297
# Cancel if active
@@ -290,7 +314,7 @@ async def _delete_download(self, model_id: ModelId) -> None:
290314
pending = DownloadPending(
291315
shard_metadata=current_status.shard_metadata,
292316
node_id=self.node_id,
293-
model_directory=self._model_dir(model_id),
317+
model_directory=self._default_model_dir(model_id),
294318
)
295319
await self.event_sender.send(
296320
NodeDownloadProgress(download_progress=pending)
@@ -314,22 +338,26 @@ async def _emit_existing_download_progress(self) -> None:
314338
continue
315339

316340
if progress.status == "complete":
317-
status: DownloadProgress = DownloadCompleted(
318-
node_id=self.node_id,
319-
shard_metadata=progress.shard,
320-
total=progress.total,
321-
model_directory=self._model_dir(
322-
progress.shard.model_card.model_id
323-
),
341+
found = await to_thread.run_sync(
342+
resolve_existing_model, model_id
324343
)
344+
if found is not None:
345+
status: DownloadProgress = self._completed_from_path(
346+
progress.shard, found, progress.total
347+
)
348+
else:
349+
status = DownloadCompleted(
350+
node_id=self.node_id,
351+
shard_metadata=progress.shard,
352+
total=progress.total,
353+
model_directory=self._default_model_dir(model_id),
354+
)
325355
elif progress.status in ["in_progress", "not_started"]:
326356
if progress.downloaded_this_session.in_bytes == 0:
327357
status = DownloadPending(
328358
node_id=self.node_id,
329359
shard_metadata=progress.shard,
330-
model_directory=self._model_dir(
331-
progress.shard.model_card.model_id
332-
),
360+
model_directory=self._default_model_dir(model_id),
333361
downloaded=progress.downloaded,
334362
total=progress.total,
335363
)
@@ -340,9 +368,7 @@ async def _emit_existing_download_progress(self) -> None:
340368
download_progress=map_repo_download_progress_to_download_progress_data(
341369
progress
342370
),
343-
model_directory=self._model_dir(
344-
progress.shard.model_card.model_id
345-
),
371+
model_directory=self._default_model_dir(model_id),
346372
)
347373
else:
348374
continue
@@ -351,8 +377,8 @@ async def _emit_existing_download_progress(self) -> None:
351377
await self.event_sender.send(
352378
NodeDownloadProgress(download_progress=status)
353379
)
354-
# Scan EXO_MODELS_PATH for pre-downloaded models
355-
if EXO_MODELS_PATH is not None:
380+
# Scan read-only directories for pre-downloaded models
381+
if EXO_MODELS_READ_ONLY_DIRS:
356382
for card in await get_model_cards():
357383
mid = card.model_id
358384
if mid in self.active_downloads:
@@ -362,8 +388,8 @@ async def _emit_existing_download_progress(self) -> None:
362388
(DownloadCompleted, DownloadOngoing, DownloadFailed),
363389
):
364390
continue
365-
found = resolve_model_in_path(mid)
366-
if found is not None:
391+
found = await to_thread.run_sync(resolve_existing_model, mid)
392+
if found is not None and is_read_only_model_dir(found):
367393
path_shard = PipelineShardMetadata(
368394
model_card=card,
369395
device_rank=0,
@@ -372,12 +398,10 @@ async def _emit_existing_download_progress(self) -> None:
372398
end_layer=card.n_layers,
373399
n_layers=card.n_layers,
374400
)
375-
path_completed: DownloadProgress = DownloadCompleted(
376-
node_id=self.node_id,
377-
shard_metadata=path_shard,
378-
total=card.storage_size,
379-
model_directory=str(found),
380-
read_only=True,
401+
path_completed: DownloadProgress = (
402+
self._completed_from_path(
403+
path_shard, found, card.storage_size
404+
)
381405
)
382406
self.download_status[mid] = path_completed
383407
await self.event_sender.send(

0 commit comments

Comments
 (0)