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%
10.5 KB · 285 lines swift
Raw Blame History
1//2//  HubService.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import Foundation1011/// Live Hugging Face Hub client: search, model info, file listings.12/// Contract documented in docs/MODELS.md §1. All responses are Codable13/// structs — no dictionary spelunking.14struct HubService: Sendable {15    /// Search scope shown in the Discover UI.16    enum Scope: String, CaseIterable, Sendable {17        case featured18        case mlxCommunity19        case allMLX20    }2122    enum Sort: String, CaseIterable, Sendable {23        case downloads24        case likes25        case newest2627        var apiValue: String {28            switch self {29            case .downloads: "downloads"30            case .likes: "likes"31            case .newest: "createdAt"32            }33        }34    }3536    /// One search result from /api/models.37    struct ModelSummary: Codable, Hashable, Sendable, Identifiable {38        var id: String39        var likes: Int?40        var downloads: Int?41        var gated: GatedValue?42        var tags: [String]?43        var createdAt: Date?44        var config: ModelConfigInfo?4546        var isGated: Bool { gated?.isGated ?? false }47        var architecture: String? { config?.modelType }4849        /// True when the architecture is known-supported by the MLX engine.50        var isSupportedArchitecture: Bool {51            guard let architecture else { return true }  // unknown → don't block, warn later52            return SupportedArchitectures.contains(architecture)53        }54    }5556    struct ModelConfigInfo: Codable, Hashable, Sendable {57        var modelType: String?5859        enum CodingKeys: String, CodingKey {60            case modelType = "model_type"61        }62    }6364    /// `gated` is `false` or a string ("auto"/"manual").65    enum GatedValue: Codable, Hashable, Sendable {66        case bool(Bool)67        case mode(String)6869        var isGated: Bool {70            switch self {71            case .bool(let b): b72            case .mode: true73            }74        }7576        init(from decoder: Decoder) throws {77            let container = try decoder.singleValueContainer()78            if let b = try? container.decode(Bool.self) {79                self = .bool(b)80            } else {81                self = .mode(try container.decode(String.self))82            }83        }8485        func encode(to encoder: Encoder) throws {86            var container = encoder.singleValueContainer()87            switch self {88            case .bool(let b): try container.encode(b)89            case .mode(let s): try container.encode(s)90            }91        }92    }9394    /// One file from /tree/main?recursive=true.95    struct RepoFile: Codable, Hashable, Sendable {96        var type: String97        var path: String98        var size: Int64?99        var lfs: LFSInfo?100101        struct LFSInfo: Codable, Hashable, Sendable {102            var oid: String?103            var size: Int64?104        }105106        var isFile: Bool { type == "file" }107108        /// Files the MLX stack needs (mirrors the package's own filter).109        var isModelFile: Bool {110            path.hasSuffix(".safetensors") || path.hasSuffix(".json") || path.hasSuffix(".jinja")111        }112    }113114    enum HubError: LocalizedError {115        case http(Int, String)116        case rateLimited(retryAfterSeconds: Int?)117        case gatedOrMissing(String)118        case invalidResponse119120        var errorDescription: String? {121            switch self {122            case .http(let code, let repo):123                "Hugging Face returned HTTP \(code) for \(repo)."124            case .rateLimited(let retry):125                "Hugging Face rate limit reached. Try again in \(retry.map { "\($0)s" } ?? "a moment")."126            case .gatedOrMissing(let repo):127                "\(repo) requires access approval or does not exist. For gated models (Llama, Gemma), add a Hugging Face token in Settings."128            case .invalidResponse:129                "Unexpected response from Hugging Face."130            }131        }132    }133134    var token: String?135136    private static let base = URL(string: "https://huggingface.co")!137138    private var session: URLSession { URLSession.shared }139140    private func request(_ url: URL) -> URLRequest {141        var request = URLRequest(url: url)142        request.setValue("ZyquoLocal/1.0.0", forHTTPHeaderField: "User-Agent")143        if let token, !token.isEmpty {144            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")145        }146        return request147    }148149    private static let decoder: JSONDecoder = {150        let decoder = JSONDecoder()151        decoder.dateDecodingStrategy = .custom { d in152            let raw = try d.singleValueContainer().decode(String.self)153            if let date = try? Date(raw, strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true)) {154                return date155            }156            if let date = try? Date(raw, strategy: .iso8601) {157                return date158            }159            throw DecodingError.dataCorrupted(.init(codingPath: d.codingPath, debugDescription: "Bad date \(raw)"))160        }161        return decoder162    }()163164    private func get<T: Decodable>(_ url: URL, as type: T.Type, context: String) async throws -> T {165        let (data, response) = try await session.data(for: request(url))166        guard let http = response as? HTTPURLResponse else { throw HubError.invalidResponse }167        switch http.statusCode {168        case 200:169            return try Self.decoder.decode(T.self, from: data)170        case 401, 403:171            throw HubError.gatedOrMissing(context)172        case 429:173            let retry = http.value(forHTTPHeaderField: "retry-after").flatMap(Int.init)174            throw HubError.rateLimited(retryAfterSeconds: retry)175        default:176            throw HubError.http(http.statusCode, context)177        }178    }179180    // MARK: - Search181182    /// Live Hub search. `scope .allMLX` searches all of HF filtered to the183    /// `mlx` library tag; architecture compatibility is checked via184    /// `config.model_type` (requested with config=true).185    func search(186        query: String,187        scope: Scope,188        sort: Sort = .downloads,189        limit: Int = 40190    ) async throws -> [ModelSummary] {191        var components = URLComponents(192            url: Self.base.appendingPathComponent("api/models"), resolvingAgainstBaseURL: false)!193        var items = [194            URLQueryItem(name: "pipeline_tag", value: "text-generation"),195            URLQueryItem(name: "sort", value: sort.apiValue),196            URLQueryItem(name: "direction", value: "-1"),197            URLQueryItem(name: "limit", value: String(limit)),198            URLQueryItem(name: "config", value: "true"),199        ]200        if !query.isEmpty {201            items.append(URLQueryItem(name: "search", value: query))202        }203        switch scope {204        case .featured, .mlxCommunity:205            items.append(URLQueryItem(name: "author", value: "mlx-community"))206        case .allMLX:207            items.append(URLQueryItem(name: "filter", value: "mlx"))208        }209        components.queryItems = items210        return try await get([ModelSummary].self, from: components.url!, context: "search")211    }212213    private func get<T: Decodable>(_ type: T.Type, from url: URL, context: String) async throws -> T {214        try await get(url, as: type, context: context)215    }216217    // MARK: - Files218219    /// Full file listing with sizes for a repo (paginates if needed).220    func files(of repoID: String) async throws -> [RepoFile] {221        var all: [RepoFile] = []222        var url: URL? = Self.base.appendingPathComponent("api/models/\(repoID)/tree/main")223            .appending(queryItems: [URLQueryItem(name: "recursive", value: "true")])224        while let current = url {225            let (data, response) = try await session.data(for: request(current))226            guard let http = response as? HTTPURLResponse else { throw HubError.invalidResponse }227            guard http.statusCode == 200 else {228                if http.statusCode == 401 || http.statusCode == 403 {229                    throw HubError.gatedOrMissing(repoID)230                }231                throw HubError.http(http.statusCode, repoID)232            }233            all += try Self.decoder.decode([RepoFile].self, from: data)234            url = Self.nextPage(from: http)235        }236        return all237    }238239    /// Files required to run the model, with total download size.240    func requiredFiles(of repoID: String) async throws -> (files: [RepoFile], totalBytes: Int64) {241        let required = try await files(of: repoID).filter { $0.isFile && $0.isModelFile }242        let total = required.reduce(Int64(0)) { $0 + ($1.size ?? 0) }243        return (required, total)244    }245246    /// Download URL for one file of a repo.247    static func resolveURL(repoID: String, path: String) -> URL {248        base.appendingPathComponent("\(repoID)/resolve/main/\(path)")249    }250251    /// Parses RFC-5988 Link header for cursor pagination.252    private static func nextPage(from response: HTTPURLResponse) -> URL? {253        guard let link = response.value(forHTTPHeaderField: "Link") else { return nil }254        for part in link.split(separator: ",") {255            let segments = part.split(separator: ";").map { $0.trimmingCharacters(in: .whitespaces) }256            guard segments.count >= 2, segments.contains(where: { $0 == "rel=\"next\"" }),257                let urlPart = segments.first, urlPart.hasPrefix("<"), urlPart.hasSuffix(">")258            else { continue }259            return URL(string: String(urlPart.dropFirst().dropLast()))260        }261        return nil262    }263}264265/// Architectures supported by the MLX Swift LLM layer (verified list from266/// docs/MLX-RESEARCH.md §4 — LLMTypeRegistry @ mlx-swift-lm 3.31.4).267enum SupportedArchitectures {268    static let all: Set<String> = [269        "mistral", "mixtral", "llama", "phi", "phi3", "phimoe", "gemma", "gemma2",270        "gemma3", "gemma3_text", "gemma3n", "gemma4", "gemma4_unified", "gemma4_text",271        "qwen2", "qwen3", "qwen3_moe", "qwen3_next", "qwen3_5", "qwen3_5_moe",272        "qwen3_5_text", "minicpm", "starcoder2", "cohere", "openelm", "internlm2",273        "deepseek_v3", "granite", "granitemoehybrid", "mimo", "mimo_v2_flash",274        "minimax", "glm4", "glm4_moe", "glm4_moe_lite", "acereason", "falcon_h1",275        "bitnet", "smollm3", "ernie4_5", "lfm2", "baichuan_m1", "exaone4", "gpt_oss",276        "lille-130m", "olmoe", "olmo2", "olmo3", "bailing_moe", "lfm2_moe",277        "nanochat", "nemotron_h", "afmoe", "jamba", "mamba2", "mistral3", "apertus",278        "nemotron_labs_diffusion",279    ]280281    static func contains(_ architecture: String) -> Bool {282        all.contains(architecture)283    }284}285