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") }
+ }
+}