Downloader: HTTP status checks, 416 resume, disk-space check, Ollama Accept header
Browse files
app/src/main/java/io/github/abrarul/modularai/model/ModelDownloader.kt
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 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
|
|
@@ -8,6 +9,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()
|
|
@@ -25,31 +27,44 @@ class ModelDownloader(private val modelsDir: File) {
|
|
| 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
|
| 37 |
-
if (!resumed) have = 0L
|
| 38 |
-
val total = have + c.contentLengthLong
|
| 39 |
|
| 40 |
-
|
| 41 |
-
if (
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
}
|
| 51 |
}
|
| 52 |
-
|
|
|
|
| 53 |
|
| 54 |
val expected = sha256 ?: digest
|
| 55 |
if (expected != null) {
|
|
@@ -60,10 +75,19 @@ class ModelDownloader(private val modelsDir: File) {
|
|
| 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)
|
|
@@ -71,7 +95,7 @@ class ModelDownloader(private val modelsDir: File) {
|
|
| 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(
|
| 75 |
val layers = m.getJSONArray("layers")
|
| 76 |
var digest = ""
|
| 77 |
for (i in 0 until layers.length()) {
|
|
|
|
| 1 |
package io.github.abrarul.modularai.model
|
| 2 |
|
| 3 |
import kotlinx.coroutines.Dispatchers
|
| 4 |
+
import kotlinx.coroutines.ensureActive
|
| 5 |
import kotlinx.coroutines.withContext
|
| 6 |
import org.json.JSONObject
|
| 7 |
import java.io.File
|
|
|
|
| 9 |
import java.net.HttpURLConnection
|
| 10 |
import java.net.URL
|
| 11 |
import java.security.MessageDigest
|
| 12 |
+
import kotlin.coroutines.coroutineContext
|
| 13 |
|
| 14 |
sealed class Source {
|
| 15 |
data class HuggingFace(val repo: String, val file: String, val rev: String = "main") : Source()
|
|
|
|
| 27 |
onProgress: (done: Long, total: Long) -> Unit = { _, _ -> }
|
| 28 |
): File = withContext(Dispatchers.IO) {
|
| 29 |
val (url, fileName, digest) = resolve(src)
|
| 30 |
+
require(!fileName.contains('/') && !fileName.contains("..")) { "Unsafe file name: $fileName" }
|
| 31 |
val out = File(modelsDir, fileName)
|
| 32 |
val part = File(modelsDir, "$fileName.part")
|
| 33 |
var have = if (part.exists()) part.length() else 0L
|
| 34 |
|
| 35 |
val c = URL(url).openConnection() as HttpURLConnection
|
| 36 |
c.instanceFollowRedirects = true
|
| 37 |
+
c.connectTimeout = 20_000
|
| 38 |
+
c.readTimeout = 60_000
|
| 39 |
if (have > 0) c.setRequestProperty("Range", "bytes=$have-")
|
| 40 |
c.connect()
|
| 41 |
+
val code = c.responseCode
|
|
|
|
|
|
|
| 42 |
|
| 43 |
+
if (code != 416) { // 416 = the .part file already holds the whole file
|
| 44 |
+
if (code !in 200..299) error("HTTP $code from $url")
|
| 45 |
+
val resumed = code == 206
|
| 46 |
+
if (!resumed) have = 0L
|
| 47 |
+
val remaining = c.contentLengthLong
|
| 48 |
+
val total = if (remaining > 0) have + remaining else -1L
|
| 49 |
+
if (total > 0 && remaining > modelsDir.usableSpace) {
|
| 50 |
+
error("Not enough free space: need ${remaining / 1_048_576} MB, have ${modelsDir.usableSpace / 1_048_576} MB")
|
| 51 |
+
}
|
| 52 |
+
RandomAccessFile(part, "rw").use { raf ->
|
| 53 |
+
if (resumed) raf.seek(have) else raf.setLength(0)
|
| 54 |
+
c.inputStream.use { inp ->
|
| 55 |
+
val buf = ByteArray(256 * 1024)
|
| 56 |
+
while (true) {
|
| 57 |
+
coroutineContext.ensureActive()
|
| 58 |
+
val n = inp.read(buf)
|
| 59 |
+
if (n < 0) break
|
| 60 |
+
raf.write(buf, 0, n)
|
| 61 |
+
have += n
|
| 62 |
+
onProgress(have, total)
|
| 63 |
+
}
|
| 64 |
}
|
| 65 |
}
|
| 66 |
+
if (total > 0 && part.length() != total) error("Download incomplete (${part.length()} of $total bytes); run the command again to resume")
|
| 67 |
+
} else c.disconnect()
|
| 68 |
|
| 69 |
val expected = sha256 ?: digest
|
| 70 |
if (expected != null) {
|
|
|
|
| 75 |
}
|
| 76 |
}
|
| 77 |
if (out.exists()) out.delete()
|
| 78 |
+
if (!part.renameTo(out)) { part.copyTo(out, overwrite = true); part.delete() }
|
| 79 |
out
|
| 80 |
}
|
| 81 |
|
| 82 |
+
private fun getText(url: String, accept: String): String {
|
| 83 |
+
val c = URL(url).openConnection() as HttpURLConnection
|
| 84 |
+
c.setRequestProperty("Accept", accept)
|
| 85 |
+
c.connectTimeout = 20_000
|
| 86 |
+
c.readTimeout = 30_000
|
| 87 |
+
if (c.responseCode !in 200..299) error("HTTP ${c.responseCode} from $url")
|
| 88 |
+
return c.inputStream.bufferedReader().use { it.readText() }
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
private fun resolve(src: Source): Triple<String, String, String?> = when (src) {
|
| 92 |
is Source.HuggingFace ->
|
| 93 |
Triple("https://huggingface.co/${src.repo}/resolve/${src.rev}/${src.file}", src.file.substringAfterLast('/'), null)
|
|
|
|
| 95 |
Triple(src.url, src.url.substringAfterLast('/').substringBefore('?').ifBlank { "model.gguf" }, null)
|
| 96 |
is Source.Ollama -> {
|
| 97 |
val base = "https://registry.ollama.ai/v2/library/${src.name}"
|
| 98 |
+
val m = JSONObject(getText("$base/manifests/${src.tag}", "application/vnd.docker.distribution.manifest.v2+json"))
|
| 99 |
val layers = m.getJSONArray("layers")
|
| 100 |
var digest = ""
|
| 101 |
for (i in 0 until layers.length()) {
|