diff --git a/Android/src/app/src/main/java/com/ollitert/llm/server/data/allowlist/BoundedHttpFetcher.kt b/Android/src/app/src/main/java/com/ollitert/llm/server/data/allowlist/BoundedHttpFetcher.kt index 14e094157..5b61c4914 100644 --- a/Android/src/app/src/main/java/com/ollitert/llm/server/data/allowlist/BoundedHttpFetcher.kt +++ b/Android/src/app/src/main/java/com/ollitert/llm/server/data/allowlist/BoundedHttpFetcher.kt @@ -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( @@ -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() @@ -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 - diff --git a/Android/src/app/src/main/java/com/ollitert/llm/server/data/download/ModelScopeFallback.kt b/Android/src/app/src/main/java/com/ollitert/llm/server/data/download/ModelScopeFallback.kt new file mode 100644 index 000000000..ccaf52ac7 --- /dev/null +++ b/Android/src/app/src/main/java/com/ollitert/llm/server/data/download/ModelScopeFallback.kt @@ -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 +} diff --git a/Android/src/app/src/main/java/com/ollitert/llm/server/data/model/Model.kt b/Android/src/app/src/main/java/com/ollitert/llm/server/data/model/Model.kt index 080ea0ce3..dc6b77500 100644 --- a/Android/src/app/src/main/java/com/ollitert/llm/server/data/model/Model.kt +++ b/Android/src/app/src/main/java/com/ollitert/llm/server/data/model/Model.kt @@ -259,6 +259,7 @@ data class Model( @Volatile var configValues: Map = 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, @@ -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, ) //////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/Android/src/app/src/main/java/com/ollitert/llm/server/data/prefs/ServerPrefs.kt b/Android/src/app/src/main/java/com/ollitert/llm/server/data/prefs/ServerPrefs.kt index 65ee2c3bf..ec7697aed 100644 --- a/Android/src/app/src/main/java/com/ollitert/llm/server/data/prefs/ServerPrefs.kt +++ b/Android/src/app/src/main/java/com/ollitert/llm/server/data/prefs/ServerPrefs.kt @@ -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) } diff --git a/Android/src/app/src/main/java/com/ollitert/llm/server/data/prefs/ServerPrefsNetwork.kt b/Android/src/app/src/main/java/com/ollitert/llm/server/data/prefs/ServerPrefsNetwork.kt index 500fb72d3..972974c44 100644 --- a/Android/src/app/src/main/java/com/ollitert/llm/server/data/prefs/ServerPrefsNetwork.kt +++ b/Android/src/app/src/main/java/com/ollitert/llm/server/data/prefs/ServerPrefsNetwork.kt @@ -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 = "*" @@ -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()) } } diff --git a/Android/src/app/src/main/java/com/ollitert/llm/server/data/repository/DownloadRepository.kt b/Android/src/app/src/main/java/com/ollitert/llm/server/data/repository/DownloadRepository.kt index fbab015d9..ceb985c96 100644 --- a/Android/src/app/src/main/java/com/ollitert/llm/server/data/repository/DownloadRepository.kt +++ b/Android/src/app/src/main/java/com/ollitert/llm/server/data/repository/DownloadRepository.kt @@ -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 @@ -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) @@ -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 = @@ -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( @@ -191,6 +197,7 @@ class DownloadRepository @Inject constructor( totalBytes = totalDownloadBytes, receivedBytes = receivedBytes, bytesPerSecond = downloadRate, + fromModelScope = fromModelScope, ), ) } @@ -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), @@ -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 { diff --git a/Android/src/app/src/main/java/com/ollitert/llm/server/data/repository/PreferencesRepository.kt b/Android/src/app/src/main/java/com/ollitert/llm/server/data/repository/PreferencesRepository.kt index 4ce2b5e75..dfe577adb 100644 --- a/Android/src/app/src/main/java/com/ollitert/llm/server/data/repository/PreferencesRepository.kt +++ b/Android/src/app/src/main/java/com/ollitert/llm/server/data/repository/PreferencesRepository.kt @@ -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 @@ -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) diff --git a/Android/src/app/src/main/java/com/ollitert/llm/server/data/storage/DownloadConstants.kt b/Android/src/app/src/main/java/com/ollitert/llm/server/data/storage/DownloadConstants.kt index 39cf52ecb..4c115520b 100644 --- a/Android/src/app/src/main/java/com/ollitert/llm/server/data/storage/DownloadConstants.kt +++ b/Android/src/app/src/main/java/com/ollitert/llm/server/data/storage/DownloadConstants.kt @@ -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" @@ -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. diff --git a/Android/src/app/src/main/java/com/ollitert/llm/server/data/storage/ModelFileManager.kt b/Android/src/app/src/main/java/com/ollitert/llm/server/data/storage/ModelFileManager.kt index eb5d47fd5..3009268f2 100644 --- a/Android/src/app/src/main/java/com/ollitert/llm/server/data/storage/ModelFileManager.kt +++ b/Android/src/app/src/main/java/com/ollitert/llm/server/data/storage/ModelFileManager.kt @@ -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 @@ -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 { @@ -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)) { @@ -188,6 +187,7 @@ class ModelFileManager( status = status, receivedBytes = receivedBytes, totalBytes = totalBytes, + fromModelScope = partial?.extension == "modelscope", ) } } diff --git a/Android/src/app/src/main/java/com/ollitert/llm/server/ui/modelmanager/DownloadGateCoordinator.kt b/Android/src/app/src/main/java/com/ollitert/llm/server/ui/modelmanager/DownloadGateCoordinator.kt index a56d37d31..5538b0ce6 100644 --- a/Android/src/app/src/main/java/com/ollitert/llm/server/ui/modelmanager/DownloadGateCoordinator.kt +++ b/Android/src/app/src/main/java/com/ollitert/llm/server/ui/modelmanager/DownloadGateCoordinator.kt @@ -17,9 +17,11 @@ package com.ollitert.llm.server.ui.modelmanager import android.util.Log -import com.ollitert.llm.server.common.GitHubConfig import com.ollitert.llm.server.data.allowlist.ModelUrlResult import com.ollitert.llm.server.data.allowlist.configuredHfTokenOrNull +import com.ollitert.llm.server.data.allowlist.isHuggingFaceUrl +import com.ollitert.llm.server.data.download.isTransientDownloadStatus +import com.ollitert.llm.server.data.download.modelScopeFallback import com.ollitert.llm.server.data.model.Model import com.ollitert.llm.server.ui.modelmanager.components.HfTokenDialogReason import java.net.HttpURLConnection @@ -30,7 +32,12 @@ import java.net.HttpURLConnection */ internal sealed class DownloadGateOutcome { /** Access confirmed — start the download with this token (null = anonymous). */ - data class StartDownload(val accessToken: String?) : DownloadGateOutcome() + data class StartDownload( + val accessToken: String?, + val modelScopePrimaryError: String? = null, + ) : DownloadGateOutcome() + + data class NeedsModelScopeConsent(val primaryError: String) : DownloadGateOutcome() /** Network failure while probing access; message is safe for the error dialog. */ data class NetworkError(val message: String) : DownloadGateOutcome() @@ -58,13 +65,14 @@ internal sealed class DownloadGateOutcome { internal class DownloadGateCoordinator( private val probeUrl: suspend (model: Model, accessToken: String?) -> ModelUrlResult, private val storedHfToken: () -> String?, + private val fallbackEnabled: () -> Boolean = { false }, ) { /** * Resolves what should happen when the user requests downloading [model]. * Performs network probes — call from a background dispatcher. */ suspend fun resolveDownloadAccess(model: Model): DownloadGateOutcome { - if (!model.url.startsWith(GitHubConfig.HUGGINGFACE_BASE_URL)) { + if (!isHuggingFaceUrl(model.url)) { Log.d(TAG, "Model '${model.name}' is not from HuggingFace. Start downloading...") return DownloadGateOutcome.StartDownload(null) } @@ -73,14 +81,16 @@ internal class DownloadGateCoordinator( return when (val firstResult = probeUrl(model, null)) { is ModelUrlResult.Error -> { Log.e(TAG, "Network error: ${firstResult.message}") - DownloadGateOutcome.NetworkError(firstResult.message) + networkFailure(model, firstResult.message, firstResult.retryable) } - is ModelUrlResult.Success -> when (firstResult.code) { - HttpURLConnection.HTTP_OK -> { + is ModelUrlResult.Success -> when { + isTransientDownloadStatus(firstResult.code) -> + networkFailure(model, "Hugging Face returned HTTP ${firstResult.code}", true) + firstResult.code == HttpURLConnection.HTTP_OK -> { Log.d(TAG, "Model '${model.name}' doesn't need auth. Start downloading...") DownloadGateOutcome.StartDownload(null) } - HttpURLConnection.HTTP_NOT_FOUND -> { + firstResult.code == HttpURLConnection.HTTP_NOT_FOUND -> { Log.d(TAG, "Model '${model.name}' returned 404 — model not found.") DownloadGateOutcome.ModelNotFound } @@ -102,16 +112,18 @@ internal class DownloadGateCoordinator( Log.e(TAG, "Network error checking HF token: ${hfResult.message}") DownloadGateOutcome.NetworkError(hfResult.message) } - is ModelUrlResult.Success -> when (hfResult.code) { - HttpURLConnection.HTTP_OK -> { + is ModelUrlResult.Success -> when { + isTransientDownloadStatus(hfResult.code) -> + DownloadGateOutcome.NetworkError("Hugging Face returned HTTP ${hfResult.code}") + hfResult.code == HttpURLConnection.HTTP_OK -> { Log.d(TAG, "Stored HF token works. Start downloading...") DownloadGateOutcome.StartDownload(token) } - HttpURLConnection.HTTP_NOT_FOUND -> { + hfResult.code == HttpURLConnection.HTTP_NOT_FOUND -> { Log.d(TAG, "Model '${model.name}' returned 404 with token — model not found.") DownloadGateOutcome.ModelNotFound } - HttpURLConnection.HTTP_FORBIDDEN -> { + hfResult.code == HttpURLConnection.HTTP_FORBIDDEN -> { Log.d(TAG, "Model needs license agreement. Opening agreement page...") DownloadGateOutcome.NeedsAgreement } @@ -123,6 +135,18 @@ internal class DownloadGateCoordinator( } } + private fun networkFailure(model: Model, message: String, retryable: Boolean): DownloadGateOutcome { + val fallback = modelScopeFallback(model.url) + if (!retryable || model.isZip || model.extraDataFiles.isNotEmpty() || + fallback == null || fallback.requiresAccessConfirmation + ) return DownloadGateOutcome.NetworkError(message) + return if (fallbackEnabled()) { + DownloadGateOutcome.StartDownload(null, modelScopePrimaryError = message) + } else { + DownloadGateOutcome.NeedsModelScopeConsent(message) + } + } + companion object { private const val TAG = "OlliteRT.DownloadBtn" @@ -131,6 +155,7 @@ internal class DownloadGateCoordinator( DownloadGateCoordinator( probeUrl = { model, accessToken -> viewModel.getModelUrlResponse(model = model, accessToken = accessToken) }, storedHfToken = { configuredHfTokenOrNull(viewModel.getHfToken()) }, + fallbackEnabled = { viewModel.isModelScopeFallbackEnabled() }, ) } } diff --git a/Android/src/app/src/main/java/com/ollitert/llm/server/ui/modelmanager/ModelManagerViewModel.kt b/Android/src/app/src/main/java/com/ollitert/llm/server/ui/modelmanager/ModelManagerViewModel.kt index 8031d75b5..4cce009ce 100644 --- a/Android/src/app/src/main/java/com/ollitert/llm/server/ui/modelmanager/ModelManagerViewModel.kt +++ b/Android/src/app/src/main/java/com/ollitert/llm/server/ui/modelmanager/ModelManagerViewModel.kt @@ -238,6 +238,20 @@ constructor( fun setInferenceConfig(prefsKey: String, configValues: Map) = preferencesRepository.setInferenceConfig(prefsKey, configValues) fun getPort(): Int = preferencesRepository.getPort() fun getHfToken(): String = preferencesRepository.getHfToken() + fun isModelScopeFallbackEnabled(): Boolean = preferencesRepository.isModelScopeFallbackEnabled() + fun setModelScopeFallbackEnabled(enabled: Boolean) = preferencesRepository.setModelScopeFallbackEnabled(enabled) + + fun dismissModelScopeConsent(model: Model) { + _uiState.update { current -> + val status = current.modelDownloadStatus[model.name] ?: return@update current + val error = status.modelScopeConsentError ?: return@update current + current.copy( + modelDownloadStatus = current.modelDownloadStatus + ( + model.name to status.copy(modelScopeConsentError = null, errorMessage = error) + ), + ) + } + } private fun notifyStorageChanged() { _uiState.update { it.copy(storageUpdateTrigger = System.currentTimeMillis()) } @@ -253,6 +267,14 @@ constructor( private val downloadStartGate = DownloadStartGate() fun downloadModel(model: Model) { + val previousStatus = uiState.value.modelDownloadStatus[model.name]?.status + if (!model.updatable && previousStatus in listOf( + ModelDownloadStatusType.FAILED, ModelDownloadStatusType.PARTIALLY_DOWNLOADED, + ) + ) { + retryDownloadModel(model) + return + } if (!downloadStartGate.tryAcquire(model.name)) { Log.d(TAG, "Ignoring duplicate download start for '${model.name}'") return diff --git a/Android/src/app/src/main/java/com/ollitert/llm/server/ui/modelmanager/components/DownloadAndTryButton.kt b/Android/src/app/src/main/java/com/ollitert/llm/server/ui/modelmanager/components/DownloadAndTryButton.kt index 8caba785f..f3838e48a 100644 --- a/Android/src/app/src/main/java/com/ollitert/llm/server/ui/modelmanager/components/DownloadAndTryButton.kt +++ b/Android/src/app/src/main/java/com/ollitert/llm/server/ui/modelmanager/components/DownloadAndTryButton.kt @@ -47,6 +47,8 @@ import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.StopCircle import androidx.compose.material3.Button +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.TextButton import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -75,7 +77,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.ollitert.llm.server.R -import com.ollitert.llm.server.common.GitHubConfig +import com.ollitert.llm.server.data.allowlist.isHuggingFaceUrl import com.ollitert.llm.server.common.ServerStatus import com.ollitert.llm.server.common.isWifiConnected import com.ollitert.llm.server.data.model.Model @@ -149,6 +151,7 @@ fun DownloadAndTryButton( var checkingToken by remember { mutableStateOf(false) } var showAgreementAckSheet by remember { mutableStateOf(false) } var showErrorDialog by remember { mutableStateOf(false) } + var modelScopeConsentError by remember { mutableStateOf(null) } var showModelNotFoundDialog by remember { mutableStateOf(false) } var showStopActiveDialog by remember { mutableStateOf(false) } var hfTokenDialogReason by remember { mutableStateOf(null) } @@ -176,6 +179,13 @@ fun DownloadAndTryButton( downloadStarted = false } } + LaunchedEffect(downloadStatus?.modelScopeConsentError) { + downloadStatus?.modelScopeConsentError?.let { + modelScopeConsentError = it + checkingToken = false + downloadStarted = false + } + } val showDownloadProgress = !downloadSucceeded && (downloadStarted || checkingToken || inProgress || isPartiallyDownloaded || failedWithProgress) @@ -223,12 +233,22 @@ fun DownloadAndTryButton( if (needToDownloadFirst) { downloadStarted = true // Optimistic flag so the progress row shows "Checking access…" during probes. - if (model.url.startsWith(GitHubConfig.HUGGINGFACE_BASE_URL)) { + if (isHuggingFaceUrl(model.url)) { checkingToken = true } when (val outcome = downloadGate.resolveDownloadAccess(model)) { is DownloadGateOutcome.StartDownload -> { - withContext(Dispatchers.Main) { startDownload(outcome.accessToken) } + withContext(Dispatchers.Main) { + model.modelScopePrimaryError = outcome.modelScopePrimaryError + startDownload(outcome.accessToken) + } + } + is DownloadGateOutcome.NeedsModelScopeConsent -> { + withContext(Dispatchers.Main) { + modelScopeConsentError = outcome.primaryError + checkingToken = false + downloadStarted = false + } } is DownloadGateOutcome.NetworkError -> { Log.e(TAG, "Network error while checking access: ${outcome.message}") @@ -443,6 +463,13 @@ fun DownloadAndTryButton( modifier = Modifier.padding(start = 12.dp).width(if (compact) 32.dp else 44.dp), ) if (!compact) { + if (downloadStatus?.fromModelScope == true) { + Text( + stringResource(R.string.modelscope_source), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } val barColor = if (failedWithProgress) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary LinearProgressIndicator( modifier = Modifier.weight(1f).padding(horizontal = 4.dp), @@ -455,8 +482,12 @@ fun DownloadAndTryButton( val cbRetry = stringResource(R.string.cd_retry_download_icon) IconButton( onClick = { - downloadStarted = true - modelManagerViewModel.retryDownloadModel(model = model) + if (downloadStatus?.modelScopeConsentError != null) { + modelScopeConsentError = downloadStatus.modelScopeConsentError + } else { + downloadStarted = true + modelManagerViewModel.retryDownloadModel(model = model) + } }, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.surfaceContainer @@ -486,6 +517,32 @@ fun DownloadAndTryButton( } } + modelScopeConsentError?.let { primaryError -> + val dismissConsent = { + modelManagerViewModel.dismissModelScopeConsent(model) + modelScopeConsentError = null + } + AlertDialog( + onDismissRequest = dismissConsent, + title = { Text(stringResource(R.string.modelscope_consent_title)) }, + text = { Text(stringResource(R.string.modelscope_consent_body)) }, + confirmButton = { + TextButton(onClick = { + modelManagerViewModel.setModelScopeFallbackEnabled(true) + model.modelScopePrimaryError = primaryError + modelScopeConsentError = null + downloadStarted = true + startDownload(configuredHfTokenOrNull(modelManagerViewModel.getHfToken())) + }) { Text(stringResource(R.string.modelscope_consent_allow)) } + }, + dismissButton = { + TextButton(onClick = dismissConsent) { + Text(stringResource(R.string.close)) + } + }, + ) + } + if (showAgreementAckSheet) { GatedAgreementSheet( model = model, diff --git a/Android/src/app/src/main/java/com/ollitert/llm/server/ui/settings/HfTokenCard.kt b/Android/src/app/src/main/java/com/ollitert/llm/server/ui/settings/HfTokenCard.kt index 83248af30..ee3bd5b6e 100644 --- a/Android/src/app/src/main/java/com/ollitert/llm/server/ui/settings/HfTokenCard.kt +++ b/Android/src/app/src/main/java/com/ollitert/llm/server/ui/settings/HfTokenCard.kt @@ -122,5 +122,7 @@ internal fun HfTokenCard(vm: SettingsViewModel, context: Context) { colors = olliteTextFieldColors(), modifier = Modifier.fillMaxWidth(), ) + SettingDivider() + ToggleCardContent(cardId = CardId.HF_TOKEN, vm = vm) } } diff --git a/Android/src/app/src/main/java/com/ollitert/llm/server/ui/settings/ModelSettingsDefs.kt b/Android/src/app/src/main/java/com/ollitert/llm/server/ui/settings/ModelSettingsDefs.kt index a2a10c7f7..00169b957 100644 --- a/Android/src/app/src/main/java/com/ollitert/llm/server/ui/settings/ModelSettingsDefs.kt +++ b/Android/src/app/src/main/java/com/ollitert/llm/server/ui/settings/ModelSettingsDefs.kt @@ -40,6 +40,17 @@ val HF_TOKEN = SettingDef.TextInput( write = { repo, v -> repo.setHfToken(v) }, ) +val MODELSCOPE_FALLBACK = SettingDef.Toggle( + key = "modelscope_fallback", + labelRes = R.string.modelscope_fallback_label, + descriptionRes = R.string.modelscope_fallback_description, + card = CardId.HF_TOKEN, + default = false, + prefsKey = "modelscope_fallback", + read = { it.isModelScopeFallbackEnabled() }, + write = { repo, value -> repo.setModelScopeFallbackEnabled(value) }, +) + // ─── Model Behaviour Card ───────────────────────────────────────────── val CUSTOM_PROMPTS = SettingDef.Toggle( diff --git a/Android/src/app/src/main/java/com/ollitert/llm/server/ui/settings/SettingsDefinitions.kt b/Android/src/app/src/main/java/com/ollitert/llm/server/ui/settings/SettingsDefinitions.kt index 5a17054e0..7797d0bbf 100644 --- a/Android/src/app/src/main/java/com/ollitert/llm/server/ui/settings/SettingsDefinitions.kt +++ b/Android/src/app/src/main/java/com/ollitert/llm/server/ui/settings/SettingsDefinitions.kt @@ -39,7 +39,7 @@ val allSettingDefs: List = listOf( // Repositories REPOSITORIES_NAV, // HF Token - HF_TOKEN, + HF_TOKEN, MODELSCOPE_FALLBACK, // General KEEP_SCREEN_AWAKE, FLOATING_MONITOR, SHOW_MODEL_RECOMMENDATIONS, RESOLVE_CLIENT_HOSTNAMES, WRAP_LOG_TEXT, AUTO_EXPAND_LOGS, STREAM_RESPONSE_PREVIEW, KEEP_PARTIAL_RESPONSE, COMPACT_IMAGE_DATA, @@ -88,7 +88,7 @@ val allCardDefs: List = listOf( id = CardId.HF_TOKEN, titleRes = R.string.settings_card_hf_token, icon = CardIcon.Vector(Icons.Outlined.Key), - settings = listOf(HF_TOKEN), + settings = listOf(HF_TOKEN, MODELSCOPE_FALLBACK), ), CardDef( id = CardId.GENERAL, diff --git a/Android/src/app/src/main/java/com/ollitert/llm/server/worker/DownloadWorker.kt b/Android/src/app/src/main/java/com/ollitert/llm/server/worker/DownloadWorker.kt index 41c5c4f94..9eff1c85f 100644 --- a/Android/src/app/src/main/java/com/ollitert/llm/server/worker/DownloadWorker.kt +++ b/Android/src/app/src/main/java/com/ollitert/llm/server/worker/DownloadWorker.kt @@ -40,6 +40,14 @@ import com.ollitert.llm.server.data.storage.DOWNLOAD_PROGRESS_UPDATE_INTERVAL_MS import com.ollitert.llm.server.data.prefs.DOWNLOAD_READ_TIMEOUT_MS import com.ollitert.llm.server.data.storage.DOWNLOAD_SPEED_ROLLING_BUFFER_SIZE import com.ollitert.llm.server.data.allowlist.isHuggingFaceUrl +import com.ollitert.llm.server.data.download.modelScopeFallback +import com.ollitert.llm.server.data.download.DownloadHttpException +import com.ollitert.llm.server.data.download.DownloadNetworkException +import com.ollitert.llm.server.data.prefs.ServerPrefs +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 com.ollitert.llm.server.data.storage.modelScopeStagingFile import com.ollitert.llm.server.data.storage.DOWNLOAD_UNZIP_BUFFER_SIZE import com.ollitert.llm.server.data.storage.KEY_MODEL_COMMIT_HASH import com.ollitert.llm.server.data.storage.KEY_MODEL_DOWNLOAD_ACCESS_TOKEN @@ -87,8 +95,11 @@ private class HttpErrorException( private const val FOREGROUND_NOTIFICATION_CHANNEL_ID = "model_download_channel_foreground" private var channelCreated = false -class DownloadWorker(context: Context, params: WorkerParameters) : - CoroutineWorker(context, params) { +class DownloadWorker @JvmOverloads constructor( + context: Context, + params: WorkerParameters, + private val openConnection: (URL) -> HttpURLConnection = { it.openConnection() as HttpURLConnection }, +) : CoroutineWorker(context, params) { private val externalFilesDir = context.getExternalFilesDir(null) private val notificationManager = @@ -128,8 +139,10 @@ class DownloadWorker(context: Context, params: WorkerParameters) : inputData.getString(KEY_MODEL_EXTRA_DATA_DOWNLOAD_FILE_NAMES)?.split(",") ?: listOf() val totalBytes = inputData.getLong(KEY_MODEL_TOTAL_BYTES, 0L) val accessToken = inputData.getString(KEY_MODEL_DOWNLOAD_ACCESS_TOKEN) + val modelScopePrimaryError = inputData.getString(KEY_MODEL_MODELSCOPE_PRIMARY_ERROR) return withContext(Dispatchers.IO) { + var fromModelScope = false if (fileUrl == null || fileName == null) { Result.failure() } else if (externalFilesDir == null) { @@ -177,9 +190,62 @@ class DownloadWorker(context: Context, params: WorkerParameters) : .joinToString(separator = File.separator), ) createdTmpFiles.add(outputTmpFile) + val fallback = modelScopeFallback(file.url)?.takeIf { + !isZip && extraDataFileUrls.isEmpty() && it.sizeInBytes == totalBytes + } + if (fallback != null) { + createdTmpFiles.add(modelScopeStagingFile(outputTmpFile, fallback.sha256)) + var lastProgressTime = 0L + var lastProgressBytes = 0L + var lastNotificationTime = 0L + val completeFile = ModelFileDownloader(openConnection).download( + primaryUrl = file.url, + staging = outputTmpFile, + fallback = fallback, + fallbackEnabled = { ServerPrefs.isModelScopeFallbackEnabled(applicationContext) }, + accessToken = accessToken, + primaryError = modelScopePrimaryError, + ) { received, mirror -> + val now = System.currentTimeMillis() + val sourceChanged = mirror != fromModelScope + if (sourceChanged) { + Log.i(TAG, "Switching '$modelName' download to ModelScope") + lastProgressTime = 0L + lastProgressBytes = received + fromModelScope = mirror + } + if (sourceChanged || now - lastProgressTime >= DOWNLOAD_PROGRESS_UPDATE_INTERVAL_MS || + received == totalBytes + ) { + val rate = if (lastProgressTime > 0 && now > lastProgressTime) { + ((received - lastProgressBytes).coerceAtLeast(0) * 1000 / (now - lastProgressTime)) + } else 0L + setProgress( + workDataOf( + KEY_MODEL_DOWNLOAD_RECEIVED_BYTES to received, + KEY_MODEL_DOWNLOAD_RATE to rate, + KEY_MODEL_FROM_MODELSCOPE to mirror, + ), + ) + lastProgressTime = now + lastProgressBytes = received + } + if (sourceChanged || now - lastNotificationTime >= DOWNLOAD_FOREGROUND_UPDATE_INTERVAL_MS) { + setForeground( + createForegroundInfo( + progress = (received * 100 / totalBytes).toInt(), + modelName = if (mirror) "$modelName (ModelScope)" else modelName, + ), + ) + lastNotificationTime = now + } + } + finalizeDownloadedFile(completeFile, File(outputDir, file.fileName)) + continue + } val outputFileBytes = outputTmpFile.length() - val connection = url.openConnection() as HttpURLConnection + val connection = openConnection(url) connection.connectTimeout = DOWNLOAD_CONNECT_TIMEOUT_MS connection.readTimeout = DOWNLOAD_READ_TIMEOUT_MS try { @@ -378,9 +444,17 @@ class DownloadWorker(context: Context, params: WorkerParameters) : zipFile.delete() } } - Result.success() + Result.success(workDataOf(KEY_MODEL_FROM_MODELSCOPE to fromModelScope)) } catch (e: CancellationException) { throw e + } catch (e: ModelScopeConsentRequiredException) { + Log.i(TAG, "ModelScope fallback requires consent: ${e.primaryError}") + Result.failure( + workDataOf( + KEY_MODEL_DOWNLOAD_ERROR_MESSAGE to applicationContext.getString(R.string.modelscope_consent_required), + KEY_MODEL_MODELSCOPE_CONSENT_ERROR to e.primaryError, + ), + ) } catch (e: SecurityException) { Log.e(TAG, "Zip path traversal blocked: ${e.message}", e) @@ -434,18 +508,26 @@ class DownloadWorker(context: Context, params: WorkerParameters) : } } + val downloadError = if (e is DownloadNetworkException) e.cause ?: e else e val errorMessage = if (isDiskFull) { applicationContext.getString(R.string.download_error_disk_full) } else { - when (e) { + when (downloadError) { + is ModelScopeDownloadException -> downloadError.message.orEmpty() + is DownloadIntegrityException -> downloadError.message.orEmpty() + is DownloadHttpException -> when (downloadError.status) { + 401, 403 -> applicationContext.getString(R.string.download_error_unauthorized) + 404 -> applicationContext.getString(R.string.download_error_not_found) + else -> applicationContext.getString(R.string.download_error_server, downloadError.status) + } is DownloadFinalizationException -> applicationContext.getString(R.string.download_error_finalize) is HttpErrorException -> when { - e.isRepoNotFound -> applicationContext.getString(R.string.download_error_not_found) - e.statusCode == HttpURLConnection.HTTP_NOT_FOUND -> applicationContext.getString(R.string.download_error_not_found) - e.statusCode == HttpURLConnection.HTTP_UNAUTHORIZED || e.statusCode == HttpURLConnection.HTTP_FORBIDDEN -> + downloadError.isRepoNotFound -> applicationContext.getString(R.string.download_error_not_found) + downloadError.statusCode == HttpURLConnection.HTTP_NOT_FOUND -> applicationContext.getString(R.string.download_error_not_found) + downloadError.statusCode == HttpURLConnection.HTTP_UNAUTHORIZED || downloadError.statusCode == HttpURLConnection.HTTP_FORBIDDEN -> applicationContext.getString(R.string.download_error_unauthorized) - else -> applicationContext.getString(R.string.download_error_server, e.statusCode) + else -> applicationContext.getString(R.string.download_error_server, downloadError.statusCode) } is SocketTimeoutException -> applicationContext.getString(R.string.download_error_timeout) is UnknownHostException -> applicationContext.getString(R.string.download_error_no_internet) @@ -455,7 +537,10 @@ class DownloadWorker(context: Context, params: WorkerParameters) : } Result.failure( - Data.Builder().putString(KEY_MODEL_DOWNLOAD_ERROR_MESSAGE, errorMessage).build() + Data.Builder() + .putString(KEY_MODEL_DOWNLOAD_ERROR_MESSAGE, errorMessage) + .putBoolean(KEY_MODEL_FROM_MODELSCOPE, fromModelScope) + .build() ) } catch (e: Exception) { Log.e(TAG, "Unexpected error during download: ${e.message}", e) diff --git a/Android/src/app/src/main/java/com/ollitert/llm/server/worker/ModelFileDownloader.kt b/Android/src/app/src/main/java/com/ollitert/llm/server/worker/ModelFileDownloader.kt new file mode 100644 index 000000000..723bdea2b --- /dev/null +++ b/Android/src/app/src/main/java/com/ollitert/llm/server/worker/ModelFileDownloader.kt @@ -0,0 +1,243 @@ +package com.ollitert.llm.server.worker + +import com.ollitert.llm.server.data.allowlist.isHuggingFaceUrl +import com.ollitert.llm.server.data.download.DownloadHttpException +import com.ollitert.llm.server.data.download.DownloadNetworkException +import com.ollitert.llm.server.data.download.ModelScopeFallback +import com.ollitert.llm.server.data.download.isTransientDownloadFailure +import com.ollitert.llm.server.data.prefs.DOWNLOAD_CONNECT_TIMEOUT_MS +import com.ollitert.llm.server.data.prefs.DOWNLOAD_READ_TIMEOUT_MS +import com.ollitert.llm.server.data.storage.modelScopeStagingFile +import java.io.EOFException +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.ProtocolException +import java.net.URL +import java.security.MessageDigest +import javax.net.ssl.SSLException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive + +internal class ModelScopeConsentRequiredException(val primaryError: String) : + IOException(primaryError) + +internal class ModelScopeDownloadException(primaryError: String, cause: IOException) : + IOException("Hugging Face: $primaryError; ModelScope: ${cause.message}", cause) + +internal class DownloadIntegrityException(message: String) : IOException(message) + +/** Transfers a known catalog artifact; only the alternate is checked against its published digest. */ +internal class ModelFileDownloader( + private val openConnection: (URL) -> HttpURLConnection = { it.openConnection() as HttpURLConnection }, +) { + suspend fun download( + primaryUrl: String, + staging: File, + fallback: ModelScopeFallback, + fallbackEnabled: () -> Boolean, + accessToken: String? = null, + primaryError: String? = null, + onProgress: suspend (receivedBytes: Long, modelScope: Boolean) -> Unit = { _, _ -> }, + ): File { + currentCoroutineContext().ensureActive() + val mirrorStaging = modelScopeStagingFile(staging, fallback.sha256) + var primaryAccessConfirmed = false + val failure = if (primaryError != null && fallbackEnabled() && !fallback.requiresAccessConfirmation) { + primaryError + } else { + try { + transfer( + primaryUrl, staging, fallback.sizeInBytes, null, accessToken, + onArtifactAccess = { primaryAccessConfirmed = true }, + ) { + onProgress(it, false) + } + cleanObsoleteMirrorPartials(staging) + return staging + } catch (error: IOException) { + if (!isTransientDownloadFailure(error) || + (fallback.requiresAccessConfirmation && !primaryAccessConfirmed) + ) throw error + error.message ?: error.javaClass.simpleName + } + } + + currentCoroutineContext().ensureActive() + if (!fallbackEnabled()) throw ModelScopeConsentRequiredException(failure) + try { + onProgress(mirrorStaging.length(), true) + transfer(fallback.url, mirrorStaging, fallback.sizeInBytes, fallback.sha256, null) { + onProgress(it, true) + } + deleteStaging(staging) + cleanObsoleteMirrorPartials(staging, keep = mirrorStaging) + return mirrorStaging + } catch (error: IOException) { + throw ModelScopeDownloadException(failure, error) + } + } + + private suspend fun transfer( + source: String, + staging: File, + expectedBytes: Long, + sha256: String?, + token: String?, + onArtifactAccess: () -> Unit = {}, + onProgress: suspend (Long) -> Unit, + ) { + try { + if (staging.length() == expectedBytes && staging.isFile) { + verifyFile(staging, expectedBytes, sha256) + onProgress(expectedBytes) + return + } + if (staging.length() > expectedBytes) { + throw DownloadIntegrityException("Partial model exceeds the expected size") + } + val offset = staging.length() + onProgress(offset) + val connection = connect(source, offset, token) + try { + val status = network { connection.responseCode } + if (status != 200 && status != 206) throw DownloadHttpException(status) + val start = if (status == 206) { + val range = connection.getHeaderField("Content-Range") + val parts = range?.let { Regex("bytes (\\d+)-(\\d+)/(\\d+)").matchEntire(it) } + ?: throw DownloadIntegrityException("Invalid model Content-Range") + val first = parts.groupValues[1].toLongOrNull() + val last = parts.groupValues[2].toLongOrNull() + val total = parts.groupValues[3].toLongOrNull() + if (first != offset || last == null || last < offset || + total == null || total != expectedBytes || last >= total + ) { + throw DownloadIntegrityException("Model resume range does not match the expected artifact") + } + offset + } else { + 0L + } + var received = start + var confirmedResponse = false + network { connection.inputStream }.use { input -> + FileOutputStream(staging, start > 0).use { output -> + onProgress(received) + val buffer = ByteArray(64 * 1024) + while (true) { + currentCoroutineContext().ensureActive() + val count = network { input.read(buffer) } + if (count == -1) break + if (count == 0) continue + received += count + if (received > expectedBytes) throw DownloadIntegrityException("Model exceeds the expected size") + output.write(buffer, 0, count) + if (!confirmedResponse && received >= 8) { + verifyModelHeader(staging) + // Only a valid response in this attempt can establish gated access. + // Neither persisted partials nor a previous preflight failure can do so. + onArtifactAccess() + confirmedResponse = true + } + onProgress(received) + } + } + } + } finally { + connection.disconnect() + } + verifyFile(staging, expectedBytes, sha256) + } catch (error: DownloadIntegrityException) { + deleteStaging(staging) + throw error + } + } + + private suspend fun connect(source: String, offset: Long, token: String?): HttpURLConnection { + var url = URL(source) + repeat(6) { redirectCount -> + currentCoroutineContext().ensureActive() + val connection = network { openConnection(url) } + try { + connection.instanceFollowRedirects = false + connection.connectTimeout = DOWNLOAD_CONNECT_TIMEOUT_MS + connection.readTimeout = DOWNLOAD_READ_TIMEOUT_MS + connection.setRequestProperty("Accept-Encoding", "identity") + if (offset > 0) connection.setRequestProperty("Range", "bytes=$offset-") + if (redirectCount == 0 && token != null && isHuggingFaceUrl(url.toString())) { + connection.setRequestProperty("Authorization", "Bearer $token") + } + val status = network { connection.responseCode } + if (status !in listOf(301, 302, 303, 307, 308)) return connection + val location = connection.getHeaderField("Location") + ?: throw ProtocolException("Download redirect has no location") + val next = URL(url, location) + if (next.protocol != "https" || next.userInfo != null) { + throw ProtocolException("Unsafe download redirect") + } + url = next + } catch (failure: Throwable) { + connection.disconnect() + throw failure + } + connection.disconnect() + } + throw ProtocolException("Too many download redirects") + } + + private suspend fun verifyFile(file: File, expectedBytes: Long, sha256: String?) { + if (file.length() < expectedBytes) throw EOFException("Model download ended before all bytes arrived") + if (file.length() != expectedBytes) throw DownloadIntegrityException("Unexpected model size") + verifyModelHeader(file) + if (sha256 != null) { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { + currentCoroutineContext().ensureActive() + val count = input.read(buffer) + if (count == -1) break + digest.update(buffer, 0, count) + } + } + val actual = digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) } + if (actual != sha256) throw DownloadIntegrityException("ModelScope SHA-256 verification failed") + } + } + + private fun verifyModelHeader(file: File) { + val header = ByteArray(8) + file.inputStream().use { input -> + if (input.read(header) != header.size || !header.contentEquals("LITERTLM".toByteArray(Charsets.US_ASCII))) { + throw DownloadIntegrityException("Downloaded file is not a LiteRT-LM model") + } + } + } + + private fun cleanObsoleteMirrorPartials(primary: File, keep: File? = null) { + val directory = primary.absoluteFile.parentFile + val candidates = directory.listFiles() + ?: throw IOException("Could not inspect model staging directory") + val namePattern = Regex("${Regex.escape(primary.name)}\\.(?:[0-9a-f]{64}\\.)?modelscope") + for (candidate in candidates) { + if (candidate.isFile && candidate != keep?.absoluteFile && namePattern.matches(candidate.name)) { + deleteStaging(candidate) + } + } + } + + private fun deleteStaging(file: File) { + if (file.exists() && !file.delete()) throw IOException("Could not remove staging file: ${file.name}") + } + + private inline fun network(operation: () -> T): T = try { + operation() + } catch (error: SSLException) { + throw error + } catch (error: ProtocolException) { + throw error + } catch (error: IOException) { + throw DownloadNetworkException(error) + } +} diff --git a/Android/src/app/src/main/res/values/strings.xml b/Android/src/app/src/main/res/values/strings.xml index 54706b410..de767bd0a 100644 --- a/Android/src/app/src/main/res/values/strings.xml +++ b/Android/src/app/src/main/res/values/strings.xml @@ -140,6 +140,13 @@ Open user agreement Unknown network error Please check your internet connection. + Allow ModelScope fallback + After connection or temporary server failures, try pinned ModelScope copies of supported models. Gated models require confirmed access during the current transfer. These are community uploads; equivalence to the publisher files is not verified. Hugging Face tokens are never sent to ModelScope. Token and license errors still require action. + Try ModelScope when Hugging Face is unavailable? + A connection or temporary server error interrupted the Hugging Face download. Allow automatic fallback to ModelScope for supported models? Gated models require confirmed access during the current transfer. These community uploads are checked against pinned ModelScope checksums, not verified as identical to the publisher files. Applicable model licenses still apply. You can turn this off in Settings under Hugging Face Token. + Allow and retry + ModelScope + Hugging Face download failed. Retry to allow ModelScope fallback. Model not found The model could not be found on HuggingFace. Verify that the model ID and commit hash in your model list are correct. HuggingFace Token Required diff --git a/Android/src/app/src/test/java/com/ollitert/llm/server/data/download/ModelScopeFallbackTest.kt b/Android/src/app/src/test/java/com/ollitert/llm/server/data/download/ModelScopeFallbackTest.kt new file mode 100644 index 000000000..fdbfd2c5d --- /dev/null +++ b/Android/src/app/src/test/java/com/ollitert/llm/server/data/download/ModelScopeFallbackTest.kt @@ -0,0 +1,83 @@ +package com.ollitert.llm.server.data.download + +import java.io.IOException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import javax.net.ssl.SSLHandshakeException +import org.junit.Assert.* +import org.junit.Test + +class ModelScopeFallbackTest { + private val gemmaUrl = + "https://huggingface.co/litert-community/Gemma3-1B-IT/resolve/42d538a932e8d5b12e6b3b455f5572560bd60b2c/gemma3-1b-it-int4.litertlm?download=true" + + @Test + fun everyBundledModelHasASizeMatchedPinnedAlternative() { + val catalogFile = generateSequence(java.io.File(System.getProperty("user.dir"))) { it.parentFile } + .map { java.io.File(it, "model_allowlists/v1/model_allowlist.json") } + .first { it.isFile } + val catalog = com.ollitert.llm.server.data.allowlist.ModelAllowlistJson.decode(catalogFile.readText()) + assertEquals(7, catalog.models.size) + for (model in catalog.models) { + val fallback = requireNotNull(modelScopeFallback(model.toModel().url)) { model.name } + assertEquals(model.sizeInBytes, fallback.sizeInBytes) + assertFalse(fallback.url.contains("/master/")) + assertTrue(fallback.sha256.matches(Regex("[0-9a-f]{64}"))) + } + } + + @Test + fun knownArtifactSelectsAnImmutableMirrorWithIntegrityMetadata() { + val fallback = requireNotNull(modelScopeFallback(gemmaUrl)) + assertEquals( + "https://modelscope.cn/models/litert-community/Gemma3-1B-IT/resolve/ea05e64beb281629d682a58fdf681d0c5116f93f/gemma3-1b-it-int4.litertlm", + fallback.url, + ) + assertEquals(584417280L, fallback.sizeInBytes) + assertEquals("1325ae366d31950f137c9c357b9fa89448b176d76998180c08ceaca78bba98be", fallback.sha256) + } + + @Test + fun unknownRevisionsAndOtherHostsCannotSelectTheMirror() { + assertNull(modelScopeFallback(gemmaUrl.replace("42d538a932e8d5b12e6b3b455f5572560bd60b2c", "main"))) + assertNull(modelScopeFallback(gemmaUrl.replace("huggingface.co", "huggingface.co.example.test"))) + assertNull(modelScopeFallback(gemmaUrl.replace("https:", "http:"))) + assertNull(modelScopeFallback(gemmaUrl.replace("gemma3-1b-it-int4.litertlm", "other.litertlm"))) + } + + @Test + fun onlyTheCanonicalDownloadQueryCanSelectTheMirror() { + val canonical = gemmaUrl.substringBefore('?') + assertNotNull(modelScopeFallback(canonical)) + assertNotNull(modelScopeFallback("$canonical?download=true")) + for (query in listOf( + "download=false", + "revision=main", + "download=true&revision=main", + "download=true&download=false", + "%64ownload=true", + "", + ) + ) { + assertNull(query, modelScopeFallback("$canonical?$query")) + } + } + + @Test + fun onlyTransientHttpFailuresPermitFallback() { + for (code in listOf(408, 429, 500, 502, 503, 504)) { + assertTrue("$code should permit fallback", isTransientDownloadStatus(code)) + } + for (code in listOf(200, 301, 400, 401, 403, 404, 416)) { + assertFalse("$code must not permit fallback", isTransientDownloadStatus(code)) + } + } + + @Test + fun localAndTlsErrorsDoNotPermitFallback() { + assertTrue(isTransientDownloadFailure(UnknownHostException("offline"))) + assertTrue(isTransientDownloadFailure(SocketTimeoutException("timed out"))) + assertFalse(isTransientDownloadFailure(IOException("disk write failed"))) + assertFalse(isTransientDownloadFailure(SSLHandshakeException("certificate rejected"))) + } +} diff --git a/Android/src/app/src/test/java/com/ollitert/llm/server/data/prefs/ModelScopeConsentTest.kt b/Android/src/app/src/test/java/com/ollitert/llm/server/data/prefs/ModelScopeConsentTest.kt new file mode 100644 index 000000000..4ead184e2 --- /dev/null +++ b/Android/src/app/src/test/java/com/ollitert/llm/server/data/prefs/ModelScopeConsentTest.kt @@ -0,0 +1,29 @@ +package com.ollitert.llm.server.data.prefs + +import android.content.SharedPreferences +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.* +import org.junit.Test + +class ModelScopeConsentTest { + @Test + fun fallbackIsOptInAndCanBeRevoked() { + val saved = mutableMapOf() + val prefs = mockk() + val editor = mockk() + every { prefs.getBoolean(any(), any()) } answers { saved[firstArg()] ?: secondArg() } + every { prefs.edit() } returns editor + every { editor.putBoolean(any(), any()) } answers { + saved[firstArg()] = secondArg() + editor + } + every { editor.apply() } returns Unit + + assertFalse(ServerPrefsNetwork.isModelScopeFallbackEnabled(prefs)) + ServerPrefsNetwork.setModelScopeFallbackEnabled(prefs, true) + assertTrue(ServerPrefsNetwork.isModelScopeFallbackEnabled(prefs)) + ServerPrefsNetwork.setModelScopeFallbackEnabled(prefs, false) + assertFalse(ServerPrefsNetwork.isModelScopeFallbackEnabled(prefs)) + } +} diff --git a/Android/src/app/src/test/java/com/ollitert/llm/server/data/repository/DownloadRepositoryTest.kt b/Android/src/app/src/test/java/com/ollitert/llm/server/data/repository/DownloadRepositoryTest.kt new file mode 100644 index 000000000..baf0b234f --- /dev/null +++ b/Android/src/app/src/test/java/com/ollitert/llm/server/data/repository/DownloadRepositoryTest.kt @@ -0,0 +1,145 @@ +package com.ollitert.llm.server.data.repository + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.work.Data +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.workDataOf +import com.ollitert.llm.server.OlliteRTLifecycleProvider +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.storage.KEY_MODEL_DOWNLOAD_ERROR_MESSAGE +import com.ollitert.llm.server.data.storage.KEY_MODEL_DOWNLOAD_RECEIVED_BYTES +import com.ollitert.llm.server.data.storage.KEY_MODEL_FROM_MODELSCOPE +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import io.mockk.verify +import java.util.UUID +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class DownloadRepositoryTest { + private val context = mockk(relaxed = true) + private val lifecycle = mockk() + private val workManager = mockk() + private val liveData = mockk>(relaxed = true) + private val observer = slot>() + private lateinit var repository: DownloadRepository + private val model = Model(name = "model", sizeInBytes = 100).apply { preProcess() } + + @Before + fun setUp() { + mockkObject(WorkManager.Companion) + every { WorkManager.getInstance(context) } returns workManager + every { lifecycle.isAppInForeground } returns true + every { workManager.getWorkInfoByIdLiveData(any()) } returns liveData + every { liveData.observeForever(capture(observer)) } just Runs + repository = DownloadRepository(context, lifecycle) + } + + @After + fun tearDown() { + unmockkObject(WorkManager.Companion) + } + + private fun workInfo( + state: WorkInfo.State, + progress: Data = Data.EMPTY, + output: Data = Data.EMPTY, + ): WorkInfo { + val info = mockk() + every { info.state } returns state + every { info.progress } returns progress + every { info.outputData } returns output + return info + } + + @Test + fun explicitPrimaryZeroProgressReplacesSavedMirrorProgress() { + var status = ModelDownloadStatus( + ModelDownloadStatusType.PARTIALLY_DOWNLOADED, + totalBytes = 100, + receivedBytes = 16, + fromModelScope = true, + ) + repository.observerWorkerProgress(UUID.randomUUID(), model) { _, updated -> status = updated } + observer.captured.onChanged(workInfo(WorkInfo.State.RUNNING)) + assertEquals(ModelDownloadStatusType.PARTIALLY_DOWNLOADED, status.status) + + observer.captured.onChanged(workInfo( + WorkInfo.State.RUNNING, + progress = workDataOf( + KEY_MODEL_DOWNLOAD_RECEIVED_BYTES to 0L, + KEY_MODEL_FROM_MODELSCOPE to false, + ), + )) + + assertEquals(ModelDownloadStatusType.IN_PROGRESS, status.status) + assertEquals(0L, status.receivedBytes) + assertEquals(100L, status.totalBytes) + assertFalse(status.fromModelScope) + } + + @Test + fun mirrorTransitionResetsProgressAndFailureRetainsItsSourceAndBytes() { + val updates = mutableListOf() + repository.observerWorkerProgress(UUID.randomUUID(), model) { _, updated -> updates.add(updated) } + for ((received, mirror) in listOf(40L to false, 0L to true, 16L to true)) { + observer.captured.onChanged(workInfo( + WorkInfo.State.RUNNING, + progress = workDataOf( + KEY_MODEL_DOWNLOAD_RECEIVED_BYTES to received, + KEY_MODEL_FROM_MODELSCOPE to mirror, + ), + )) + } + observer.captured.onChanged(workInfo( + WorkInfo.State.FAILED, + output = workDataOf( + KEY_MODEL_DOWNLOAD_ERROR_MESSAGE to "both providers failed", + KEY_MODEL_FROM_MODELSCOPE to true, + ), + )) + + assertEquals(listOf(40L, 0L, 16L, 16L), updates.map { it.receivedBytes }) + assertEquals(listOf(false, true, true, true), updates.map { it.fromModelScope }) + assertEquals(ModelDownloadStatusType.FAILED, updates.last().status) + assertEquals("both providers failed", updates.last().errorMessage) + assertEquals(100L, updates.last().totalBytes) + verify(exactly = 1) { liveData.removeObserver(observer.captured) } + } + + @Test + fun cancellationClearsProviderProgressAndStopsObserving() { + val updates = mutableListOf() + repository.observerWorkerProgress(UUID.randomUUID(), model) { _, updated -> updates.add(updated) } + observer.captured.onChanged(workInfo( + WorkInfo.State.RUNNING, + progress = workDataOf( + KEY_MODEL_DOWNLOAD_RECEIVED_BYTES to 16L, + KEY_MODEL_FROM_MODELSCOPE to true, + ), + )) + assertTrue(updates.last().fromModelScope) + + observer.captured.onChanged(workInfo(WorkInfo.State.CANCELLED)) + + assertEquals(ModelDownloadStatusType.NOT_DOWNLOADED, updates.last().status) + assertEquals(0L, updates.last().receivedBytes) + assertEquals(0L, updates.last().totalBytes) + assertFalse(updates.last().fromModelScope) + verify(exactly = 1) { liveData.removeObserver(observer.captured) } + } +} diff --git a/Android/src/app/src/test/java/com/ollitert/llm/server/data/repository/DownloadRequestDataTest.kt b/Android/src/app/src/test/java/com/ollitert/llm/server/data/repository/DownloadRequestDataTest.kt index 2790d74d9..09ea4262c 100644 --- a/Android/src/app/src/test/java/com/ollitert/llm/server/data/repository/DownloadRequestDataTest.kt +++ b/Android/src/app/src/test/java/com/ollitert/llm/server/data/repository/DownloadRequestDataTest.kt @@ -17,6 +17,18 @@ import org.junit.Assert.assertEquals import org.junit.Test class DownloadRequestDataTest { + @Test + fun preflightFailureSurvivesWorkerEnqueueWithoutChangingArtifactIdentity() { + val model = Model( + name = "model", url = "https://huggingface.co/org/model", version = "pinned", + modelScopePrimaryError = "DNS failure", + ) + val data = buildDownloadRequestData(model) + assertEquals("DNS failure", data.getString("KEY_MODEL_MODELSCOPE_PRIMARY_ERROR")) + assertEquals("https://huggingface.co/org/model", data.getString("KEY_MODEL_URL")) + assertEquals("pinned", data.getString("KEY_MODEL_COMMIT_HASH")) + } + @Test fun buildDownloadRequestDataUsesCanonicalTotalIncludingExtraFilesOnce() { val model = diff --git a/Android/src/app/src/test/java/com/ollitert/llm/server/data/repository/FakePreferencesRepository.kt b/Android/src/app/src/test/java/com/ollitert/llm/server/data/repository/FakePreferencesRepository.kt index e812d29ac..1b5e563eb 100644 --- a/Android/src/app/src/test/java/com/ollitert/llm/server/data/repository/FakePreferencesRepository.kt +++ b/Android/src/app/src/test/java/com/ollitert/llm/server/data/repository/FakePreferencesRepository.kt @@ -47,6 +47,7 @@ class FakePreferencesRepository : PreferencesRepository { private var _updateCheckEnabled: Boolean = true private var _updateCheckIntervalHours: Int = 24 private var _hfToken: String = "" + private var _modelScopeFallback: Boolean = false private var _customPromptsEnabled: Boolean = false private var _autoTruncateHistory: Boolean = false private var _autoTrimPrompts: Boolean = false @@ -126,6 +127,8 @@ class FakePreferencesRepository : PreferencesRepository { override fun setUpdateCheckIntervalHours(hours: Int) { this._updateCheckIntervalHours = hours } override fun getHfToken(): String = _hfToken override fun setHfToken(token: String) { this._hfToken = token } + override fun isModelScopeFallbackEnabled(): Boolean = _modelScopeFallback + override fun setModelScopeFallbackEnabled(enabled: Boolean) { _modelScopeFallback = enabled } override fun isCustomPromptsEnabled(): Boolean = _customPromptsEnabled override fun setCustomPromptsEnabled(enabled: Boolean) { this._customPromptsEnabled = enabled } override fun isAutoTruncateHistory(): Boolean = _autoTruncateHistory diff --git a/Android/src/app/src/test/java/com/ollitert/llm/server/data/repository/ModelStorageRepositoryTest.kt b/Android/src/app/src/test/java/com/ollitert/llm/server/data/repository/ModelStorageRepositoryTest.kt index 46f03f9cb..c8b726664 100644 --- a/Android/src/app/src/test/java/com/ollitert/llm/server/data/repository/ModelStorageRepositoryTest.kt +++ b/Android/src/app/src/test/java/com/ollitert/llm/server/data/repository/ModelStorageRepositoryTest.kt @@ -20,6 +20,9 @@ import android.content.Context import com.ollitert.llm.server.data.model.IMPORTS_DIR import com.ollitert.llm.server.data.model.Model import com.ollitert.llm.server.data.model.ModelDownloadStatusType +import com.ollitert.llm.server.data.download.modelScopeFallback +import com.ollitert.llm.server.data.storage.TMP_FILE_EXT +import com.ollitert.llm.server.data.storage.modelScopeStagingFile import io.mockk.every import io.mockk.mockk import org.junit.Assert.assertEquals @@ -115,4 +118,24 @@ class ModelStorageRepositoryTest { val status = repository.getModelDownloadStatus(model) assertEquals(ModelDownloadStatusType.NOT_DOWNLOADED, status.status) } + + @Test + fun mirrorPartialIsFoundAfterRestartWithoutBeingTreatedAsACompleteModel() { + val model = Model( + name = "Gemma3-1B-IT", + downloadFileName = "gemma3-1b-it-int4.litertlm", + version = "42d538a932e8d5b12e6b3b455f5572560bd60b2c", + url = "https://huggingface.co/litert-community/Gemma3-1B-IT/resolve/42d538a932e8d5b12e6b3b455f5572560bd60b2c/gemma3-1b-it-int4.litertlm", + ) + val primary = File(model.getPath(mockContext, fileName = "${model.downloadFileName}.$TMP_FILE_EXT")) + primary.parentFile.mkdirs() + val fallback = requireNotNull(modelScopeFallback(model.url)) + modelScopeStagingFile(primary, fallback.sha256).writeBytes(ByteArray(16)) + + val reloaded = DefaultModelStorageRepository(mockContext).getModelDownloadStatus(model) + + assertEquals(ModelDownloadStatusType.PARTIALLY_DOWNLOADED, reloaded.status) + assertEquals(16L, reloaded.receivedBytes) + assertTrue(reloaded.fromModelScope) + } } diff --git a/Android/src/app/src/test/java/com/ollitert/llm/server/ui/modelmanager/ConfiguredHfTokenTest.kt b/Android/src/app/src/test/java/com/ollitert/llm/server/ui/modelmanager/ConfiguredHfTokenTest.kt index bf8bd8e09..16fa96f89 100644 --- a/Android/src/app/src/test/java/com/ollitert/llm/server/ui/modelmanager/ConfiguredHfTokenTest.kt +++ b/Android/src/app/src/test/java/com/ollitert/llm/server/ui/modelmanager/ConfiguredHfTokenTest.kt @@ -42,6 +42,22 @@ class ConfiguredHfTokenTest { fun huggingFaceHostsAndSubdomainsAreAllowed() { assertTrue(isHuggingFaceUrl("https://huggingface.co/repo/file.litertlm")) assertTrue(isHuggingFaceUrl("https://cdn-lfs.huggingface.co/repo/file.litertlm")) + assertTrue(isHuggingFaceUrl("https://huggingface.co:443/repo/file.litertlm")) + assertTrue(isHuggingFaceUrl("HTTPS://HUGGINGFACE.CO/repo/file.litertlm")) + } + + @Test + fun tokensAreNotAllowedOnCleartextOrAmbiguousOrigins() { + for (url in listOf( + "http://huggingface.co/repo/file.litertlm", + "http://cdn-lfs.huggingface.co/repo/file.litertlm", + "ftp://huggingface.co/repo/file.litertlm", + "https://user@huggingface.co/repo/file.litertlm", + "https://huggingface.co:8443/repo/file.litertlm", + ) + ) { + assertFalse(url, isHuggingFaceUrl(url)) + } } @Test diff --git a/Android/src/app/src/test/java/com/ollitert/llm/server/ui/modelmanager/DownloadGateCoordinatorTest.kt b/Android/src/app/src/test/java/com/ollitert/llm/server/ui/modelmanager/DownloadGateCoordinatorTest.kt index a7af30c76..b5e3d0856 100644 --- a/Android/src/app/src/test/java/com/ollitert/llm/server/ui/modelmanager/DownloadGateCoordinatorTest.kt +++ b/Android/src/app/src/test/java/com/ollitert/llm/server/ui/modelmanager/DownloadGateCoordinatorTest.kt @@ -22,6 +22,7 @@ import com.ollitert.llm.server.data.model.Model import com.ollitert.llm.server.ui.modelmanager.components.HfTokenDialogReason import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test import java.net.HttpURLConnection @@ -31,15 +32,21 @@ import java.net.HttpURLConnection */ class DownloadGateCoordinatorTest { - private val hfModel = Model(name = "hf-model", url = "${GitHubConfig.HUGGINGFACE_BASE_URL}org/repo") + private val hfModel = Model(name = "hf-model", url = "${GitHubConfig.HUGGINGFACE_BASE_URL}/org/repo") private val plainModel = Model(name = "plain", url = "https://example.com/model.task") + private val mirroredModel = Model( + name = "Qwen2.5-1.5B-Instruct", + url = "https://huggingface.co/litert-community/Qwen2.5-1.5B-Instruct/resolve/19edb84c69a0212f29a6ef17ba0d6f278b6a1614/Qwen2.5-1.5B-Instruct_multi-prefill-seq_q8_ekv4096.litertlm", + ) private fun coordinator( probe: (Model, String?) -> ModelUrlResult, storedToken: String? = null, + fallbackEnabled: Boolean = false, ) = DownloadGateCoordinator( probeUrl = { model, token -> probe(model, token) }, storedHfToken = { storedToken }, + fallbackEnabled = { fallbackEnabled }, ) @Test @@ -71,6 +78,142 @@ class DownloadGateCoordinatorTest { assertEquals(DownloadGateOutcome.NetworkError("offline"), gate.resolveDownloadAccess(hfModel)) } + @Test + fun transientServerFailureDoesNotAskForAHuggingFaceToken() = runTest { + val gate = coordinator(probe = { _, _ -> ModelUrlResult.Success(503) }) + + assertTrue(gate.resolveDownloadAccess(hfModel) is DownloadGateOutcome.NetworkError) + } + + @Test + fun allFourPublicMappingsCanFailOverOnTemporaryNetworkOrServerFailures() = runTest { + val urls = listOf( + mirroredModel.url, + "https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/6e5c4f1e395deb959c494953478fa5cec4b8008f/gemma-4-E2B-it.litertlm", + "https://huggingface.co/litert-community/gemma-4-E4B-it-litert-lm/resolve/28299f30ee4d43294517a4ac93abd6163412f07f/gemma-4-E4B-it.litertlm", + "https://huggingface.co/litert-community/DeepSeek-R1-Distill-Qwen-1.5B/resolve/e34bb88632342d1f9640bad579a45134eb1cf988/DeepSeek-R1-Distill-Qwen-1.5B_multi-prefill-seq_q8_ekv4096.litertlm", + ) + for (url in urls) { + for (failure in listOf( + ModelUrlResult.Error("timeout", retryable = true), + ModelUrlResult.Success(408), + ModelUrlResult.Success(429), + ModelUrlResult.Success(503), + ) + ) { + val gate = coordinator(probe = { _, _ -> failure }, fallbackEnabled = true) + val outcome = gate.resolveDownloadAccess(mirroredModel.copy(url = url)) + assertTrue(outcome is DownloadGateOutcome.StartDownload) + assertTrue((outcome as DownloadGateOutcome.StartDownload).modelScopePrimaryError != null) + assertEquals(null, outcome.accessToken) + } + } + } + + @Test + fun noncanonicalQueryDoesNotOfferOrStartPreflightFallback() = runTest { + val model = mirroredModel.copy(url = "${mirroredModel.url}?download=false") + for (consent in listOf(false, true)) { + val gate = coordinator( + probe = { _, _ -> ModelUrlResult.Error("offline", retryable = true) }, + fallbackEnabled = consent, + ) + assertEquals(DownloadGateOutcome.NetworkError("offline"), gate.resolveDownloadAccess(model)) + } + } + + @Test + fun eligibleFailureRequiresConsentByDefault() = runTest { + val gate = coordinator(probe = { _, _ -> ModelUrlResult.Error("offline", retryable = true) }) + assertEquals( + DownloadGateOutcome.NeedsModelScopeConsent("offline"), + gate.resolveDownloadAccess(mirroredModel), + ) + } + + @Test + fun failedAnonymousAccessCheckCannotAuthorizeAGatedMirror() = runTest { + val gatedUrls = listOf( + "https://huggingface.co/litert-community/Gemma3-1B-IT/resolve/42d538a932e8d5b12e6b3b455f5572560bd60b2c/gemma3-1b-it-int4.litertlm", + "https://huggingface.co/google/gemma-3n-E2B-it-litert-lm/resolve/ba9ca88da013b537b6ed38108be609b8db1c3a16/gemma-3n-E2B-it-int4.litertlm", + "https://huggingface.co/google/gemma-3n-E4B-it-litert-lm/resolve/297ed75955702dec3503e00c2c2ecbbf475300bc/gemma-3n-E4B-it-int4.litertlm", + ) + for (url in gatedUrls) { + for (failure in listOf(ModelUrlResult.Error("timeout", retryable = true), ModelUrlResult.Success(503))) { + for (token in listOf(null, "unverified-token")) { + val gate = coordinator(probe = { _, _ -> failure }, storedToken = token, fallbackEnabled = true) + assertTrue( + "An unverified token or mirror consent must not establish access to $url", + gate.resolveDownloadAccess(mirroredModel.copy(url = url)) is DownloadGateOutcome.NetworkError, + ) + } + } + } + } + + @Test + fun cleartextHuggingFaceUrlDoesNotEnterTheStoredTokenFlow() = runTest { + val gate = coordinator( + probe = { _, _ -> error("Cleartext URL must not be probed with credentials") }, + storedToken = "test-token", + ) + assertEquals( + DownloadGateOutcome.StartDownload(null), + gate.resolveDownloadAccess(hfModel.copy(url = "http://huggingface.co/org/repo")), + ) + } + + @Test + fun consentedFailureStartsMirrorWithoutForwardingTheToken() = runTest { + val gate = coordinator( + probe = { _, _ -> ModelUrlResult.Error("offline", retryable = true) }, + storedToken = "private-token", + fallbackEnabled = true, + ) + assertEquals( + DownloadGateOutcome.StartDownload(null, modelScopePrimaryError = "offline"), + gate.resolveDownloadAccess(mirroredModel), + ) + } + + @Test + fun consentDoesNotBypassAuthenticationOrCertificateErrors() = runTest { + for (code in listOf(401, 403)) { + val gate = coordinator(probe = { _, _ -> ModelUrlResult.Success(code) }, fallbackEnabled = true) + assertTrue(gate.resolveDownloadAccess(mirroredModel) is DownloadGateOutcome.NeedsHfToken) + } + val gate = coordinator( + probe = { _, _ -> ModelUrlResult.Error("certificate rejected", retryable = false) }, + fallbackEnabled = true, + ) + assertEquals( + DownloadGateOutcome.NetworkError("certificate rejected"), + gate.resolveDownloadAccess(mirroredModel), + ) + } + + @Test + fun healthyPrimaryDoesNotSelectMirrorEvenAfterConsent() = runTest { + val gate = coordinator(probe = { _, _ -> ModelUrlResult.Success(200) }, fallbackEnabled = true) + assertEquals(DownloadGateOutcome.StartDownload(null), gate.resolveDownloadAccess(mirroredModel)) + } + + @Test + fun failureWhileVerifyingATokenDoesNotBypassAnEarlierAccessDenial() = runTest { + val gate = coordinator( + probe = { _, token -> + if (token == null) ModelUrlResult.Success(401) + else ModelUrlResult.Error("timeout", retryable = true) + }, + storedToken = "token", + fallbackEnabled = true, + ) + assertEquals( + DownloadGateOutcome.NetworkError("timeout"), + gate.resolveDownloadAccess(mirroredModel), + ) + } + @Test fun authRequiredWithoutStoredTokenPromptsForMissingToken() = runTest { var probes = 0 diff --git a/Android/src/app/src/test/java/com/ollitert/llm/server/ui/modelmanager/ModelManagerFallbackTest.kt b/Android/src/app/src/test/java/com/ollitert/llm/server/ui/modelmanager/ModelManagerFallbackTest.kt new file mode 100644 index 000000000..e591f7692 --- /dev/null +++ b/Android/src/app/src/test/java/com/ollitert/llm/server/ui/modelmanager/ModelManagerFallbackTest.kt @@ -0,0 +1,84 @@ +package com.ollitert.llm.server.ui.modelmanager + +import android.content.Context +import com.ollitert.llm.server.OlliteRTLifecycleProvider +import com.ollitert.llm.server.data.allowlist.RepositoryManager +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.repository.* +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ModelManagerFallbackTest { + private val dispatcher = StandardTestDispatcher() + private val storage = mockk(relaxed = true) + private val downloads = mockk(relaxed = true) + + @Before fun setUp() { Dispatchers.setMain(dispatcher) } + @After fun tearDown() { Dispatchers.resetMain() } + + private fun viewModel(): ModelManagerViewModel { + val proto = mockk(relaxed = true) + coEvery { proto.isOnboardingCompleted() } returns true + return ModelManagerViewModel( + downloadRepository = downloads, + protoDataStoreRepository = proto, + lifecycleProvider = mockk(relaxed = true), + repositoryManager = mockk(relaxed = true), + context = mockk(relaxed = true), + preferencesRepository = FakePreferencesRepository(), + serverStateRepository = mockk(relaxed = true), + modelStorageRepository = storage, + ioDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + } + + @Test + fun rejectingConsentLeavesTheUserAbleToRetryThePrimary() = runTest(dispatcher) { + val vm = viewModel() + val model = Model("model") + vm.setDownloadStatus(model, ModelDownloadStatus( + ModelDownloadStatusType.FAILED, modelScopeConsentError = "timeout", + )) + + vm.dismissModelScopeConsent(model) + + assertFalse(vm.isModelScopeFallbackEnabled()) + val status = requireNotNull(vm.uiState.value.modelDownloadStatus[model.name]) + assertNull(status.modelScopeConsentError) + assertEquals("timeout", status.errorMessage) + vm.downloadModel(model) + assertEquals(ModelDownloadStatusType.IN_PROGRESS, vm.uiState.value.modelDownloadStatus[model.name]?.status) + advanceUntilIdle() + } + + @Test + fun retryAfterConsentPreservesExistingProviderPartials() = runTest(dispatcher) { + val vm = viewModel() + val model = Model("model") + vm.setDownloadStatus(model, ModelDownloadStatus( + ModelDownloadStatusType.FAILED, receivedBytes = 20, totalBytes = 100, + modelScopeConsentError = "timeout", + )) + vm.setModelScopeFallbackEnabled(true) + model.modelScopePrimaryError = "timeout" + + vm.downloadModel(model) + + verify(exactly = 0) { storage.deleteDirFromExternalFilesDir(any()) } + verify(exactly = 1) { downloads.downloadModel(model, any()) } + assertTrue(vm.isModelScopeFallbackEnabled()) + advanceUntilIdle() + } +} diff --git a/Android/src/app/src/test/java/com/ollitert/llm/server/ui/modelmanager/ModelUrlProbeTest.kt b/Android/src/app/src/test/java/com/ollitert/llm/server/ui/modelmanager/ModelUrlProbeTest.kt index 320006e08..68ec06125 100644 --- a/Android/src/app/src/test/java/com/ollitert/llm/server/ui/modelmanager/ModelUrlProbeTest.kt +++ b/Android/src/app/src/test/java/com/ollitert/llm/server/ui/modelmanager/ModelUrlProbeTest.kt @@ -25,10 +25,60 @@ import io.mockk.mockk import io.mockk.verify import java.net.HttpURLConnection import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class ModelUrlProbeTest { + @Test + fun probeDoesNotAttachCredentialsToCleartextHuggingFace() { + val connection = mockk(relaxed = true) + every { connection.responseCode } returns 200 + probeModelUrl("http://huggingface.co/org/repo", "test-token") { connection } + verify(exactly = 0) { connection.setRequestProperty("Authorization", any()) } + } + + @Test + fun authenticatedProbeUsesTheTokenButDoesNotForwardItToTheCdn() { + val initial = mockk(relaxed = true) + val cdn = mockk(relaxed = true) + every { initial.responseCode } returns 302 + every { initial.getHeaderField("Location") } returns "https://cdn.example.test/model.litertlm" + every { cdn.responseCode } returns 200 + every { cdn.contentType } returns "application/octet-stream" + + val result = probeModelUrl("https://huggingface.co/org/repo", "test-token") { url -> + if (url.startsWith("https://huggingface.co/")) initial else cdn + } + + assertEquals(ModelUrlResult.Success(200), result) + verify { initial.setRequestProperty("Authorization", "Bearer test-token") } + verify(exactly = 0) { cdn.setRequestProperty("Authorization", any()) } + } + + @Test + fun actualNetworkExceptionsAreClassifiedWithoutParsingTheirMessages() { + val result = probeModelUrl("https://huggingface.co/model", null) { + throw java.net.UnknownHostException("localized error") + } + assertTrue((result as ModelUrlResult.Error).retryable) + val tls = probeModelUrl("https://huggingface.co/model", null) { + throw javax.net.ssl.SSLHandshakeException("localized error") + } + assertFalse((tls as ModelUrlResult.Error).retryable) + } + + @Test + fun cancellationIsNotConvertedIntoAnOrdinaryNetworkError() { + try { + probeModelUrl("https://huggingface.co/model", null) { + throw kotlinx.coroutines.CancellationException() + } + org.junit.Assert.fail("Expected cancellation") + } catch (_: kotlinx.coroutines.CancellationException) { } + } + @Test fun redirectProbeBoundsBothConnections() { val initialConnection = mockk(relaxed = true) diff --git a/Android/src/app/src/test/java/com/ollitert/llm/server/worker/DownloadWorkerTest.kt b/Android/src/app/src/test/java/com/ollitert/llm/server/worker/DownloadWorkerTest.kt new file mode 100644 index 000000000..905066f09 --- /dev/null +++ b/Android/src/app/src/test/java/com/ollitert/llm/server/worker/DownloadWorkerTest.kt @@ -0,0 +1,385 @@ +package com.ollitert.llm.server.worker + +import android.app.Notification +import android.content.Context +import android.content.SharedPreferences +import android.os.Environment +import android.os.StatFs +import androidx.core.app.NotificationCompat +import androidx.work.Data +import androidx.work.ForegroundUpdater +import androidx.work.ListenableWorker +import androidx.work.ProgressUpdater +import androidx.work.WorkerParameters +import androidx.work.impl.utils.futures.SettableFuture +import com.ollitert.llm.server.R +import com.ollitert.llm.server.data.download.modelScopeFallback +import com.ollitert.llm.server.data.model.Model +import com.ollitert.llm.server.data.model.ModelDownloadStatusType +import com.ollitert.llm.server.data.prefs.KEY_MODELSCOPE_FALLBACK +import com.ollitert.llm.server.data.prefs.ServerPrefs +import com.ollitert.llm.server.data.repository.DefaultModelStorageRepository +import com.ollitert.llm.server.data.repository.buildDownloadRequestData +import com.ollitert.llm.server.data.storage.KEY_MODEL_DOWNLOAD_ERROR_MESSAGE +import com.ollitert.llm.server.data.storage.KEY_MODEL_DOWNLOAD_RECEIVED_BYTES +import com.ollitert.llm.server.data.storage.KEY_MODEL_FROM_MODELSCOPE +import com.ollitert.llm.server.data.storage.TMP_FILE_EXT +import com.ollitert.llm.server.data.storage.modelScopeStagingFile +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.unmockkConstructor +import io.mockk.unmockkStatic +import java.io.ByteArrayInputStream +import java.io.File +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.ProtocolException +import java.net.SocketException +import java.net.SocketTimeoutException +import java.net.URL +import java.net.UnknownHostException +import java.util.UUID +import javax.net.ssl.SSLHandshakeException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class DownloadWorkerTest { + @get:Rule val temp = TemporaryFolder() + private val context = mockk(relaxed = true) + private val preferences = mockk(relaxed = true) + private val progress = mutableListOf() + private var fallbackEnabled = false + + @Before + fun setUp() { + every { context.applicationContext } returns context + every { context.getExternalFilesDir(null) } returns temp.root + every { context.getSharedPreferences(any(), any()) } returns preferences + ServerPrefs.resetToDefaults(context) + every { preferences.getBoolean(KEY_MODELSCOPE_FALLBACK, false) } answers { fallbackEnabled } + every { context.getString(any()) } returns "notification" + every { context.getString(any(), *anyVararg()) } returns "notification" + every { context.getString(R.string.download_error_timeout) } returns "timeout" + every { context.getString(R.string.download_error_no_internet) } returns "offline" + every { context.getString(R.string.download_error_connection_lost) } returns "connection lost" + every { context.getString(R.string.download_error_network) } returns "network" + every { context.getString(R.string.download_error_unauthorized) } returns "unauthorized" + every { context.getString(R.string.download_error_not_found) } returns "not found" + mockkStatic(Environment::class) + every { Environment.getDataDirectory() } returns temp.root + mockkConstructor(StatFs::class, NotificationCompat.Builder::class) + every { anyConstructed().availableBytes } returns 2L * 1024 * 1024 * 1024 + every { anyConstructed().build() } returns mockk() + } + + @After + fun tearDown() { + ServerPrefs.resetToDefaults(context) + unmockkConstructor(StatFs::class, NotificationCompat.Builder::class) + unmockkStatic(Environment::class) + } + + private fun gatedModel() = Model( + name = "Gemma3-1B-IT", + downloadFileName = "gemma3-1b-it-int4.litertlm", + version = "42d538a932e8d5b12e6b3b455f5572560bd60b2c", + url = "https://huggingface.co/litert-community/Gemma3-1B-IT/resolve/42d538a932e8d5b12e6b3b455f5572560bd60b2c/gemma3-1b-it-int4.litertlm", + sizeInBytes = 584417280L, + ).apply { preProcess() } + + private fun publicModel() = Model( + name = "Gemma-4-E2B", + downloadFileName = "gemma-4-E2B-it.litertlm", + version = "6e5c4f1e395deb959c494953478fa5cec4b8008f", + url = "https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/6e5c4f1e395deb959c494953478fa5cec4b8008f/gemma-4-E2B-it.litertlm", + sizeInBytes = 2588147712L, + ).apply { preProcess() } + + private fun worker( + model: Model, + openConnection: (URL) -> HttpURLConnection, + ): DownloadWorker { + val foregroundUpdater = mockk() + every { foregroundUpdater.setForegroundAsync(any(), any(), any()) } answers { + SettableFuture.create().apply { set(null) } + } + val progressUpdater = mockk() + every { progressUpdater.updateProgress(any(), any(), any()) } answers { + progress.add(thirdArg()) + SettableFuture.create().apply { set(null) } + } + val params = mockk(relaxed = true) + every { params.id } returns UUID.randomUUID() + every { params.inputData } returns buildDownloadRequestData(model) + every { params.workerContext } returns Dispatchers.IO + every { params.foregroundUpdater } returns foregroundUpdater + every { params.progressUpdater } returns progressUpdater + return DownloadWorker(context, params, openConnection) + } + + private class Response( + url: URL, + private val body: ByteArray = byteArrayOf(), + private val status: Int = 200, + private val headers: Map = emptyMap(), + ) : HttpURLConnection(url) { + override fun connect() = Unit + override fun disconnect() = Unit + override fun usingProxy() = false + override fun getResponseCode() = status + override fun getHeaderField(name: String): String? = headers[name] + override fun getInputStream(): InputStream = ByteArrayInputStream(body) + } + + @Test + fun gatedNetworkFailuresKeepTheirSpecificWorkerErrorMessages() = runTest { + val model = gatedModel() + for ((failure, expected) in listOf( + SocketTimeoutException("test timeout") to "timeout", + UnknownHostException("test DNS failure") to "offline", + SocketException("test disconnect") to "connection lost", + ) + ) { + for (consent in listOf(false, true)) { + fallbackEnabled = consent + val requests = mutableListOf() + val worker = worker(model) { url -> + requests.add(url.toString()) + throw failure + } + + val result = worker.doWork() as ListenableWorker.Result.Failure + + assertEquals(expected, result.outputData.getString(KEY_MODEL_DOWNLOAD_ERROR_MESSAGE)) + assertFalse(result.outputData.getBoolean(KEY_MODEL_FROM_MODELSCOPE, false)) + assertEquals(listOf(model.url), requests) + } + } + } + + @Test + fun restoredProgressMatchesThePrimaryPartialResumedByTheWorker() = runTest { + val model = gatedModel() + val staging = File(model.getPath(context, "${model.downloadFileName}.$TMP_FILE_EXT")) + staging.parentFile.mkdirs() + staging.writeText("LITERTLM") + val fallback = requireNotNull(modelScopeFallback(model.url)) + val mirror = modelScopeStagingFile(staging, fallback.sha256).apply { writeBytes(ByteArray(16)) } + val stored = DefaultModelStorageRepository(context).getModelDownloadStatus(model) + lateinit var response: Response + val requests = mutableListOf() + val worker = worker(model) { url -> + requests.add(url.toString()) + Response( + url, status = 206, + headers = mapOf("Content-Range" to "bytes 8-584417279/584417280"), + ).also { response = it } + } + + val result = worker.doWork() + + assertTrue(result is ListenableWorker.Result.Failure) + assertEquals(listOf(model.url), requests) + assertEquals("bytes=8-", response.getRequestProperty("Range")) + assertEquals(8L, progress.first().getLong(KEY_MODEL_DOWNLOAD_RECEIVED_BYTES, -1L)) + assertEquals(ModelDownloadStatusType.PARTIALLY_DOWNLOADED, stored.status) + assertEquals(8L, stored.receivedBytes) + assertFalse(stored.fromModelScope) + assertEquals(8L, staging.length()) + assertEquals(16L, mirror.length()) + } + + @Test + fun retryReportsPrimaryProgressBeforeConnectingWithOnlyAMirrorPartial() = runTest { + val model = gatedModel() + val staging = File(model.getPath(context, "${model.downloadFileName}.$TMP_FILE_EXT")) + staging.parentFile.mkdirs() + val fallback = requireNotNull(modelScopeFallback(model.url)) + val mirror = modelScopeStagingFile(staging, fallback.sha256).apply { writeBytes(ByteArray(16)) } + val stored = DefaultModelStorageRepository(context).getModelDownloadStatus(model) + assertTrue(stored.fromModelScope) + assertEquals(16L, stored.receivedBytes) + var progressBeforeRequest = emptyList() + val requests = mutableListOf() + fallbackEnabled = true + val worker = worker(model) { url -> + requests.add(url.toString()) + progressBeforeRequest = progress.toList() + throw SocketTimeoutException("test timeout") + } + + val result = worker.doWork() as ListenableWorker.Result.Failure + + assertEquals(listOf(model.url), requests) + assertEquals(1, progressBeforeRequest.size) + assertEquals(0L, progressBeforeRequest.single().getLong(KEY_MODEL_DOWNLOAD_RECEIVED_BYTES, -1L)) + assertFalse(progressBeforeRequest.single().getBoolean(KEY_MODEL_FROM_MODELSCOPE, false)) + assertEquals("timeout", result.outputData.getString(KEY_MODEL_DOWNLOAD_ERROR_MESSAGE)) + assertFalse(staging.exists()) + assertEquals(16L, mirror.length()) + } + + @Test + fun workManagerCanStillUseTheTwoArgumentConstructor() { + assertNotNull(DownloadWorker::class.java.getConstructor(Context::class.java, WorkerParameters::class.java)) + } + + @Test + fun unmappedDownloadsKeepTheirSpecificNetworkMessages() = runTest { + val model = gatedModel().copy(url = "https://huggingface.co/org/model/resolve/revision/model.litertlm") + for ((failure, expected) in listOf( + SocketTimeoutException("test timeout") to "timeout", + UnknownHostException("test DNS failure") to "offline", + SocketException("test disconnect") to "connection lost", + ) + ) { + val requests = mutableListOf() + val worker = worker(model) { url -> + requests.add(url.toString()) + throw failure + } + + val result = worker.doWork() as ListenableWorker.Result.Failure + + assertEquals(expected, result.outputData.getString(KEY_MODEL_DOWNLOAD_ERROR_MESSAGE)) + assertEquals(listOf(model.url), requests) + } + } + + @Test + fun permissionAndMissingFileErrorsDoNotTryTheMirror() = runTest { + val model = publicModel() + fallbackEnabled = true + for ((code, expected) in listOf(401 to "unauthorized", 403 to "unauthorized", 404 to "not found")) { + val requests = mutableListOf() + val worker = worker(model) { url -> + requests.add(url.toString()) + Response(url, status = code) + } + + val result = worker.doWork() as ListenableWorker.Result.Failure + + assertEquals(expected, result.outputData.getString(KEY_MODEL_DOWNLOAD_ERROR_MESSAGE)) + assertEquals(listOf(model.url), requests) + assertFalse(result.outputData.getBoolean(KEY_MODEL_FROM_MODELSCOPE, false)) + } + } + + @Test + fun certificateAndProtocolErrorsDoNotTryTheMirror() = runTest { + val model = publicModel() + fallbackEnabled = true + for (failure in listOf(SSLHandshakeException("test certificate error"), ProtocolException("test redirect error"))) { + val requests = mutableListOf() + val worker = worker(model) { url -> + requests.add(url.toString()) + throw failure + } + + val result = worker.doWork() as ListenableWorker.Result.Failure + + assertEquals("network", result.outputData.getString(KEY_MODEL_DOWNLOAD_ERROR_MESSAGE)) + assertEquals(listOf(model.url), requests) + assertFalse(result.outputData.getBoolean(KEY_MODEL_FROM_MODELSCOPE, false)) + } + } + + @Test + fun cancellationPropagatesWithoutTryingTheMirror() = runTest { + val model = publicModel() + fallbackEnabled = true + val requests = mutableListOf() + val cancellation = CancellationException("test cancellation") + val worker = worker(model) { url -> + requests.add(url.toString()) + throw cancellation + } + + try { + worker.doWork() + fail("Expected cancellation") + } catch (failure: CancellationException) { + assertEquals("test cancellation", failure.message) + } + + assertEquals(listOf(model.url), requests) + } + + @Test + fun localWriteFailurePreservesTheInstalledModelWithoutTryingTheMirror() = runTest { + val model = publicModel() + fallbackEnabled = true + val staging = File(model.getPath(context, "${model.downloadFileName}.$TMP_FILE_EXT")) + staging.mkdirs() + val installed = File(model.getPath(context)).apply { writeText("installed model") } + val requests = mutableListOf() + val worker = worker(model) { url -> + requests.add(url.toString()) + Response(url, "LITERTLM".toByteArray()) + } + + val result = worker.doWork() as ListenableWorker.Result.Failure + + assertEquals("network", result.outputData.getString(KEY_MODEL_DOWNLOAD_ERROR_MESSAGE)) + assertEquals(listOf(model.url), requests) + assertEquals("installed model", installed.readText()) + assertTrue(staging.isDirectory) + } + + @Test + fun bothProviderFailuresKeepTheirReasonsAndTheActiveSource() = runTest { + val model = publicModel() + val fallback = requireNotNull(modelScopeFallback(model.url)) + fallbackEnabled = true + val requests = mutableListOf() + val worker = worker(model) { url -> + requests.add(url.toString()) + if (url.toString() == model.url) throw UnknownHostException("test DNS failure") + throw SocketTimeoutException("test mirror timeout") + } + + val result = worker.doWork() as ListenableWorker.Result.Failure + val message = result.outputData.getString(KEY_MODEL_DOWNLOAD_ERROR_MESSAGE).orEmpty() + + assertEquals(listOf(model.url, fallback.url), requests) + assertTrue(message.contains("Hugging Face: Connection failed (UnknownHostException)")) + assertTrue(message.contains("ModelScope: Connection failed (SocketTimeoutException)")) + assertTrue(result.outputData.getBoolean(KEY_MODEL_FROM_MODELSCOPE, false)) + assertEquals(listOf(false, true), progress.map { it.getBoolean(KEY_MODEL_FROM_MODELSCOPE, false) }.distinct()) + } + + @Test + fun stalePreflightAndCachedMirrorDoNotSkipCurrentGatedAuthorization() = runTest { + val model = gatedModel().apply { modelScopePrimaryError = "earlier timeout" } + fallbackEnabled = true + val staging = File(model.getPath(context, "${model.downloadFileName}.$TMP_FILE_EXT")) + staging.parentFile.mkdirs() + val fallback = requireNotNull(modelScopeFallback(model.url)) + val mirror = modelScopeStagingFile(staging, fallback.sha256).apply { writeBytes(ByteArray(16)) } + val requests = mutableListOf() + val worker = worker(model) { url -> + requests.add(url.toString()) + Response(url, status = 403) + } + + val result = worker.doWork() as ListenableWorker.Result.Failure + + assertEquals("unauthorized", result.outputData.getString(KEY_MODEL_DOWNLOAD_ERROR_MESSAGE)) + assertEquals(listOf(model.url), requests) + assertFalse(result.outputData.getBoolean(KEY_MODEL_FROM_MODELSCOPE, false)) + assertEquals(16L, mirror.length()) + } +} diff --git a/Android/src/app/src/test/java/com/ollitert/llm/server/worker/ModelFileDownloaderTest.kt b/Android/src/app/src/test/java/com/ollitert/llm/server/worker/ModelFileDownloaderTest.kt new file mode 100644 index 000000000..3ea92c710 --- /dev/null +++ b/Android/src/app/src/test/java/com/ollitert/llm/server/worker/ModelFileDownloaderTest.kt @@ -0,0 +1,550 @@ +package com.ollitert.llm.server.worker + +import com.ollitert.llm.server.data.download.ModelScopeFallback +import com.ollitert.llm.server.data.download.modelScopeFallback +import com.ollitert.llm.server.data.download.DownloadNetworkException +import com.ollitert.llm.server.data.storage.modelScopeStagingFile +import java.io.ByteArrayInputStream +import java.io.File +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.SocketTimeoutException +import java.net.URL +import javax.net.ssl.SSLHandshakeException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class ModelFileDownloaderTest { + @get:Rule val temp = TemporaryFolder() + private val primary = "https://huggingface.co/org/model/resolve/revision/model.litertlm" + private val mirrorUrl = "https://modelscope.cn/models/org/model/resolve/pinned/model.litertlm" + private val bytes = "LITERTLMtest model bytes".toByteArray() + private val hash = "b2894da5f244943816b2b1f89715a493091c6db89b9da06a2a5d20903448865c" + private val mirror = ModelScopeFallback( + mirrorUrl, bytes.size.toLong(), hash, requiresAccessConfirmation = false, + ) + private val gatedMirror = requireNotNull(modelScopeFallback( + "https://huggingface.co/litert-community/Gemma3-1B-IT/resolve/42d538a932e8d5b12e6b3b455f5572560bd60b2c/gemma3-1b-it-int4.litertlm", + )).copy(url = mirrorUrl, sizeInBytes = bytes.size.toLong(), sha256 = hash) + + private open class Response( + url: URL, + private val body: ByteArray, + private val status: Int = 200, + private val headers: Map = emptyMap(), + ) : HttpURLConnection(url) { + override fun connect() = Unit + override fun disconnect() = Unit + override fun usingProxy() = false + override fun getResponseCode() = status + override fun getHeaderField(name: String): String? = headers[name] + override fun getInputStream(): InputStream = ByteArrayInputStream(body) + } + + private fun staging(): File = File(temp.root, "model.olliterttmp") + + @Test + fun successfulPrimaryNeverContactsMirror() = runTest { + val requests = mutableListOf() + val downloader = ModelFileDownloader { url -> + requests.add(url.toString()) + Response(url, bytes) + } + val result = downloader.download(primary, staging(), mirror, { true }) + assertArrayEquals(bytes, result.readBytes()) + assertEquals(listOf(primary), requests) + } + + @Test + fun primaryUsesTheActualBearerTokenAndKeepsItOffRedirectHosts() = runTest { + lateinit var primaryRequest: Response + lateinit var cdnRequest: Response + val downloader = ModelFileDownloader { url -> + if (url.toString() == primary) { + Response(url, byteArrayOf(), 302, mapOf("Location" to "https://cdn.example.test/model")) + .also { primaryRequest = it } + } else { + Response(url, bytes).also { cdnRequest = it } + } + } + + val result = downloader.download(primary, staging(), mirror, { true }, accessToken = "test-token") + + assertArrayEquals(bytes, result.readBytes()) + assertEquals("Bearer test-token", primaryRequest.getRequestProperty("Authorization")) + assertNull(cdnRequest.getRequestProperty("Authorization")) + } + + @Test + fun huggingFaceRedirectRequestsDoNotReceivePrimaryCredentials() = runTest { + for (redirectUrl in listOf( + "https://cdn-lfs.huggingface.co/model", + "https://huggingface.co/redirected/model", + ) + ) { + lateinit var primaryRequest: Response + lateinit var redirectRequest: Response + val downloader = ModelFileDownloader { url -> + if (url.toString() == primary) { + Response(url, byteArrayOf(), 302, mapOf("Location" to redirectUrl)) + .also { primaryRequest = it } + } else { + Response(url, bytes).also { redirectRequest = it } + } + } + val staging = File(temp.newFolder(), "model.olliterttmp") + + val result = downloader.download(primary, staging, mirror, { true }, accessToken = "test-token") + + assertArrayEquals(bytes, result.readBytes()) + assertEquals("Bearer test-token", primaryRequest.getRequestProperty("Authorization")) + assertNull(redirectUrl, redirectRequest.getRequestProperty("Authorization")) + } + } + + @Test + fun redirectingBackToThePrimaryDoesNotReattachCredentials() = runTest { + val requests = mutableListOf() + val cdn = "https://cdn-lfs.huggingface.co/model" + val downloader = ModelFileDownloader { url -> + when (requests.size) { + 0 -> Response(url, byteArrayOf(), 302, mapOf("Location" to cdn)) + 1 -> Response(url, byteArrayOf(), 302, mapOf("Location" to primary)) + else -> Response(url, bytes) + }.also { requests.add(it) } + } + + val result = downloader.download(primary, staging(), mirror, { true }, accessToken = "test-token") + + assertArrayEquals(bytes, result.readBytes()) + assertEquals(listOf(primary, cdn, primary), requests.map { it.url.toString() }) + assertEquals("Bearer test-token", requests.first().getRequestProperty("Authorization")) + assertTrue(requests.drop(1).all { it.getRequestProperty("Authorization") == null }) + } + + @Test + fun successRemovesObsoleteMirrorPartialsButPreservesOtherFiles() = runTest { + val staging = staging() + val obsolete = modelScopeStagingFile(staging, "0".repeat(64)).apply { writeText("old partial") } + val legacy = File("${staging.path}.modelscope").apply { writeText("legacy partial") } + val unrelated = File(temp.root, "different.olliterttmp.${"1".repeat(64)}.modelscope") + .apply { writeText("other download") } + val finalFile = File(temp.root, "model.litertlm").apply { writeText("installed model") } + val directory = File("${staging.path}.${"2".repeat(64)}.modelscope").apply { mkdir() } + val downloader = ModelFileDownloader { url -> Response(url, bytes) } + + val result = downloader.download(primary, staging, mirror, { true }) + + assertArrayEquals(bytes, result.readBytes()) + assertFalse(obsolete.exists()) + assertFalse(legacy.exists()) + assertEquals("other download", unrelated.readText()) + assertEquals("installed model", finalFile.readText()) + assertTrue(directory.isDirectory) + } + + @Test + fun networkFailureRequiresConsentWithoutContactingMirror() = runTest { + val requests = mutableListOf() + val downloader = ModelFileDownloader { url -> + requests.add(url.toString()) + throw SocketTimeoutException("timed out") + } + try { + downloader.download(primary, staging(), mirror, { false }) + fail("Expected consent request") + } catch (failure: ModelScopeConsentRequiredException) { + assertTrue(failure.primaryError.isNotBlank()) + } + assertEquals(listOf(primary), requests) + } + + @Test + fun gatedNetworkFailureDoesNotTreatATokenOrMirrorConsentAsAccess() = runTest { + val requests = mutableListOf() + val downloader = ModelFileDownloader { url -> + requests.add(url.toString()) + throw SocketTimeoutException("no access response") + } + try { + downloader.download(primary, staging(), gatedMirror, { true }, accessToken = "unverified-token") + fail("Expected the original network failure without a mirror request") + } catch (_: DownloadNetworkException) { } + assertEquals(listOf(primary), requests) + } + + @Test + fun gatedPreflightFailureCannotSkipFreshAccessValidation() = runTest { + for (code in listOf(401, 403)) { + val requests = mutableListOf() + val downloader = ModelFileDownloader { url -> + requests.add(url.toString()) + Response(url, byteArrayOf(), code) + } + try { + downloader.download( + primary, staging(), gatedMirror, { true }, accessToken = "revoked-token", + primaryError = "an earlier request timed out", + ) + fail("Expected the current permission denial") + } catch (failure: com.ollitert.llm.server.data.download.DownloadHttpException) { + assertEquals(code, failure.status) + } + assertEquals(listOf(primary), requests) + } + } + + @Test + fun gatedTransferCanFallBackAfterReceivingAuthorizedModelBytes() = runTest { + val requests = mutableListOf() + val downloader = ModelFileDownloader { url -> + requests.add(url.toString()) + if (url.toString() == primary) { + object : Response(url, byteArrayOf()) { + override fun getInputStream(): InputStream = + object : ByteArrayInputStream(bytes.copyOf(12)) { + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (available() == 0) throw SocketTimeoutException("transfer interrupted") + return super.read(buffer, offset, length) + } + } + } + } else Response(url, bytes) + } + val result = downloader.download(primary, staging(), gatedMirror, { true }, accessToken = "valid-token") + assertArrayEquals(bytes, result.readBytes()) + assertEquals(listOf(primary, mirrorUrl), requests) + } + + @Test + fun loginHtmlIsNotEvidenceOfAuthorizedArtifactAccess() = runTest { + val requests = mutableListOf() + val downloader = ModelFileDownloader { url -> + requests.add(url.toString()) + if (url.toString() == primary) Response(url, "sign in".toByteArray()) + else Response(url, bytes) + } + try { + downloader.download(primary, staging(), gatedMirror, { true }, accessToken = "invalid-token") + fail("Expected invalid model content, not mirror fallback") + } catch (_: DownloadIntegrityException) { } + assertEquals(listOf(primary), requests) + } + + @Test + fun alternateStartsAtZeroAndNeverReceivesPrimaryCredentials() = runTest { + val staging = staging().apply { writeBytes("old primary bytes".toByteArray()) } + lateinit var mirrorRequest: Response + val downloader = ModelFileDownloader { url -> + if (url.toString() == primary) throw SocketTimeoutException("timed out") + Response(url, bytes).also { mirrorRequest = it } + } + val result = downloader.download(primary, staging, mirror, { true }, accessToken = "hf-private") + assertArrayEquals(bytes, result.readBytes()) + assertNull(mirrorRequest.getRequestProperty("Authorization")) + assertNull(mirrorRequest.getRequestProperty("Range")) + assertFalse(staging.exists()) + } + + @Test + fun mirrorRedirectDoesNotReceivePrimaryCredentials() = runTest { + lateinit var cdnRequest: Response + val downloader = ModelFileDownloader { url -> + when (url.host) { + "huggingface.co" -> throw SocketTimeoutException() + "modelscope.cn" -> Response( + url, byteArrayOf(), 302, mapOf("Location" to "https://cdn-lfs-cn-1.modelscope.cn/file"), + ) + else -> Response(url, bytes).also { cdnRequest = it } + } + } + downloader.download(primary, staging(), mirror, { true }, accessToken = "hf-private") + assertNull(cdnRequest.getRequestProperty("Authorization")) + } + + @Test + fun savedMirrorPartialResumesOnlyAgainstTheSameMirror() = runTest { + val staging = staging() + modelScopeStagingFile(staging, mirror.sha256).writeBytes(bytes.take(8).toByteArray()) + lateinit var mirrorRequest: Response + val downloader = ModelFileDownloader { url -> + if (url.toString() == primary) throw SocketTimeoutException() + Response( + url, bytes.drop(8).toByteArray(), 206, + mapOf("Content-Range" to "bytes 8-${bytes.lastIndex}/${bytes.size}"), + ).also { mirrorRequest = it } + } + val result = downloader.download(primary, staging, mirror, { true }) + assertEquals("bytes=8-", mirrorRequest.getRequestProperty("Range")) + assertArrayEquals(bytes, result.readBytes()) + } + + @Test + fun ignoredRangeRestartsInsteadOfAppending() = runTest { + val staging = staging().apply { writeBytes(bytes.take(8).toByteArray()) } + val downloader = ModelFileDownloader { url -> Response(url, bytes) } + val result = downloader.download(primary, staging, mirror, { true }) + assertArrayEquals(bytes, result.readBytes()) + } + + @Test + fun bothProviderFailuresAreReportedAndAttemptsAreBounded() = runTest { + val requests = mutableListOf() + val downloader = ModelFileDownloader { url -> + requests.add(url.toString()) + Response(url, byteArrayOf(), 503) + } + try { + downloader.download(primary, staging(), mirror, { true }) + fail("Expected both failures") + } catch (failure: ModelScopeDownloadException) { + assertTrue(failure.message.orEmpty().contains("Hugging Face")) + assertTrue(failure.message.orEmpty().contains("ModelScope")) + } + assertEquals(listOf(primary, mirrorUrl), requests) + } + + @Test + fun authenticationNotFoundAndCertificateErrorsNeverFallBack() = runTest { + for (code in listOf(401, 403, 404)) { + val requests = mutableListOf() + val downloader = ModelFileDownloader { url -> + requests.add(url.toString()) + Response(url, byteArrayOf(), code) + } + try { + downloader.download(primary, staging(), mirror, { true }) + fail("Expected HTTP failure") + } catch (_: com.ollitert.llm.server.data.download.DownloadHttpException) { } + assertEquals(listOf(primary), requests) + } + val requests = mutableListOf() + val downloader = ModelFileDownloader { url -> + requests.add(url.toString()) + throw SSLHandshakeException("certificate rejected") + } + try { + downloader.download(primary, staging(), mirror, { true }) + fail("Expected TLS failure") + } catch (_: SSLHandshakeException) { } + assertEquals(listOf(primary), requests) + } + + @Test + fun changedMirrorBytesCannotBePublishedDespiteMatchingSizeAndHeader() = runTest { + val corrupt = bytes.copyOf().apply { this[lastIndex] = 0 } + val downloader = ModelFileDownloader { url -> + if (url.toString() == primary) throw SocketTimeoutException() + Response(url, corrupt) + } + try { + downloader.download(primary, staging(), mirror, { true }) + fail("Expected checksum failure") + } catch (failure: ModelScopeDownloadException) { + assertTrue(failure.cause is DownloadIntegrityException) + } + assertFalse(modelScopeStagingFile(staging(), mirror.sha256).exists()) + } + + @Test + fun revokedConsentPreventsSkippingToTheMirror() = runTest { + val requests = mutableListOf() + val downloader = ModelFileDownloader { url -> + requests.add(url.toString()) + Response(url, bytes) + } + downloader.download(primary, staging(), mirror, { false }, primaryError = "earlier timeout") + assertEquals(listOf(primary), requests) + } + + @Test + fun approvedPreflightFailureDoesNotRepeatThePrimaryAttempt() = runTest { + val requests = mutableListOf() + val downloader = ModelFileDownloader { url -> + requests.add(url.toString()) + Response(url, bytes) + } + downloader.download(primary, staging(), mirror, { true }, primaryError = "DNS failed") + assertEquals(listOf(mirrorUrl), requests) + } + + @Test + fun cancellationNeverContactsTheAlternate() = runTest { + val requests = mutableListOf() + val downloader = ModelFileDownloader { url -> + requests.add(url.toString()) + throw CancellationException("cancelled") + } + try { + downloader.download(primary, staging(), mirror, { true }) + fail("Expected cancellation") + } catch (_: CancellationException) { } + assertEquals(listOf(primary), requests) + } + + @Test + fun failedLocalWriteNeverContactsTheAlternate() = runTest { + val requests = mutableListOf() + val downloader = ModelFileDownloader { url -> + requests.add(url.toString()) + Response(url, bytes) + } + val missingParent = File(temp.root, "missing/model.olliterttmp") + try { + downloader.download(primary, missingParent, mirror, { true }) + fail("Expected local file failure") + } catch (_: java.io.FileNotFoundException) { } + assertEquals(listOf(primary), requests) + } + + @Test + fun truncatedPrimaryFallsBackWithoutCombiningBytes() = runTest { + val downloader = ModelFileDownloader { url -> + Response(url, if (url.toString() == primary) bytes.take(10).toByteArray() else bytes) + } + val result = downloader.download(primary, staging(), mirror, { true }) + assertArrayEquals(bytes, result.readBytes()) + assertEquals(modelScopeStagingFile(staging(), mirror.sha256), result) + } + + @Test + fun invalidMirrorResumeRangeIsRejectedAndItsStagingFileRemoved() = runTest { + val staging = staging() + modelScopeStagingFile(staging, mirror.sha256).writeBytes(bytes.take(8).toByteArray()) + val downloader = ModelFileDownloader { url -> + if (url.toString() == primary) throw SocketTimeoutException() + Response(url, bytes, 206, mapOf("Content-Range" to "bytes 0-${bytes.lastIndex}/${bytes.size}")) + } + try { + downloader.download(primary, staging, mirror, { true }) + fail("Expected resume mismatch") + } catch (failure: ModelScopeDownloadException) { + assertTrue(failure.cause is DownloadIntegrityException) + } + assertFalse(modelScopeStagingFile(staging, mirror.sha256).exists()) + } + + @Test + fun completedMirrorStagingIsVerifiedBeforeReuse() = runTest { + val staging = staging() + modelScopeStagingFile(staging, mirror.sha256).writeBytes(bytes) + val requests = mutableListOf() + val downloader = ModelFileDownloader { url -> + requests.add(url.toString()) + throw SocketTimeoutException() + } + val result = downloader.download(primary, staging, mirror, { true }) + assertArrayEquals(bytes, result.readBytes()) + assertEquals(listOf(primary), requests) + } + + @Test + fun downgradeRedirectIsRejectedWithoutFallback() = runTest { + val requests = mutableListOf() + val downloader = ModelFileDownloader { url -> + requests.add(url.toString()) + Response(url, byteArrayOf(), 302, mapOf("Location" to "http://example.test/model")) + } + try { + downloader.download(primary, staging(), mirror, { true }) + fail("Expected unsafe redirect rejection") + } catch (_: java.net.ProtocolException) { } + assertEquals(listOf(primary), requests) + } + + @Test + fun changingTheMirrorArtifactNeverReusesItsOldPartialFile() = runTest { + val staging = staging() + val oldMirror = mirror.copy(url = "$mirrorUrl-old", sha256 = "0".repeat(64)) + val first = ModelFileDownloader { url -> + if (url.toString() == primary) throw SocketTimeoutException() + Response(url, bytes.take(10).toByteArray()) + } + try { + first.download(primary, staging, oldMirror, { true }) + fail("Expected incomplete old mirror") + } catch (_: ModelScopeDownloadException) { } + + lateinit var mirrorRequest: Response + val second = ModelFileDownloader { url -> + if (url.toString() == primary) throw SocketTimeoutException() + Response(url, bytes).also { mirrorRequest = it } + } + val result = second.download(primary, staging, mirror, { true }) + assertArrayEquals(bytes, result.readBytes()) + assertNull(mirrorRequest.getRequestProperty("Range")) + assertFalse(modelScopeStagingFile(staging, oldMirror.sha256).exists()) + } + + @Test + fun aCompletedMirrorPartialDoesNotAuthorizeAGatedRetry() = runTest { + val staging = staging() + val cached = modelScopeStagingFile(staging, gatedMirror.sha256).apply { writeBytes(bytes) } + val requests = mutableListOf() + val downloader = ModelFileDownloader { url -> + requests.add(url.toString()) + throw SocketTimeoutException("access unavailable") + } + + try { + downloader.download(primary, staging, gatedMirror, { true }, accessToken = "unverified-token") + fail("A saved mirror file must not bypass the current access check") + } catch (_: DownloadNetworkException) { } + + assertEquals(listOf(primary), requests) + assertArrayEquals(bytes, cached.readBytes()) + } + + @Test + fun cachedPrimaryHeaderAndAnEmptyReadDoNotEstablishCurrentAccess() = runTest { + val staging = staging().apply { writeBytes(bytes.copyOf(8)) } + val requests = mutableListOf() + val downloader = ModelFileDownloader { url -> + requests.add(url.toString()) + if (url.toString() != primary) { + Response(url, bytes) + } else { + object : Response( + url, byteArrayOf(), 206, + mapOf("Content-Range" to "bytes 8-${bytes.lastIndex}/${bytes.size}"), + ) { + override fun getInputStream(): InputStream = object : InputStream() { + private var emptyRead = false + override fun read(): Int = throw SocketTimeoutException("no data") + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (emptyRead) throw SocketTimeoutException("no data") + emptyRead = true + return 0 + } + } + } + } + } + try { + downloader.download(primary, staging, gatedMirror, { true }, accessToken = "token") + fail("No new model bytes arrived") + } catch (_: DownloadNetworkException) { } + assertEquals(listOf(primary), requests) + } + + @Test + fun primaryCredentialsNeverReachAnInsecureOrAmbiguousOrigin() = runTest { + for (origin in listOf( + "http://huggingface.co", + "https://user@huggingface.co", + "https://huggingface.co:8443", + "https://huggingface.co.example.test", + ) + ) { + lateinit var connection: Response + val downloader = ModelFileDownloader { url -> Response(url, bytes).also { connection = it } } + downloader.download("$origin/model", staging(), mirror, { true }, accessToken = "test-token") + assertNull(connection.getRequestProperty("Authorization")) + assertTrue(staging().delete()) + } + } +} diff --git a/docs/MODELS.md b/docs/MODELS.md index 8af74c5bf..ca1091635 100644 --- a/docs/MODELS.md +++ b/docs/MODELS.md @@ -3,6 +3,7 @@ ## Table of Contents - [Supported Models](#supported-models) +- [ModelScope Download Fallback](#modelscope-download-fallback) - [Which Model Should I Pick?](#which-model-should-i-pick) - [Capabilities Explained](#capabilities-explained) - [RAM Requirements](#ram-requirements) @@ -26,7 +27,62 @@ | **Qwen 2.5 1.5B** | 1.5 GB | 4K | Text | 6 GB | Good text quality for its size, longer context than Gemma 3 1B | | **DeepSeek-R1 1.5B** | 1.7 GB | 4K | Text | 6 GB | Reasoning-focused, includes chain-of-thought | -All models are downloaded from [HuggingFace](https://huggingface.co/litert-community) in `.litertlm` format and run on-device via Google's [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM) runtime. +The default models download from [HuggingFace](https://huggingface.co/litert-community) first, in `.litertlm` format, and run on-device via Google's [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM) runtime. + +## ModelScope Download Fallback + +For the seven bundled model revisions, OlliteRT can try a pinned +[ModelScope](https://modelscope.cn/) download after a Hugging Face connection +failure or HTTP 408, 429, or 5xx response. No region detection is used. + +Fallback is off by default. On an eligible failure, choose **Allow and retry** +to remember consent for future downloads. You can enable or disable +**Allow ModelScope fallback** in **Settings > Hugging Face Token**. Settings +changes use the existing Save action. + +Each download tries Hugging Face first and at most one ModelScope alternative. +For the public Gemma 4 E2B/E4B, Qwen 2.5, and DeepSeek-R1 mappings, an initial +network or temporary-server failure can switch directly to the alternative +after consent, without repeating the failed check. If both sources fail, the +model card reports both errors. The progress row and download notification +identify ModelScope while it is in use. + +The Gemma 3 1B and Gemma 3n E2B/E4B mappings are gated on Hugging Face. +A timeout, a saved token, or mirror consent does not establish access. These +models can fall back only after the current transfer has received valid +model bytes from Hugging Face or its download CDN and then fails due to a +network interruption. Retries re-establish access; previous access checks +and saved partial files cannot authorize a new mirror attempt. + +Authentication and license failures (401/403), missing files (404), certificate +errors, cancellation, and storage failures do not trigger fallback. Hugging +Face credentials are attached only to the initial Hugging Face request for +these mapped downloads, never to redirects or ModelScope. Accepting fallback +does not replace any model-license requirements. + +The ModelScope copies are community uploads. Downloads use fixed ModelScope +revisions and must match their bundled size, LiteRT-LM header, and SHA-256 +before being marked complete. This validates the selected ModelScope artifact; +it does not prove equality with the publisher's Hugging Face file or publisher +endorsement. + +Partial downloads are kept separately for each source, with ModelScope +partials also keyed by artifact checksum. Bytes from different sources or +mirror revisions are never appended together. Separate partials can +temporarily consume additional storage; the unused partial is removed when a +download completes, along with obsolete checksum-specific partials for that +same artifact. Cancellation removes the model's partial downloads. + +Saved progress prefers the Hugging Face partial when both sources have one. +A mirror-only partial is labelled ModelScope; a new primary attempt resets +progress to its own saved bytes, including zero, before connecting. + +Mappings are limited to the exact bundled Hugging Face revisions in +`ModelScopeFallback.kt`, with no query string or only `?download=true`. +A catalog entry with a new revision, another query string, a custom URL, a ZIP, +or extra data files does not inherit a fallback automatically. Endpoint +availability does not guarantee access from every network in China or +successful inference on a particular phone. ## Which Model Should I Pick? @@ -164,4 +220,3 @@ Models are stored in the app's private storage directory. You can manage them fr ## SDK Compatibility The LiteRT SDK version bundled with OlliteRT determines which models can run. Some newer models may require an app update even if they appear in a model source. See [SDK Compatibility](SDK_COMPATIBILITY.md) for the full compatibility table. - diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index af06b7bd2..019e84af2 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -86,6 +86,9 @@ The [keep-alive](FAQ.md#what-is-keep-alive--idle-unload) feature automatically u ### Model download fails +- **Hugging Face is unreachable** - For a supported bundled model, choose **Allow and retry** when offered to enable the [ModelScope fallback](MODELS.md#modelscope-download-fallback). This can also be enabled in Settings under Hugging Face Token. Fallback does not bypass authentication, model licenses, or certificate errors. +- **No fallback offered for a gated model** - Gemma 3 1B and Gemma 3n E2B/E4B must establish access during the current Hugging Face transfer. A timeout before model data arrives cannot establish permission, even with a stored token and fallback enabled. +- **ModelScope verification failed** - The downloaded file did not match its pinned size, format, or checksum. It is not accepted as a completed model; retry rather than loading the rejected file. - **Gated models** — some models require accepting a license agreement on HuggingFace before downloading. Add your HuggingFace token in Settings and accept the agreement on the model's HuggingFace page - **Network errors** — if a download fails mid-way, try again. Downloads resume from where they left off unless being cancelled by user - **No internet** — the app needs an internet connection to download models. After downloading, the app works fully offline.