From 66146cb3dff3d7ac0a71df2034b56bf3941e2e79 Mon Sep 17 00:00:00 2001 From: Kefeng Zhou Date: Sun, 10 May 2026 14:07:08 +0700 Subject: [PATCH] feat(lora): engine-side LoRA application + dual-format adapter detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third of four v0.5 LoRA PRs. Engine-side glue tying the Foundation (#36) and PEFT→mlx Converter (#37) together. - InferenceEngine.applyAdapter(_:) protocol method with a default extension-level no-op, so test stubs and future CPU/Python engines compile unchanged. - MLXSwiftEngine.applyAdapter(_:) auto-routes PEFT through LoRAAdapterConverter (cached at ~/.mac-mlx/adapters/.cache// so repeat loads skip the conversion), then calls LoRAContainer.from(directory:) + LanguageModel.load(adapter:). - EngineError.adapterApplyFailed(reason:) for typed surfacing of conversion or LoRAContainer failures. - EngineCoordinator.load(_, adapter:) — optional default nil keeps every existing call site working; when set, applyAdapter runs immediately after the base load. - AdapterStore detects mlx-native format too (adapters.safetensors + mlx-schema adapter_config.json), with mlx-native winning when both formats coexist in the same dir. - LocalAdapter.format: Format ('peft' / 'mlx') drives the engine's auto-conversion branch. Custom decoder defaults pre-v0.5 records to .peft for backwards compat. - ModelParameters.adapterName: String? persists the user's pick; custom decoder defaults to nil so pre-v0.5 per-model JSON files load unchanged. 4 new tests (2 LocalAdapter format / mlx round-trip, 2 AdapterStore mlx detection / dual-format precedence). 137/137 Core tests green. Local Xcode app build green. Parameters-inspector picker UI + Settings adapters section land in v0.5 part 4. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 33 +++++++ .../MacMLXCore/Engine/EngineError.swift | 3 + .../MacMLXCore/Engine/InferenceEngine.swift | 20 +++++ .../MacMLXCore/Engine/MLXSwiftEngine.swift | 67 +++++++++++++++ .../MacMLXCore/Managers/AdapterStore.swift | 85 +++++++++++++++---- .../Managers/ModelParametersStore.swift | 26 +++++- .../MacMLXCore/Models/LocalAdapter.swift | 41 +++++++-- .../Managers/AdapterStoreTests.swift | 59 +++++++++++++ .../Models/LocalAdapterTests.swift | 34 ++++++++ macMLX/macMLX/App/EngineCoordinator.swift | 22 ++++- 10 files changed, 367 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3371ac..b76f8e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,39 @@ Versioning follows [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added +- **LoRA Engine integration** (v0.5, part 3 of 4). Engine-side + glue tying the v0.5 LoRA Foundation (#36) and PEFT → mlx + Converter (#37) together so a downloaded HuggingFace adapter + works end-to-end via the InferenceEngine protocol. + - `InferenceEngine.applyAdapter(_:)` — new protocol method with + a default `extension`-level no-op so test stubs and future + CPU/Python engines compile unchanged. Implementations that + DO support adapters wire it to their LoRA loader. + - `MLXSwiftEngine.applyAdapter(_:)` — auto-routes PEFT-format + adapters through `LoRAAdapterConverter` (cached at + `~/.mac-mlx/adapters/.cache//` so repeat loads + skip conversion), then calls + `LoRAContainer.from(directory:)` and + `LanguageModel.load(adapter:)`. Throws + `EngineError.adapterApplyFailed(reason:)` on either step. + - `EngineCoordinator.load(_, adapter:)` — optional adapter + parameter; default `nil` keeps every existing call site + unchanged. When provided, the adapter is applied immediately + after the base model loads. + - `AdapterStore.scan(_:)` now detects mlx-native format + (`adapters.safetensors` + mlx-schema `adapter_config.json`) + in addition to PEFT, with mlx-native taking precedence when + both files coexist (caller already has the converter output + side-by-side with the source). + - `LocalAdapter.format: Format` (`peft` / `mlx`) drives the + engine's auto-conversion branch. Backwards-compatible decode + defaults pre-v0.5 records to `.peft`. + - `ModelParameters.adapterName: String?` persists the user's + adapter pick per model. Custom decoder defaults to nil so + pre-v0.5 `~/.mac-mlx/model-params/*.json` files load unchanged. + - 4 new tests (2 LocalAdapter format/round-trip, 2 AdapterStore + mlx detection / dual-format precedence). 137/137 Core green. + - Parameters-inspector picker UI lands in v0.5 part 4. - **LoRA PEFT → mlx Converter** (v0.5, part 2 of 3). Pure-Swift in-process converter that turns a HuggingFace PEFT-format adapter directory into the mlx-swift-lm native format that diff --git a/MacMLXCore/Sources/MacMLXCore/Engine/EngineError.swift b/MacMLXCore/Sources/MacMLXCore/Engine/EngineError.swift index fc351c0..944f217 100644 --- a/MacMLXCore/Sources/MacMLXCore/Engine/EngineError.swift +++ b/MacMLXCore/Sources/MacMLXCore/Engine/EngineError.swift @@ -7,6 +7,7 @@ public enum EngineError: LocalizedError, Equatable, Sendable { case engineNotReady case generationInProgress case modelLoadFailed(reason: String) + case adapterApplyFailed(reason: String) case unsupportedOperation(String) public var errorDescription: String? { @@ -21,6 +22,8 @@ public enum EngineError: LocalizedError, Equatable, Sendable { return "A generation is already in progress on this engine." case .modelLoadFailed(let reason): return "Model failed to load: \(reason)" + case .adapterApplyFailed(let reason): + return "LoRA adapter failed to apply: \(reason)" case .unsupportedOperation(let op): return "Operation not supported by this engine: \(op)." } diff --git a/MacMLXCore/Sources/MacMLXCore/Engine/InferenceEngine.swift b/MacMLXCore/Sources/MacMLXCore/Engine/InferenceEngine.swift index d13b32c..06f427e 100644 --- a/MacMLXCore/Sources/MacMLXCore/Engine/InferenceEngine.swift +++ b/MacMLXCore/Sources/MacMLXCore/Engine/InferenceEngine.swift @@ -19,6 +19,17 @@ public protocol InferenceEngine: Actor { /// Bring a model into memory. Replaces any previously loaded model. func load(_ model: LocalModel) async throws + /// Apply a LoRA adapter to the currently-loaded model (v0.5+). + /// + /// Called after `load(_:)` to layer adapter weights on top of the + /// base model. The protocol-extension default is a no-op so engines + /// that don't support adapters (test stubs, future CPU/Python + /// engines) compile unchanged. The MLX engine routes through + /// `LoRAContainer.from(directory:)` + `LanguageModel.load(adapter:)`, + /// auto-converting PEFT-format adapters via + /// `LoRAAdapterConverter` when needed. + func applyAdapter(_ adapter: LocalAdapter) async throws + /// Release the loaded model (and any caches) from memory. func unload() async throws @@ -36,3 +47,12 @@ public protocol InferenceEngine: Actor { /// Synchronously confirm the engine is responsive. func healthCheck() async -> Bool } + +extension InferenceEngine { + /// Default no-op for engines that don't yet support LoRA adapters + /// (test stubs, future CPU/Python engines, …). Throws nothing, + /// silently leaves the model unchanged. + public func applyAdapter(_ adapter: LocalAdapter) async throws { + // intentional no-op + } +} diff --git a/MacMLXCore/Sources/MacMLXCore/Engine/MLXSwiftEngine.swift b/MacMLXCore/Sources/MacMLXCore/Engine/MLXSwiftEngine.swift index f26cb23..09d4b32 100644 --- a/MacMLXCore/Sources/MacMLXCore/Engine/MLXSwiftEngine.swift +++ b/MacMLXCore/Sources/MacMLXCore/Engine/MLXSwiftEngine.swift @@ -252,6 +252,73 @@ public actor MLXSwiftEngine: InferenceEngine { status = .idle } + /// Apply a LoRA adapter (v0.5+) to the currently-loaded model. + /// + /// PEFT-format adapters are auto-converted to mlx-native format + /// via `LoRAAdapterConverter`, with the conversion output cached + /// at `~/.mac-mlx/adapters/.cache//` so repeat + /// loads reuse the converted bytes. mlx-native adapters skip the + /// converter and load directly. + public func applyAdapter(_ adapter: LocalAdapter) async throws { + guard let container = loadedSupport.container else { + throw EngineError.modelNotLoaded + } + + // Resolve the directory the LoRAContainer should read from. + // PEFT → run the converter into a sibling cache dir; mlx- + // native → use the adapter's own directory. + let mlxDirectory: URL + switch adapter.format { + case .mlx: + mlxDirectory = adapter.directory + case .peft: + mlxDirectory = try await convertedDirectory(for: adapter) + } + + // Hand the mlx-format directory to LoRAContainer.from then + // load it into the model. Both calls happen inside the + // ModelContainer's actor so we serialise correctly with any + // concurrent generation. + do { + try await container.perform { context in + let loraContainer = try LoRAContainer.from(directory: mlxDirectory) + try context.model.load(adapter: loraContainer) + } + } catch { + throw EngineError.adapterApplyFailed(reason: error.localizedDescription) + } + + await LogManager.shared.info( + "LoRA adapter applied: \(adapter.name) (format=\(adapter.format.rawValue))", + category: .inference + ) + } + + /// Convert a PEFT-format adapter to the mlx-native cache layout. + /// Cached at `~/.mac-mlx/adapters/.cache//` so repeat + /// loads of the same adapter reuse the conversion result. + private func convertedDirectory(for adapter: LocalAdapter) async throws -> URL { + let cacheDir = DataRoot.macMLX("adapters/.cache") + .appending(path: adapter.name, directoryHint: .isDirectory) + let configURL = cacheDir.appending(path: "adapter_config.json", directoryHint: .notDirectory) + let weightsURL = cacheDir.appending(path: "adapters.safetensors", directoryHint: .notDirectory) + + if FileManager.default.fileExists(atPath: configURL.path), + FileManager.default.fileExists(atPath: weightsURL.path) { + return cacheDir + } + + do { + try LoRAAdapterConverter.convertPEFTAdapter( + source: adapter.directory, + destination: cacheDir + ) + } catch { + throw EngineError.adapterApplyFailed(reason: "PEFT → mlx conversion failed: \(error)") + } + return cacheDir + } + /// Stream tokens for a generation request. /// /// This method is `nonisolated` so the `AsyncThrowingStream` is returned diff --git a/MacMLXCore/Sources/MacMLXCore/Managers/AdapterStore.swift b/MacMLXCore/Sources/MacMLXCore/Managers/AdapterStore.swift index f52dc97..a3d31a7 100644 --- a/MacMLXCore/Sources/MacMLXCore/Managers/AdapterStore.swift +++ b/MacMLXCore/Sources/MacMLXCore/Managers/AdapterStore.swift @@ -33,22 +33,77 @@ public actor AdapterStore { guard try url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true, !url.lastPathComponent.hasPrefix(".") else { continue } - let configURL = url.appendingPathComponent("adapter_config.json") - let weightsURL = url.appendingPathComponent("adapter_model.safetensors") - guard fileManager.fileExists(atPath: configURL.path), - fileManager.fileExists(atPath: weightsURL.path), - let data = try? Data(contentsOf: configURL), - let cfg = try? JSONDecoder().decode(LocalAdapter.PEFTConfig.self, from: data) - else { continue } - - results.append(LocalAdapter( - name: url.lastPathComponent, - directory: url, - targetModel: cfg.baseModelNameOrPath, - rank: cfg.r, - targetModules: cfg.targetModules ?? [] - )) + if let mlxAdapter = readMLXAdapter(at: url) { + results.append(mlxAdapter) + } else if let peftAdapter = readPEFTAdapter(at: url) { + results.append(peftAdapter) + } } return results.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } } + + // MARK: - Format-specific decoders + + /// Detect mlx-native format: `adapter_config.json` (mlx schema) + /// + `adapters.safetensors`. Returns `nil` if either file is + /// missing or the config doesn't decode cleanly. + private func readMLXAdapter(at url: URL) -> LocalAdapter? { + let configURL = url.appendingPathComponent("adapter_config.json") + let weightsURL = url.appendingPathComponent("adapters.safetensors") + guard fileManager.fileExists(atPath: configURL.path), + fileManager.fileExists(atPath: weightsURL.path), + let data = try? Data(contentsOf: configURL), + let cfg = try? JSONDecoder().decode(MLXAdapterConfig.self, from: data) + else { return nil } + return LocalAdapter( + name: url.lastPathComponent, + directory: url, + format: .mlx, + targetModel: nil, // mlx-native config doesn't carry base model id + rank: cfg.loraParameters.rank, + targetModules: cfg.loraParameters.keys ?? [] + ) + } + + /// Detect PEFT format: `adapter_config.json` (PEFT schema) + /// + `adapter_model.safetensors`. Returns `nil` if either file is + /// missing or the config doesn't decode cleanly. + private func readPEFTAdapter(at url: URL) -> LocalAdapter? { + let configURL = url.appendingPathComponent("adapter_config.json") + let weightsURL = url.appendingPathComponent("adapter_model.safetensors") + guard fileManager.fileExists(atPath: configURL.path), + fileManager.fileExists(atPath: weightsURL.path), + let data = try? Data(contentsOf: configURL), + let cfg = try? JSONDecoder().decode(LocalAdapter.PEFTConfig.self, from: data) + else { return nil } + return LocalAdapter( + name: url.lastPathComponent, + directory: url, + format: .peft, + targetModel: cfg.baseModelNameOrPath, + rank: cfg.r, + targetModules: cfg.targetModules ?? [] + ) + } +} + +/// Minimal mirror of `MLXLMCommon.LoRAConfiguration` shape used to +/// detect mlx-native adapter directories without depending on +/// MLXLMCommon at this layer (Manager file stays MLX-free). +private struct MLXAdapterConfig: Decodable { + let numLayers: Int + let fineTuneType: String + let loraParameters: LoRAParameters + + struct LoRAParameters: Decodable { + let rank: Int + let scale: Float + let keys: [String]? + } + + private enum CodingKeys: String, CodingKey { + case numLayers = "num_layers" + case fineTuneType = "fine_tune_type" + case loraParameters = "lora_parameters" + } } diff --git a/MacMLXCore/Sources/MacMLXCore/Managers/ModelParametersStore.swift b/MacMLXCore/Sources/MacMLXCore/Managers/ModelParametersStore.swift index d794b87..3536d76 100644 --- a/MacMLXCore/Sources/MacMLXCore/Managers/ModelParametersStore.swift +++ b/MacMLXCore/Sources/MacMLXCore/Managers/ModelParametersStore.swift @@ -27,17 +27,41 @@ public struct ModelParameters: Codable, Hashable, Sendable { public var maxTokens: Int /// System prompt prepended to every generation. public var systemPrompt: String + /// Optional LoRA adapter name (folder under + /// `~/.mac-mlx/adapters//`) to apply on model load (v0.5+). + /// Empty string is treated identically to `nil` and means "no + /// adapter" — matches how the parameters inspector represents + /// the "None" pick. + public var adapterName: String? public init( temperature: Double = 0.7, topP: Double = 0.95, maxTokens: Int = 2048, - systemPrompt: String = "You are a helpful assistant." + systemPrompt: String = "You are a helpful assistant.", + adapterName: String? = nil ) { self.temperature = temperature self.topP = topP self.maxTokens = maxTokens self.systemPrompt = systemPrompt + self.adapterName = adapterName + } + + /// Backwards-compatible decoder. Pre-v0.5 JSON has no + /// `adapterName` key — default to nil so existing per-model + /// override files load unchanged. + private enum CodingKeys: String, CodingKey { + case temperature, topP, maxTokens, systemPrompt, adapterName + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.temperature = try c.decode(Double.self, forKey: .temperature) + self.topP = try c.decode(Double.self, forKey: .topP) + self.maxTokens = try c.decode(Int.self, forKey: .maxTokens) + self.systemPrompt = try c.decode(String.self, forKey: .systemPrompt) + self.adapterName = try c.decodeIfPresent(String.self, forKey: .adapterName) } /// Factory for the factory defaults — handy in "Reset" buttons. diff --git a/MacMLXCore/Sources/MacMLXCore/Models/LocalAdapter.swift b/MacMLXCore/Sources/MacMLXCore/Models/LocalAdapter.swift index 812d4c5..e468e97 100644 --- a/MacMLXCore/Sources/MacMLXCore/Models/LocalAdapter.swift +++ b/MacMLXCore/Sources/MacMLXCore/Models/LocalAdapter.swift @@ -2,8 +2,10 @@ import Foundation /// One LoRA adapter directory present on the local filesystem. /// -/// Discovered by `AdapterStore.scan(_:)` via the presence of -/// `adapter_config.json` + `adapter_model.safetensors` (PEFT format). +/// Discovered by `AdapterStore.scan(_:)` via the presence of one of: +/// - PEFT format: `adapter_config.json` + `adapter_model.safetensors` +/// - mlx-native: `adapter_config.json` (mlx schema) + `adapters.safetensors` +/// /// `targetModel` is advisory — the engine layer applies the adapter /// regardless and surfaces a clear typed error if the dimensions /// don't fit the loaded base model. @@ -11,11 +13,13 @@ public struct LocalAdapter: Codable, Hashable, Identifiable, Sendable { public var id: String { name } public let name: String public let directory: URL + /// On-disk format of the adapter weights / config. + public let format: Format /// Base-model id from the adapter's config (e.g. - /// `mlx-community/Qwen3-8B-4bit`). Optional — older adapters - /// don't always carry it. + /// `mlx-community/Qwen3-8B-4bit`). Optional — only PEFT carries + /// it; mlx-native adapters don't include the base-model id. public let targetModel: String? - /// LoRA rank (`r` in PEFT config). Nil if absent / unparseable. + /// LoRA rank. Nil if absent / unparseable. public let rank: Int? /// Names of the linear layers the adapter touches (e.g. /// `["q_proj", "v_proj"]`). Empty array if absent. @@ -24,17 +28,44 @@ public struct LocalAdapter: Codable, Hashable, Identifiable, Sendable { public init( name: String, directory: URL, + format: Format = .peft, targetModel: String?, rank: Int?, targetModules: [String] ) { self.name = name self.directory = directory + self.format = format self.targetModel = targetModel self.rank = rank self.targetModules = targetModules } + /// On-disk format of the adapter directory. Drives engine-side + /// behaviour: PEFT adapters get auto-converted to mlx-native + /// before `LoRAContainer.from(directory:)` is called. + public enum Format: String, Codable, Hashable, Sendable { + case peft // adapter_config.json + adapter_model.safetensors (HuggingFace standard) + case mlx // adapter_config.json (mlx schema) + adapters.safetensors + } + + /// Backwards-compatible decoder. Adapters persisted before format + /// tagging existed default to `.peft` (the only previously- + /// recognised format). + private enum CodingKeys: String, CodingKey { + case name, directory, format, targetModel, rank, targetModules + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.name = try c.decode(String.self, forKey: .name) + self.directory = try c.decode(URL.self, forKey: .directory) + self.format = try c.decodeIfPresent(Format.self, forKey: .format) ?? .peft + self.targetModel = try c.decodeIfPresent(String.self, forKey: .targetModel) + self.rank = try c.decodeIfPresent(Int.self, forKey: .rank) + self.targetModules = try c.decode([String].self, forKey: .targetModules) + } + /// On-disk PEFT `adapter_config.json` shape. /// /// Exposed publicly so `AdapterStore` and tests can decode it diff --git a/MacMLXCore/Tests/MacMLXCoreTests/Managers/AdapterStoreTests.swift b/MacMLXCore/Tests/MacMLXCoreTests/Managers/AdapterStoreTests.swift index 8b79e1a..7d67c1e 100644 --- a/MacMLXCore/Tests/MacMLXCoreTests/Managers/AdapterStoreTests.swift +++ b/MacMLXCore/Tests/MacMLXCoreTests/Managers/AdapterStoreTests.swift @@ -71,6 +71,48 @@ struct AdapterStoreTests { #expect(found.isEmpty) } + @Test + func scanDetectsMLXNativeFormat() async throws { + let temp = try TempDir() + try writeMLXAdapter(in: temp.url, name: "mlx-cached", rank: 8, scale: 2.0, keys: ["q_proj"]) + let store = AdapterStore() + let found = try await store.scan(temp.url) + #expect(found.count == 1) + #expect(found[0].format == .mlx) + #expect(found[0].rank == 8) + #expect(found[0].targetModel == nil) // mlx-native doesn't carry base id + #expect(found[0].targetModules == ["q_proj"]) + } + + @Test + func scanPrefersMLXOverPEFTWhenBothFilesPresent() async throws { + // A directory that has both PEFT and mlx-native files (e.g. a + // user kept the converter output side-by-side with the source) + // should be reported as .mlx — that's the format the engine + // can load directly without re-converting. + let temp = try TempDir() + let dir = temp.url.appendingPathComponent("dual-format") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + // Both weights files… + try Data().write(to: dir.appendingPathComponent("adapter_model.safetensors")) + try Data().write(to: dir.appendingPathComponent("adapters.safetensors")) + // …but only the mlx config (the readMLXAdapter path is tried + // first, so this is what the scan should latch onto). + let mlxCfg = """ + { + "num_layers": 1, + "fine_tune_type": "lora", + "lora_parameters": { "rank": 4, "scale": 2.0, "keys": null } + } + """ + try Data(mlxCfg.utf8).write(to: dir.appendingPathComponent("adapter_config.json")) + + let store = AdapterStore() + let found = try await store.scan(temp.url) + #expect(found.count == 1) + #expect(found[0].format == .mlx) + } + @Test func scanSortsAdaptersByName() async throws { let temp = try TempDir() @@ -82,6 +124,23 @@ struct AdapterStoreTests { #expect(found.map(\.name) == ["alpha", "mu", "zeta"]) } + /// Lay down a directory in mlx-native format (mlx schema config + + /// `adapters.safetensors`). Same shape as the converter writes. + private func writeMLXAdapter(in root: URL, name: String, rank: Int, scale: Float, keys: [String]) throws { + let dir = root.appendingPathComponent(name) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let keysJSON = "[\(keys.map { "\"\($0)\"" }.joined(separator: ","))]" + let cfg = """ + { + "num_layers": 1, + "fine_tune_type": "lora", + "lora_parameters": { "rank": \(rank), "scale": \(scale), "keys": \(keysJSON) } + } + """ + try Data(cfg.utf8).write(to: dir.appendingPathComponent("adapter_config.json")) + try Data().write(to: dir.appendingPathComponent("adapters.safetensors")) + } + private func writeAdapter(in root: URL, name: String, targetModel: String?, r: Int) throws { let dir = root.appendingPathComponent(name) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) diff --git a/MacMLXCore/Tests/MacMLXCoreTests/Models/LocalAdapterTests.swift b/MacMLXCore/Tests/MacMLXCoreTests/Models/LocalAdapterTests.swift index c377557..ea3c83b 100644 --- a/MacMLXCore/Tests/MacMLXCoreTests/Models/LocalAdapterTests.swift +++ b/MacMLXCore/Tests/MacMLXCoreTests/Models/LocalAdapterTests.swift @@ -49,6 +49,40 @@ struct LocalAdapterTests { #expect(back == original) } + @Test + func defaultsFormatToPEFTWhenMissing() throws { + // Pre-v0.5 JSON has no `format` key. The custom decoder + // defaults to .peft so on-disk adapter records keep loading. + let legacy = """ + { + "name": "old-adapter", + "directory": "file:///tmp/old", + "targetModel": null, + "rank": 4, + "targetModules": ["q_proj"] + } + """ + let decoded = try JSONDecoder().decode(LocalAdapter.self, from: Data(legacy.utf8)) + #expect(decoded.format == .peft) + #expect(decoded.rank == 4) + } + + @Test + func mlxFormatRoundTrips() throws { + let original = LocalAdapter( + name: "qwen3-cached", + directory: URL(fileURLWithPath: "/tmp/cached"), + format: .mlx, + targetModel: nil, + rank: 8, + targetModules: ["q_proj", "v_proj"] + ) + let data = try JSONEncoder().encode(original) + let back = try JSONDecoder().decode(LocalAdapter.self, from: data) + #expect(back.format == .mlx) + #expect(back == original) + } + @Test func idMirrorsName() { let a = LocalAdapter( diff --git a/macMLX/macMLX/App/EngineCoordinator.swift b/macMLX/macMLX/App/EngineCoordinator.swift index 671d4c9..662d4bc 100644 --- a/macMLX/macMLX/App/EngineCoordinator.swift +++ b/macMLX/macMLX/App/EngineCoordinator.swift @@ -118,7 +118,10 @@ public final class EngineCoordinator { /// engines (`.swiftLM`, `.pythonMLX`) fail fast since the pool's /// factory always produces `MLXSwiftEngine`. @discardableResult - public func load(_ model: LocalModel) async -> Result { + public func load( + _ model: LocalModel, + adapter: LocalAdapter? = nil + ) async -> Result { guard engineID == .mlxSwift else { let err = EngineError.unsupportedOperation( "Engine \(engineID.rawValue) is detection-only in v0.1" @@ -129,7 +132,22 @@ public final class EngineCoordinator { status = .loading(model: model.id) await logs.log("Loading model: \(model.id)", level: .info, category: .engine) do { - _ = try await pool.load(model) + let engine = try await pool.load(model) + + // Layer the LoRA adapter on top of the freshly-loaded + // base model (v0.5+). Engines that don't support adapters + // (CPU stubs, future Python engines) get the protocol- + // extension default no-op; MLXSwiftEngine routes through + // LoRAContainer.from(directory:) + auto-PEFT-conversion. + if let adapter { + try await engine.applyAdapter(adapter) + await logs.log( + "LoRA adapter applied: \(adapter.name) (format=\(adapter.format.rawValue))", + level: .info, + category: .engine + ) + } + currentModel = model currentModelID = model.id status = .ready(model: model.id)