Skip to content

feat(download): allow direct offline loading of complete model folders without internet - #2469

Open
presentyourlove wants to merge 1 commit into
exo-explore:mainfrom
presentyourlove:feature/offline-model-folder-loading
Open

presentyourlove wants to merge 1 commit into
exo-explore:mainfrom
presentyourlove:feature/offline-model-folder-loading

Conversation

@presentyourlove

Copy link
Copy Markdown

Description

This PR resolves item 8 in TODO.md: If model folder exists, load directly without checking anything (allows using models without internet connection).

Background & Motivation

Previously, Exo attempted to fetch the model's file list from HuggingFace even if the model was already fully downloaded locally. If internet was unavailable:

  1. fetch_file_list_with_cache would fall back to _build_file_list_from_local_directory.
  2. However, _scan_model_directory strictly required a *.safetensors.index.json file.
  3. For standalone models, single .safetensors / .gguf weights, or copied/offline model folders lacking an explicit index JSON, _scan_model_directory returned None, triggering a fatal FileNotFoundError: No internet connection and no cached file list for <model_id>.

This prevented users from running models in air-gapped environments, private subnets, or using pre-downloaded model folders.

Key Changes

  1. Model Folder Readiness Detection (is_local_model_folder_ready):

    • Added validation in src/exo/download/download_utils.py to check that a model directory is non-empty, contains recognized model artifacts (.safetensors, .bin, .pt, .gguf, .onnx, config.json, etc.), and contains no incomplete download fragments (.partial files).
    • Added has_partial_files helper to quickly detect in-progress or corrupted downloads.
  2. Standalone & Copied Directory Scanning (_scan_model_directory):

    • Expanded _scan_model_directory into two complementary branches:
      • Branch A (Sharded Index Models): Retains existing logic for reading *.safetensors.index.json when present.
      • Branch B (Offline/Copied Model Directories - TODO Is windows supported ? #8): When no index file is present, scans the local files directly and populates FileListEntry objects with actual file sizes via filesystem stats. If any .partial file is encountered, scanning safely aborts (returning None) to prevent loading half-downloaded weights.
  3. Offline File List Cache Persistence:

    • When offline fallback constructs a valid file list from a ready local directory, the result is automatically persisted to {model_id}--{revision}--file_list.json in the cache folder. Subsequent launches bypass filesystem re-scanning.
  4. Unit Tests (src/exo/download/tests/test_offline_model_loading.py):

    • Added 5 comprehensive test cases covering:
      • test_has_partial_files: In-progress fragment detection.
      • test_is_local_model_folder_ready: Validation of complete vs. empty/non-model directories.
      • test_scan_model_directory_offline_copy_without_index: Correct file list assembly for index-less offline models.
      • test_scan_model_directory_with_partial_file_fails: Safety rejection when partial downloads exist.
      • test_fetch_file_list_with_cache_offline_fallback: End-to-end verification of offline loading without internet and cache generation.

Verification

  • Unit Tests:
    .venv/Scripts/pytest.exe src/exo/download/tests/test_offline_model_loading.py -v
    # 5 passed in 1.46s
  • Existing Download Suite Compatibility:
    .venv/Scripts/pytest.exe src/exo/download/tests/test_model_dirs.py src/exo/download/tests/test_offline_mode.py src/exo/download/tests/test_safetensors_index.py src/exo/download/tests/test_offline_model_loading.py -v
    # 36 passed in 1.67s
  • Code Linting:
    .venv/Scripts/ruff.exe check src/exo/download/tests/test_offline_model_loading.py
    # All checks passed!

Related Issues

  • Resolves: Item 8 in TODO.md (If model folder exists, load directly without checking anything (allows using models without internet connection))

cdvankammen pushed a commit to cdvankammen/exo that referenced this pull request Oct 23, 2026
…I (exo#2470)

Ports upstream exo-explore#2470 (aa9e120) onto the fork chain head
a485ca9 (the exo-explore#2469 offline-loading port). Resolves TODO.md item 14.

Applied as a clean cherry-pick: no conflicts, no fork-side rewrites of the
touched hunks. Verified first that the base had none of the new symbols
(git grep ip_to_interface_type / connectionCategory on a485ca9: both absent),
so this is a pure addition rather than a divergent re-implementation.

What the port does
------------------
Backend (src/exo/):
- InterfaceType Literal widened from 5 to 8 values: adds tb5, rdma, virtual.
- system_info.py splits the old macOS-only networksetup parser into
  _parse_networksetup_ports() (pure, testable) and adds
  get_heuristic_interface_type(), a cross-platform name-prefix classifier
  (tb5/tb/thunderbolt, ib/mlx -> rdma, docker/veth/br-/tun/... -> virtual,
  wlan/wl -> wifi, eth/en* -> ethernet). get_network_interfaces() now falls
  back to the heuristic whenever networksetup did not classify the iface, so
  Linux and Windows hosts get real types instead of "unknown".

Dashboard (dashboard/src/):
- app.svelte.ts carries interface type through the topology transform into a
  new ip_to_interface_type map, and classifies every edge into a
  connectionCategory (tb5 > tb > rdma > ethernet > wifi > virtual > unknown)
  using the per-node interface type, falling back to an IP heuristic
  (192.168.100.x / 169.254.x -> Thunderbolt bridge, else Ethernet).
- TopologyGraph.svelte renders a themed, colour-coded badge pill at each edge
  midpoint with an SVG title tooltip listing every underlying connection, and
  styles the link itself (stroke colour/width/dash by category).
- +page.svelte appends the interface type to the interface label tooltips.

Verification
------------
- npm run build: exit 0.
- npm run check: 15 errors on the changed tree AND 15 on the base worktree
  (a485ca9); the error-location sets are byte-identical (18 normalized
  file:line entries, comm diff empty both directions). No new diagnostics.
- pytest src/exo/utils/info_gatherer: 6 passed on base, 8 passed on the
  changed tree (the 2 new upstream tests).

Wiring control (upstream's tests are tautological on this hunk)
---------------------------------------------------------------
Upstream's two tests only call get_heuristic_interface_type() and
_parse_networksetup_ports() directly. Neither proves that
get_network_interfaces() USES the heuristic -- and on Linux/Windows
_get_interface_types_from_networksetup() returns {}, so without the wiring
hunk every interface silently stays "unknown" while both tests still pass.
Demonstrated with a harness that stubs psutil.net_if_addrs() to expose a
single "tb5_0" iface and stubs the macOS port lookup to return {}:
  - changed tree: type == "tb5"  (HEURISTIC_WIRED, exit 0)
  - wiring hunk reverted: type == "unknown" (HEURISTIC_NOT_WIRED, exit 1)
    ... and the upstream suite still reported 8 passed in that state.
That is the negative control for this port: it names the one hunk upstream's
tests cannot see. The file was restored from the index afterwards and the
control re-run green before staging.

Completeness: git diff against upstream/pr2470 for all six touched files is
empty -- the port is byte-identical to upstream's tip.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant