SPB Git

spb/zyquo-mlx Public MIT

The local MLX foundry for your Mac — run, fine-tune, quantize, and ship models. Nothing leaves your machine.

Swift 93.4% Python 3.8% Makefile 2.2% Shell 0.5%
6.7 KB · 187 lines swift
Raw Blame History
1//2//  HubService.swift3//  Zyquo MLX4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import Foundation1011/// One search result / catalog entry from the Hugging Face Hub.12struct HubModel: Identifiable, Codable, Hashable, Sendable {13    var id: String // repo id, e.g. "mlx-community/Qwen3-4B-4bit"14    var pipelineTag: String?15    var downloads: Int16    var likes: Int17    var tags: [String]18    var gated: Bool1920    var name: String { id.split(separator: "/").last.map(String.init) ?? id }2122    /// Best-effort type from the pipeline tag (MODELS.md §1.1).23    var modelType: ModelType {24        switch pipelineTag {25        case "image-text-to-text": .vlm26        case "feature-extraction", "sentence-similarity": .embedding27        case "automatic-speech-recognition": .speech28        case "image-to-image", "text-to-image": .imageGeneration29        default: .llm30        }31    }32}3334/// A file inside a Hub repo (from the tree endpoint).35struct HubFile: Codable, Sendable {36    var path: String37    var size: Int6438}3940enum HubServiceError: LocalizedError {41    case badResponse(Int)42    case gatedRepo(String)4344    var errorDescription: String? {45        switch self {46        case .badResponse(let code):47            "Hugging Face returned HTTP \(code). Check your connection (or your token for gated models)."48        case .gatedRepo(let id):49            "\(id) is gated — add a Hugging Face token in Settings to access it."50        }51    }52}5354/// Live Hugging Face Hub API client (verified behavior in docs/MODELS.md §1):55/// search, file listing (sizes from the recursive tree), and resolve URLs.56actor HubService {5758    static let shared = HubService()5960    private let session = URLSession.shared6162    /// Search MLX models (library tag `mlx`), sorted by downloads.63    func search(query: String, author: String? = "mlx-community", limit: Int = 30) async throws -> [HubModel] {64        var components = URLComponents(string: "https://huggingface.co/api/models")!65        var items = [66            URLQueryItem(name: "filter", value: "mlx"),67            URLQueryItem(name: "sort", value: "downloads"),68            URLQueryItem(name: "direction", value: "-1"),69            URLQueryItem(name: "limit", value: String(limit)),70        ]71        if !query.isEmpty { items.append(URLQueryItem(name: "search", value: query)) }72        if let author { items.append(URLQueryItem(name: "author", value: author)) }73        components.queryItems = items7475        let data = try await get(components.url!)76        struct Item: Codable {77            var id: String78            var pipeline_tag: String?79            var downloads: Int?80            var likes: Int?81            var tags: [String]?82            var gated: GatedValue?83        }84        // `gated` is false | "manual" | "auto" in the live API.85        enum GatedValue: Codable {86            case bool(Bool), string(String)87            init(from decoder: Decoder) throws {88                let container = try decoder.singleValueContainer()89                if let b = try? container.decode(Bool.self) { self = .bool(b) } else {90                    self = .string(try container.decode(String.self))91                }92            }93            func encode(to encoder: Encoder) throws {94                var container = encoder.singleValueContainer()95                switch self {96                case .bool(let b): try container.encode(b)97                case .string(let s): try container.encode(s)98                }99            }100            var isGated: Bool {101                if case .bool(let b) = self { return b }102                return true103            }104        }105        return try JSONDecoder().decode([Item].self, from: data).map {106            HubModel(107                id: $0.id,108                pipelineTag: $0.pipeline_tag,109                downloads: $0.downloads ?? 0,110                likes: $0.likes ?? 0,111                tags: $0.tags ?? [],112                gated: $0.gated?.isGated ?? false)113        }114    }115116    /// Recursive file listing with true byte sizes (MODELS.md §1.2 —117    /// `recursive=true` is mandatory for subdirectory layouts).118    func files(repo: String, revision: String = "main") async throws -> [HubFile] {119        let url = URL(string: "https://huggingface.co/api/models/\(repo)/tree/\(revision)?recursive=true")!120        let data = try await get(url)121        struct Entry: Codable {122            var type: String123            var path: String124            var size: Int64?125        }126        return try JSONDecoder().decode([Entry].self, from: data)127            .filter { $0.type == "file" }128            .map { HubFile(path: $0.path, size: $0.size ?? 0) }129    }130131    /// Total weight bytes of a repo (for RAM badges before download).132    func weightSize(repo: String) async throws -> Int64 {133        try await files(repo: repo)134            .filter { $0.path.hasSuffix(".safetensors") || $0.path.hasSuffix(".npz") }135            .reduce(0) { $0 + $1.size }136    }137138    /// Resolve URL for one file (302s to the CDN; supports Range).139    nonisolated static func resolveURL(repo: String, path: String, revision: String = "main") -> URL {140        URL(string: "https://huggingface.co/\(repo)/resolve/\(revision)/\(path)")!141    }142143    private func get(_ url: URL) async throws -> Data {144        var request = URLRequest(url: url)145        if let token = HFTokenStore.token, !token.isEmpty {146            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")147        }148        let (data, response) = try await session.data(for: request)149        guard let http = response as? HTTPURLResponse else { throw HubServiceError.badResponse(0) }150        guard (200..<300).contains(http.statusCode) else {151            throw HubServiceError.badResponse(http.statusCode)152        }153        return data154    }155}156157/// Hugging Face token storage in the login Keychain (charter: encrypted158/// vault pattern; never plaintext on disk).159enum HFTokenStore {160    private static let service = "com.zyquo.mlx.hf-token"161162    static var token: String? {163        let query: [String: Any] = [164            kSecClass as String: kSecClassGenericPassword,165            kSecAttrService as String: service,166            kSecReturnData as String: true,167        ]168        var result: AnyObject?169        guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,170            let data = result as? Data171        else { return nil }172        return String(data: data, encoding: .utf8)173    }174175    static func save(_ token: String) {176        let base: [String: Any] = [177            kSecClass as String: kSecClassGenericPassword,178            kSecAttrService as String: service,179        ]180        SecItemDelete(base as CFDictionary)181        guard !token.isEmpty else { return }182        var add = base183        add[kSecValueData as String] = Data(token.utf8)184        SecItemAdd(add as CFDictionary, nil)185    }186}187