@dr33m/react-native-litert-lm 0.5.0
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/LICENSE +21 -0
- package/README.md +627 -0
- package/android/CMakeLists.txt +32 -0
- package/android/build.gradle +104 -0
- package/android/src/main/AndroidManifest.xml +19 -0
- package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLM.kt +888 -0
- package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/HybridModelStore.kt +189 -0
- package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/LiteRTLMRegistry.kt +32 -0
- package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/MultimodalPartFactories.kt +15 -0
- package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/StreamingCallbackListener.kt +89 -0
- package/android/src/main/java/dev/litert/litertlm/LiteRTLMInitProvider.kt +57 -0
- package/android/src/main/java/dev/litert/litertlm/LiteRTLMPackage.kt +43 -0
- package/android/src/test/java/com/margelo/nitro/core/Promise.kt +46 -0
- package/android/src/test/java/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLMTest.kt +122 -0
- package/app.plugin.js +58 -0
- package/cpp/cpp-adapter.cpp +21 -0
- package/cpp/include/README.md +32 -0
- package/cpp/include/litert_lm_engine.h +516 -0
- package/cpp/include/stb_image.h +7988 -0
- package/ios/HybridLiteRTLM+Execute.swift +287 -0
- package/ios/HybridLiteRTLM+Streaming.swift +153 -0
- package/ios/HybridLiteRTLM.swift +541 -0
- package/ios/HybridModelStore.swift +206 -0
- package/ios/MultimodalPart+Factories.swift +22 -0
- package/ios/Tests/HybridLiteRTLMTests.swift +187 -0
- package/lib/__mocks__/react-native-nitro-modules.d.ts +81 -0
- package/lib/__mocks__/react-native-nitro-modules.js +98 -0
- package/lib/__tests__/hooks.test.d.ts +1 -0
- package/lib/__tests__/hooks.test.js +175 -0
- package/lib/__tests__/inferenceRouting.test.d.ts +1 -0
- package/lib/__tests__/inferenceRouting.test.js +28 -0
- package/lib/__tests__/memoryTracker.test.d.ts +1 -0
- package/lib/__tests__/memoryTracker.test.js +74 -0
- package/lib/__tests__/modelFactory.test.d.ts +1 -0
- package/lib/__tests__/modelFactory.test.js +107 -0
- package/lib/__tests__/modelPath.test.d.ts +1 -0
- package/lib/__tests__/modelPath.test.js +12 -0
- package/lib/__tests__/modelRegistry.test.d.ts +1 -0
- package/lib/__tests__/modelRegistry.test.js +61 -0
- package/lib/hooks.d.ts +46 -0
- package/lib/hooks.js +159 -0
- package/lib/index.d.ts +142 -0
- package/lib/index.js +184 -0
- package/lib/inferenceRouting.d.ts +18 -0
- package/lib/inferenceRouting.js +63 -0
- package/lib/memoryTracker.d.ts +128 -0
- package/lib/memoryTracker.js +155 -0
- package/lib/modelFactory.d.ts +24 -0
- package/lib/modelFactory.js +128 -0
- package/lib/modelPath.d.ts +8 -0
- package/lib/modelPath.js +18 -0
- package/lib/modelRegistry.d.ts +55 -0
- package/lib/modelRegistry.js +82 -0
- package/lib/specs/LiteRTLM.nitro.d.ts +380 -0
- package/lib/specs/LiteRTLM.nitro.js +2 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/LiteRTLM+autolinking.cmake +83 -0
- package/nitrogen/generated/android/LiteRTLM+autolinking.gradle +27 -0
- package/nitrogen/generated/android/LiteRTLMOnLoad.cpp +74 -0
- package/nitrogen/generated/android/LiteRTLMOnLoad.hpp +34 -0
- package/nitrogen/generated/android/c++/JBackend.hpp +61 -0
- package/nitrogen/generated/android/c++/JExecuteResult.hpp +86 -0
- package/nitrogen/generated/android/c++/JFunc_void_double.hpp +75 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__string_bool.hpp +76 -0
- package/nitrogen/generated/android/c++/JGenerationStats.hpp +77 -0
- package/nitrogen/generated/android/c++/JHybridLiteRTLMSpec.cpp +371 -0
- package/nitrogen/generated/android/c++/JHybridLiteRTLMSpec.hpp +83 -0
- package/nitrogen/generated/android/c++/JHybridModelStoreSpec.cpp +101 -0
- package/nitrogen/generated/android/c++/JHybridModelStoreSpec.hpp +67 -0
- package/nitrogen/generated/android/c++/JLLMConfig.hpp +125 -0
- package/nitrogen/generated/android/c++/JMemoryUsage.hpp +69 -0
- package/nitrogen/generated/android/c++/JMessage.hpp +63 -0
- package/nitrogen/generated/android/c++/JModelFile.hpp +69 -0
- package/nitrogen/generated/android/c++/JMultimodalPart.hpp +78 -0
- package/nitrogen/generated/android/c++/JPartType.hpp +61 -0
- package/nitrogen/generated/android/c++/JRole.hpp +61 -0
- package/nitrogen/generated/android/c++/JToolCall.hpp +61 -0
- package/nitrogen/generated/android/c++/JToolDefinition.hpp +65 -0
- package/nitrogen/generated/android/c++/JToolResponse.hpp +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/Backend.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/ExecuteResult.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/Func_void_double.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/Func_void_std__string_bool.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/GenerationStats.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLMSpec.kt +165 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/HybridModelStoreSpec.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/LLMConfig.kt +106 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/LiteRTLMOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/MemoryUsage.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/Message.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/ModelFile.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/MultimodalPart.kt +71 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/PartType.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/Role.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/ToolCall.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/ToolDefinition.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/ToolResponse.kt +56 -0
- package/nitrogen/generated/ios/LiteRTLM+autolinking.rb +62 -0
- package/nitrogen/generated/ios/LiteRTLM-Swift-Cxx-Bridge.cpp +98 -0
- package/nitrogen/generated/ios/LiteRTLM-Swift-Cxx-Bridge.hpp +585 -0
- package/nitrogen/generated/ios/LiteRTLM-Swift-Cxx-Umbrella.hpp +93 -0
- package/nitrogen/generated/ios/LiteRTLMAutolinking.mm +41 -0
- package/nitrogen/generated/ios/LiteRTLMAutolinking.swift +38 -0
- package/nitrogen/generated/ios/c++/HybridLiteRTLMSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridLiteRTLMSpecSwift.hpp +279 -0
- package/nitrogen/generated/ios/c++/HybridModelStoreSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridModelStoreSpecSwift.hpp +117 -0
- package/nitrogen/generated/ios/swift/Backend.swift +44 -0
- package/nitrogen/generated/ios/swift/ExecuteResult.swift +45 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_ExecuteResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_double.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string_bool.swift +46 -0
- package/nitrogen/generated/ios/swift/GenerationStats.swift +54 -0
- package/nitrogen/generated/ios/swift/HybridLiteRTLMSpec.swift +75 -0
- package/nitrogen/generated/ios/swift/HybridLiteRTLMSpec_cxx.swift +516 -0
- package/nitrogen/generated/ios/swift/HybridModelStoreSpec.swift +59 -0
- package/nitrogen/generated/ios/swift/HybridModelStoreSpec_cxx.swift +203 -0
- package/nitrogen/generated/ios/swift/LLMConfig.swift +239 -0
- package/nitrogen/generated/ios/swift/MemoryUsage.swift +44 -0
- package/nitrogen/generated/ios/swift/Message.swift +34 -0
- package/nitrogen/generated/ios/swift/ModelFile.swift +44 -0
- package/nitrogen/generated/ios/swift/MultimodalPart.swift +101 -0
- package/nitrogen/generated/ios/swift/PartType.swift +44 -0
- package/nitrogen/generated/ios/swift/Role.swift +44 -0
- package/nitrogen/generated/ios/swift/ToolCall.swift +34 -0
- package/nitrogen/generated/ios/swift/ToolDefinition.swift +39 -0
- package/nitrogen/generated/ios/swift/ToolResponse.swift +34 -0
- package/nitrogen/generated/shared/c++/Backend.hpp +80 -0
- package/nitrogen/generated/shared/c++/ExecuteResult.hpp +94 -0
- package/nitrogen/generated/shared/c++/GenerationStats.hpp +103 -0
- package/nitrogen/generated/shared/c++/HybridLiteRTLMSpec.cpp +41 -0
- package/nitrogen/generated/shared/c++/HybridLiteRTLMSpec.hpp +109 -0
- package/nitrogen/generated/shared/c++/HybridModelStoreSpec.cpp +25 -0
- package/nitrogen/generated/shared/c++/HybridModelStoreSpec.hpp +71 -0
- package/nitrogen/generated/shared/c++/LLMConfig.hpp +134 -0
- package/nitrogen/generated/shared/c++/MemoryUsage.hpp +95 -0
- package/nitrogen/generated/shared/c++/Message.hpp +89 -0
- package/nitrogen/generated/shared/c++/ModelFile.hpp +95 -0
- package/nitrogen/generated/shared/c++/MultimodalPart.hpp +103 -0
- package/nitrogen/generated/shared/c++/PartType.hpp +80 -0
- package/nitrogen/generated/shared/c++/Role.hpp +80 -0
- package/nitrogen/generated/shared/c++/ToolCall.hpp +87 -0
- package/nitrogen/generated/shared/c++/ToolDefinition.hpp +91 -0
- package/nitrogen/generated/shared/c++/ToolResponse.hpp +87 -0
- package/package.json +115 -0
- package/react-native-litert-lm.podspec +62 -0
- package/react-native.config.js +16 -0
- package/scripts/download-ios-frameworks.sh +39 -0
- package/scripts/framework-source.js +46 -0
- package/scripts/postinstall.js +145 -0
- package/src/__mocks__/react-native-nitro-modules.ts +132 -0
- package/src/__tests__/hooks.test.ts +216 -0
- package/src/__tests__/inferenceRouting.test.ts +36 -0
- package/src/__tests__/memoryTracker.test.ts +87 -0
- package/src/__tests__/modelFactory.test.ts +153 -0
- package/src/__tests__/modelPath.test.ts +14 -0
- package/src/__tests__/modelRegistry.test.ts +73 -0
- package/src/hooks.ts +234 -0
- package/src/index.ts +214 -0
- package/src/inferenceRouting.ts +80 -0
- package/src/memoryTracker.ts +268 -0
- package/src/modelFactory.ts +163 -0
- package/src/modelPath.ts +16 -0
- package/src/modelRegistry.ts +104 -0
- package/src/specs/LiteRTLM.nitro.ts +451 -0
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
///
|
|
2
|
+
/// HybridLiteRTLM.kt
|
|
3
|
+
/// Kotlin implementation of LiteRTLM HybridObject using LiteRT-LM Android SDK.
|
|
4
|
+
///
|
|
5
|
+
|
|
6
|
+
package com.margelo.nitro.dev.litert.litertlm
|
|
7
|
+
|
|
8
|
+
import android.util.Log
|
|
9
|
+
import android.os.Debug
|
|
10
|
+
import android.app.ActivityManager
|
|
11
|
+
import android.content.Context
|
|
12
|
+
import java.util.Collections
|
|
13
|
+
import java.util.concurrent.CountDownLatch
|
|
14
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
15
|
+
import androidx.annotation.Keep
|
|
16
|
+
import com.facebook.proguard.annotations.DoNotStrip
|
|
17
|
+
import dev.litert.litertlm.LiteRTLMInitProvider
|
|
18
|
+
import com.google.ai.edge.litertlm.Engine
|
|
19
|
+
import com.google.ai.edge.litertlm.Conversation
|
|
20
|
+
import com.google.ai.edge.litertlm.EngineConfig
|
|
21
|
+
import com.google.ai.edge.litertlm.ConversationConfig
|
|
22
|
+
import com.google.ai.edge.litertlm.SamplerConfig
|
|
23
|
+
import com.margelo.nitro.dev.litert.litertlm.Backend
|
|
24
|
+
import com.margelo.nitro.dev.litert.litertlm.GenerationStats
|
|
25
|
+
import com.margelo.nitro.dev.litert.litertlm.HybridLiteRTLMSpec
|
|
26
|
+
import com.margelo.nitro.dev.litert.litertlm.LLMConfig
|
|
27
|
+
import com.margelo.nitro.dev.litert.litertlm.Message
|
|
28
|
+
import com.margelo.nitro.dev.litert.litertlm.Role
|
|
29
|
+
import com.margelo.nitro.core.Promise
|
|
30
|
+
import com.google.ai.edge.litertlm.Content
|
|
31
|
+
import com.google.ai.edge.litertlm.Contents
|
|
32
|
+
import com.google.ai.edge.litertlm.ExperimentalApi
|
|
33
|
+
import com.google.ai.edge.litertlm.ExperimentalFlags
|
|
34
|
+
import com.google.ai.edge.litertlm.OpenApiTool
|
|
35
|
+
import com.google.ai.edge.litertlm.ToolProvider
|
|
36
|
+
import com.google.ai.edge.litertlm.tool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
// Alias to avoid confusion with our generated Message type
|
|
41
|
+
// Alias to avoid confusion
|
|
42
|
+
typealias LiteRTMessage = com.google.ai.edge.litertlm.Message
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Kotlin implementation of LiteRTLM using the LiteRT-LM Android SDK.
|
|
48
|
+
* This class bridges between React Native (via Nitro) and the Google LiteRT-LM Engine.
|
|
49
|
+
*/
|
|
50
|
+
@DoNotStrip
|
|
51
|
+
@Keep
|
|
52
|
+
class HybridLiteRTLM : HybridLiteRTLMSpec() {
|
|
53
|
+
|
|
54
|
+
companion object {
|
|
55
|
+
private const val TAG = "HybridLiteRTLM"
|
|
56
|
+
private val initLock = Any()
|
|
57
|
+
|
|
58
|
+
/** Cached result of OpenCL availability probe (null = not yet checked). */
|
|
59
|
+
@Volatile
|
|
60
|
+
private var openCLAvailable: Boolean? = null
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Initialize the native library.
|
|
64
|
+
* Must be called from Application.onCreate() to register the HybridObject.
|
|
65
|
+
*/
|
|
66
|
+
fun initialize() {
|
|
67
|
+
try {
|
|
68
|
+
// Call generated internal OnLoad to load the library
|
|
69
|
+
LiteRTLMOnLoad.initializeNative()
|
|
70
|
+
} catch (e: Throwable) {
|
|
71
|
+
Log.e(TAG, "Failed to initialize LiteRTLM native library", e)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
init {
|
|
77
|
+
LiteRTLMRegistry.register(this)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// LiteRT-LM Engine and Conversation
|
|
81
|
+
private var engine: Engine? = null
|
|
82
|
+
private var conversation: Conversation? = null
|
|
83
|
+
|
|
84
|
+
@Volatile
|
|
85
|
+
private var isClosed = false
|
|
86
|
+
|
|
87
|
+
private val modelStore = HybridModelStore()
|
|
88
|
+
private var loadedModelPath: String? = null
|
|
89
|
+
|
|
90
|
+
// Conversation history for getHistory()
|
|
91
|
+
// Synchronized to prevent ConcurrentModificationException: history is
|
|
92
|
+
// written from Promise.parallel workers and sendMessageAsync SDK callbacks,
|
|
93
|
+
// and read from getHistory() which may be called from the JS thread.
|
|
94
|
+
private val history: MutableList<Message> = Collections.synchronizedList(mutableListOf())
|
|
95
|
+
|
|
96
|
+
// Tool calls captured during inference via ToolProvider.execute()
|
|
97
|
+
private val pendingToolCalls: MutableList<ToolCall> = Collections.synchronizedList(mutableListOf())
|
|
98
|
+
|
|
99
|
+
// Last generation stats
|
|
100
|
+
private var lastStats = GenerationStats(
|
|
101
|
+
promptTokens = 0.0,
|
|
102
|
+
completionTokens = 0.0,
|
|
103
|
+
totalTokens = 0.0,
|
|
104
|
+
timeToFirstToken = 0.0,
|
|
105
|
+
totalTime = 0.0,
|
|
106
|
+
tokensPerSecond = 0.0
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
// Configuration
|
|
110
|
+
private var backend: Backend = Backend.CPU
|
|
111
|
+
private var temperature: Double = 0.7
|
|
112
|
+
private var topK: Int = 40
|
|
113
|
+
private var topP: Double = 0.95
|
|
114
|
+
private var maxContextTokens: Int = 4096
|
|
115
|
+
private var maxOutputTokens: Int = 1024
|
|
116
|
+
private var systemPrompt: String? = null
|
|
117
|
+
private var tools: Array<ToolDefinition>? = null
|
|
118
|
+
private var enableSpeculativeDecoding: Boolean = false
|
|
119
|
+
private var enableThinking: Boolean = false
|
|
120
|
+
|
|
121
|
+
override val memorySize: Long
|
|
122
|
+
get() = 1024L * 1024L * 1024L // ~1GB (models are large)
|
|
123
|
+
|
|
124
|
+
// -------------------------------------------------------------------------
|
|
125
|
+
// loadModel - Initialize LiteRT-LM Engine and Conversation
|
|
126
|
+
// -------------------------------------------------------------------------
|
|
127
|
+
override fun loadModel(modelPath: String, config: LLMConfig?): Promise<Unit> {
|
|
128
|
+
return Promise.parallel {
|
|
129
|
+
// Serialize initialization to prevent OOM from concurrent loads
|
|
130
|
+
synchronized(initLock) {
|
|
131
|
+
if (isClosed) {
|
|
132
|
+
throw RuntimeException("Cannot load model: LiteRTLM instance is closed")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
Log.i(TAG, "loadModel: $modelPath")
|
|
136
|
+
|
|
137
|
+
// Clean up existing resources
|
|
138
|
+
// We call internal cleanup that doesn't set isClosed
|
|
139
|
+
cleanupInternal()
|
|
140
|
+
|
|
141
|
+
// Apply configuration
|
|
142
|
+
config?.let { cfg ->
|
|
143
|
+
cfg.backend?.let { backend = it }
|
|
144
|
+
cfg.temperature?.let { temperature = it }
|
|
145
|
+
cfg.topK?.let { topK = it.toInt() }
|
|
146
|
+
cfg.topP?.let { topP = it }
|
|
147
|
+
// New split fields take priority over legacy maxTokens
|
|
148
|
+
cfg.maxContextTokens?.let { maxContextTokens = it.toInt() }
|
|
149
|
+
cfg.maxOutputTokens?.let { maxOutputTokens = it.toInt() }
|
|
150
|
+
// Legacy: if only maxTokens is set, map to both for backward compat
|
|
151
|
+
if (cfg.maxContextTokens == null && cfg.maxOutputTokens == null) {
|
|
152
|
+
cfg.maxTokens?.let {
|
|
153
|
+
maxContextTokens = it.toInt()
|
|
154
|
+
maxOutputTokens = it.toInt()
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
cfg.systemPrompt?.let { systemPrompt = it }
|
|
158
|
+
tools = cfg.tools
|
|
159
|
+
enableSpeculativeDecoding = cfg.enableSpeculativeDecoding ?: false
|
|
160
|
+
enableThinking = cfg.enableThinking ?: false
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
// Early GPU hardware check: probe for OpenCL library.
|
|
165
|
+
// LiteRT-LM's GPU delegate requires OpenCL, which is absent on
|
|
166
|
+
// most Samsung/Qualcomm devices. Log a warning — fallback will
|
|
167
|
+
// handle it gracefully below.
|
|
168
|
+
if (backend == Backend.GPU) {
|
|
169
|
+
val hasOpenCL = openCLAvailable ?: run {
|
|
170
|
+
val result = try {
|
|
171
|
+
System.loadLibrary("OpenCL")
|
|
172
|
+
true
|
|
173
|
+
} catch (_: UnsatisfiedLinkError) {
|
|
174
|
+
val paths = arrayOf(
|
|
175
|
+
"/vendor/lib64/libOpenCL.so",
|
|
176
|
+
"/system/vendor/lib64/libOpenCL.so",
|
|
177
|
+
"/vendor/lib/libOpenCL.so",
|
|
178
|
+
"/system/lib64/libOpenCL.so"
|
|
179
|
+
)
|
|
180
|
+
var loaded = false
|
|
181
|
+
for (path in paths) {
|
|
182
|
+
try {
|
|
183
|
+
System.load(path)
|
|
184
|
+
loaded = true
|
|
185
|
+
break
|
|
186
|
+
} catch (_: UnsatisfiedLinkError) {}
|
|
187
|
+
}
|
|
188
|
+
loaded
|
|
189
|
+
}
|
|
190
|
+
openCLAvailable = result
|
|
191
|
+
result
|
|
192
|
+
}
|
|
193
|
+
if (!hasOpenCL) {
|
|
194
|
+
Log.w(TAG, "OpenCL library not found — GPU backend will likely fail, fallback chain will attempt CPU")
|
|
195
|
+
} else {
|
|
196
|
+
Log.i(TAG, "OpenCL library found — GPU backend is available")
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Detect multimodal support. Check config.multimodal flag first, then fall back to filename sniffing.
|
|
201
|
+
// Only Gemma 3n bundles vision/audio executors; Gemma 4 E2B is text-only.
|
|
202
|
+
// Passing vision/audio backends to a text-only model causes
|
|
203
|
+
// vision_litert_compiled_model_executor init failures.
|
|
204
|
+
val modelFileName = modelPath.substringAfterLast("/").lowercase()
|
|
205
|
+
val isMultimodal = config?.multimodal ?: (modelFileName.contains("3n") || modelFileName.contains("gemma3"))
|
|
206
|
+
|
|
207
|
+
// Get cache directory from application context
|
|
208
|
+
val cacheDirectory = LiteRTLMInitProvider.applicationContext?.cacheDir?.absolutePath
|
|
209
|
+
Log.i(TAG, "Using cache directory: $cacheDirectory")
|
|
210
|
+
|
|
211
|
+
if (enableSpeculativeDecoding) {
|
|
212
|
+
@OptIn(ExperimentalApi::class)
|
|
213
|
+
ExperimentalFlags.enableSpeculativeDecoding = true
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Helper: attempt engine creation with given backends, return null on failure
|
|
217
|
+
fun tryCreateEngine(
|
|
218
|
+
mainBackend: com.google.ai.edge.litertlm.Backend,
|
|
219
|
+
visionBackend: com.google.ai.edge.litertlm.Backend?,
|
|
220
|
+
audioBackend: com.google.ai.edge.litertlm.Backend?
|
|
221
|
+
): Engine? {
|
|
222
|
+
return try {
|
|
223
|
+
val cfg = if (visionBackend != null && audioBackend != null) {
|
|
224
|
+
EngineConfig(
|
|
225
|
+
modelPath = modelPath,
|
|
226
|
+
backend = mainBackend,
|
|
227
|
+
visionBackend = visionBackend,
|
|
228
|
+
audioBackend = audioBackend,
|
|
229
|
+
maxNumTokens = maxContextTokens,
|
|
230
|
+
cacheDir = cacheDirectory
|
|
231
|
+
)
|
|
232
|
+
} else {
|
|
233
|
+
EngineConfig(
|
|
234
|
+
modelPath = modelPath,
|
|
235
|
+
backend = mainBackend,
|
|
236
|
+
maxNumTokens = maxContextTokens,
|
|
237
|
+
cacheDir = cacheDirectory
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
Engine(cfg).also { it.initialize() }
|
|
241
|
+
} catch (e: Exception) {
|
|
242
|
+
Log.w(TAG, "Engine creation failed with backend $mainBackend: ${e.message}")
|
|
243
|
+
null
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Map our Backend enum to LiteRT-LM Backend sealed class
|
|
248
|
+
val lmBackend = when (backend) {
|
|
249
|
+
Backend.GPU -> com.google.ai.edge.litertlm.Backend.GPU()
|
|
250
|
+
Backend.NPU -> {
|
|
251
|
+
Log.i(TAG, "NPU backend requested - requires hardware support")
|
|
252
|
+
com.google.ai.edge.litertlm.Backend.NPU()
|
|
253
|
+
}
|
|
254
|
+
else -> com.google.ai.edge.litertlm.Backend.CPU()
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
val lmVisionBackend = if (isMultimodal) com.google.ai.edge.litertlm.Backend.GPU() else null
|
|
258
|
+
val lmAudioBackend = if (isMultimodal) com.google.ai.edge.litertlm.Backend.CPU() else null
|
|
259
|
+
|
|
260
|
+
Log.i(TAG, "Backend config: main=$lmBackend, vision=$lmVisionBackend, audio=$lmAudioBackend, multimodal=$isMultimodal")
|
|
261
|
+
|
|
262
|
+
if (isClosed) return@synchronized
|
|
263
|
+
|
|
264
|
+
// Attempt primary backend
|
|
265
|
+
var eng = tryCreateEngine(lmBackend, lmVisionBackend, lmAudioBackend)
|
|
266
|
+
|
|
267
|
+
// Fallback sequence if GPU/NPU fails to initialize (mirrors iOS behavior)
|
|
268
|
+
if (eng == null && backend != Backend.CPU) {
|
|
269
|
+
val requestedName = if (backend == Backend.GPU) "GPU" else "NPU"
|
|
270
|
+
Log.w(TAG, "$requestedName backend failed — trying fallback chain...")
|
|
271
|
+
|
|
272
|
+
// Fallback 1: CPU main + GPU vision + CPU audio
|
|
273
|
+
eng = tryCreateEngine(
|
|
274
|
+
com.google.ai.edge.litertlm.Backend.CPU(),
|
|
275
|
+
if (isMultimodal) com.google.ai.edge.litertlm.Backend.GPU() else null,
|
|
276
|
+
if (isMultimodal) com.google.ai.edge.litertlm.Backend.CPU() else null
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
// Fallback 2: Full CPU for all modalities
|
|
280
|
+
if (eng == null) {
|
|
281
|
+
eng = tryCreateEngine(
|
|
282
|
+
com.google.ai.edge.litertlm.Backend.CPU(),
|
|
283
|
+
if (isMultimodal) com.google.ai.edge.litertlm.Backend.CPU() else null,
|
|
284
|
+
if (isMultimodal) com.google.ai.edge.litertlm.Backend.CPU() else null
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Fallback 3: Text-only CPU (no vision/audio executors)
|
|
289
|
+
if (eng == null) {
|
|
290
|
+
eng = tryCreateEngine(
|
|
291
|
+
com.google.ai.edge.litertlm.Backend.CPU(),
|
|
292
|
+
null,
|
|
293
|
+
null
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (eng != null) {
|
|
298
|
+
Log.w(TAG, "$requestedName backend unavailable — fell back to CPU successfully")
|
|
299
|
+
backend = Backend.CPU
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
engine = eng ?: throw RuntimeException(
|
|
304
|
+
"Failed to create LiteRT-LM engine. Tried primary backend and all CPU fallbacks."
|
|
305
|
+
)
|
|
306
|
+
Log.i(TAG, "Engine created and initialized successfully")
|
|
307
|
+
|
|
308
|
+
// Create Conversation
|
|
309
|
+
createNewConversation()
|
|
310
|
+
Log.i(TAG, "Conversation created successfully")
|
|
311
|
+
loadedModelPath = modelPath
|
|
312
|
+
|
|
313
|
+
} catch (e: Exception) {
|
|
314
|
+
Log.e(TAG, "Failed to load model: ${e.message}", e)
|
|
315
|
+
// Clean up partial state so isReady() returns false
|
|
316
|
+
cleanupInternal()
|
|
317
|
+
throw RuntimeException("Failed to load model: ${e.message}", e)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Legacy inference — shapes mirror src/inferenceRouting.ts; JS createLLM routes via execute.
|
|
324
|
+
override fun sendMessage(message: String): Promise<ExecuteResult> =
|
|
325
|
+
execute(parts = arrayOf(MultimodalPartFactories.textPart(message)), onToken = null)
|
|
326
|
+
|
|
327
|
+
override fun sendMessageAsync(message: String, onToken: (String, Boolean) -> Unit): Promise<Unit> =
|
|
328
|
+
executeVoid(parts = arrayOf(MultimodalPartFactories.textPart(message)), onToken = onToken)
|
|
329
|
+
|
|
330
|
+
// -------------------------------------------------------------------------
|
|
331
|
+
// Multimodal methods
|
|
332
|
+
// -------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Resolve non-filesystem URIs to real file paths.
|
|
336
|
+
* Handles: asset:/// , file:///android_asset/ , content:// ,
|
|
337
|
+
* drawable resource names, and raw resource names.
|
|
338
|
+
*/
|
|
339
|
+
private fun resolveAssetUri(uri: String, ext: String): String {
|
|
340
|
+
// Already a real filesystem path
|
|
341
|
+
if (uri.startsWith("/") && java.io.File(uri).exists()) return uri
|
|
342
|
+
if (uri.startsWith("file:///") && !uri.startsWith("file:///android_asset/")) {
|
|
343
|
+
val path = uri.removePrefix("file://")
|
|
344
|
+
if (java.io.File(path).exists()) return path
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
val context = LiteRTLMInitProvider.applicationContext
|
|
348
|
+
?: throw RuntimeException("Application context not available for asset resolution")
|
|
349
|
+
val tempFile = java.io.File(context.cacheDir, "litert_asset_${java.util.UUID.randomUUID()}.$ext")
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
// content:// URI (from image picker or some RN asset resolvers)
|
|
353
|
+
if (uri.startsWith("content://")) {
|
|
354
|
+
context.contentResolver.openInputStream(android.net.Uri.parse(uri))?.use { input ->
|
|
355
|
+
java.io.FileOutputStream(tempFile).use { output -> input.copyTo(output) }
|
|
356
|
+
} ?: throw RuntimeException("Could not open content URI: $uri")
|
|
357
|
+
return tempFile.absolutePath
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// asset:/// or file:///android_asset/ — APK assets folder
|
|
361
|
+
val assetName = when {
|
|
362
|
+
uri.startsWith("asset:///") -> uri.removePrefix("asset:///")
|
|
363
|
+
uri.startsWith("file:///android_asset/") -> uri.removePrefix("file:///android_asset/")
|
|
364
|
+
else -> null
|
|
365
|
+
}
|
|
366
|
+
if (assetName != null) {
|
|
367
|
+
context.assets.open(assetName).use { input ->
|
|
368
|
+
java.io.FileOutputStream(tempFile).use { output -> input.copyTo(output) }
|
|
369
|
+
}
|
|
370
|
+
return tempFile.absolutePath
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Plain name (e.g. "test") — React Native drawable/raw resource
|
|
374
|
+
if (!uri.contains("/") && !uri.contains(":")) {
|
|
375
|
+
// Try raw resource first (preserves original bytes — works for any format)
|
|
376
|
+
val rawId = context.resources.getIdentifier(uri, "raw", context.packageName)
|
|
377
|
+
if (rawId != 0) {
|
|
378
|
+
context.resources.openRawResource(rawId).use { input ->
|
|
379
|
+
java.io.FileOutputStream(tempFile).use { output -> input.copyTo(output) }
|
|
380
|
+
}
|
|
381
|
+
return tempFile.absolutePath
|
|
382
|
+
}
|
|
383
|
+
// Try drawable (images only — re-encodes as JPEG)
|
|
384
|
+
val drawableId = context.resources.getIdentifier(uri, "drawable", context.packageName)
|
|
385
|
+
if (drawableId != 0) {
|
|
386
|
+
val bitmap = android.graphics.BitmapFactory.decodeResource(context.resources, drawableId)
|
|
387
|
+
if (bitmap != null) {
|
|
388
|
+
try {
|
|
389
|
+
java.io.FileOutputStream(tempFile).use { out ->
|
|
390
|
+
bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 95, out)
|
|
391
|
+
}
|
|
392
|
+
return tempFile.absolutePath
|
|
393
|
+
} finally {
|
|
394
|
+
bitmap.recycle()
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
} catch (e: Exception) {
|
|
400
|
+
tempFile.delete()
|
|
401
|
+
Log.e(TAG, "Failed to resolve asset URI '$uri': ${e.message}", e)
|
|
402
|
+
throw RuntimeException("Failed to resolve media path: $uri", e)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return uri
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Resize image if dimensions exceed maxDimension to prevent OOM.
|
|
410
|
+
* Gemma 3n's vision encoder is optimized for 512x512 or 1024x1024.
|
|
411
|
+
* Passing larger images can spike memory 500MB+.
|
|
412
|
+
*/
|
|
413
|
+
private fun resizeImageIfNeeded(imagePath: String, maxDimension: Int = 1024): String {
|
|
414
|
+
val originalBitmap = android.graphics.BitmapFactory.decodeFile(imagePath)
|
|
415
|
+
?: throw RuntimeException("Failed to decode image: $imagePath")
|
|
416
|
+
|
|
417
|
+
val width = originalBitmap.width
|
|
418
|
+
val height = originalBitmap.height
|
|
419
|
+
|
|
420
|
+
// If already within bounds, return original path
|
|
421
|
+
if (width <= maxDimension && height <= maxDimension) {
|
|
422
|
+
originalBitmap.recycle()
|
|
423
|
+
return imagePath
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
Log.i(TAG, "Resizing image from ${width}x${height} to fit ${maxDimension}px")
|
|
427
|
+
|
|
428
|
+
val scale = maxDimension.toFloat() / maxOf(width, height)
|
|
429
|
+
val newWidth = (width * scale).toInt()
|
|
430
|
+
val newHeight = (height * scale).toInt()
|
|
431
|
+
|
|
432
|
+
val resizedBitmap = android.graphics.Bitmap.createScaledBitmap(originalBitmap, newWidth, newHeight, true)
|
|
433
|
+
originalBitmap.recycle()
|
|
434
|
+
|
|
435
|
+
// Save to temp file
|
|
436
|
+
val cacheDir = LiteRTLMInitProvider.applicationContext?.cacheDir
|
|
437
|
+
?: throw RuntimeException("Application context not available for image resizing")
|
|
438
|
+
val tempFile = java.io.File(cacheDir, "resized_${java.util.UUID.randomUUID()}.jpg")
|
|
439
|
+
java.io.FileOutputStream(tempFile).use { out ->
|
|
440
|
+
resizedBitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 90, out)
|
|
441
|
+
}
|
|
442
|
+
resizedBitmap.recycle()
|
|
443
|
+
|
|
444
|
+
Log.i(TAG, "Resized image saved to: ${tempFile.absolutePath} (${newWidth}x${newHeight})")
|
|
445
|
+
return tempFile.absolutePath
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
override fun sendMessageWithImage(message: String, imagePath: String): Promise<ExecuteResult> =
|
|
449
|
+
execute(
|
|
450
|
+
parts = arrayOf(MultimodalPartFactories.textPart(message), MultimodalPartFactories.imagePart(imagePath)),
|
|
451
|
+
onToken = null,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
override fun sendMessageWithImageAsync(message: String, imagePath: String, onToken: (String, Boolean) -> Unit): Promise<Unit> =
|
|
455
|
+
executeVoid(
|
|
456
|
+
parts = arrayOf(MultimodalPartFactories.textPart(message), MultimodalPartFactories.imagePart(imagePath)),
|
|
457
|
+
onToken = onToken,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
override fun downloadModel(url: String, fileName: String, onProgress: ((Double) -> Unit)?): Promise<String> {
|
|
461
|
+
return modelStore.downloadFile(url, fileName, "{}", onProgress ?: {})
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
override fun deleteModel(fileName: String): Promise<Unit> {
|
|
465
|
+
return Promise.parallel {
|
|
466
|
+
modelStore.deleteFile(fileName)
|
|
467
|
+
val currentlyLoadedName = loadedModelPath?.substringAfterLast("/")?.lowercase()
|
|
468
|
+
if (currentlyLoadedName != null && currentlyLoadedName == fileName.lowercase()) {
|
|
469
|
+
if (engine != null) {
|
|
470
|
+
cleanupInternal()
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
override fun sendMessageWithAudioAsync(message: String, audioPath: String, onToken: (String, Boolean) -> Unit): Promise<Unit> =
|
|
477
|
+
executeVoid(
|
|
478
|
+
parts = arrayOf(MultimodalPartFactories.textPart(message), MultimodalPartFactories.audioPart(audioPath)),
|
|
479
|
+
onToken = onToken,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
override fun sendMessageWithAudio(message: String, audioPath: String): Promise<ExecuteResult> =
|
|
483
|
+
execute(
|
|
484
|
+
parts = arrayOf(MultimodalPartFactories.textPart(message), MultimodalPartFactories.audioPart(audioPath)),
|
|
485
|
+
onToken = null,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
// -------------------------------------------------------------------------
|
|
489
|
+
// Helpers
|
|
490
|
+
// -------------------------------------------------------------------------
|
|
491
|
+
override fun getHistory(): Array<Message> {
|
|
492
|
+
// Synchronized list requires manual sync for iteration/copy
|
|
493
|
+
synchronized(history) {
|
|
494
|
+
return history.toTypedArray()
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
override fun resetConversation() {
|
|
499
|
+
synchronized(history) {
|
|
500
|
+
history.clear()
|
|
501
|
+
}
|
|
502
|
+
createNewConversation()
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
override fun isReady(): Boolean {
|
|
506
|
+
return isLoaded_
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Property backing field for isReady check
|
|
510
|
+
private val isLoaded_: Boolean
|
|
511
|
+
get() = engine != null
|
|
512
|
+
|
|
513
|
+
override fun getStats(): GenerationStats {
|
|
514
|
+
return lastStats
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
override fun getMemoryUsage(): MemoryUsage {
|
|
518
|
+
// Native heap: allocated bytes from Debug APIs (most accurate for native allocations)
|
|
519
|
+
val nativeHeapBytes = Debug.getNativeHeapAllocatedSize().toDouble()
|
|
520
|
+
|
|
521
|
+
// Process RSS: read from /proc/self/status (VmRSS) in kB
|
|
522
|
+
var residentBytes = 0.0
|
|
523
|
+
try {
|
|
524
|
+
java.io.File("/proc/self/status").forEachLine { line ->
|
|
525
|
+
if (line.startsWith("VmRSS:")) {
|
|
526
|
+
val kb = line.substringAfter("VmRSS:").trim().split("\\s+".toRegex())[0].toDoubleOrNull()
|
|
527
|
+
if (kb != null) {
|
|
528
|
+
residentBytes = kb * 1024.0
|
|
529
|
+
}
|
|
530
|
+
return@forEachLine
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
} catch (e: Exception) {
|
|
534
|
+
Log.w(TAG, "Failed to read /proc/self/status: ${e.message}")
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Available memory and low-memory flag from ActivityManager
|
|
538
|
+
var availableMemoryBytes = 0.0
|
|
539
|
+
var isLowMemory = false
|
|
540
|
+
try {
|
|
541
|
+
val context = LiteRTLMInitProvider.applicationContext
|
|
542
|
+
if (context != null) {
|
|
543
|
+
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
|
544
|
+
val memInfo = ActivityManager.MemoryInfo()
|
|
545
|
+
activityManager.getMemoryInfo(memInfo)
|
|
546
|
+
availableMemoryBytes = memInfo.availMem.toDouble()
|
|
547
|
+
isLowMemory = memInfo.lowMemory
|
|
548
|
+
}
|
|
549
|
+
} catch (e: Exception) {
|
|
550
|
+
Log.w(TAG, "Failed to get ActivityManager memory info: ${e.message}")
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return MemoryUsage(
|
|
554
|
+
nativeHeapBytes = nativeHeapBytes,
|
|
555
|
+
residentBytes = residentBytes,
|
|
556
|
+
availableMemoryBytes = availableMemoryBytes,
|
|
557
|
+
isLowMemory = isLowMemory
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
override fun getActiveBackend(): Backend = backend
|
|
562
|
+
|
|
563
|
+
override fun stopGeneration() {
|
|
564
|
+
try {
|
|
565
|
+
conversation?.cancelProcess()
|
|
566
|
+
Log.d(TAG, "stopGeneration: cancelled active inference")
|
|
567
|
+
} catch (e: Exception) {
|
|
568
|
+
Log.w(TAG, "stopGeneration: ${e.message}")
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
override fun close() {
|
|
573
|
+
Log.d(TAG, "Closing resources")
|
|
574
|
+
isClosed = true
|
|
575
|
+
cleanupInternal()
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private fun cleanupInternal() {
|
|
579
|
+
synchronized(initLock) {
|
|
580
|
+
try {
|
|
581
|
+
conversation?.close()
|
|
582
|
+
conversation = null
|
|
583
|
+
engine?.close() // Direct call
|
|
584
|
+
engine = null
|
|
585
|
+
loadedModelPath = null
|
|
586
|
+
} catch (e: Exception) {
|
|
587
|
+
Log.e(TAG, "Error closing resources", e)
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private fun ensureLoaded() {
|
|
593
|
+
if (engine == null) {
|
|
594
|
+
throw RuntimeException("LiteRTLM: No model loaded. Call loadModel() first.")
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private fun createNewConversation() {
|
|
599
|
+
ensureLoaded()
|
|
600
|
+
// v0.10.2 enforces single-session: close existing conversation first
|
|
601
|
+
conversation?.let { oldConv ->
|
|
602
|
+
try {
|
|
603
|
+
oldConv.close()
|
|
604
|
+
} catch (e: Exception) {
|
|
605
|
+
Log.w(TAG, "Failed to close old conversation: ${e.message}")
|
|
606
|
+
}
|
|
607
|
+
conversation = null
|
|
608
|
+
}
|
|
609
|
+
// Map tools — capture tool calls for JS instead of executing natively
|
|
610
|
+
val lmTools: List<ToolProvider>? = tools?.map { toolDef ->
|
|
611
|
+
val apiTool = object : OpenApiTool {
|
|
612
|
+
override fun getToolDescriptionJsonString(): String {
|
|
613
|
+
// SDK expects full OpenAPI tool description with name, description, and parameters
|
|
614
|
+
val fullDesc = org.json.JSONObject()
|
|
615
|
+
fullDesc.put("name", toolDef.name)
|
|
616
|
+
fullDesc.put("description", toolDef.description)
|
|
617
|
+
fullDesc.put("parameters", org.json.JSONObject(toolDef.parametersJson))
|
|
618
|
+
return fullDesc.toString()
|
|
619
|
+
}
|
|
620
|
+
override fun execute(paramsJsonString: String): String {
|
|
621
|
+
Log.d(TAG, "Tool called: ${toolDef.name} with args: $paramsJsonString")
|
|
622
|
+
pendingToolCalls.add(ToolCall(
|
|
623
|
+
name = toolDef.name,
|
|
624
|
+
argumentsJson = paramsJsonString
|
|
625
|
+
))
|
|
626
|
+
return "{\"status\": \"pending\", \"message\": \"Tool execution delegated to application\"}"
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
tool(apiTool)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Create conversation with explicit SamplerConfig (required by Gallery pattern).
|
|
633
|
+
// GPU backend may fail silently without proper sampler params.
|
|
634
|
+
val convConfig = ConversationConfig(
|
|
635
|
+
samplerConfig = SamplerConfig(
|
|
636
|
+
topK = topK,
|
|
637
|
+
topP = topP.toDouble(),
|
|
638
|
+
temperature = temperature.toDouble(),
|
|
639
|
+
),
|
|
640
|
+
systemInstruction = systemPrompt?.let { Contents.of(Content.Text(it)) },
|
|
641
|
+
tools = lmTools ?: emptyList()
|
|
642
|
+
)
|
|
643
|
+
// TODO: maxOutputTokens is not configurable on Android — the Kotlin SDK's
|
|
644
|
+
// ConversationConfig does not expose this parameter. Only EngineConfig.maxNumTokens
|
|
645
|
+
// (context budget) is supported. maxOutputTokens is effective on iOS only.
|
|
646
|
+
//
|
|
647
|
+
// Upstream is actively adding max_output_tokens across API surfaces:
|
|
648
|
+
// - C API: PR #2470 (merged 2026-06-04)
|
|
649
|
+
// - Python: PR #2476 (merged 2026-06-04)
|
|
650
|
+
// - OpenAI: PR #2433 (in progress)
|
|
651
|
+
// - Kotlin: Not yet available — track at https://github.com/google-ai-edge/LiteRT-LM
|
|
652
|
+
//
|
|
653
|
+
// Once the Kotlin SDK exposes this, wire it via ConversationConfig here.
|
|
654
|
+
conversation = engine!!.createConversation(convConfig)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
override fun sendMultimodalMessage(parts: Array<MultimodalPart>): Promise<ExecuteResult> {
|
|
660
|
+
return execute(parts = parts, onToken = null)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Streaming adapter for legacy `Promise<Unit>` APIs — all inference runs through [execute]. */
|
|
664
|
+
private fun executeVoid(
|
|
665
|
+
parts: Array<MultimodalPart>,
|
|
666
|
+
onToken: (String, Boolean) -> Unit,
|
|
667
|
+
): Promise<Unit> {
|
|
668
|
+
val voidPromise = Promise<Unit>()
|
|
669
|
+
try {
|
|
670
|
+
execute(parts, onToken)
|
|
671
|
+
.then { _ -> voidPromise.resolve(Unit) }
|
|
672
|
+
.catch { voidPromise.reject(it) }
|
|
673
|
+
} catch (e: Throwable) {
|
|
674
|
+
voidPromise.reject(e)
|
|
675
|
+
}
|
|
676
|
+
return voidPromise
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
private class PreprocessedPart(
|
|
680
|
+
val type: PartType,
|
|
681
|
+
val text: String?,
|
|
682
|
+
val path: String?,
|
|
683
|
+
val bytes: ByteArray?
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
override fun execute(parts: Array<MultimodalPart>, onToken: ((token: String, done: Boolean) -> Unit)?): Promise<ExecuteResult> {
|
|
687
|
+
// Preprocess synchronously on the JS/JSI thread to safely extract JS buffer bytes
|
|
688
|
+
val preprocessed = parts.map { part ->
|
|
689
|
+
val bytes = when (part.type) {
|
|
690
|
+
PartType.IMAGE -> part.imageBuffer?.let { buf ->
|
|
691
|
+
val javaBuf = buf.getBuffer(false)
|
|
692
|
+
val arr = ByteArray(javaBuf.remaining())
|
|
693
|
+
javaBuf.get(arr)
|
|
694
|
+
arr
|
|
695
|
+
}
|
|
696
|
+
PartType.AUDIO -> part.audioBuffer?.let { buf ->
|
|
697
|
+
val javaBuf = buf.getBuffer(false)
|
|
698
|
+
val arr = ByteArray(javaBuf.remaining())
|
|
699
|
+
javaBuf.get(arr)
|
|
700
|
+
arr
|
|
701
|
+
}
|
|
702
|
+
else -> null
|
|
703
|
+
}
|
|
704
|
+
PreprocessedPart(
|
|
705
|
+
type = part.type,
|
|
706
|
+
text = part.text,
|
|
707
|
+
path = part.path,
|
|
708
|
+
bytes = bytes
|
|
709
|
+
)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return Promise.parallel {
|
|
713
|
+
ensureLoaded()
|
|
714
|
+
// Clear any previous tool calls before new inference
|
|
715
|
+
pendingToolCalls.clear()
|
|
716
|
+
|
|
717
|
+
val tempFiles = mutableListOf<java.io.File>()
|
|
718
|
+
|
|
719
|
+
try {
|
|
720
|
+
val contents = mutableListOf<Content>()
|
|
721
|
+
var userTextRepresentation = ""
|
|
722
|
+
|
|
723
|
+
for (part in preprocessed) {
|
|
724
|
+
when (part.type) {
|
|
725
|
+
PartType.TEXT -> part.text?.let {
|
|
726
|
+
contents.add(Content.Text(it))
|
|
727
|
+
userTextRepresentation += "$it "
|
|
728
|
+
}
|
|
729
|
+
PartType.IMAGE -> {
|
|
730
|
+
val imagePath = when {
|
|
731
|
+
part.path != null -> {
|
|
732
|
+
val resolved = resolveAssetUri(part.path, "jpg")
|
|
733
|
+
if (resolved != part.path) tempFiles.add(java.io.File(resolved))
|
|
734
|
+
resolved
|
|
735
|
+
}
|
|
736
|
+
part.bytes != null -> {
|
|
737
|
+
val tmp = java.io.File(
|
|
738
|
+
LiteRTLMInitProvider.applicationContext!!.cacheDir,
|
|
739
|
+
"litert_buf_${java.util.UUID.randomUUID()}.jpg"
|
|
740
|
+
)
|
|
741
|
+
tmp.writeBytes(part.bytes)
|
|
742
|
+
tempFiles.add(tmp)
|
|
743
|
+
tmp.absolutePath
|
|
744
|
+
}
|
|
745
|
+
else -> null
|
|
746
|
+
}
|
|
747
|
+
if (imagePath != null) {
|
|
748
|
+
val processedPath = resizeImageIfNeeded(imagePath)
|
|
749
|
+
if (processedPath != imagePath) tempFiles.add(java.io.File(processedPath))
|
|
750
|
+
contents.add(Content.ImageFile(processedPath))
|
|
751
|
+
userTextRepresentation += "[Image] "
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
PartType.AUDIO -> {
|
|
755
|
+
val audioPath = when {
|
|
756
|
+
part.path != null -> {
|
|
757
|
+
val resolved = resolveAssetUri(part.path, "wav")
|
|
758
|
+
if (resolved != part.path) tempFiles.add(java.io.File(resolved))
|
|
759
|
+
resolved
|
|
760
|
+
}
|
|
761
|
+
part.bytes != null -> {
|
|
762
|
+
val tmp = java.io.File(
|
|
763
|
+
LiteRTLMInitProvider.applicationContext!!.cacheDir,
|
|
764
|
+
"litert_buf_${java.util.UUID.randomUUID()}.wav"
|
|
765
|
+
)
|
|
766
|
+
tmp.writeBytes(part.bytes)
|
|
767
|
+
tempFiles.add(tmp)
|
|
768
|
+
tmp.absolutePath
|
|
769
|
+
}
|
|
770
|
+
else -> null
|
|
771
|
+
}
|
|
772
|
+
if (audioPath != null) {
|
|
773
|
+
contents.add(Content.AudioFile(audioPath))
|
|
774
|
+
userTextRepresentation += "[Audio] "
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
userTextRepresentation = userTextRepresentation.trim()
|
|
781
|
+
history.add(Message(Role.USER, userTextRepresentation))
|
|
782
|
+
|
|
783
|
+
val userMsg = LiteRTMessage.user(Contents.of(contents))
|
|
784
|
+
|
|
785
|
+
val extraContext: Map<String, String> = if (enableThinking) mapOf("enable_thinking" to "true") else emptyMap()
|
|
786
|
+
|
|
787
|
+
if (onToken != null) {
|
|
788
|
+
// ── Streaming path ────────────────────────────────────────────────
|
|
789
|
+
val latch = CountDownLatch(1)
|
|
790
|
+
val errorRef = AtomicReference<Throwable?>(null)
|
|
791
|
+
val fullResponseBuilder = StringBuilder()
|
|
792
|
+
val thinkingBuilder = StringBuilder()
|
|
793
|
+
|
|
794
|
+
val listener = StreamingCallbackListener(
|
|
795
|
+
onToken = { token, done ->
|
|
796
|
+
onToken(token, done)
|
|
797
|
+
if (done) latch.countDown()
|
|
798
|
+
},
|
|
799
|
+
responseBuilder = fullResponseBuilder,
|
|
800
|
+
thinkingBuilder = thinkingBuilder,
|
|
801
|
+
history = history,
|
|
802
|
+
userMessage = userTextRepresentation,
|
|
803
|
+
onStatsReady = { stats -> lastStats = stats },
|
|
804
|
+
onFailure = { e -> errorRef.set(e) }
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
try {
|
|
808
|
+
conversation!!.sendMessageAsync(message = userMsg, callback = listener, extraContext = extraContext)
|
|
809
|
+
} catch (e: Exception) {
|
|
810
|
+
Log.e(TAG, "execute streaming failed", e)
|
|
811
|
+
errorRef.set(e)
|
|
812
|
+
onToken("Error: ${e.message}", true)
|
|
813
|
+
latch.countDown()
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
latch.await()
|
|
817
|
+
errorRef.get()?.let { throw RuntimeException("execute streaming failed: ${it.message}", it) }
|
|
818
|
+
val capturedToolCalls = synchronized(pendingToolCalls) {
|
|
819
|
+
pendingToolCalls.toTypedArray().also { pendingToolCalls.clear() }
|
|
820
|
+
}
|
|
821
|
+
ExecuteResult(
|
|
822
|
+
text = fullResponseBuilder.toString(),
|
|
823
|
+
toolCalls = capturedToolCalls,
|
|
824
|
+
thinkingText = thinkingBuilder.toString()
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
} else {
|
|
828
|
+
// ── Blocking path ─────────────────────────────────────────────────
|
|
829
|
+
val startTime = System.nanoTime()
|
|
830
|
+
val responseMsg = conversation!!.sendMessage(message = userMsg, extraContext = extraContext)
|
|
831
|
+
val elapsedMs = (System.nanoTime() - startTime) / 1_000_000.0
|
|
832
|
+
|
|
833
|
+
val response = responseMsg.contents.contents
|
|
834
|
+
.filterIsInstance<Content.Text>()
|
|
835
|
+
.joinToString("") { it.text }
|
|
836
|
+
|
|
837
|
+
val thinkingText = responseMsg.channels["thought"] ?: ""
|
|
838
|
+
|
|
839
|
+
history.add(Message(Role.MODEL, response))
|
|
840
|
+
|
|
841
|
+
val promptTokens = userTextRepresentation.length / 4.0
|
|
842
|
+
val completionTokens = response.length / 4.0
|
|
843
|
+
lastStats = GenerationStats(
|
|
844
|
+
promptTokens = promptTokens,
|
|
845
|
+
completionTokens = completionTokens,
|
|
846
|
+
totalTokens = promptTokens + completionTokens,
|
|
847
|
+
timeToFirstToken = 0.0,
|
|
848
|
+
totalTime = elapsedMs,
|
|
849
|
+
tokensPerSecond = if (elapsedMs > 0) completionTokens / (elapsedMs / 1000.0) else 0.0
|
|
850
|
+
)
|
|
851
|
+
val capturedToolCalls = synchronized(pendingToolCalls) {
|
|
852
|
+
pendingToolCalls.toTypedArray().also { pendingToolCalls.clear() }
|
|
853
|
+
}
|
|
854
|
+
ExecuteResult(
|
|
855
|
+
text = response,
|
|
856
|
+
toolCalls = capturedToolCalls,
|
|
857
|
+
thinkingText = thinkingText
|
|
858
|
+
)
|
|
859
|
+
}
|
|
860
|
+
} finally {
|
|
861
|
+
// Clean up all temp files created during this execute call
|
|
862
|
+
for (f in tempFiles) {
|
|
863
|
+
try { f.delete() } catch (e: Exception) {
|
|
864
|
+
Log.w(TAG, "Failed to delete temp file: ${f.absolutePath}")
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
override fun sendToolResponse(
|
|
872
|
+
responses: Array<ToolResponse>,
|
|
873
|
+
onToken: ((token: String, done: Boolean) -> Unit)?
|
|
874
|
+
): Promise<ExecuteResult> {
|
|
875
|
+
// Format tool results as a message and send to the conversation
|
|
876
|
+
val toolResultText = responses.joinToString("\n") { response ->
|
|
877
|
+
"Tool '${response.name}' result: ${response.responseJson}"
|
|
878
|
+
}
|
|
879
|
+
return execute(
|
|
880
|
+
parts = arrayOf(MultimodalPartFactories.textPart(toolResultText)),
|
|
881
|
+
onToken = onToken
|
|
882
|
+
)
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
override fun countTokens(text: String): Double {
|
|
886
|
+
return -1.0
|
|
887
|
+
}
|
|
888
|
+
}
|