@dr33m/react-native-litert-lm 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,28 @@ android {
29
29
  }
30
30
 
31
31
  ndk {
32
- abiFilters 'arm64-v8a'
32
+ // Follow the app's `reactNativeArchitectures` rather than pinning
33
+ // arm64. Pinning it broke x86_64 emulator builds of consuming apps:
34
+ // this module still configured an arm64-v8a build while the app —
35
+ // and so the NitroModules prefab it links against — was built
36
+ // x86_64 only, and LiteRTLM failed to link with undefined
37
+ // `margelo::nitro::JHybridObject::*` vtable symbols.
38
+ //
39
+ // The pin bought nothing: com.google.ai.edge.litertlm ships both
40
+ // arm64-v8a and x86_64.
41
+ def supportedAbis = ['arm64-v8a', 'x86_64']
42
+ def requestedAbis = (project.findProperty('reactNativeArchitectures')
43
+ ?: rootProject.findProperty('reactNativeArchitectures')
44
+ ?: supportedAbis.join(','))
45
+ .toString()
46
+ .split(',')
47
+ .collect { it.trim() }
48
+ def selectedAbis = requestedAbis.findAll { supportedAbis.contains(it) }
49
+ if (selectedAbis.isEmpty()) {
50
+ selectedAbis = supportedAbis
51
+ }
52
+ abiFilters.clear()
53
+ abiFilters.addAll(selectedAbis)
33
54
  }
34
55
  }
35
56
 
@@ -11,6 +11,7 @@ import android.app.ActivityManager
11
11
  import android.content.Context
12
12
  import java.util.Collections
13
13
  import java.util.concurrent.CountDownLatch
14
+ import java.util.concurrent.TimeUnit
14
15
  import java.util.concurrent.atomic.AtomicReference
15
16
  import androidx.annotation.Keep
16
17
  import com.facebook.proguard.annotations.DoNotStrip
@@ -53,6 +54,9 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
53
54
 
54
55
  companion object {
55
56
  private const val TAG = "HybridLiteRTLM"
57
+
58
+ /** How long a teardown waits for a cancelled decode loop to stop. */
59
+ private const val CANCEL_SETTLE_TIMEOUT_SECONDS = 2L
56
60
  private val initLock = Any()
57
61
 
58
62
  /** Cached result of OpenCL availability probe (null = not yet checked). */
@@ -88,6 +92,17 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
88
92
  @Volatile
89
93
  private var isClosed = false
90
94
 
95
+ /**
96
+ * Latch of the streaming worker currently decoding, if any.
97
+ *
98
+ * Published so a teardown can wait for the decode loop to actually stop.
99
+ * Closing a conversation out from under a running worker deletes native
100
+ * state the worker is still reading, which is a crash the Kotlin side
101
+ * cannot catch.
102
+ */
103
+ @Volatile
104
+ private var activeStreamingLatch: CountDownLatch? = null
105
+
91
106
  private val modelStore = HybridModelStore()
92
107
  private var loadedModelPath: String? = null
93
108
 
@@ -240,8 +255,13 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
240
255
  // Only Gemma 3n bundles vision/audio executors; Gemma 4 E2B is text-only.
241
256
  // Passing vision/audio backends to a text-only model causes
242
257
  // vision_litert_compiled_model_executor init failures.
258
+ //
259
+ // "gemma3" used to be on this list and was wrong: Gemma 3 1B
260
+ // is text-only, and matching it here attached vision and
261
+ // audio executors that the model has no weights for. Only
262
+ // Gemma 3n is multimodal, and "3n" already catches it.
243
263
  val modelFileName = modelPath.substringAfterLast("/").lowercase()
244
- val isMultimodal = config?.multimodal ?: (modelFileName.contains("3n") || modelFileName.contains("gemma3"))
264
+ val isMultimodal = config?.multimodal ?: modelFileName.contains("3n")
245
265
 
246
266
  // Get cache directory from application context
247
267
  val cacheDirectory = LiteRTLMInitProvider.applicationContext?.cacheDir?.absolutePath
@@ -252,6 +272,10 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
252
272
  ExperimentalFlags.enableSpeculativeDecoding = true
253
273
  }
254
274
 
