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 |
+
}
|