Skip to content

feat: let SwiftBuddy load a model from an arbitrary local folder - #161

Merged
solderzzc merged 2 commits into
mainfrom
feat/local-model-directory-support
Aug 27, 2026
Merged

feat: let SwiftBuddy load a model from an arbitrary local folder#161
solderzzc merged 2 commits into
mainfrom
feat/local-model-directory-support

Conversation

@solderzzc

Copy link
Copy Markdown
Member

Summary

Fixes #160. A user storing models on an external drive (downloaded via hf download --local-dir <path>, entirely outside the app's HF cache) had no way to point SwiftBuddy at that folder directly — only the SwiftLM CLI's --model <path> already supported this. Adds a matching "Add Local Model…" option to the SwiftBuddy app.

Distinct from issue #110's "hand-copied into a recognised cache-root layout" support (ModelStorage.localLoadDirectory, left unchanged and still used for that case): this is for a directory that can be anywhere on disk, addressed by its own absolute path rather than an HF-style org/name id resolved relative to cacheRoot.

What's new

  • ModelStorage.swift: isLocalDirectoryPath(_:), validateLocalModelDirectory(_:), and directory-addressed variants of readModelConfig/readMaxContextLength (refactored to share logic with the existing id-addressed versions rather than duplicating it).
  • InferenceEngine.swift: load(modelId:) recognises a local-directory modelId early and skips the usual verify-or-download flow (which would otherwise try to download the path as if it were a repo id). loadVerifiedModel threads an explicitLocalDirectory: URL? through model config construction, SSD-streaming setup, the post-load integrity check, and context-length reading — all of which normally go through ModelStorage's id-based helpers that only ever resolve paths under cacheRoot. Also suppresses the "Delete & Re-download" recovery flow for local-directory load failures (no repo to re-download from — confirmed ModelStorage.delete would silently no-op for an external path anyway, but offering that option would still be misleading).
  • ModelManagementView.swift (macOS only): "Add Local Model…" button opening an NSOpenPanel, validated before calling engine.load(modelId:).

Tests

New LocalModelDirectoryTests.swift (13 tests), deliberately independent of cacheRootOverride since these functions must work regardless of where the folder actually is. Full suite: 282/282 passing, zero regressions (including all 21 existing ModelStorageLayoutTests — confirms the readModelConfig/readMaxContextLength refactor preserved #110's behavior exactly).

Model persistence across app relaunch (lastLoadedModelId) works unchanged for a local path since it's just a plain string, same as an HF id.

Test plan

  • swift build --target MLXInferenceCore / SwiftBuddy — clean.
  • Full test suite: 282/282 passing.
  • Manual: pick a real local model folder via the new button and confirm it loads and generates.
  • CI green.

Fixes #160. A user storing models on an external drive (downloaded via
`hf download --local-dir <path>`, entirely outside the app's HF cache)
had no way to point SwiftBuddy at that folder directly — only the
`SwiftLM` CLI's `--model <path>` already supported this.

Distinct from issue #110's "hand-copied into a recognised cache-root
layout" support (ModelStorage.localLoadDirectory, still used unchanged
for that case): this is for a directory that can be anywhere on disk,
addressed by its own absolute path rather than an HF-style "org/name"
id resolved relative to cacheRoot.

## ModelStorage.swift

- `isLocalDirectoryPath(_:)` — is this string actually a directory that
  exists on disk, mirroring the identical check Server.swift's CLI
  `--model` handling already does.
- `validateLocalModelDirectory(_:)` — reuses the same weight/config
  validation `verifyModelIntegrity` applies to HF-cache layouts, so a
  bad folder selection is rejected with a clear message before
  `InferenceEngine.load` ever attempts to construct a model from it.
- `readModelConfig(inDirectory:)` / `readMaxContextLength(inDirectory:)`
  — directory-addressed variants of the existing id-addressed
  functions (refactored to share the same underlying logic rather than
  duplicating it).

## InferenceEngine.swift

`load(modelId:)` now recognises a local-directory modelId early and
skips straight to loading it — the usual verify-or-download flow
assumes an HF-style id and would otherwise try to download the path as
if it were a repo id. `loadVerifiedModel` threads an
`explicitLocalDirectory: URL?` through: building
`ModelConfiguration(directory:)`, the SSD-streaming directory, the
post-load integrity check, and the context-length read all branch on
it instead of going through the id-based `ModelStorage` helpers (which
only ever resolve paths under cacheRoot and would find nothing for an
external path). Also: a load failure for a local directory no longer
offers the "Delete & Re-download" recovery flow, since there's no repo
to re-download from (confirmed `ModelStorage.delete` would silently
no-op for such a path anyway — it only ever resolves candidates under
cacheRoot — but showing that recovery option would still be misleading
UX).

## ModelManagementView.swift (macOS only)

"Add Local Model…" button next to "Search HuggingFace MLX Models" (in
both the populated list and the empty state), opening an NSOpenPanel
folder picker. Validates the selection with
`ModelStorage.validateLocalModelDirectory` before calling
`engine.load(modelId: url.path)`; a bad selection shows an alert
instead of proceeding.

## Tests

New tests/SwiftBuddyTests/LocalModelDirectoryTests.swift (13 tests) —
deliberately does NOT use `cacheRootOverride`, unlike the #110 layout
tests, since these functions must work independent of cacheRoot
entirely. Full suite: 282/282 passing, zero regressions (including all
21 existing ModelStorageLayoutTests, confirming the readModelConfig/
readMaxContextLength refactor didn't change #110's behavior).

