Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ fun fetchBoundedResult(

sealed class ModelUrlResult {
data class Success(val code: Int) : ModelUrlResult()
data class Error(val message: String) : ModelUrlResult()
data class Error(val message: String, val retryable: Boolean = false) : ModelUrlResult()
}

internal fun probeModelUrl(
Expand Down Expand Up @@ -160,8 +160,13 @@ internal fun probeModelUrl(
return ModelUrlResult.Success(redirectConnection.responseCode)
}
return ModelUrlResult.Success(responseCode)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
return ModelUrlResult.Error(e.message ?: "Unknown network error")
return ModelUrlResult.Error(
e.message ?: "Unknown network error",
retryable = com.ollitert.llm.server.data.download.isTransientDownloadFailure(e),
)
} finally {
connection?.disconnect()
redirectConnection?.disconnect()
Expand All @@ -178,17 +183,21 @@ internal fun configuredHfTokenOrNull(rawToken: String): String? =
* True when it is safe to attach the user's Hugging Face bearer token to requests
* for [url]. Allowlist entries and user-added repos can declare arbitrary download
* URLs — a hostile repo operator must never receive the credential, so the token
* is scoped to huggingface.co (and subdomains) only.
* is scoped to HTTPS on huggingface.co (and subdomains), without URL credentials
* or a nonstandard port.
*/
internal fun isHuggingFaceUrl(url: String): Boolean {
val host = try {
URL(url).host.lowercase()
} catch (_: Exception) {
val parsed = try {
java.net.URI(url)
} catch (_: java.net.URISyntaxException) {
return false
}
if (!parsed.scheme.equals("https", ignoreCase = true) ||
parsed.rawUserInfo != null || parsed.port !in listOf(-1, 443)
) return false
val host = parsed.host?.lowercase(java.util.Locale.ROOT) ?: return false
return host == "huggingface.co" || host.endsWith(".huggingface.co")
}

private fun defaultOpenConnection(url: String): HttpURLConnection =
URL(url).openConnection() as HttpURLConnection

Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.ollitert.llm.server.data.download

import java.io.EOFException
import java.io.IOException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.URI
import java.net.UnknownHostException

internal data class ModelScopeFallback(
val url: String,
val sizeInBytes: Long,
val sha256: String,
val requiresAccessConfirmation: Boolean = true,
)

private data class MirrorArtifact(
val repository: String,
val file: String,
val primaryRevision: String,
val mirrorRevision: String,
val bytes: Long,
val sha256: String,
val requiresAccessConfirmation: Boolean = true,
)

// Exact revision mappings: a catalog update must not silently reuse an older mirror.
private val artifacts = listOf(
MirrorArtifact(
"litert-community/gemma-4-E2B-it-litert-lm", "gemma-4-E2B-it.litertlm",
"6e5c4f1e395deb959c494953478fa5cec4b8008f", "1bacc155f57965ae45832a9ccfe96cdfcb0e94e9",
2588147712L, "181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c",
requiresAccessConfirmation = false,
),
MirrorArtifact(
"litert-community/gemma-4-E4B-it-litert-lm", "gemma-4-E4B-it.litertlm",
"28299f30ee4d43294517a4ac93abd6163412f07f", "46feb4dd5a76f4620508e7055ac9f13cecc267d2",
3659530240L, "0b2a8980ce155fd97673d8e820b4d29d9c7d99b8fa6806f425d969b145bd52e0",
requiresAccessConfirmation = false,
),
MirrorArtifact(
"google/gemma-3n-E2B-it-litert-lm", "gemma-3n-E2B-it-int4.litertlm",
"ba9ca88da013b537b6ed38108be609b8db1c3a16", "5c2307361e9740e1e253a247eaac8e29c0170619",
3655827456L, "2ed7bc3a0026c93d5b8a4544b352d9d00cd66ff0bac3ef6a20ac3d2cba4010d6",
),
MirrorArtifact(
"google/gemma-3n-E4B-it-litert-lm", "gemma-3n-E4B-it-int4.litertlm",
"297ed75955702dec3503e00c2c2ecbbf475300bc", "f1cf210e20560c3d17966e6e5dded590f0d813d3",
4919541760L, "2e67a6cd51dfe0f793431e6bd4ed8d029c88e10f52ca0469ad38445e3cd3c1f4",
),
MirrorArtifact(
"litert-community/Gemma3-1B-IT", "gemma3-1b-it-int4.litertlm",
"42d538a932e8d5b12e6b3b455f5572560bd60b2c", "ea05e64beb281629d682a58fdf681d0c5116f93f",
584417280L, "1325ae366d31950f137c9c357b9fa89448b176d76998180c08ceaca78bba98be",
),
MirrorArtifact(
"litert-community/Qwen2.5-1.5B-Instruct",
"Qwen2.5-1.5B-Instruct_multi-prefill-seq_q8_ekv4096.litertlm",
"19edb84c69a0212f29a6ef17ba0d6f278b6a1614", "d800dafdf808efa44d49ae6b643c9b6bff024adf",
1597931520L, "faa60663b333290c1496c499828b21d3e3254a788cacd8cce917ce0f761a2dc9",
requiresAccessConfirmation = false,
),
MirrorArtifact(
"litert-community/DeepSeek-R1-Distill-Qwen-1.5B",
"DeepSeek-R1-Distill-Qwen-1.5B_multi-prefill-seq_q8_ekv4096.litertlm",
"e34bb88632342d1f9640bad579a45134eb1cf988", "b407f8a7661f11e7e1282a9828535d4aa2d890c9",
1833451520L, "69b35f01759eed765641ab4af589bbe98131fd2825662a086d9037409b8c1295",
requiresAccessConfirmation = false,
),
)

internal fun modelScopeFallback(primaryUrl: String): ModelScopeFallback? {
val uri = try {
URI(primaryUrl)
} catch (_: java.net.URISyntaxException) {
return null
}
if (uri.scheme != "https" || uri.host != "huggingface.co" ||
uri.userInfo != null || uri.port !in listOf(-1, 443) ||
uri.rawQuery !in listOf(null, "download=true")
) return null
val artifact = artifacts.firstOrNull {
uri.rawPath == "/${it.repository}/resolve/${it.primaryRevision}/${it.file}"
} ?: return null
return ModelScopeFallback(
url = "https://modelscope.cn/models/${artifact.repository}/resolve/${artifact.mirrorRevision}/${artifact.file}",
sizeInBytes = artifact.bytes,
sha256 = artifact.sha256,
requiresAccessConfirmation = artifact.requiresAccessConfirmation,
)
}

internal class DownloadNetworkException(cause: IOException) :
IOException("Connection failed (${cause.javaClass.simpleName})", cause)

internal class DownloadHttpException(val status: Int) : IOException("HTTP $status")

internal fun isTransientDownloadStatus(status: Int): Boolean =
status == 408 || status == 429 || status in 500..599

internal fun isTransientDownloadFailure(failure: Throwable): Boolean = when (failure) {
is DownloadHttpException -> isTransientDownloadStatus(failure.status)
is DownloadNetworkException, is UnknownHostException, is SocketTimeoutException,
is SocketException, is EOFException -> true
else -> false
}
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ data class Model(
@Volatile var configValues: Map<String, Any> = mapOf(),
var totalBytes: Long = 0L,
var accessToken: String? = null,
var modelScopePrimaryError: String? = null,

/** Set to true when a stale version is found on disk and a newer version is available. */
var updatable: Boolean = false,
Expand Down Expand Up @@ -378,6 +379,8 @@ data class ModelDownloadStatus(
val receivedBytes: Long = 0,
val errorMessage: String = "",
val bytesPerSecond: Long = 0,
val fromModelScope: Boolean = false,
val modelScopeConsentError: String? = null,
)

////////////////////////////////////////////////////////////////////////////////////////////////////
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,13 @@ object ServerPrefs {
fun getHfToken(context: Context): String =
ServerPrefsNetwork.getHfToken(prefs(context))

fun isModelScopeFallbackEnabled(context: Context): Boolean =
ServerPrefsNetwork.isModelScopeFallbackEnabled(prefs(context))

fun setModelScopeFallbackEnabled(context: Context, enabled: Boolean) {
ServerPrefsNetwork.setModelScopeFallbackEnabled(prefs(context), enabled)
}

fun setHfToken(context: Context, token: String) {
ServerPrefsNetwork.setHfToken(prefs(context), token)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ internal const val KEY_CLIENT_IP_POLICY_MODE = "client_ip_policy_mode"
internal const val KEY_CLIENT_IP_RULES = "client_ip_rules"
internal const val KEY_BEARER_TOKEN = "bearer_token"
internal const val KEY_HF_TOKEN = "hf_token"
internal const val KEY_MODELSCOPE_FALLBACK = "modelscope_fallback"
internal const val KEY_CORS_ALLOWED_ORIGINS = "cors_allowed_origins"
internal const val DEFAULT_CORS_ALLOWED_ORIGINS = "*"

Expand Down Expand Up @@ -84,6 +85,13 @@ internal object ServerPrefsNetwork {
fun getHfToken(prefs: SharedPreferences): String =
prefs.getString(KEY_HF_TOKEN, "") ?: ""

fun isModelScopeFallbackEnabled(prefs: SharedPreferences): Boolean =
prefs.getBoolean(KEY_MODELSCOPE_FALLBACK, false)

fun setModelScopeFallbackEnabled(prefs: SharedPreferences, enabled: Boolean) {
prefs.edit { putBoolean(KEY_MODELSCOPE_FALLBACK, enabled) }
}

fun setHfToken(prefs: SharedPreferences, token: String) {
prefs.edit { putString(KEY_HF_TOKEN, token.trim()) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ import com.ollitert.llm.server.data.storage.KEY_MODEL_START_UNZIPPING
import com.ollitert.llm.server.data.storage.KEY_MODEL_TOTAL_BYTES
import com.ollitert.llm.server.data.storage.KEY_MODEL_UNZIPPED_DIR
import com.ollitert.llm.server.data.storage.KEY_MODEL_URL
import com.ollitert.llm.server.data.storage.KEY_MODEL_MODELSCOPE_PRIMARY_ERROR
import com.ollitert.llm.server.data.storage.KEY_MODEL_MODELSCOPE_CONSENT_ERROR
import com.ollitert.llm.server.data.storage.KEY_MODEL_FROM_MODELSCOPE

import android.Manifest
import android.app.NotificationChannel
Expand Down Expand Up @@ -72,6 +75,7 @@ internal fun buildDownloadRequestData(model: Model): Data {
Data.Builder()
.putString(KEY_MODEL_NAME, model.name)
.putString(KEY_MODEL_URL, model.url)
.putString(KEY_MODEL_MODELSCOPE_PRIMARY_ERROR, model.modelScopePrimaryError)
.putString(KEY_MODEL_COMMIT_HASH, model.version)
.putString(KEY_MODEL_DOWNLOAD_MODEL_DIR, model.normalizedName)
.putString(KEY_MODEL_DOWNLOAD_FILE_NAME, model.downloadFileName)
Expand Down Expand Up @@ -111,6 +115,7 @@ class DownloadRepository @Inject constructor(
onStatusUpdated: (model: Model, status: ModelDownloadStatus) -> Unit,
) {
val inputData = buildDownloadRequestData(model)
model.modelScopePrimaryError = null

// Create worker request.
val downloadWorkRequest =
Expand Down Expand Up @@ -179,9 +184,10 @@ class DownloadRepository @Inject constructor(
val receivedBytes = workInfo.progress.getLong(KEY_MODEL_DOWNLOAD_RECEIVED_BYTES, 0L)
val downloadRate = workInfo.progress.getLong(KEY_MODEL_DOWNLOAD_RATE, 0L)
val startUnzipping = workInfo.progress.getBoolean(KEY_MODEL_START_UNZIPPING, false)
val fromModelScope = workInfo.progress.getBoolean(KEY_MODEL_FROM_MODELSCOPE, false)

if (!startUnzipping) {
if (receivedBytes != 0L) {
if (workInfo.progress.keyValueMap.containsKey(KEY_MODEL_DOWNLOAD_RECEIVED_BYTES) || fromModelScope) {
lastReceivedBytes = receivedBytes
lastTotalBytes = totalDownloadBytes
onStatusUpdated(
Expand All @@ -191,6 +197,7 @@ class DownloadRepository @Inject constructor(
totalBytes = totalDownloadBytes,
receivedBytes = receivedBytes,
bytesPerSecond = downloadRate,
fromModelScope = fromModelScope,
),
)
}
Expand All @@ -205,7 +212,13 @@ class DownloadRepository @Inject constructor(
WorkInfo.State.SUCCEEDED -> {
try {
Log.d(TAG, "worker %s success".format(workerId.toString()))
onStatusUpdated(model, ModelDownloadStatus(status = ModelDownloadStatusType.SUCCEEDED))
onStatusUpdated(
model,
ModelDownloadStatus(
status = ModelDownloadStatusType.SUCCEEDED,
fromModelScope = workInfo.outputData.getBoolean(KEY_MODEL_FROM_MODELSCOPE, false),
),
)
sendNotification(
title = context.getString(R.string.notification_title_success),
text = context.getString(R.string.notification_content_success).format(model.name),
Expand Down Expand Up @@ -241,6 +254,8 @@ class DownloadRepository @Inject constructor(
errorMessage = errorMessage,
receivedBytes = if (status == ModelDownloadStatusType.FAILED) lastReceivedBytes else 0L,
totalBytes = if (status == ModelDownloadStatusType.FAILED) lastTotalBytes else 0L,
fromModelScope = workInfo.outputData.getBoolean(KEY_MODEL_FROM_MODELSCOPE, false),
modelScopeConsentError = workInfo.outputData.getString(KEY_MODEL_MODELSCOPE_CONSENT_ERROR),
),
)
} finally {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ interface PreferencesRepository {
fun setBearerToken(token: String)
fun getHfToken(): String
fun setHfToken(token: String)
fun isModelScopeFallbackEnabled(): Boolean
fun setModelScopeFallbackEnabled(enabled: Boolean)
fun getDefaultModelName(): String?
fun setDefaultModelName(name: String?)
fun isAutoStartOnBoot(): Boolean
Expand Down Expand Up @@ -299,6 +301,8 @@ class DefaultPreferencesRepository @Inject constructor(
override fun setBearerToken(token: String) = ServerPrefs.setBearerToken(context, token)
override fun getHfToken(): String = ServerPrefs.getHfToken(context)
override fun setHfToken(token: String) = ServerPrefs.setHfToken(context, token)
override fun isModelScopeFallbackEnabled(): Boolean = ServerPrefs.isModelScopeFallbackEnabled(context)
override fun setModelScopeFallbackEnabled(enabled: Boolean) = ServerPrefs.setModelScopeFallbackEnabled(context, enabled)
override fun getDefaultModelName(): String? = ServerPrefs.getDefaultModelName(context)
override fun setDefaultModelName(name: String?) = ServerPrefs.setDefaultModelName(context, name)
override fun isAutoStartOnBoot(): Boolean = ServerPrefs.isAutoStartOnBoot(context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ package com.ollitert.llm.server.data.storage

// Keys used to send/receive data to WorkManager.
const val KEY_MODEL_URL = "KEY_MODEL_URL"
const val KEY_MODEL_MODELSCOPE_PRIMARY_ERROR = "KEY_MODEL_MODELSCOPE_PRIMARY_ERROR"
const val KEY_MODEL_MODELSCOPE_CONSENT_ERROR = "KEY_MODEL_MODELSCOPE_CONSENT_ERROR"
const val KEY_MODEL_FROM_MODELSCOPE = "KEY_MODEL_FROM_MODELSCOPE"
const val KEY_MODEL_NAME = "KEY_MODEL_NAME"
const val KEY_MODEL_COMMIT_HASH = "KEY_MODEL_COMMIT_HASH"
const val KEY_MODEL_DOWNLOAD_MODEL_DIR = "KEY_MODEL_DOWNLOAD_MODEL_DIR"
Expand All @@ -37,6 +40,9 @@ const val KEY_MODEL_START_UNZIPPING = "KEY_MODEL_START_UNZIPPING"
// The extension of the tmp download files.
const val TMP_FILE_EXT = "olliterttmp"

internal fun modelScopeStagingFile(primary: java.io.File, sha256: String): java.io.File =
java.io.File("${primary.path}.$sha256.modelscope")

// Suffix of the sibling temp directory used during zip extraction. The extracted
// tree is renamed into its final name only after a complete, successful unzip, so
// an interrupted extraction can never be mistaken for a downloaded model.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.ollitert.llm.server.data.model.IMPORTS_DIR
import com.ollitert.llm.server.data.model.Model
import com.ollitert.llm.server.data.model.ModelDownloadStatus
import com.ollitert.llm.server.data.model.ModelDownloadStatusType
import com.ollitert.llm.server.data.download.modelScopeFallback

import android.content.Context
import android.util.Log
Expand Down Expand Up @@ -98,13 +99,13 @@ class ModelFileManager(
}
}

fun isModelPartiallyDownloaded(model: Model): Boolean {
if (model.localModelFilePathOverride.isNotEmpty()) {
return false
}
val tmpFilePath =
model.getPath(context = context, fileName = "${model.downloadFileName}.$TMP_FILE_EXT")
return File(tmpFilePath).exists()
fun isModelPartiallyDownloaded(model: Model): Boolean = partialDownloadFile(model) != null

private fun partialDownloadFile(model: Model): File? {
if (model.localModelFilePathOverride.isNotEmpty()) return null
val primary = File(model.getPath(context = context, fileName = "${model.downloadFileName}.$TMP_FILE_EXT"))
val mirror = modelScopeFallback(model.url)?.let { modelScopeStagingFile(primary, it.sha256) }
return listOfNotNull(primary, mirror).firstOrNull { it.isFile }
}

fun isModelDownloaded(model: Model): Boolean {
Expand Down Expand Up @@ -168,13 +169,11 @@ class ModelFileManager(
var status = ModelDownloadStatusType.NOT_DOWNLOADED
var receivedBytes = 0L
var totalBytes = 0L
val partial = partialDownloadFile(model)

if (isModelPartiallyDownloaded(model = model)) {
if (partial != null) {
status = ModelDownloadStatusType.PARTIALLY_DOWNLOADED
val tmpFilePath =
model.getPath(context = context, fileName = "${model.downloadFileName}.$TMP_FILE_EXT")
val tmpFile = File(tmpFilePath)
receivedBytes = tmpFile.length()
receivedBytes = partial.length()
totalBytes = model.totalBytes
Log.d(TAG, "${model.name} is partially downloaded. $receivedBytes/$totalBytes")
} else if (isModelDownloaded(model = model)) {
Expand All @@ -188,6 +187,7 @@ class ModelFileManager(
status = status,
receivedBytes = receivedBytes,
totalBytes = totalBytes,
fromModelScope = partial?.extension == "modelscope",
)
}
}
Loading