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%
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