Model persistence across app relaunch (`lastLoadedModelId`) works
unchanged for a local path — it's stored as a plain string, same as an
HF id, so no separate persistence code was needed.
…y support

A code review found several issues in the previous commit's local-directory
support (issue #160), stemming from modelId being overloaded to mean either
an HF repo id or a local path with no single place resolving that once.

## Fixed

- isMoE always false for local-directory models: ModelCatalog.all.first(where:
  { $0.id == modelId }) can never match a filesystem path, silently disabling
  SSD expert streaming for exactly the "large MoE model on external drive"
  use case this feature exists for. Now falls back to inspecting the local
  config.json directly (new ModelStorage.configIndicatesMoE, checking the
  same expert-count field names ModelProfiler.findExpertCounts uses) when the
  catalog lookup misses. Fixed in both InferenceEngine.loadVerifiedModel and
  the same independent pattern in SettingsView's currentModelIsMoE.

- markModelCorrupted's "Delete & Re-download" recovery was only suppressed
  for local directories at the load-time catch block; three generation-time
  call sites (SSD streaming error, generic corruption error, latched SSD
  error) had no such guard, so a transient error during generation on a
  local-directory model would still offer a recovery button that doesn't
  work (delete no-ops, re-download treats the path as a repo id). Moved the
  guard into markModelCorrupted itself so every current and future call site
  gets it, rather than relying on each one remembering to add it.

- A stale persisted local path (drive unplugged, folder moved/deleted since
  last session) silently fell through to the HF-id download path, attempting
  to download the raw filesystem path as a repo id instead of showing a clear
  error. load() now recognizes a path-shaped modelId ("/...", which no real
  HF id ever is) that no longer resolves to a directory and surfaces "the
  folder may have moved or its drive isn't connected" immediately.

- isLocalDirectoryPath was checked twice per load (once in load(), again
  inside loadVerifiedModel), leaving a narrow window where the two checks
  could disagree if the path's existence changed in between.
  loadVerifiedModel now takes the already-resolved localDirectory as a
  parameter instead of re-deriving it.

- Local-directory detection was duplicated three ways with two different
  definitions: Server.swift's CLI --model handling (plain dir check),
  Server.swift's resolveModelDirectory (dir + config.json check), and the new
  ModelStorage.isLocalDirectoryPath (plain dir check, previously described in
  its own doc comment as merely "mirroring" Server.swift rather than sharing
  logic with it). Both Server.swift call sites now call
  ModelStorage.isLocalDirectoryPath directly (the CLI target already depends
  on MLXInferenceCore).

- A model loaded via "Add Local Model…" was invisible everywhere in the app
  once loaded — never in dm.downloadedModels since it was never downloaded,
  so no view indicated it as the current model. ModelManagementView now shows
  a "Current Model (Local)" section for it, with a "Show in Finder" action
  and deliberately no delete option (there is nothing in the app's cache to
  remove).

- Load-failure error messages interpolated the raw absolute path; now shown
  as just the folder name, matching how downloaded models are already
  displayed elsewhere trimmed to their last path component.

## Tests

- New ModelStorage.configIndicatesMoE tests (top-level and nested
  text_config expert-count fields, dense-model and zero-count negative
  cases).
- New InferenceEngine test verifying a missing local path produces a clear,
  immediate error without ever reaching the network (hermetic — no real
  download attempt).
- Full suite: 288/288 passing, zero regressions.
@solderzzc

Copy link
Copy Markdown
Member Author

Pushed a follow-up commit addressing all 8 findings from the code review:

Fixed:

  • isMoE always false for local-directory models — the exact regression that defeated this feature's own purpose. Now falls back to reading config.json directly (new ModelStorage.configIndicatesMoE) when the catalog lookup misses, fixed in both InferenceEngine and the same independent pattern found in SettingsView.
  • Corruption-recovery UI not gated everywhere — moved the guard into markModelCorrupted itself so all 4 call sites (not just the one that had been patched) skip the broken "Delete & Re-download" flow for local models.
  • Stale local path silently treated as an HF repo idload() now recognizes a path-shaped modelId that no longer resolves to a directory and shows a clear "drive isn't connected" error instead of attempting a bogus download.
  • Duplicated local-directory detection — both Server.swift (CLI) call sites now call the shared ModelStorage.isLocalDirectoryPath instead of re-implementing the same check.
  • Local model invisible in the appModelManagementView now shows a "Current Model (Local)" section when one is loaded, with a "Show in Finder" action and deliberately no delete option.
  • TOCTOU between the two isLocalDirectoryPath checks — resolved once in load(), threaded into loadVerifiedModel instead of re-derived.
  • Raw path in error messages — now shows just the folder name, matching how downloaded models are already displayed elsewhere.

Not fixed (scope): the full ModelSource enum refactor suggested for the four repeated local-vs-remote branches — a reasonable idea but a larger structural change than warranted for this PR; the TOCTOU-causing duplication it would also have fixed was addressed directly instead.

New tests for configIndicatesMoE and the stale-path error path. Full suite: 288/288 passing, zero regressions.

@solderzzc
solderzzc merged commit 28344ee into main Aug 27, 2026
14 checks passed
@solderzzc
solderzzc deleted the feat/local-model-directory-support branch August 27, 2026 06:53
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.

Specify Alternate Model Location

1 participant