SPB Git

spb/zyquo-local Public MIT

Native macOS AI chat that runs LLMs 100% locally on Apple Silicon with MLX — no cloud, no API keys.

Swift 97.2% Shell 1.8% Makefile 1%
9.2 KB · 264 lines swift
Raw Blame History
1//2//  InferenceEngine.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import Foundation10import MLX11import MLXHuggingFace12import MLXLLM13import MLXLMCommon14import Tokenizers1516/// Events streamed by the engine during generation.17enum GenerationEvent: Sendable {18    case token(String)19    case stats(GenerationStats)20    case finished(GenerationFinishReason)21}2223enum GenerationFinishReason: String, Sendable {24    case stop25    case length26    case cancelled27}2829/// Final per-response statistics.30struct GenerationStats: Sendable {31    var timeToFirstToken: TimeInterval32    var tokensPerSecond: Double33    var promptTokenCount: Int34    var generationTokenCount: Int35    var peakMemoryBytes: Int36}3738/// Engine lifecycle state.39enum EngineState: Sendable, Equatable {40    case unloaded41    case loading(repoID: String)42    case ready(repoID: String)43    case generating(repoID: String)44}4546/// Errors surfaced by the engine, with human-readable messages.47enum EngineError: LocalizedError {48    case noModelLoaded49    case modelDirectoryInvalid(URL)50    case loadFailed(String, underlying: String)51    case unsupportedArchitecture(String)52    case outOfMemory(String)5354    var errorDescription: String? {55        switch self {56        case .noModelLoaded:57            "No model is loaded. Load a model from the Library first."58        case .modelDirectoryInvalid(let url):59            "The model folder is missing required files (config.json, weights, tokenizer): \(url.path)"60        case .loadFailed(let repo, let underlying):61            "Could not load \(repo): \(underlying)"62        case .unsupportedArchitecture(let arch):63            "This model's architecture “\(arch)” is not supported by the MLX engine yet."64        case .outOfMemory(let repo):65            "Not enough memory to load \(repo). Try a smaller quantization (e.g. 4-bit) or a smaller model."66        }67    }68}6970/// The single owner of all inference. One model loaded at a time (v1).71/// States: unloaded → loading → ready ⇄ generating.72actor InferenceEngine {73    private(set) var state: EngineState = .unloaded7475    private var container: ModelContainer?76    private var loadedModel: LocalModel?77    private var session: ChatSession?78    private var generationTask: Task<Void, Never>?7980    /// Context window of the loaded model (from config.json), defaulting81    /// conservatively when absent.82    private(set) var contextWindow: Int = 40968384    // MARK: - Load / unload8586    /// Loads a model from its local directory, replacing any loaded model.87    func load(model: LocalModel) async throws {88        await stopGeneration()89        unloadInternal()90        state = .loading(repoID: model.repoID)91        do {92            guard FileManager.default.fileExists(93                atPath: model.directory.appendingPathComponent("config.json").path)94            else {95                throw EngineError.modelDirectoryInvalid(model.directory)96            }97            contextWindow = Self.readContextWindow(directory: model.directory) ?? 409698            let container = try await loadModelContainer(99                from: model.directory,100                using: #huggingFaceTokenizerLoader()101            )102            self.container = container103            self.loadedModel = model104            state = .ready(repoID: model.repoID)105        } catch let error as EngineError {106            state = .unloaded107            throw error108        } catch {109            state = .unloaded110            let text = String(describing: error)111            if text.localizedCaseInsensitiveContains("Unsupported model type") {112                throw EngineError.unsupportedArchitecture(model.architecture ?? "unknown")113            }114            if text.localizedCaseInsensitiveContains("memory") {115                throw EngineError.outOfMemory(model.repoID)116            }117            throw EngineError.loadFailed(model.repoID, underlying: error.localizedDescription)118        }119    }120121    /// Unloads the current model and verifiably frees memory.122    func unload() async {123        await stopGeneration()124        unloadInternal()125    }126127    private func unloadInternal() {128        session = nil129        container = nil130        loadedModel = nil131        MemoryAdvisor.reclaimMemory()132        state = .unloaded133    }134135    var currentModel: LocalModel? { loadedModel }136137    // MARK: - Sessions138139    /// Binds (or rebinds) the engine to a conversation. Rebuilding the session140    /// drops the KV cache, so this is only done when switching conversations,141    /// or when the system prompt changed.142    func startSession(conversation: Conversation) throws {143        guard let container else { throw EngineError.noModelLoaded }144        session = ChatSession(145            container: container,146            conversation: conversation,147            contextWindow: contextWindow,148            params: conversation.params149        )150    }151152    /// Ensures the active session matches the conversation, preserving the153    /// KV cache when it does.154    func ensureSession(conversation: Conversation) throws {155        if session?.conversationID != conversation.id {156            try startSession(conversation: conversation)157        }158    }159160    var contextUsage: (used: Int, window: Int)? {161        session?.estimatedContextUsage162    }163164    // MARK: - Generation165166    /// Streams a response to `prompt` inside the bound session.167    /// Cancellation: cancel the consuming task or call `stopGeneration()` —168    /// the underlying generation loop actually stops.169    func generate(prompt: String, params: GenerationParams) throws170        -> AsyncThrowingStream<GenerationEvent, Error>171    {172        guard let session else { throw EngineError.noModelLoaded }173        guard let repoID = loadedModel?.repoID else { throw EngineError.noModelLoaded }174175        let (stream, continuation) = AsyncThrowingStream.makeStream(of: GenerationEvent.self)176        state = .generating(repoID: repoID)177        MemoryAdvisor.resetPeakMemory()178179        let task = Task {180            let start = ContinuousClock.now181            var firstTokenTime: TimeInterval?182            var response = ""183            var finished: GenerationFinishReason = .cancelled184            do {185                for try await generation in session.stream(prompt: prompt, params: params) {186                    if Task.isCancelled { break }187                    switch generation {188                    case .chunk(let text):189                        if firstTokenTime == nil {190                            firstTokenTime = Self.seconds(since: start)191                        }192                        response += text193                        continuation.yield(.token(text))194                    case .info(let info):195                        let stats = GenerationStats(196                            timeToFirstToken: firstTokenTime ?? info.promptTime,197                            tokensPerSecond: info.tokensPerSecond,198                            promptTokenCount: info.promptTokenCount,199                            generationTokenCount: info.generationTokenCount,200                            peakMemoryBytes: MemoryAdvisor.peakMemoryBytes201                        )202                        finished = switch info.stopReason {203                        case .stop: .stop204                        case .length: .length205                        case .cancelled: .cancelled206                        }207                        continuation.yield(.stats(stats))208                    case .toolCall:209                        break210                    }211                }212                session.noteResponse(response)213                continuation.yield(.finished(Task.isCancelled ? .cancelled : finished))214                continuation.finish()215            } catch {216                continuation.finish(throwing: error)217            }218            self.finishGeneration()219        }220        generationTask = task221        continuation.onTermination = { termination in222            if case .cancelled = termination { task.cancel() }223        }224        return stream225    }226227    /// Stops any in-flight generation and waits for it to wind down.228    func stopGeneration() async {229        generationTask?.cancel()230        _ = await generationTask?.value231        generationTask = nil232    }233234    private func finishGeneration() {235        if let repoID = loadedModel?.repoID {236            state = .ready(repoID: repoID)237        }238        generationTask = nil239    }240241    // MARK: - Helpers242243    private static func seconds(since start: ContinuousClock.Instant) -> TimeInterval {244        let duration = start.duration(to: .now)245        return Double(duration.components.seconds)246            + Double(duration.components.attoseconds) * 1e-18247    }248249    /// Reads max_position_embeddings from config.json.250    static func readContextWindow(directory: URL) -> Int? {251        let url = directory.appendingPathComponent("config.json")252        guard let data = try? Data(contentsOf: url),253            let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]254        else { return nil }255        if let value = json["max_position_embeddings"] as? Int { return value }256        if let text = json["text_config"] as? [String: Any],257            let value = text["max_position_embeddings"] as? Int258        {259            return value260        }261        return nil262    }263}264