diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/util/GgufFileInspector.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/util/GgufFileInspector.kt new file mode 100644 index 00000000..452e795e --- /dev/null +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/util/GgufFileInspector.kt @@ -0,0 +1,79 @@ +package com.itsaky.androidide.plugins.aiassistant.util + +import android.content.ContentResolver +import android.net.Uri +import android.util.Log +import java.io.InputStream + +/** + * Best-effort GGUF validation for a SAF-selected document. + * + * Kept out of AiSettingsViewModel so the ViewModel stays focused on UI state (SRP). It is + * self-contained rather than reusing ai-core's `GgufModelInspector` because ai-assistant and + * ai-core are separate plugins with isolated classloaders — ai-assistant cannot import ai-core's + * classes, so this magic-byte check is unavoidably (and deliberately) duplicated across the + * boundary. This object is the single definition on the ai-assistant side; keep the magic in sync + * with `GgufModelInspector.GGUF_MAGIC` in ai-core if the format ever changes. + */ +internal object GgufFileInspector { + + private const val TAG = "GgufFileInspector" + + /** The GGUF magic: the four ASCII bytes a `.gguf` file starts with. */ + private val GGUF_MAGIC = "GGUF".toByteArray(Charsets.US_ASCII) + + /** + * @param header first bytes of a candidate file + * @return true if [header] starts with the GGUF magic ("GGUF") + */ + fun bytesAreGgufMagic(header: ByteArray): Boolean = + header.size >= GGUF_MAGIC.size && GGUF_MAGIC.indices.all { header[it] == GGUF_MAGIC[it] } + + /** + * Best-effort check that the picked document is a GGUF model, reading only its first four + * bytes. Fails OPEN (returns true) on any read error or short file so a real model is never + * wrongly rejected; SAF can't filter the picker by a .gguf extension. Do NOT call on the main + * thread. + * @param resolver content resolver used to open [uriString] + * @param uriString content URI (or path) of the selected document + * @return false only when four bytes were read and are not the GGUF magic; true otherwise + */ + fun looksLikeGguf(resolver: ContentResolver, uriString: String): Boolean = + looksLikeGguf( + openStream = { resolver.openInputStream(Uri.parse(uriString)) }, + onReadError = { e -> + Log.w(TAG, "Could not read model header for $uriString; skipping pre-check", e) + }, + ) + + /** + * Android-free core of [looksLikeGguf], so every fail-open path (document that can't be + * opened, stream that throws mid-read, truncated file) is unit-testable without a device. + * @param openStream opens the candidate document, or returns null if it can't be opened + * @param onReadError invoked with the failure when the stream can't be opened or read + * @return false only when four bytes were read and are not the GGUF magic; true otherwise + */ + fun looksLikeGguf(openStream: () -> InputStream?, onReadError: (Exception) -> Unit): Boolean = + try { + openStream()?.use { readsGgufMagic(it) } ?: true + } catch (e: Exception) { + onReadError(e) + true + } + + /** + * @param input stream positioned at the start of the candidate document + * @return true if the first four bytes are the GGUF magic, or if the stream ended early + */ + private fun readsGgufMagic(input: InputStream): Boolean { + val header = ByteArray(GGUF_MAGIC.size) + var off = 0 + // Read may return fewer than 4 bytes at a time, so loop until full or EOF. + while (off < header.size) { + val n = input.read(header, off, header.size - off) + if (n < 0) break + off += n + } + return if (off < header.size) true else bytesAreGgufMagic(header) + } +} diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModel.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModel.kt index be4e2b1c..56f3a4b6 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModel.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModel.kt @@ -6,6 +6,8 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.itsaky.androidide.plugins.aiassistant.security.SecureApiKeyStore +import com.itsaky.androidide.plugins.aiassistant.R +import com.itsaky.androidide.plugins.aiassistant.util.GgufFileInspector import com.itsaky.androidide.plugins.services.LlmInferenceService import com.itsaky.androidide.plugins.services.SharedServices import kotlinx.coroutines.CoroutineDispatcher @@ -379,6 +381,14 @@ class AiSettingsViewModel( // Resolve the real file name (not the raw content-URI doc id) for display. val fileName = resolveDisplayName(uriString) + // Reject a non-GGUF pick up front, so no bad path is persisted or shown as "Loaded". + if (!GgufFileInspector.looksLikeGguf(context.contentResolver, uriString)) { + _modelLoadingState.postValue( + ModelLoadingState.Error(context.getString(R.string.error_model_not_gguf, fileName)) + ) + return@launch + } + // Persist the name before the path so the savedModelPath observer can read it. saveLocalModelName(fileName) saveLocalModelPath(uriString) diff --git a/ai-assistant/src/main/res/values/strings.xml b/ai-assistant/src/main/res/values/strings.xml index 50ef9b78..fea862b9 100644 --- a/ai-assistant/src/main/res/values/strings.xml +++ b/ai-assistant/src/main/res/values/strings.xml @@ -147,4 +147,7 @@ Loading… Refresh Models Model changed to %s + + + \"%1$s\" isn\'t a valid .gguf model (it may be the wrong file or a corrupt or partial download). Select a .gguf chat model. diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/util/GgufFileInspectorTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/util/GgufFileInspectorTest.kt new file mode 100644 index 00000000..099e1bf2 --- /dev/null +++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/util/GgufFileInspectorTest.kt @@ -0,0 +1,93 @@ +package com.itsaky.androidide.plugins.aiassistant.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream + +class GgufFileInspectorTest { + + /** Captures whatever [GgufFileInspector.looksLikeGguf] reports, standing in for Log.w. */ + private var reportedError: Exception? = null + + private fun looksLikeGguf(openStream: () -> InputStream?): Boolean = + GgufFileInspector.looksLikeGguf(openStream) { reportedError = it } + + @Test + fun givenGgufMagic_whenChecked_thenAccepted() { + val header = byteArrayOf(0x47, 0x47, 0x55, 0x46) // "GGUF" + assertTrue(GgufFileInspector.bytesAreGgufMagic(header)) + } + + @Test + fun givenMagicWithTrailingBytes_whenChecked_thenAccepted() { + val header = byteArrayOf(0x47, 0x47, 0x55, 0x46, 0x03, 0x00) + assertTrue(GgufFileInspector.bytesAreGgufMagic(header)) + } + + @Test + fun givenZipMagic_whenChecked_thenRejected() { + val header = byteArrayOf(0x50, 0x4B, 0x03, 0x04) // "PK.." — a .zip renamed to .gguf + assertFalse(GgufFileInspector.bytesAreGgufMagic(header)) + } + + @Test + fun givenFewerThanFourBytes_whenChecked_thenRejected() { + assertFalse(GgufFileInspector.bytesAreGgufMagic(byteArrayOf(0x47, 0x47, 0x55))) + } + + @Test + fun givenGgufStream_whenInspected_thenAccepted() { + assertTrue(looksLikeGguf { ByteArrayInputStream(byteArrayOf(0x47, 0x47, 0x55, 0x46, 0x03)) }) + assertNull(reportedError) + } + + @Test + fun givenNonGgufStream_whenInspected_thenRejected() { + assertFalse(looksLikeGguf { ByteArrayInputStream(byteArrayOf(0x50, 0x4B, 0x03, 0x04)) }) + assertNull(reportedError) + } + + @Test + fun givenStreamThatThrowsMidRead_whenInspected_thenFailsOpenAndReports() { + // SAF can drop a content:// stream mid-transfer; a real model must not be rejected for it. + val truncating = object : InputStream() { + private var served = 0 + override fun read(): Int = throw IOException("stream died") + override fun read(b: ByteArray, off: Int, len: Int): Int { + if (served > 0) throw IOException("stream died") + served = 2 + b[off] = 0x47 + b[off + 1] = 0x47 + return 2 + } + } + + assertTrue(looksLikeGguf { truncating }) + assertEquals("stream died", reportedError?.message) + } + + @Test + fun givenUnopenableDocument_whenInspected_thenFailsOpenAndReports() { + assertTrue(looksLikeGguf { throw SecurityException("permission revoked") }) + assertEquals("permission revoked", reportedError?.message) + } + + @Test + fun givenNullStream_whenInspected_thenFailsOpenWithoutError() { + // openInputStream() returns null for a provider that can't produce the document. + assertTrue(looksLikeGguf { null }) + assertNull(reportedError) + } + + @Test + fun givenTruncatedStream_whenInspected_thenFailsOpen() { + // Fewer than four bytes available: not enough to judge, so don't reject. + assertTrue(looksLikeGguf { ByteArrayInputStream(byteArrayOf(0x47, 0x47)) }) + assertNull(reportedError) + } +} diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/ByteSize.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/ByteSize.kt new file mode 100644 index 00000000..cbdd0acf --- /dev/null +++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/ByteSize.kt @@ -0,0 +1,30 @@ +package com.itsaky.androidide.plugins.aicore + +import java.util.Locale + +/** + * Formats byte counts for display. + * + * Deliberately binary (÷1024³) and US-locale rather than + * `android.text.format.Formatter.formatShortFileSize()`, which reports decimal (SI) units: these + * figures describe device RAM, which every Android surface (Settings, `ActivityManager`, spec + * sheets) reports in binary units, so a decimal "8.6 GB" for 8 GiB of RAM would read as wrong. + * Kept out of the backend so the engine has no formatting logic and this stays unit-testable. + */ +internal object ByteSize { + + private const val BYTES_PER_MB = 1024.0 * 1024.0 + private const val BYTES_PER_GB = BYTES_PER_MB * 1024.0 + + /** + * Formats [bytes] with the largest unit that keeps the figure meaningful. Sub-gigabyte values + * fall back to MB: a device with 12 MB free must not be described as having "0.0 GB", which + * reads as a broken string rather than as a memory shortage. + * + * @param bytes a byte count + * @return the size as a one-decimal "X.X GB" or "X.X MB" string + */ + fun format(bytes: Long): String = + if (bytes >= BYTES_PER_GB) String.format(Locale.US, "%.1f GB", bytes / BYTES_PER_GB) + else String.format(Locale.US, "%.1f MB", bytes / BYTES_PER_MB) +} diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GgufModelInspector.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GgufModelInspector.kt index 5a061469..8114784f 100644 --- a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GgufModelInspector.kt +++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GgufModelInspector.kt @@ -51,6 +51,17 @@ object GgufModelInspector { val isEmbeddingOnly: Boolean get() = kind == ModelKind.EMBEDDING } + /** + * Cheap magic-only check that never throws; reads just the first 4 bytes. + * @param modelPath path to the candidate file + * @return true if the file begins with the GGUF magic; false on any read error or mismatch + */ + fun isGguf(modelPath: String): Boolean = try { + DataInputStream(BufferedInputStream(FileInputStream(File(modelPath)), 16)).use { readU32(it) == GGUF_MAGIC } + } catch (_: Exception) { + false + } + /** Reads [modelPath]'s GGUF header and classifies it. Never throws. */ fun classify(modelPath: String): Result { val arch = try { diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LlmInferenceServiceImpl.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LlmInferenceServiceImpl.kt index a676a171..e58c3237 100644 --- a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LlmInferenceServiceImpl.kt +++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LlmInferenceServiceImpl.kt @@ -130,8 +130,8 @@ class LlmInferenceServiceImpl : LlmInferenceService { return } - // Delegate to Gemini backend with tool support - (backend as GeminiBackend).generateStreamingWithTools(prompt, history, config, tools, callback) + // Delegate to Gemini backend with tool support (smart-cast by the guard above) + backend.generateStreamingWithTools(prompt, history, config, tools, callback) } override fun getEmbeddings(text: String, backendId: String): CompletableFuture { diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LocalLlmBackend.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LocalLlmBackend.kt index 61bc01f0..459bf513 100644 --- a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LocalLlmBackend.kt +++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LocalLlmBackend.kt @@ -1,5 +1,7 @@ package com.itsaky.androidide.plugins.aicore +import android.app.ActivityManager +import android.content.Context import android.llama.cpp.LLamaAndroid import android.net.Uri import android.provider.OpenableColumns @@ -61,6 +63,9 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend, Cancella @Volatile private var currentStreamingJob: Job? = null @Volatile private var currentGenerateJob: Job? = null + /** Renders load diagnoses as user-facing text; keeps R.string lookups out of this engine. */ + private val loadMessages by lazy { ModelLoadMessages(context.androidContext) } + @Volatile private var modelLoaded = false @Volatile private var currentModelPath: String? = null @@ -212,6 +217,13 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend, Cancella } } + /** + * Loads [modelPath] unless it is already resident, diagnosing any native failure into a + * [ModelLoadException]. Cancellation is rethrown first because [CancellationException] extends + * [IllegalStateException] and would otherwise be diagnosed as a corrupt model. + * + * @param modelPath the configured model path or content URI + */ private suspend fun ensureModelLoaded(modelPath: String) { // Resolve content URI to actual file path val resolvedPath = resolveContentUriToPath(modelPath) @@ -242,14 +254,36 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend, Cancella currentModelPath = null } - // Load new model with resolved path context.logger.info("Loading model: $resolvedPath") - llama.load(resolvedPath) + try { + llama.load(resolvedPath) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (e is UserActionableLlmException) throw e + // Native load_model() signals failure only with a null handle, so diagnose the likely cause. + context.logger.error("Native model load failed for $resolvedPath", e) + val diagnosis = ModelLoadDiagnostics.diagnose(resolvedPath, availableMemoryBytes(), e.message) + throw ModelLoadException(loadMessages.describe(diagnosis), diagnosis) + } modelLoaded = true currentModelPath = resolvedPath context.logger.info("Model loaded successfully") } + /** + * @return free RAM the OS reports, or -1 if unreadable (diagnosis then skips the low-memory case) + */ + private fun availableMemoryBytes(): Long = try { + val am = context.androidContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val info = ActivityManager.MemoryInfo() + am.getMemoryInfo(info) + info.availMem + } catch (e: Exception) { + context.logger.warn("Could not read available memory: ${e.message}") + -1L + } + /** * Wraps a turn in ChatML — the prompt format Qwen and most other on-device instruct GGUFs * were fine-tuned on. @@ -369,7 +403,7 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend, Cancella throw ce } catch (e: Exception) { context.logger.error("Error during generation", e) - if (e is ModelNotConfiguredException || e is IncompatibleModelException) { + if (e is UserActionableLlmException) { UserFeedback.notify(context.androidContext, e.message ?: "Local LLM is not configured.") } future.complete(LlmResponse.failure("Error: ${e.message}")) @@ -474,7 +508,7 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend, Cancella throw ce } catch (e: Exception) { context.logger.error("Error during streaming generation", e) - if (e is ModelNotConfiguredException || e is IncompatibleModelException) { + if (e is UserActionableLlmException) { UserFeedback.notify(context.androidContext, e.message ?: "Local LLM is not configured.") } callback.onError("Error: ${e.message}") diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/ModelLoadDiagnostics.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/ModelLoadDiagnostics.kt new file mode 100644 index 00000000..881112d5 --- /dev/null +++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/ModelLoadDiagnostics.kt @@ -0,0 +1,84 @@ +package com.itsaky.androidide.plugins.aicore + +import java.io.File + +/** + * Classifies why a native model load failed, as a pure function of the file, free memory, and the + * native loader's error text, so it is unit-testable off-device. The caller maps the result to a + * user-facing message from strings.xml (this object holds no Context and no display text). + * See ADFA "Better error messages …". + */ +object ModelLoadDiagnostics { + + /** Headroom floor: a small model still needs a KV cache, which scales with context, not size. */ + private const val MIN_HEADROOM_BYTES = 256L * 1024 * 1024 + + /** Most likely cause of a load failure; the caller resolves each case to a user-facing string. */ + sealed interface Diagnosis { + data object FileMissing : Diagnosis + data object FileEmpty : Diagnosis + data object NotGguf : Diagnosis + /** + * @param neededBytes the free-RAM headroom the check required (see [diagnose]) + * @param availableBytes free RAM the OS reported + */ + data class LowMemory(val neededBytes: Long, val availableBytes: Long) : Diagnosis + + /** A model is already resident in the process-global run loop (a state issue, not the file). */ + data object ModelBusy : Diagnosis + + /** The file is a valid GGUF but the loader couldn't allocate its context/buffers (memory pressure). */ + data object InitializationFailed : Diagnosis + + /** The file is a valid GGUF but the loader rejected the weights themselves (format/quantization/corruption). */ + data object UnsupportedOrCorrupt : Diagnosis + } + + /** + * Weights are mmap'd, so the file need not fit in free RAM — only the KV cache and compute + * buffers must be resident. The memory check therefore tests a conservative headroom, because + * overestimating it would blame a corrupt model on memory and send users chasing smaller files. + * + * @param modelPath resolved filesystem path the native loader was handed + * @param availableMemoryBytes free RAM reported by the OS, or negative if unknown + * @param nativeError the load failure's message text, or null when unavailable + * @return the most likely cause of the load failure + */ + fun diagnose(modelPath: String, availableMemoryBytes: Long, nativeError: String? = null): Diagnosis { + val file = File(modelPath) + if (!file.exists()) return Diagnosis.FileMissing + + val sizeBytes = file.length() + if (sizeBytes <= 0L) return Diagnosis.FileEmpty + if (!GgufModelInspector.isGguf(modelPath)) return Diagnosis.NotGguf + + // "Already loaded" is a run-loop state problem, not a file or memory one, so report it + // before the memory heuristic — otherwise a busy loop is mis-reported as low memory. + if (indicatesModelBusy(nativeError)) return Diagnosis.ModelBusy + + // Headroom only: a fraction of the file, floored for KV-cache-dominated small models. + val requiredHeadroomBytes = maxOf(MIN_HEADROOM_BYTES, sizeBytes / 4) + // Only a NEGATIVE reading means "unknown"; 0 is a genuine out-of-memory reading. + if (availableMemoryBytes in 0L until requiredHeadroomBytes) { + return Diagnosis.LowMemory(requiredHeadroomBytes, availableMemoryBytes) + } + + // The file is a valid GGUF and RAM looks sufficient, so trust the native failure reason: + // an allocation failure points at memory pressure, anything else at an unsupported/corrupt model. + return if (indicatesInitFailure(nativeError)) Diagnosis.InitializationFailed + else Diagnosis.UnsupportedOrCorrupt + } + + // The markers below mirror the messages thrown by LLamaAndroid.load(); keep them in sync with + // that file. Matching on text is best-effort — an unrecognized message falls back to + // UnsupportedOrCorrupt, which is the safe default for a valid-looking file. + private fun indicatesModelBusy(nativeError: String?): Boolean = + nativeError?.contains("already loaded", ignoreCase = true) == true + + private fun indicatesInitFailure(nativeError: String?): Boolean { + val error = nativeError ?: return false + return error.contains("new_context", ignoreCase = true) || + error.contains("new_batch", ignoreCase = true) || + error.contains("new_sampler", ignoreCase = true) + } +} diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/ModelLoadMessages.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/ModelLoadMessages.kt new file mode 100644 index 00000000..ab70d3e6 --- /dev/null +++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/ModelLoadMessages.kt @@ -0,0 +1,34 @@ +package com.itsaky.androidide.plugins.aicore + +import android.content.Context +import com.itsaky.androidide.plugins.aicore.ModelLoadDiagnostics.Diagnosis + +/** + * Presentation layer for [ModelLoadDiagnostics]: renders a [Diagnosis] as user-facing text. + * + * Split out of [LocalLlmBackend] so the inference engine holds no Android string resources — it + * classifies and throws, this class decides wording. Because a [ModelLoadException] crosses the + * plugin boundary (consumers live in other plugins with isolated classloaders and cannot see + * [Diagnosis]), the text has to be resolved on this side of the boundary; the exception still + * carries the structured [Diagnosis] so any in-module caller can re-render it differently. + */ +internal class ModelLoadMessages(private val context: Context) { + + /** + * @param diagnosis the classified cause of a load failure + * @return display-ready text explaining the failure and what the user can do about it + */ + fun describe(diagnosis: Diagnosis): String = when (diagnosis) { + Diagnosis.FileMissing -> context.getString(R.string.llm_load_error_missing) + Diagnosis.FileEmpty -> context.getString(R.string.llm_load_error_empty) + Diagnosis.NotGguf -> context.getString(R.string.llm_load_error_not_gguf) + is Diagnosis.LowMemory -> context.getString( + R.string.llm_load_error_low_memory, + ByteSize.format(diagnosis.neededBytes), + ByteSize.format(diagnosis.availableBytes), + ) + Diagnosis.ModelBusy -> context.getString(R.string.llm_load_error_busy) + Diagnosis.InitializationFailed -> context.getString(R.string.llm_load_error_runtime) + Diagnosis.UnsupportedOrCorrupt -> context.getString(R.string.llm_load_error_unsupported) + } +} diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/UserFeedback.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/UserFeedback.kt index 078537b6..26e8ea2e 100644 --- a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/UserFeedback.kt +++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/UserFeedback.kt @@ -46,17 +46,37 @@ object UserFeedback { } /** - * Thrown when generation can't proceed because the local LLM isn't set up (no model selected, or - * the configured model path can't be resolved). Distinct from a runtime generation failure so the - * backend can surface it to the user instead of failing silently. The [message] is written to be - * shown directly to the user. + * Base type for LLM failures the user can act on; backends catch this one type to route it to + * [UserFeedback], so a new actionable subtype surfaces without touching the catch sites. + * @param message user-facing, display-ready text */ -class ModelNotConfiguredException(message: String) : IllegalStateException(message) +sealed class UserActionableLlmException(message: String) : IllegalStateException(message) /** - * Thrown when the selected local model is the wrong *kind* for the requested operation — most - * commonly an embedding (encoder-only) model selected for chat, which would abort native - * inference. Like [ModelNotConfiguredException] the [message] is written for direct display, and - * the backend surfaces it to the user instead of attempting generation. See ADFA-4388. + * Thrown when the local LLM isn't set up (no model selected, or the path can't be resolved), so the + * backend can surface it to the user instead of failing silently. + * @param message user-facing, display-ready text */ -class IncompatibleModelException(message: String) : IllegalStateException(message) +class ModelNotConfiguredException(message: String) : UserActionableLlmException(message) + +/** + * Thrown when the selected model is the wrong kind for the request (e.g. an embedding model for + * chat, which would abort native inference). See ADFA-4388. + * @param message user-facing, display-ready text + */ +class IncompatibleModelException(message: String) : UserActionableLlmException(message) + +/** + * Thrown when the native loader rejects the selected .gguf. + * + * [message] is display-ready text because the exception crosses the plugin boundary, where + * consumers cannot see [ModelLoadDiagnostics.Diagnosis]; [diagnosis] carries the structured cause + * so callers inside this plugin can branch on it or render it their own way instead of parsing + * the text. + * @param message user-facing, display-ready text (see [ModelLoadMessages]) + * @param diagnosis the classified cause, or null if the failure wasn't classified + */ +class ModelLoadException( + message: String, + val diagnosis: ModelLoadDiagnostics.Diagnosis? = null, +) : UserActionableLlmException(message) diff --git a/ai-core/src/main/res/values/strings.xml b/ai-core/src/main/res/values/strings.xml new file mode 100644 index 00000000..9c894370 --- /dev/null +++ b/ai-core/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + + + The model file could not be found. Re-select the .gguf model in AI Settings. + The model file is empty — the download may have been interrupted. Re-download the .gguf model and select it again. + This file isn\'t a valid .gguf model (it may be corrupt or only partially downloaded). Re-download the model and select it again. + Loading this model needs at least %1$s of free memory, but only %2$s is available on this device. Close other apps and try again, or pick a smaller or more heavily quantized model (for example a Q4_K_M build of a 1–3B model). + The model couldn\'t be initialized, most likely because too little memory is free right now. Close other apps and try again, or pick a smaller or more heavily quantized model. + Another model is still loading or in use. Wait a moment and try again. + The model couldn\'t be loaded. It may use a format or quantization this build doesn\'t support, or the file may be corrupt. Try a different .gguf — a Q4_K_M quantization of a smaller model is most likely to work. + diff --git a/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/ByteSizeTest.kt b/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/ByteSizeTest.kt new file mode 100644 index 00000000..adaeb700 --- /dev/null +++ b/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/ByteSizeTest.kt @@ -0,0 +1,42 @@ +package com.itsaky.androidide.plugins.aicore + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ByteSizeTest { + + @Test + fun givenExactGibibyte_whenFormatted_thenBinaryUnits() { + // Binary, not SI: 1 GiB must read as "1.0 GB", not "1.1 GB". + assertEquals("1.0 GB", ByteSize.format(1L shl 30)) + } + + @Test + fun givenMultipleGibibytes_whenFormatted_thenOneDecimal() { + assertEquals("4.5 GB", ByteSize.format((4.5 * (1L shl 30)).toLong())) + } + + @Test + fun givenZero_whenFormatted_thenZeroMb() { + // The 0-free-RAM diagnosis renders this, so it must not produce "-0.0" or an empty string. + assertEquals("0.0 MB", ByteSize.format(0L)) + } + + @Test + fun givenSubGigabyte_whenFormatted_thenMegabytes() { + // Below 1 GiB the value switches to MB rather than collapsing to a useless "0.5 GB"/"0.0 GB". + assertEquals("512.0 MB", ByteSize.format(1L shl 29)) + } + + @Test + fun givenJustUnderOneGibibyte_whenFormatted_thenStillMegabytes() { + // Boundary: only >= 1 GiB promotes to GB. + assertEquals("1023.0 MB", ByteSize.format((1L shl 30) - (1L shl 20))) + } + + @Test + fun givenSubMegabyte_whenFormatted_thenFractionalMegabytes() { + // A near-exhausted device: must still read as a small number, not "0.0 GB". + assertEquals("0.5 MB", ByteSize.format(512L shl 10)) + } +} diff --git a/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/ModelLoadDiagnosticsTest.kt b/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/ModelLoadDiagnosticsTest.kt new file mode 100644 index 00000000..614e27fe --- /dev/null +++ b/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/ModelLoadDiagnosticsTest.kt @@ -0,0 +1,139 @@ +package com.itsaky.androidide.plugins.aicore + +import com.itsaky.androidide.plugins.aicore.ModelLoadDiagnostics.Diagnosis +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class ModelLoadDiagnosticsTest { + + private fun tempFile(bytes: Int, magic: Boolean = true): File = + File.createTempFile("model", ".gguf").apply { + deleteOnExit() + outputStream().use { out -> + if (magic && bytes >= 4) { + out.write(byteArrayOf(0x47, 0x47, 0x55, 0x46)) // "GGUF" + out.write(ByteArray(bytes - 4)) + } else { + out.write(ByteArray(bytes)) + } + } + } + + @Test + fun givenMissingFile_whenDiagnosed_thenFileMissing() { + val d = ModelLoadDiagnostics.diagnose("/does/not/exist.gguf", availableMemoryBytes = 8L shl 30) + assertEquals(Diagnosis.FileMissing, d) + } + + @Test + fun givenEmptyFile_whenDiagnosed_thenFileEmpty() { + val d = ModelLoadDiagnostics.diagnose(tempFile(0).absolutePath, availableMemoryBytes = 8L shl 30) + assertEquals(Diagnosis.FileEmpty, d) + } + + @Test + fun givenNonGgufContent_whenDiagnosed_thenNotGguf() { + val d = ModelLoadDiagnostics.diagnose(tempFile(2048, magic = false).absolutePath, availableMemoryBytes = 8L shl 30) + assertEquals(Diagnosis.NotGguf, d) + } + + @Test + fun givenValidGgufAndLowMemory_whenDiagnosed_thenLowMemory() { + // 1 MB "model", only 512 KB free -> below the headroom floor. + val d = ModelLoadDiagnostics.diagnose(tempFile(1 shl 20).absolutePath, availableMemoryBytes = 512L shl 10) + assertTrue(d is Diagnosis.LowMemory) + // neededBytes reports the headroom that tripped the check; here the 256 MB floor dominates. + assertEquals(256L shl 20, (d as Diagnosis.LowMemory).neededBytes) + } + + @Test + fun givenLargeModelAndHeadroomBelowFileSize_whenDiagnosed_thenNotLowMemory() { + // Guards the mmap property: free RAM under the file size is not itself a shortage. + val d = ModelLoadDiagnostics.diagnose( + tempFile(1 shl 20).absolutePath, + availableMemoryBytes = 512L shl 20, // 512 MB free, above the floor + ) + assertEquals(Diagnosis.UnsupportedOrCorrupt, d) + } + + @Test + fun givenValidGgufAndAmpleMemory_whenDiagnosed_thenUnsupportedOrCorrupt() { + val d = ModelLoadDiagnostics.diagnose(tempFile(1 shl 20).absolutePath, availableMemoryBytes = 8L shl 30) + assertEquals(Diagnosis.UnsupportedOrCorrupt, d) + } + + @Test + fun givenUnknownMemory_whenDiagnosed_thenNotLowMemory() { + // availMem < 0 (unreadable) must not be treated as "no memory". + val d = ModelLoadDiagnostics.diagnose(tempFile(1 shl 20).absolutePath, availableMemoryBytes = -1L) + assertEquals(Diagnosis.UnsupportedOrCorrupt, d) + } + + @Test + fun givenZeroFreeMemory_whenDiagnosed_thenLowMemory() { + // 0 free bytes is a genuine out-of-memory reading (only a negative value means "unknown"), + // so it must classify as low memory rather than falling through to unsupported/corrupt. + val d = ModelLoadDiagnostics.diagnose(tempFile(1 shl 20).absolutePath, availableMemoryBytes = 0L) + assertTrue(d is Diagnosis.LowMemory) + assertEquals(0L, (d as Diagnosis.LowMemory).availableBytes) + } + + @Test + fun givenAlreadyLoadedError_whenDiagnosed_thenModelBusy() { + // A valid file with ample RAM but the run loop is busy must not be blamed on the file. + val d = ModelLoadDiagnostics.diagnose( + tempFile(1 shl 20).absolutePath, + availableMemoryBytes = 8L shl 30, + nativeError = "Model already loaded", + ) + assertEquals(Diagnosis.ModelBusy, d) + } + + @Test + fun givenAlreadyLoadedErrorAndLowMemory_whenDiagnosed_thenModelBusyWinsOverMemory() { + // "Already loaded" is a state issue, so it outranks the low-memory heuristic. + val d = ModelLoadDiagnostics.diagnose( + tempFile(1 shl 20).absolutePath, + availableMemoryBytes = 512L shl 10, + nativeError = "Model already loaded", + ) + assertEquals(Diagnosis.ModelBusy, d) + } + + @Test + fun givenContextAllocError_whenDiagnosed_thenInitializationFailed() { + // A valid file with ample RAM that still fails to allocate its context is memory pressure, + // not a corrupt file. + val d = ModelLoadDiagnostics.diagnose( + tempFile(1 shl 20).absolutePath, + availableMemoryBytes = 8L shl 30, + nativeError = "new_context() failed", + ) + assertEquals(Diagnosis.InitializationFailed, d) + } + + @Test + fun givenUnrecognizedError_whenDiagnosed_thenUnsupportedOrCorrupt() { + // An unknown native message on a valid-looking file falls back to the safe default. + val d = ModelLoadDiagnostics.diagnose( + tempFile(1 shl 20).absolutePath, + availableMemoryBytes = 8L shl 30, + nativeError = "something unexpected", + ) + assertEquals(Diagnosis.UnsupportedOrCorrupt, d) + } + + @Test + fun givenGgufMagic_whenIsGguf_thenTrue() { + assertTrue(GgufModelInspector.isGguf(tempFile(64).absolutePath)) + } + + @Test + fun givenNonGgufContent_whenIsGguf_thenFalse() { + assertFalse(GgufModelInspector.isGguf(tempFile(64, magic = false).absolutePath)) + assertFalse(GgufModelInspector.isGguf("/does/not/exist.gguf")) + } +} diff --git a/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/ModelLoadMessagesTest.kt b/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/ModelLoadMessagesTest.kt new file mode 100644 index 00000000..0474fae8 --- /dev/null +++ b/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/ModelLoadMessagesTest.kt @@ -0,0 +1,78 @@ +package com.itsaky.androidide.plugins.aicore + +import android.content.Context +import com.itsaky.androidide.plugins.aicore.ModelLoadDiagnostics.Diagnosis +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Guards the diagnosis → string-resource routing that used to live in LocalLlmBackend. Each case + * must reach its own message; a copy/paste slip here would tell the user the wrong fix. + */ +class ModelLoadMessagesTest { + + private val context = mockk(relaxed = true) + private val messages = ModelLoadMessages(context) + + @Test + fun givenFileMissing_whenDescribed_thenMissingString() { + messages.describe(Diagnosis.FileMissing) + verify { context.getString(R.string.llm_load_error_missing) } + } + + @Test + fun givenFileEmpty_whenDescribed_thenEmptyString() { + messages.describe(Diagnosis.FileEmpty) + verify { context.getString(R.string.llm_load_error_empty) } + } + + @Test + fun givenNotGguf_whenDescribed_thenNotGgufString() { + messages.describe(Diagnosis.NotGguf) + verify { context.getString(R.string.llm_load_error_not_gguf) } + } + + @Test + fun givenModelBusy_whenDescribed_thenBusyString() { + messages.describe(Diagnosis.ModelBusy) + verify { context.getString(R.string.llm_load_error_busy) } + } + + @Test + fun givenInitializationFailed_whenDescribed_thenRuntimeString() { + messages.describe(Diagnosis.InitializationFailed) + verify { context.getString(R.string.llm_load_error_runtime) } + } + + @Test + fun givenUnsupportedOrCorrupt_whenDescribed_thenUnsupportedString() { + messages.describe(Diagnosis.UnsupportedOrCorrupt) + verify { context.getString(R.string.llm_load_error_unsupported) } + } + + @Test + fun givenLowMemory_whenDescribed_thenNeedBeforeAvailableInBinaryGb() { + // Argument order matters: "needs %1$s but only %2$s is free" reads backwards if swapped. + messages.describe(Diagnosis.LowMemory(neededBytes = 3L shl 30, availableBytes = 1L shl 30)) + verify { context.getString(R.string.llm_load_error_low_memory, "3.0 GB", "1.0 GB") } + } + + @Test + fun givenZeroFreeMemory_whenDescribed_thenReportsZeroInMegabytes() { + // 0 free bytes must reach the user as "0.0 MB"; "0.0 GB" would read as a formatting bug. + every { context.getString(R.string.llm_load_error_low_memory, any(), any()) } returns "low" + assertEquals("low", messages.describe(Diagnosis.LowMemory(neededBytes = 1L shl 30, availableBytes = 0L))) + verify { context.getString(R.string.llm_load_error_low_memory, "1.0 GB", "0.0 MB") } + } + + @Test + fun givenHeadroomFloor_whenDescribed_thenNeedRendersInMegabytes() { + // The 256 MB floor is the figure users see most often; it must not collapse to "0.2 GB". + every { context.getString(R.string.llm_load_error_low_memory, any(), any()) } returns "low" + messages.describe(Diagnosis.LowMemory(neededBytes = 256L shl 20, availableBytes = 12L shl 20)) + verify { context.getString(R.string.llm_load_error_low_memory, "256.0 MB", "12.0 MB") } + } +}