← back to Exo
Fix offline no cache (#1402)
f0107e96706872f250304a42fa8b9bdb4092ce7c · 2026-02-06 12:57:01 +0000 · rltakashige
## Motivation
In offline mode, exo complains if there is no caches directory, even if
the files are there.
## Changes
Check safetensors index and the directory structure to build caches
directory.
## Test Plan
### Manual Testing
<img width="2338" height="1102" alt="image"
src="https://github.com/user-attachments/assets/ad769911-399b-4fca-ac80-aeaa046af06b"
/>
<img width="656" height="1668" alt="image"
src="https://github.com/user-attachments/assets/6080986c-3904-4600-a340-8c70f1b33266"
/>
Files touched
M src/exo/download/download_utils.pyM src/exo/download/impl_shard_downloader.py
Diff
commit f0107e96706872f250304a42fa8b9bdb4092ce7c
Author: rltakashige <rl.takashige@gmail.com>
Date: Fri Feb 6 12:57:01 2026 +0000
Fix offline no cache (#1402)
## Motivation
In offline mode, exo complains if there is no caches directory, even if
the files are there.
## Changes
Check safetensors index and the directory structure to build caches
directory.
## Test Plan
### Manual Testing
<img width="2338" height="1102" alt="image"
src="https://github.com/user-attachments/assets/ad769911-399b-4fca-ac80-aeaa046af06b"
/>
<img width="656" height="1668" alt="image"
src="https://github.com/user-attachments/assets/6080986c-3904-4600-a340-8c70f1b33266"
/>
---
src/exo/download/download_utils.py | 90 ++++++++++++++++++++++++++++++-
src/exo/download/impl_shard_downloader.py | 6 ++-
2 files changed, 94 insertions(+), 2 deletions(-)
diff --git a/src/exo/download/download_utils.py b/src/exo/download/download_utils.py
index 5a8feeb9..e0554cd4 100644
--- a/src/exo/download/download_utils.py
+++ b/src/exo/download/download_utils.py
@@ -158,6 +158,78 @@ async def seed_models(seed_dir: str | Path):
logger.error(traceback.format_exc())
+async def _build_file_list_from_local_directory(
+ model_id: ModelId,
+ recursive: bool = False,
+) -> list[FileListEntry] | None:
+ """Build a file list from locally existing model files.
+
+ We can only figure out the files we need from safetensors index, so
+ a local directory must contain a *.safetensors.index.json and
+ safetensors listed there.
+ """
+ model_dir = (await ensure_models_dir()) / model_id.normalize()
+ 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)
+ if not file_list:
+ return None
+ return file_list
+
+
_fetched_file_lists_this_session: set[str] = set()
@@ -183,6 +255,14 @@ async def fetch_file_list_with_cache(
if await aios.path.exists(cache_file):
async with aiofiles.open(cache_file, "r") as f:
return TypeAdapter(list[FileListEntry]).validate_json(await f.read())
+ local_file_list = await _build_file_list_from_local_directory(
+ model_id, recursive
+ )
+ if local_file_list is not None:
+ logger.warning(
+ f"No internet and no cached file list for {model_id} - using local file list"
+ )
+ return local_file_list
raise FileNotFoundError(
f"No internet connection and no cached file list for {model_id}"
)
@@ -203,10 +283,18 @@ async def fetch_file_list_with_cache(
except Exception as e:
if await aios.path.exists(cache_file):
logger.warning(
- f"Failed to fetch file list for {model_id}, using cached data: {e}"
+ f"No internet and no cached file list for {model_id} - using local file list"
)
async with aiofiles.open(cache_file, "r") as f:
return TypeAdapter(list[FileListEntry]).validate_json(await f.read())
+ local_file_list = await _build_file_list_from_local_directory(
+ model_id, recursive
+ )
+ if local_file_list is not None:
+ logger.warning(
+ f"Failed to fetch file list for {model_id} and no cache exists, "
+ )
+ return local_file_list
raise FileNotFoundError(f"Failed to fetch file list for {model_id}: {e}") from e
diff --git a/src/exo/download/impl_shard_downloader.py b/src/exo/download/impl_shard_downloader.py
index 0e7aea1e..0f03074c 100644
--- a/src/exo/download/impl_shard_downloader.py
+++ b/src/exo/download/impl_shard_downloader.py
@@ -195,6 +195,10 @@ class ResumableShardDownloader(ShardDownloader):
self, shard: ShardMetadata
) -> RepoDownloadProgress:
_, progress = await download_shard(
- shard, self.on_progress_wrapper, skip_download=True
+ shard,
+ self.on_progress_wrapper,
+ skip_download=True,
+ skip_internet=not self.internet_connection,
+ on_connection_lost=lambda: self.set_internet_connection(False),
)
return progress
← 9f502793 fix: retry downloads on transient errors instead of breaking
·
back to Exo
·
skip tensor ring on bench (#1403) c8dbbee2 →