Abrarul8541 commited on
Commit
dda0d7c
·
verified ·
1 Parent(s): ae8d3b0

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 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) {
@@ -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(URL("$base/manifests/${src.tag}").readText())
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()) {