Skip to content

Commit c2ac2eb

Browse files
committed
feat(ai-plugins): add actionable feedback for local model load failures
Refactors model loading to diagnose errors (e.g., low memory, invalid format) and provide clear, user-facing messages.
1 parent c87f6a2 commit c2ac2eb

15 files changed

Lines changed: 684 additions & 16 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package com.itsaky.androidide.plugins.aiassistant.util
2+
3+
import android.content.ContentResolver
4+
import android.net.Uri
5+
import android.util.Log
6+
import java.io.InputStream
7+
8+
/**
9+
* Best-effort GGUF validation for a SAF-selected document.
10+
*
11+
* Kept out of AiSettingsViewModel so the ViewModel stays focused on UI state (SRP). It is
12+
* self-contained rather than reusing ai-core's `GgufModelInspector` because ai-assistant and
13+
* ai-core are separate plugins with isolated classloaders — ai-assistant cannot import ai-core's
14+
* classes, so this magic-byte check is unavoidably (and deliberately) duplicated across the
15+
* boundary. This object is the single definition on the ai-assistant side; keep the magic in sync
16+
* with `GgufModelInspector.GGUF_MAGIC` in ai-core if the format ever changes.
17+
*/
18+
internal object GgufFileInspector {
19+
20+
private const val TAG = "GgufFileInspector"
21+
22+
/** The GGUF magic: the four ASCII bytes a `.gguf` file starts with. */
23+
private val GGUF_MAGIC = "GGUF".toByteArray(Charsets.US_ASCII)
24+
25+
/**
26+
* @param header first bytes of a candidate file
27+
* @return true if [header] starts with the GGUF magic ("GGUF")
28+
*/
29+
fun bytesAreGgufMagic(header: ByteArray): Boolean =
30+
header.size >= GGUF_MAGIC.size && GGUF_MAGIC.indices.all { header[it] == GGUF_MAGIC[it] }
31+
32+
/**
33+
* Best-effort check that the picked document is a GGUF model, reading only its first four
34+
* bytes. Fails OPEN (returns true) on any read error or short file so a real model is never
35+
* wrongly rejected; SAF can't filter the picker by a .gguf extension. Do NOT call on the main
36+
* thread.
37+
* @param resolver content resolver used to open [uriString]
38+
* @param uriString content URI (or path) of the selected document
39+
* @return false only when four bytes were read and are not the GGUF magic; true otherwise
40+
*/
41+
fun looksLikeGguf(resolver: ContentResolver, uriString: String): Boolean =
42+
looksLikeGguf(
43+
openStream = { resolver.openInputStream(Uri.parse(uriString)) },
44+
onReadError = { e ->
45+
Log.w(TAG, "Could not read model header for $uriString; skipping pre-check", e)
46+
},
47+
)
48+
49+
/**
50+
* Android-free core of [looksLikeGguf], so every fail-open path (document that can't be
51+
* opened, stream that throws mid-read, truncated file) is unit-testable without a device.
52+
* @param openStream opens the candidate document, or returns null if it can't be opened
53+
* @param onReadError invoked with the failure when the stream can't be opened or read
54+
* @return false only when four bytes were read and are not the GGUF magic; true otherwise
55+
*/
56+
fun looksLikeGguf(openStream: () -> InputStream?, onReadError: (Exception) -> Unit): Boolean =
57+
try {
58+
openStream()?.use { readsGgufMagic(it) } ?: true
59+
} catch (e: Exception) {
60+
onReadError(e)
61+
true
62+
}
63+
64+
/**
65+
* @param input stream positioned at the start of the candidate document
66+
* @return true if the first four bytes are the GGUF magic, or if the stream ended early
67+
*/
68+
private fun readsGgufMagic(input: InputStream): Boolean {
69+
val header = ByteArray(GGUF_MAGIC.size)
70+
var off = 0
71+
// Read may return fewer than 4 bytes at a time, so loop until full or EOF.
72+
while (off < header.size) {
73+
val n = input.read(header, off, header.size - off)
74+
if (n < 0) break
75+
off += n
76+
}
77+
return if (off < header.size) true else bytesAreGgufMagic(header)
78+
}
79+
}

ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModel.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import androidx.lifecycle.MutableLiveData
66
import androidx.lifecycle.ViewModel
77
import androidx.lifecycle.viewModelScope
88
import com.itsaky.androidide.plugins.aiassistant.security.SecureApiKeyStore
9+
import com.itsaky.androidide.plugins.aiassistant.R
10+
import com.itsaky.androidide.plugins.aiassistant.util.GgufFileInspector
911
import com.itsaky.androidide.plugins.services.LlmInferenceService
1012
import com.itsaky.androidide.plugins.services.SharedServices
1113
import kotlinx.coroutines.CoroutineDispatcher
@@ -379,6 +381,14 @@ class AiSettingsViewModel(
379381
// Resolve the real file name (not the raw content-URI doc id) for display.
380382
val fileName = resolveDisplayName(uriString)
381383

384+
// Reject a non-GGUF pick up front, so no bad path is persisted or shown as "Loaded".
385+
if (!GgufFileInspector.looksLikeGguf(context.contentResolver, uriString)) {
386+
_modelLoadingState.postValue(
387+
ModelLoadingState.Error(context.getString(R.string.error_model_not_gguf, fileName))
388+
)
389+
return@launch
390+
}
391+
382392
// Persist the name before the path so the savedModelPath observer can read it.
383393
saveLocalModelName(fileName)
384394
saveLocalModelPath(uriString)

ai-assistant/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,7 @@
147147
<string name="loading">Loading…</string>
148148
<string name="refresh_models">Refresh Models</string>
149149
<string name="model_changed">Model changed to %s</string>
150+
151+
<!-- Local model selection -->
152+
<string name="error_model_not_gguf">\"%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.</string>
150153
</resources>
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.itsaky.androidide.plugins.aiassistant.util
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertFalse
5+
import org.junit.Assert.assertNull
6+
import org.junit.Assert.assertTrue
7+
import org.junit.Test
8+
import java.io.ByteArrayInputStream
9+
import java.io.IOException
10+
import java.io.InputStream
11+
12+
class GgufFileInspectorTest {
13+
14+
/** Captures whatever [GgufFileInspector.looksLikeGguf] reports, standing in for Log.w. */
15+
private var reportedError: Exception? = null
16+
17+
private fun looksLikeGguf(openStream: () -> InputStream?): Boolean =
18+
GgufFileInspector.looksLikeGguf(openStream) { reportedError = it }
19+
20+
@Test
21+
fun givenGgufMagic_whenChecked_thenAccepted() {
22+
val header = byteArrayOf(0x47, 0x47, 0x55, 0x46) // "GGUF"
23+
assertTrue(GgufFileInspector.bytesAreGgufMagic(header))
24+
}
25+
26+
@Test
27+
fun givenMagicWithTrailingBytes_whenChecked_thenAccepted() {
28+
val header = byteArrayOf(0x47, 0x47, 0x55, 0x46, 0x03, 0x00)
29+
assertTrue(GgufFileInspector.bytesAreGgufMagic(header))
30+
}
31+
32+
@Test
33+
fun givenZipMagic_whenChecked_thenRejected() {
34+
val header = byteArrayOf(0x50, 0x4B, 0x03, 0x04) // "PK.." — a .zip renamed to .gguf
35+
assertFalse(GgufFileInspector.bytesAreGgufMagic(header))
36+
}
37+
38+
@Test
39+
fun givenFewerThanFourBytes_whenChecked_thenRejected() {
40+
assertFalse(GgufFileInspector.bytesAreGgufMagic(byteArrayOf(0x47, 0x47, 0x55)))
41+
}
42+
43+
@Test
44+
fun givenGgufStream_whenInspected_thenAccepted() {
45+
assertTrue(looksLikeGguf { ByteArrayInputStream(byteArrayOf(0x47, 0x47, 0x55, 0x46, 0x03)) })
46+
assertNull(reportedError)
47+
}
48+
49+
@Test
50+
fun givenNonGgufStream_whenInspected_thenRejected() {
51+
assertFalse(looksLikeGguf { ByteArrayInputStream(byteArrayOf(0x50, 0x4B, 0x03, 0x04)) })
52+
assertNull(reportedError)
53+
}
54+
55+
@Test
56+
fun givenStreamThatThrowsMidRead_whenInspected_thenFailsOpenAndReports() {
57+
// SAF can drop a content:// stream mid-transfer; a real model must not be rejected for it.
58+
val truncating = object : InputStream() {
59+
private var served = 0
60+
override fun read(): Int = throw IOException("stream died")
61+
override fun read(b: ByteArray, off: Int, len: Int): Int {
62+
if (served > 0) throw IOException("stream died")
63+
served = 2
64+
b[off] = 0x47
65+
b[off + 1] = 0x47
66+
return 2
67+
}
68+
}
69+
70+
assertTrue(looksLikeGguf { truncating })
71+
assertEquals("stream died", reportedError?.message)
72+
}
73+
74+
@Test
75+
fun givenUnopenableDocument_whenInspected_thenFailsOpenAndReports() {
76+
assertTrue(looksLikeGguf { throw SecurityException("permission revoked") })
77+
assertEquals("permission revoked", reportedError?.message)
78+
}
79+
80+
@Test
81+
fun givenNullStream_whenInspected_thenFailsOpenWithoutError() {
82+
// openInputStream() returns null for a provider that can't produce the document.
83+
assertTrue(looksLikeGguf { null })
84+
assertNull(reportedError)
85+
}
86+
87+
@Test
88+
fun givenTruncatedStream_whenInspected_thenFailsOpen() {
89+
// Fewer than four bytes available: not enough to judge, so don't reject.
90+
assertTrue(looksLikeGguf { ByteArrayInputStream(byteArrayOf(0x47, 0x47)) })
91+
assertNull(reportedError)
92+
}
93+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.itsaky.androidide.plugins.aicore
2+
3+
import java.util.Locale
4+
5+
/**
6+
* Formats byte counts for display.
7+
*
8+
* Deliberately binary (÷1024³) and US-locale rather than
9+
* `android.text.format.Formatter.formatShortFileSize()`, which reports decimal (SI) units: these
10+
* figures describe device RAM, which every Android surface (Settings, `ActivityManager`, spec
11+
* sheets) reports in binary units, so a decimal "8.6 GB" for 8 GiB of RAM would read as wrong.
12+
* Kept out of the backend so the engine has no formatting logic and this stays unit-testable.
13+
*/
14+
internal object ByteSize {
15+
16+
private const val BYTES_PER_MB = 1024.0 * 1024.0
17+
private const val BYTES_PER_GB = BYTES_PER_MB * 1024.0
18+
19+
/**
20+
* Formats [bytes] with the largest unit that keeps the figure meaningful. Sub-gigabyte values
21+
* fall back to MB: a device with 12 MB free must not be described as having "0.0 GB", which
22+
* reads as a broken string rather than as a memory shortage.
23+
*
24+
* @param bytes a byte count
25+
* @return the size as a one-decimal "X.X GB" or "X.X MB" string
26+
*/
27+
fun format(bytes: Long): String =
28+
if (bytes >= BYTES_PER_GB) String.format(Locale.US, "%.1f GB", bytes / BYTES_PER_GB)
29+
else String.format(Locale.US, "%.1f MB", bytes / BYTES_PER_MB)
30+
}

ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GgufModelInspector.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@ object GgufModelInspector {
5151
val isEmbeddingOnly: Boolean get() = kind == ModelKind.EMBEDDING
5252
}
5353

54+
/**
55+
* Cheap magic-only check that never throws; reads just the first 4 bytes.
56+
* @param modelPath path to the candidate file
57+
* @return true if the file begins with the GGUF magic; false on any read error or mismatch
58+
*/
59+
fun isGguf(modelPath: String): Boolean = try {
60+
DataInputStream(BufferedInputStream(FileInputStream(File(modelPath)), 16)).use { readU32(it) == GGUF_MAGIC }
61+
} catch (_: Exception) {
62+
false
63+
}
64+
5465
/** Reads [modelPath]'s GGUF header and classifies it. Never throws. */
5566
fun classify(modelPath: String): Result {
5667
val arch = try {

ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LlmInferenceServiceImpl.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,8 @@ class LlmInferenceServiceImpl : LlmInferenceService {
130130
return
131131
}
132132

133-
// Delegate to Gemini backend with tool support
134-
(backend as GeminiBackend).generateStreamingWithTools(prompt, history, config, tools, callback)
133+
// Delegate to Gemini backend with tool support (smart-cast by the guard above)
134+
backend.generateStreamingWithTools(prompt, history, config, tools, callback)
135135
}
136136

137137
override fun getEmbeddings(text: String, backendId: String): CompletableFuture<FloatArray> {

ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LocalLlmBackend.kt

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.itsaky.androidide.plugins.aicore
22

3+
import android.app.ActivityManager
4+
import android.content.Context
35
import android.llama.cpp.LLamaAndroid
46
import android.net.Uri
57
import android.provider.OpenableColumns
@@ -61,6 +63,9 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend, Cancella
6163
@Volatile private var currentStreamingJob: Job? = null
6264
@Volatile private var currentGenerateJob: Job? = null
6365

66+
/** Renders load diagnoses as user-facing text; keeps R.string lookups out of this engine. */
67+
private val loadMessages by lazy { ModelLoadMessages(context.androidContext) }
68+
6469
@Volatile private var modelLoaded = false
6570
@Volatile private var currentModelPath: String? = null
6671

@@ -212,6 +217,13 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend, Cancella
212217
}
213218
}
214219

220+
/**
221+
* Loads [modelPath] unless it is already resident, diagnosing any native failure into a
222+
* [ModelLoadException]. Cancellation is rethrown first because [CancellationException] extends
223+
* [IllegalStateException] and would otherwise be diagnosed as a corrupt model.
224+
*
225+
* @param modelPath the configured model path or content URI
226+
*/
215227
private suspend fun ensureModelLoaded(modelPath: String) {
216228
// Resolve content URI to actual file path
217229
val resolvedPath = resolveContentUriToPath(modelPath)
@@ -242,14 +254,36 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend, Cancella
242254
currentModelPath = null
243255
}
244256

245-
// Load new model with resolved path
246257
context.logger.info("Loading model: $resolvedPath")
247-
llama.load(resolvedPath)
258+
try {
259+
llama.load(resolvedPath)
260+
} catch (e: CancellationException) {
261+
throw e
262+
} catch (e: Exception) {
263+
if (e is UserActionableLlmException) throw e
264+
// Native load_model() signals failure only with a null handle, so diagnose the likely cause.
265+
context.logger.error("Native model load failed for $resolvedPath", e)
266+
val diagnosis = ModelLoadDiagnostics.diagnose(resolvedPath, availableMemoryBytes(), e.message)
267+
throw ModelLoadException(loadMessages.describe(diagnosis), diagnosis)
268+
}
248269
modelLoaded = true
249270
currentModelPath = resolvedPath
250271
context.logger.info("Model loaded successfully")
251272
}
252273

274+
/**
275+
* @return free RAM the OS reports, or -1 if unreadable (diagnosis then skips the low-memory case)
276+
*/
277+
private fun availableMemoryBytes(): Long = try {
278+
val am = context.androidContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
279+
val info = ActivityManager.MemoryInfo()
280+
am.getMemoryInfo(info)
281+
info.availMem
282+
} catch (e: Exception) {
283+
context.logger.warn("Could not read available memory: ${e.message}")
284+
-1L
285+
}
286+
253287
/**
254288
* Wraps a turn in ChatML — the prompt format Qwen and most other on-device instruct GGUFs
255289
* were fine-tuned on.
@@ -369,7 +403,7 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend, Cancella
369403
throw ce
370404
} catch (e: Exception) {
371405
context.logger.error("Error during generation", e)
372-
if (e is ModelNotConfiguredException || e is IncompatibleModelException) {
406+
if (e is UserActionableLlmException) {
373407
UserFeedback.notify(context.androidContext, e.message ?: "Local LLM is not configured.")
374408
}
375409
future.complete(LlmResponse.failure("Error: ${e.message}"))
@@ -474,7 +508,7 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend, Cancella
474508
throw ce
475509
} catch (e: Exception) {
476510
context.logger.error("Error during streaming generation", e)
477-
if (e is ModelNotConfiguredException || e is IncompatibleModelException) {
511+
if (e is UserActionableLlmException) {
478512
UserFeedback.notify(context.androidContext, e.message ?: "Local LLM is not configured.")
479513
}
480514
callback.onError("Error: ${e.message}")

0 commit comments

Comments
 (0)