Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 101 additions & 12 deletions Sources/MLXInferenceCore/InferenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,32 @@ public final class InferenceEngine: ObservableObject {
}
corruptedModelId = nil

// A path the user pointed the app at directly (e.g. via "Add Local
// Model…") — never downloaded, never in the HF cache, so the usual
// verify-or-download flow doesn't apply; go straight to loading it.
// Resolved once here (not re-checked inside loadVerifiedModel) so the
// answer can't change between this check and that one.
if ModelStorage.isLocalDirectoryPath(modelId) {
await loadVerifiedModel(modelId: modelId, localDirectory: URL(filePath: modelId))
return
}

// A path-shaped modelId (starts with "/") that ISN'T currently a valid
// directory — most likely a local model whose directory the app last saw
// is now inaccessible (external drive unplugged, folder moved/renamed).
// A real HuggingFace repo id is always "org/name" with no leading slash,
// so this can't misfire on one. Without this check, `lastLoadedModelId`
// persisting a local path and auto-resuming on launch (SwiftBuddyApp)
// would fall through to verifyModelIntegrity/downloadThenLoad below and
// try to download the raw filesystem path as if it were a repo id.
if modelId.hasPrefix("/") {
state = .error(
"Can't find \"\(URL(filePath: modelId).lastPathComponent)\" — the folder may have "
+ "moved or its drive isn't connected."
)
return
}

guard ModelStorage.verifyModelIntegrity(for: modelId) else {
await downloadThenLoad(modelId: modelId)
return
Expand Down Expand Up @@ -343,9 +369,22 @@ public final class InferenceEngine: ObservableObject {
}
}

