Abrarul8541 commited on
Commit
9d0beff
·
verified ·
1 Parent(s): b616d3a

Phase 3: resumable multi-source downloader (HF, Ollama, direct URL)

Browse files
app/src/main/java/io/github/abrarul/modularai/model/ModelDownloader.kt ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package io.github.abrarul.modularai.model
2
+
3
+ import kotlinx.coroutines.Dispatchers
4
+ import kotlinx.coroutines.withContext
5
+ import org.json.JSONObject
6
+ import java.io.File
7
+ import java.io.RandomAccessFile
8
+ import java.net.HttpURLConnection
9
+ import java.net.URL
10
+ import java.security.MessageDigest
11
+
12
+ sealed class Source {
13
+ data class HuggingFace(val repo: String, val file: String, val rev: String = "main") : Source()
14
+ data class Ollama(val name: String, val tag: String = "latest") : Source()
15
+ data class Url(val url: String) : Source()
16
+ }
17
+
18
+ class ModelDownloader(private val modelsDir: File) {
19
+
20
+ init { modelsDir.mkdirs() }
21
+
22
+ suspend fun download(
23
+ src: Source,
24
+ sha256: String? = null,
25
+ onProgress: (done: Long, total: Long) -> Unit = { _, _ -> }
26
+ ): File = withContext(Dispatchers.IO) {
27
+ val (url, fileName, digest) = resolve(src)
28
+ val out = File(modelsDir, fileName)
29
+ val part = File(modelsDir, "$fileName.part")
30
+ var have = if (part.exists()) part.length() else 0L
31
+
32
+ val c = URL(url).openConnection() as HttpURLConnection
33
+ c.instanceFollowRedirects = true
34
+ if (have > 0) c.setRequestProperty("Range", "bytes=$have-")
35
+ c.connect()
36
+ val resumed = c.responseCode == 206
37
+ if (!resumed) have = 0L
38
+ val total = have + c.contentLengthLong
39
+
40
+ RandomAccessFile(part, "rw").use { raf ->
41
+ if (resumed) raf.seek(have) else raf.setLength(0)
42
+ c.inputStream.use { inp ->
43
+ val buf = ByteArray(256 * 1024)
44
+ while (true) {
45
+ val n = inp.read(buf)
46
+ if (n < 0) break
47
+ raf.write(buf, 0, n)
48
+ have += n
49
+ onProgress(have, total)
50
+ }
51
+ }
52
+ }
53
+
54
+ val expected = sha256 ?: digest
55
+ if (expected != null) {
56
+ val actual = sha256Of(part)
57
+ if (!actual.equals(expected.removePrefix("sha256:"), ignoreCase = true)) {
58
+ part.delete()
59
+ error("Checksum mismatch for $fileName")
60
+ }
61
+ }
62
+ if (out.exists()) out.delete()
63
+ part.renameTo(out)
64
+ out
65
+ }
66
+
67
+ private fun resolve(src: Source): Triple<String, String, String?> = when (src) {
68
+ is Source.HuggingFace ->
69
+ Triple("https://huggingface.co/${src.repo}/resolve/${src.rev}/${src.file}", src.file.substringAfterLast('/'), null)
70
+ is Source.Url ->
71
+ Triple(src.url, src.url.substringAfterLast('/').substringBefore('?').ifBlank { "model.gguf" }, null)
72
+ is Source.Ollama -> {
73
+ val base = "https://registry.ollama.ai/v2/library/${src.name}"
74
+ val m = JSONObject(URL("$base/manifests/${src.tag}").readText())
75
+ val layers = m.getJSONArray("layers")
76
+ var digest = ""
77
+ for (i in 0 until layers.length()) {
78
+ val l = layers.getJSONObject(i)
79
+ if (l.getString("mediaType") == "application/vnd.ollama.image.model") digest = l.getString("digest")
80
+ }
81
+ require(digest.isNotEmpty()) { "No model layer in Ollama manifest" }
82
+ Triple("$base/blobs/$digest", "${src.name}-${src.tag}.gguf", digest)
83
+ }
84
+ }
85
+
86
+ fun list(): List<File> = modelsDir.listFiles { f -> f.extension in setOf("gguf", "onnx") }?.toList() ?: emptyList()
87
+ fun remove(f: File) = f.delete()
88
+ fun wipeAll() = modelsDir.listFiles()?.forEach { it.delete() }
89
+
90
+ private fun sha256Of(f: File): String {
91
+ val md = MessageDigest.getInstance("SHA-256")
92
+ f.inputStream().use { i ->
93
+ val b = ByteArray(1 shl 20)
94
+ while (true) { val n = i.read(b); if (n < 0) break; md.update(b, 0, n) }
95
+ }
96
+ return md.digest().joinToString("") { "%02x".format(it) }
97
+ }
98
+ }