275
+ // The most recent engine failure, kept so the thrown error can say
276
+ // what went wrong instead of only that everything was tried.
277
+ var lastEngineError: String? = null
278
+
255
279
  // Helper: attempt engine creation with given backends, return null on failure
256
280
  fun tryCreateEngine(
257
281
  mainBackend: com.google.ai.edge.litertlm.Backend,
@@ -278,6 +302,11 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
278
302
  }
279
303
  Engine(cfg).also { it.initialize() }
280
304
  } catch (e: Exception) {
305
+ // Held onto because it is the only description of what
306
+ // actually went wrong. Reporting only "tried everything"
307
+ // turned a precise engine error ("Invalid magic number")
308
+ // into an unactionable one by the time it reached JS.
309
+ lastEngineError = e.message
281
310
  Log.w(TAG, "Engine creation failed with backend $mainBackend: ${e.message}")
282
311
  null
283
312
  }
@@ -322,47 +351,44 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
322
351
 
323
352
  if (isClosed) return@synchronized
324
353
 
325
- // Attempt primary backend
326
- var eng = tryCreateEngine(lmBackend, lmVisionBackend, lmAudioBackend)
327
-
328
- // Fallback sequence if GPU/NPU fails to initialize (mirrors iOS behavior)
329
- if (eng == null && backend != Backend.CPU) {
330
- val requestedName = if (backend == Backend.GPU) "GPU" else "NPU"
331
- Log.w(TAG, "$requestedName backend failed trying fallback chain...")
332
-
333
- // Fallback 1: CPU main + GPU vision + CPU audio
334
- eng = tryCreateEngine(
335
- com.google.ai.edge.litertlm.Backend.CPU(),
336
- if (isMultimodal) com.google.ai.edge.litertlm.Backend.GPU() else null,
337
- if (isMultimodal) com.google.ai.edge.litertlm.Backend.CPU() else null
338
- )
339
-
340
- // Fallback 2: Full CPU for all modalities
341
- if (eng == null) {
342
- eng = tryCreateEngine(
343
- com.google.ai.edge.litertlm.Backend.CPU(),
344
- if (isMultimodal) com.google.ai.edge.litertlm.Backend.CPU() else null,
345
- if (isMultimodal) com.google.ai.edge.litertlm.Backend.CPU() else null
346
- )
347
- }
348
-
349
- // Fallback 3: Text-only CPU (no vision/audio executors)
350
- if (eng == null) {
351
- eng = tryCreateEngine(
352
- com.google.ai.edge.litertlm.Backend.CPU(),
353
- null,
354
- null
355
- )
356
- }
357
-
358
- if (eng != null) {
359
- Log.w(TAG, "$requestedName backend unavailable — fell back to CPU successfully")
354
+ // Engine configurations to try, each dropping one more
355
+ // capability than the last and ending at plain text-only CPU.
356
+ //
357
+ // The text-only row matters even when CPU was the backend
358
+ // asked for. A text-only model that gets sniffed as
359
+ // multimodal fails with vision and audio executors attached
360
+ // and loads the moment they are dropped, and gating this
361
+ // whole chain on "the caller wanted GPU or NPU" left the
362
+ // default CPU path with no recovery at all: one failed
363
+ // attempt and straight to an error.
364
+ fun cpu() = com.google.ai.edge.litertlm.Backend.CPU()
365
+ val attempts = mutableListOf(
366
+ Triple(lmBackend, lmVisionBackend, lmAudioBackend)
367
+ )
368
+ if (isMultimodal) {
369
+ attempts += Triple(cpu(), com.google.ai.edge.litertlm.Backend.GPU(), cpu())
370
+ attempts += Triple(cpu(), cpu(), cpu())
371
+ }
372
+ attempts += Triple(cpu(), null, null)
373
+
374
+ var eng: Engine? = null
375
+ for ((index, attempt) in attempts.withIndex()) {
376
+ if (isClosed) return@synchronized
377
+ val (main, vision, audio) = attempt
378
+ eng = tryCreateEngine(main, vision, audio)
379
+ if (eng == null) continue
380
+ if (index > 0) {
381
+ Log.w(TAG, "Primary backend failed; loaded on fallback #$index (main=$main, vision=$vision, audio=$audio)")
382
+ // Every fallback runs the main graph on CPU, so the
383
+ // reported active backend has to follow.
360
384
  backend = Backend.CPU
361
385
  }
386
+ break
362
387
  }
363
388
 
364
389
  engine = eng ?: throw RuntimeException(
365
- "Failed to create LiteRT-LM engine. Tried primary backend and all CPU fallbacks."
390
+ "Failed to create LiteRT-LM engine after ${attempts.size} attempts: " +
391
+ (lastEngineError ?: "no error reported")
366
392
  )
367
393
  Log.i(TAG, "Engine created and initialized successfully")
368
394
 
@@ -557,6 +583,9 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
557
583
  }
558
584
 
559
585
  override fun resetConversation() {
586
+ // A reset that recreates the conversation under a running decode loop
587
+ // deletes the native state that loop is still reading.
588
+ cancelInFlightGeneration()
560
589
  synchronized(history) {
561
590
  history.clear()
562
591
  }
@@ -667,13 +696,58 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
667
696
  }
668
697
  }