private func loadVerifiedModel(modelId: String) async {
/// - Parameter localDirectory: pre-resolved by `load()` when `modelId` is
/// itself a directory path (e.g. picked via "Add Local Model…", possibly on
/// an external drive entirely outside `cacheRoot`) — `nil` for every normal
/// HuggingFace-id model. Passed in rather than re-derived here so the two
/// checks can't disagree if the path's existence changes in between (the
/// directory is deleted/unmounted between `load()`'s check and this call).
///
/// Distinct from `ModelStorage.localLoadDirectory(for:)` below: that's for
/// an HF-style "org/name" id whose files were copied by hand into a
/// recognised `cacheRoot`-relative layout (issue #110) rather than
/// downloaded, resolved through the existing `ModelStorage` id-based
/// helpers, which still work for that case unchanged.
private func loadVerifiedModel(modelId: String, localDirectory: URL? = nil) async {
setLoadingState(progress: 0.05, stage: "Preparing model configuration")
currentModelId = modelId
let explicitLocalDirectory = localDirectory

do {
let hub = HubApi(downloadBase: ModelStorage.cacheRoot)
Expand All @@ -360,12 +399,28 @@ public final class InferenceEngine: ObservableObject {
// pointing the loader at them would list the model and then re-download it —
// several GB for a model already on disk. Load such models by directory.
var config: ModelConfiguration
if let localDirectory = ModelStorage.localLoadDirectory(for: modelId) {
if let explicitLocalDirectory {
config = ModelConfiguration(directory: explicitLocalDirectory)
} else if let localDirectory = ModelStorage.localLoadDirectory(for: modelId) {
config = ModelConfiguration(directory: localDirectory)
} else {
config = ModelConfiguration(id: modelId)
}
let isMoE = ModelCatalog.all.first(where: { $0.id == modelId })?.isMoE ?? false
// A local directory never matches a catalog id (the catalog only lists
// HuggingFace-style ids) — fall back to inspecting its own config.json
// rather than silently defaulting to "not MoE" and disabling SSD expert
// streaming for exactly the large-MoE-on-external-drive case this local-
// directory support exists for.
let isMoE: Bool
if let catalogIsMoE = ModelCatalog.all.first(where: { $0.id == modelId })?.isMoE {
isMoE = catalogIsMoE
} else if let explicitLocalDirectory,
let localConfig = ModelStorage.readModelConfig(inDirectory: explicitLocalDirectory)
{
isMoE = ModelStorage.configIndicatesMoE(localConfig)
} else {
isMoE = false
}
let generationConfig = GenerationConfig.load()
if generationConfig.enableMTP {
setenv("SWIFTLM_MTP_ENABLE", "1", 1)
Expand All @@ -377,7 +432,7 @@ public final class InferenceEngine: ObservableObject {
let shouldStream = generationConfig.effectiveStreamExperts(defaultingTo: isMoE)
if shouldStream {
config.lazyLoad = true
let modelDir = ModelStorage.snapshotDirectory(for: modelId)
let modelDir = explicitLocalDirectory ?? ModelStorage.snapshotDirectory(for: modelId)
ExpertStreamingConfig.shared.activate(
modelDirectory: modelDir,
useDirectIO: {
Expand Down Expand Up @@ -436,15 +491,27 @@ public final class InferenceEngine: ObservableObject {
downloadManager.lastLoadedModelId = modelId
downloadManager.refresh()

// Verify integrity to catch incomplete downloads before marking as ready
// Verify integrity to catch incomplete downloads before marking as ready.
// A local directory was already validated once before load() was ever
// called (see ModelManagementView's "Add Local Model…" flow) — no
// "delete and re-download" recovery makes sense for a folder outside our
// cache, so this re-check exists to catch the same class of problem
// (missing/truncated weights) with a message that doesn't imply that.
setLoadingState(progress: 0.94, stage: "Verifying model files")
guard ModelStorage.verifyModelIntegrity(for: modelId) else {
throw NSError(domain: "InferenceEngine", code: 1, userInfo: [NSLocalizedDescriptionKey: "Model safetensors files are incomplete. Please delete and re-download."])
let integrityOK = explicitLocalDirectory.map(ModelStorage.validateLocalModelDirectory)
?? ModelStorage.verifyModelIntegrity(for: modelId)
guard integrityOK else {
let message = explicitLocalDirectory != nil
? "Model safetensors files are missing or incomplete in this folder."
: "Model safetensors files are incomplete. Please delete and re-download."
throw NSError(domain: "InferenceEngine", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
}

// Read the model's actual max context length from config.json
setLoadingState(progress: 0.98, stage: "Reading model limits")
if let ctxLen = ModelStorage.readMaxContextLength(for: modelId) {
let ctxLen = explicitLocalDirectory.map(ModelStorage.readMaxContextLength(inDirectory:))
?? ModelStorage.readMaxContextLength(for: modelId)
if let ctxLen {
self.maxContextWindow = ctxLen
print("[InferenceEngine] Model context window: \(ctxLen) tokens")
} else {
Expand All @@ -457,9 +524,17 @@ public final class InferenceEngine: ObservableObject {
} catch {
ExpertStreamingConfig.shared.deactivate()
downloadManager.clearProgress(modelId: modelId)
state = .error("Failed to load \(modelId): \(error.localizedDescription)")

// If the model is incomplete/corrupted, flag it so the UI shows the "Delete & Re-download" button
// A local directory's modelId is a full absolute path — show just the
// folder name in the error text, matching how "Downloaded" rows
// elsewhere in the app already display HF ids trimmed to their last
// component, rather than a raw POSIX path that wraps awkwardly in a
// narrow error banner.
let displayName = explicitLocalDirectory?.lastPathComponent ?? modelId
state = .error("Failed to load \(displayName): \(error.localizedDescription)")

// If the model is incomplete/corrupted, flag it so the UI shows the "Delete
// & Re-download" button. markModelCorrupted itself no-ops corruptedModelId
// for a local directory (see its doc comment) — no guard needed here.
let nsError = error as NSError
if nsError.domain == "InferenceEngine" && nsError.code == 1 || Self.isModelCorruptionError(error) {
markModelCorrupted(
Expand Down Expand Up @@ -497,11 +572,25 @@ public final class InferenceEngine: ObservableObject {
state = .loading(progress: min(max(progress, 0), 1), stage: stage)
}

/// Flags a model as corrupted so the UI offers "Delete & Re-download" — except
/// for a local directory, where that recovery makes no sense: there's no repo
/// to re-download from, and delete would silently no-op anyway
/// (`ModelStorage.delete` only ever resolves paths under `cacheRoot`, so an
/// external-drive path never matches anything it would try to remove). This
/// guard lives here, not at each call site, so every current and future
/// caller gets it — a per-call-site guard is easy to add to one call and
/// forget on the others, which is exactly what happened before this was
/// centralized (three generation-time call sites had no guard while the
/// load-time one did).
private func markModelCorrupted(modelId: String?, message: String) {
let failedModelId = modelId ?? currentModelId
releaseLoadedModelResources()
state = .error(message)
corruptedModelId = failedModelId
if let failedModelId, ModelStorage.isLocalDirectoryPath(failedModelId) {
corruptedModelId = nil
} else {
corruptedModelId = failedModelId
}
}

private static func isModelCorruptionError(_ error: Error) -> Bool {
Expand Down
81 changes: 75 additions & 6 deletions Sources/MLXInferenceCore/ModelStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,19 @@ public enum ModelStorage {
/// Checks `text_config.max_position_embeddings` first (VLM/MoE models),
/// then falls back to top-level `max_position_embeddings`.
public static func readMaxContextLength(for modelId: String) -> Int? {
guard let config = readModelConfig(for: modelId) else { return nil }
maxContextLength(fromConfig: readModelConfig(for: modelId))
}

/// Same as `readMaxContextLength(for:)` but for a model addressed by an
/// arbitrary directory rather than a HuggingFace-cache-resolved model ID —
/// used when loading a model the user pointed at directly (see
/// `isLocalDirectoryPath`).
public static func readMaxContextLength(inDirectory directory: URL) -> Int? {
maxContextLength(fromConfig: readModelConfig(inDirectory: directory))
}

private static func maxContextLength(fromConfig config: [String: Any]?) -> Int? {
guard let config else { return nil }

// VLM/MoE models nest the context length in text_config
if let textConfig = config["text_config"] as? [String: Any],
Expand All @@ -243,6 +255,31 @@ public enum ModelStorage {
return nil
}

// MARK: — Local Directory Models

/// Whether `modelId` is actually a filesystem path to a directory the user
/// pointed the app at directly (e.g. a model on an external drive downloaded
/// via `hf download --local-dir`), rather than a HuggingFace repo ID.
///
/// Mirrors the identical check the `SwiftLM` CLI's `Server.swift` already
/// does for `--model`: a plain `FileManager` existence + directory check, no
/// `~` expansion (the caller is expected to hand over an already-resolved
/// absolute path — see `ModelManagementView`'s `NSOpenPanel` flow).
public static func isLocalDirectoryPath(_ modelId: String) -> Bool {
var isDirectory: ObjCBool = false
return FileManager.default.fileExists(atPath: modelId, isDirectory: &isDirectory)
&& isDirectory.boolValue
}

/// Whether `directory` looks like a usable model folder — same weight/config
/// validation `verifyModelIntegrity` applies to HuggingFace-cache layouts,
/// reused here so a folder picked via `NSOpenPanel` can be rejected with a
/// clear error before `InferenceEngine.load` ever attempts to construct a
/// model from it.
public static func validateLocalModelDirectory(_ directory: URL) -> Bool {
validateModelFiles(in: directory, logFailures: true)
}

/// Read the raw config.json dictionary for a downloaded model.
/// Verifies that all required safetensors files are present in the snapshot directory.
/// This prevents the engine from entering `.ready` state if a download was interrupted or corrupted.
Expand Down Expand Up @@ -347,15 +384,47 @@ public enum ModelStorage {

public static func readModelConfig(for modelId: String) -> [String: Any]? {
for directory in modelContentDirectories(for: modelId) {
let configPath = directory.appendingPathComponent("config.json")
guard let data = try? Data(contentsOf: configPath),
let config = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { continue }
return config
if let config = readModelConfig(inDirectory: directory) { return config }
}
return nil
}

/// Same as `readModelConfig(for:)` but for an already-resolved directory —
/// used for local models addressed directly by path (see
/// `isLocalDirectoryPath`), which have no HuggingFace-cache layout to scan.
public static func readModelConfig(inDirectory directory: URL) -> [String: Any]? {
let configPath = directory.appendingPathComponent("config.json")
guard let data = try? Data(contentsOf: configPath),
let config = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return nil }
return config
}

/// A cheap "does this config declare a Mixture-of-Experts model" check, for
/// models with no `ModelCatalog` entry to consult — chiefly local-directory
/// models (`isLocalDirectoryPath`), which can never match a catalog id since
/// the catalog only lists HuggingFace-style ids.
///
/// Checks the same field names `Sources/SwiftLM/ModelProfiler.swift`'s
/// `findExpertCounts` looks for (`num_local_experts`/`num_experts`/
/// `n_routed_experts`), at the top level and inside `text_config` (the most
/// common one-level VLM/multimodal wrapper). `ModelProfiler` itself lives in
/// the CLI target and isn't importable from here; this intentionally doesn't
/// replicate its full breadth-first nested-wrapper walk — this check only
/// needs a yes/no answer for "should SSD expert streaming default on," not
/// the exact expert counts, so the common cases are enough.
public static func configIndicatesMoE(_ config: [String: Any]) -> Bool {
let expertKeys = ["num_local_experts", "num_experts", "n_routed_experts"]
func hasExpertCount(_ container: [String: Any]) -> Bool {
expertKeys.contains { (container[$0] as? Int).map { $0 > 0 } ?? false }
}
if hasExpertCount(config) { return true }
if let textConfig = config["text_config"] as? [String: Any], hasExpertCount(textConfig) {
return true
}
return false
}

// MARK: — Disk Operations

/// Total bytes used by all model files on disk.
Expand Down
16 changes: 4 additions & 12 deletions Sources/SwiftLM/Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -438,16 +438,9 @@ struct MLXServer: AsyncParsableCommand {

// ── Load model ──
var modelConfig: ModelConfiguration
let fileManager = FileManager.default
if fileManager.fileExists(atPath: modelId) {
var isDir: ObjCBool = false
fileManager.fileExists(atPath: modelId, isDirectory: &isDir)
if isDir.boolValue {
print("[SwiftLM] Loading from local directory: \(modelId)")
modelConfig = ModelConfiguration(directory: URL(filePath: modelId))
} else {
modelConfig = ModelConfiguration(id: modelId)
}
if ModelStorage.isLocalDirectoryPath(modelId) {
print("[SwiftLM] Loading from local directory: \(modelId)")
modelConfig = ModelConfiguration(directory: URL(filePath: modelId))
} else if let localDirectory = ModelStorage.validatedContentDirectory(for: modelId) {
// Any validated copy in the shared HF cache, in any supported layout. Note
// this deliberately does NOT use localLoadDirectory: that skips the
Expand Down Expand Up @@ -1340,8 +1333,7 @@ func resolveModelDirectory(modelId: String) -> URL? {
let fm = FileManager.default

// Direct local path
var isDir: ObjCBool = false
if fm.fileExists(atPath: modelId, isDirectory: &isDir), isDir.boolValue {
if ModelStorage.isLocalDirectoryPath(modelId) {
let url = URL(filePath: modelId)
// Verify config.json exists
if fm.fileExists(atPath: url.appendingPathComponent("config.json").path) {
Expand Down
Loading
Loading