@dr33m/react-native-litert-lm 0.6.0 → 0.6.1
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.
- package/android/build.gradle +22 -1
- package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLM.kt +116 -42
- package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/LiteRTLMRegistry.kt +25 -6
- package/android/src/main/java/dev/litert/litertlm/LiteRTLMInitProvider.kt +4 -1
- package/cpp/include/README.md +12 -16
- package/cpp/include/litert_lm_engine.h +1359 -71
- package/ios/HybridLiteRTLM+Streaming.swift +9 -6
- package/ios/HybridLiteRTLM.swift +3 -1
- package/package.json +4 -4
- package/react-native-litert-lm.podspec +4 -0
package/android/build.gradle
CHANGED
|
@@ -29,7 +29,28 @@ android {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
ndk {
|
|
32
|
-
|
|
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 ?:
|
|
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
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if (eng == null)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
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,6 +696,42 @@ 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
|
+
Log.w(TAG, "Releasing engine under memory pressure; instance stays reloadable")
|
|
732
|
+
cleanupInternal()
|
|
733
|
+
}
|
|
734
|
+
|
|
670
735
|
override fun close() {
|
|
671
736
|
Log.d(TAG, "Closing resources")
|
|
672
737
|
isClosed = true
|
|
@@ -674,6 +739,9 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
|
|
|
674
739
|
}
|
|
675
740
|
|
|
676
741
|
private fun cleanupInternal() {
|
|
742
|
+
// Outside the lock: the decode loop does not need initLock, and waiting
|
|
743
|
+
// for it while holding the lock would block loadModel behind it.
|
|
744
|
+
cancelInFlightGeneration()
|
|
677
745
|
synchronized(initLock) {
|
|
678
746
|
try {
|
|
679
747
|
conversation?.close()
|
|
@@ -737,11 +805,12 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
|
|
|
737
805
|
),
|
|
738
806
|
systemInstruction = systemPrompt?.let { Contents.of(Content.Text(it)) },
|
|
739
807
|
initialMessages = initialMessages,
|
|
740
|
-
tools = lmTools ?: emptyList()
|
|
808
|
+
tools = lmTools ?: emptyList(),
|
|
809
|
+
// Available since LiteRT-LM 0.15.0; before that the Kotlin SDK had no
|
|
810
|
+
// way to express it and every Android reply ran to the engine default.
|
|
811
|
+
maxOutputToken = maxOutputTokens,
|
|
741
812
|
)
|
|
742
|
-
|
|
743
|
-
// ConversationConfig does not expose this parameter. Only EngineConfig.maxNumTokens
|
|
744
|
-
// (context budget) is supported. maxOutputTokens is effective on iOS only.
|
|
813
|
+
|
|
745
814
|
//
|
|
746
815
|
// Upstream is actively adding max_output_tokens across API surfaces:
|
|
747
816
|
// - C API: PR #2470 (merged 2026-06-04)
|
|
@@ -896,6 +965,7 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
|
|
|
896
965
|
if (onToken != null) {
|
|
897
966
|
// ── Streaming path ────────────────────────────────────────────────
|
|
898
967
|
val latch = CountDownLatch(1)
|
|
968
|
+
activeStreamingLatch = latch
|
|
899
969
|
val errorRef = AtomicReference<Throwable?>(null)
|
|
900
970
|
val fullResponseBuilder = StringBuilder()
|
|
901
971
|
val thinkingBuilder = StringBuilder()
|
|
@@ -922,7 +992,11 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
|
|
|
922
992
|
latch.countDown()
|
|
923
993
|
}
|
|
924
994
|
|
|
925
|
-
|
|
995
|
+
try {
|
|
996
|
+
latch.await()
|
|
997
|
+
} finally {
|
|
998
|
+
activeStreamingLatch = null
|
|
999
|
+
}
|
|
926
1000
|
errorRef.get()?.let { throw RuntimeException("execute streaming failed: ${it.message}", it) }
|
|
927
1001
|
val capturedToolCalls = synchronized(pendingToolCalls) {
|
|
928
1002
|
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,31 @@ object LiteRTLMRegistry {
|
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Whether a trim level is a real emergency worth dropping engines for.
|
|
26
|
+
*
|
|
27
|
+
* Trim levels are event codes, not a severity scale, so a `>=` comparison
|
|
28
|
+
* is the wrong shape entirely: TRIM_MEMORY_UI_HIDDEN is 20 and fires every
|
|
29
|
+
* time the screen locks or the reader presses home, which says nothing
|
|
30
|
+
* about memory. Testing `level >= TRIM_MEMORY_RUNNING_LOW` (10) therefore
|
|
31
|
+
* matched a screen lock and tore the engine down on it.
|
|
32
|
+
*
|
|
33
|
+
* Only two codes mean the process dies without intervention:
|
|
34
|
+
* RUNNING_CRITICAL (foreground, memory critical) and COMPLETE (cached and
|
|
35
|
+
* next in line to be killed).
|
|
36
|
+
*/
|
|
37
|
+
fun isMemoryEmergency(level: Int): Boolean =
|
|
38
|
+
level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL ||
|
|
39
|
+
level >= ComponentCallbacks2.TRIM_MEMORY_COMPLETE
|
|
40
|
+
|
|
23
41
|
fun onTrimMemory(level: Int) {
|
|
24
|
-
Log.w(TAG, "
|
|
42
|
+
Log.w(TAG, "Memory emergency (level=$level). Releasing engines...")
|
|
25
43
|
synchronized(instances) {
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
44
|
+
// Release the heavy native resources but keep each instance
|
|
45
|
+
// reloadable. close() would set isClosed, and an instance that
|
|
46
|
+
// refuses loadModel() afterwards is worse than one holding memory:
|
|
47
|
+
// JS still points at it and has no way to tell it has been retired.
|
|
48
|
+
instances.forEach { it.releaseUnderMemoryPressure() }
|
|
30
49
|
}
|
|
31
50
|
}
|
|
32
51
|
}
|
|
@@ -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
|
-
|
|
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
|
}
|
package/cpp/include/README.md
CHANGED
|
@@ -1,32 +1,28 @@
|
|
|
1
1
|
# LiteRT-LM Headers Fallback
|
|
2
2
|
|
|
3
|
-
This directory contains the LiteRT-LM C API
|
|
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`,
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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).
|