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