669
698
 
699
+ /**
700
+ * Signal the native decode loop to stop, then wait for the streaming worker
701
+ * to settle.
702
+ *
703
+ * `cancelProcess()` only asks; the worker keeps running until it notices.
704
+ * Anything that then closes or recreates the conversation would be deleting
705
+ * native state while that worker still reads it, so every such path waits
706
+ * here first. The timeout is a backstop: a worker that never settles is
707
+ * worth a warning and a teardown anyway, because the alternative is hanging
708
+ * the caller forever.
709
+ */
710
+ private fun cancelInFlightGeneration() {
711
+ val latch = activeStreamingLatch ?: return
712
+ try {
713
+ conversation?.cancelProcess()
714
+ if (!latch.await(CANCEL_SETTLE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
715
+ Log.w(TAG, "cancelInFlightGeneration: worker did not settle in time")
716
+ }
717
+ } catch (e: Exception) {
718
+ Log.w(TAG, "cancelInFlightGeneration: ${e.message}")
719
+ }
720
+ }
721
+
722
+ /**
723
+ * Free the engine and its KV cache while leaving this instance usable.
724
+ *
725
+ * The distinction from `close()` is the whole point: `close()` sets
726
+ * `isClosed`, after which even `loadModel()` is refused, so using it to
727
+ * answer memory pressure permanently retired an instance that JS still
728
+ * held and believed in.
729
+ */
730
+ fun releaseUnderMemoryPressure() {
731
+ // Nothing loaded is nothing to free, and saying otherwise turns one
732
+ // emergency into a page of identical warnings.
733
+ if (engine == null) return
734
+ Log.w(TAG, "Releasing engine under memory pressure; instance stays reloadable")
735
+ cleanupInternal()
736
+ }
737
+
670
738
  override fun close() {
671
739
  Log.d(TAG, "Closing resources")
672
740
  isClosed = true
673
741
  cleanupInternal()
742
+ // A closed instance can never load again, so it has no business being
743
+ // woken by the next memory emergency.
744
+ LiteRTLMRegistry.unregister(this)
674
745
  }
675
746
 
676
747
  private fun cleanupInternal() {
748
+ // Outside the lock: the decode loop does not need initLock, and waiting
749
+ // for it while holding the lock would block loadModel behind it.
750
+ cancelInFlightGeneration()
677
751
  synchronized(initLock) {
678
752
  try {
679
753
  conversation?.close()
@@ -737,11 +811,12 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
737
811
  ),
738
812
  systemInstruction = systemPrompt?.let { Contents.of(Content.Text(it)) },
739
813
  initialMessages = initialMessages,
740
- tools = lmTools ?: emptyList()
814
+ tools = lmTools ?: emptyList(),
815
+ // Available since LiteRT-LM 0.15.0; before that the Kotlin SDK had no
816
+ // way to express it and every Android reply ran to the engine default.
817
+ maxOutputToken = maxOutputTokens,
741
818
  )
742
- // TODO: maxOutputTokens is not configurable on Android — the Kotlin SDK's
743
- // ConversationConfig does not expose this parameter. Only EngineConfig.maxNumTokens
744
- // (context budget) is supported. maxOutputTokens is effective on iOS only.
819
+
745
820
  //
746
821
  // Upstream is actively adding max_output_tokens across API surfaces:
747
822
  // - C API: PR #2470 (merged 2026-06-04)
@@ -891,11 +966,22 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
891
966
 
892
967
  val userMsg = LiteRTMessage.user(Contents.of(contents))
893
968
 
894
- val extraContext: Map<String, String> = if (enableThinking) mapOf("enable_thinking" to "true") else emptyMap()
969
+ // Always stated, never omitted.
970
+ //
971
+ // `enable_thinking` is a chat-template variable, not a Gemma
972
+ // feature, and templates test it as "defined and false". Qwen3's
973
+ // is exactly that shape, so leaving the key out is not the same
974
+ // as setting it to false: undefined falls through to the
975
+ // template's own default, which for a reasoning model is to
976
+ // reason. Omitting it meant thinking could be turned on but
977
+ // never off, and the reasoning arrived inline in the answer.
978
+ val extraContext: Map<String, String> =
979
+ mapOf("enable_thinking" to enableThinking.toString())
895
980
 
896
981
  if (onToken != null) {
897
982
  // ── Streaming path ────────────────────────────────────────────────
898
983
  val latch = CountDownLatch(1)
984
+ activeStreamingLatch = latch
899
985
  val errorRef = AtomicReference<Throwable?>(null)
900
986
  val fullResponseBuilder = StringBuilder()
901
987
  val thinkingBuilder = StringBuilder()
@@ -922,7 +1008,11 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
922
1008
  latch.countDown()
923
1009
  }
924
1010
 
925
- latch.await()
1011
+ try {
1012
+ latch.await()
1013
+ } finally {
1014
+ activeStreamingLatch = null
1015
+ }
926
1016
  errorRef.get()?.let { throw RuntimeException("execute streaming failed: ${it.message}", it) }
927
1017
  val capturedToolCalls = synchronized(pendingToolCalls) {
928
1018
  pendingToolCalls.toTypedArray().also { pendingToolCalls.clear() }
@@ -1,5 +1,6 @@
1
1
  package com.margelo.nitro.dev.litert.litertlm
2
2
 
3
+ import android.content.ComponentCallbacks2
3
4
  import java.util.Collections
4
5
  import java.util.WeakHashMap
5
6
  import android.util.Log
@@ -10,7 +11,7 @@ import android.util.Log
10
11
  */
11
12
  object LiteRTLMRegistry {
12
13
  private const val TAG = "LiteRTLMRegistry"
13
-
14
+
14
15
  // Use WeakSet-like structure to prevent leaks
15
16
  private val instances = Collections.newSetFromMap(WeakHashMap<HybridLiteRTLM, Boolean>())
16
17
 
@@ -20,13 +21,48 @@ object LiteRTLMRegistry {
20
21
  }
21
22
  }
22
23
 
24
+ /**
25
+ * Drop a retired instance.
26
+ *
27
+ * The set holds weak keys, so a dead instance does leave eventually — but
28
+ * only once GC gets to it, and until then it is still iterated and still
29
+ * logged against. Every `loadModel()` mints a fresh HybridLiteRTLM, so a
30
+ * few reloads were enough for one memory emergency to report five engines
31
+ * released when only the last of them held anything. Closing is a definite
32
+ * end, so it is a better moment to forget an instance than a collection
33
+ * that may not have happened yet.
34
+ */
35
+ fun unregister(instance: HybridLiteRTLM) {
36
+ synchronized(instances) {
37
+ instances.remove(instance)
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Whether a trim level is a real emergency worth dropping engines for.
43
+ *
44
+ * Trim levels are event codes, not a severity scale, so a `>=` comparison
45
+ * is the wrong shape entirely: TRIM_MEMORY_UI_HIDDEN is 20 and fires every
46
+ * time the screen locks or the reader presses home, which says nothing
47
+ * about memory. Testing `level >= TRIM_MEMORY_RUNNING_LOW` (10) therefore
48
+ * matched a screen lock and tore the engine down on it.
49
+ *
50
+ * Only two codes mean the process dies without intervention:
51
+ * RUNNING_CRITICAL (foreground, memory critical) and COMPLETE (cached and
52
+ * next in line to be killed).
53
+ */
54
+ fun isMemoryEmergency(level: Int): Boolean =
55
+ level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL ||
56
+ level >= ComponentCallbacks2.TRIM_MEMORY_COMPLETE
57
+
23
58
  fun onTrimMemory(level: Int) {
24
- Log.w(TAG, "Received memory warning (level=$level). Releasing resources...")
59
+ Log.w(TAG, "Memory emergency (level=$level). Releasing engines...")
25
60
  synchronized(instances) {
26
- instances.forEach { it.close() }
27
- // Note: We don't clear the set here, as close() should be idempotent
28
- // and the instance might still be ref-counted by JS.
29
- // We just ensure the HEAVY native resources are gone.
61
+ // Release the heavy native resources but keep each instance
62
+ // reloadable. close() would set isClosed, and an instance that
63
+ // refuses loadModel() afterwards is worse than one holding memory:
64
+ // JS still points at it and has no way to tell it has been retired.
65
+ instances.forEach { it.releaseUnderMemoryPressure() }
30
66
  }
31
67
  }
32
68
  }
@@ -20,7 +20,10 @@ class LiteRTLMInitProvider : ContentProvider() {
20
20
 
21
21
  applicationContext?.registerComponentCallbacks(object : android.content.ComponentCallbacks2 {
22
22
  override fun onTrimMemory(level: Int) {
23
- if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) {
23
+ // Trim levels are event codes, not severities: a `>=` gate here
24
+ // matched TRIM_MEMORY_UI_HIDDEN (20), which fires on every screen
25
+ // lock, and dropped the loaded engine along with it.
26
+ if (com.margelo.nitro.dev.litert.litertlm.LiteRTLMRegistry.isMemoryEmergency(level)) {
24
27
  com.margelo.nitro.dev.litert.litertlm.LiteRTLMRegistry.onTrimMemory(level)
25
28
  }
26
29
  }
@@ -1,32 +1,28 @@
1
1
  # LiteRT-LM Headers Fallback
2
2
 
3
- This directory contains the LiteRT-LM C API header (`litert_lm_engine.h`) used by the iOS C++ implementation.
3
+ This directory contains the LiteRT-LM C API headers (`litert_lm_engine.h`,
4
+ `capabilities_c.h`) used by the iOS C++ implementation. They are vendored
5
+ copies pinned to the LiteRT-LM version in `package.json` (`litertLm.version`).
4
6
 
5
7
  ## If Headers Are Missing
6
8
 
7
- If you get compilation errors like `litert_lm_engine.h: No such file or directory`, you need to manually copy the LiteRT-LM C API header here:
9
+ If you get compilation errors like `litert_lm_engine.h: No such file or directory`, re-download the headers (replace `v0.15.0` with the pinned version):
8
10
 
9
- 1. Clone LiteRT-LM repository:
10
-
11
- ```bash
12
- git clone https://github.com/google-ai-edge/LiteRT-LM.git /tmp/LiteRT-LM
13
- cd /tmp/LiteRT-LM && git checkout v0.10.2
14
- ```
15
-
16
- 2. Copy the header:
17
- ```bash
18
- cp /tmp/LiteRT-LM/c/litert_lm_engine.h ./
19
- ```
11
+ ```bash
12
+ curl -sL "https://raw.githubusercontent.com/google-ai-edge/LiteRT-LM/v0.15.0/c/engine.h" -o litert_lm_engine.h
13
+ curl -sL "https://raw.githubusercontent.com/google-ai-edge/LiteRT-LM/v0.15.0/schema/capabilities/capabilities_c.h" -o capabilities_c.h
14
+ ```
20
15
 
21
- The expected directory structure after copying:
16
+ The expected directory structure:
22
17
 
23
18
  ```
24
19
  cpp/include/
25
- ├── litert_lm_engine.h # LiteRT-LM C API header
20
+ ├── litert_lm_engine.h # LiteRT-LM C API header (upstream c/engine.h)
21
+ ├── capabilities_c.h # Model capability probing (upstream schema/capabilities/capabilities_c.h)
26
22
  ├── stb_image.h # Image loading for multimodal
27
23
  └── README.md
28
24
  ```
29
25
 
30
26
  ## Note
31
27
 
32
- On **Android**, headers are provided by the `litertlm-android` AAR via Prefab — this directory is only needed for the **iOS** build which uses the raw C API via the prebuilt XCFramework.
28
+ On **Android**, headers are provided by the `litertlm-android` AAR via Prefab — this directory is only needed for the **iOS** build which uses the raw C API via the prebuilt XCFramework (which bundles its own copies of these headers).