@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.
Files changed (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +627 -0
  3. package/android/CMakeLists.txt +32 -0
  4. package/android/build.gradle +104 -0
  5. package/android/src/main/AndroidManifest.xml +19 -0
  6. package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLM.kt +888 -0
  7. package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/HybridModelStore.kt +189 -0
  8. package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/LiteRTLMRegistry.kt +32 -0
  9. package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/MultimodalPartFactories.kt +15 -0
  10. package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/StreamingCallbackListener.kt +89 -0
  11. package/android/src/main/java/dev/litert/litertlm/LiteRTLMInitProvider.kt +57 -0
  12. package/android/src/main/java/dev/litert/litertlm/LiteRTLMPackage.kt +43 -0
  13. package/android/src/test/java/com/margelo/nitro/core/Promise.kt +46 -0
  14. package/android/src/test/java/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLMTest.kt +122 -0
  15. package/app.plugin.js +58 -0
  16. package/cpp/cpp-adapter.cpp +21 -0
  17. package/cpp/include/README.md +32 -0
  18. package/cpp/include/litert_lm_engine.h +516 -0
  19. package/cpp/include/stb_image.h +7988 -0
  20. package/ios/HybridLiteRTLM+Execute.swift +287 -0
  21. package/ios/HybridLiteRTLM+Streaming.swift +153 -0
  22. package/ios/HybridLiteRTLM.swift +541 -0
  23. package/ios/HybridModelStore.swift +206 -0
  24. package/ios/MultimodalPart+Factories.swift +22 -0
  25. package/ios/Tests/HybridLiteRTLMTests.swift +187 -0
  26. package/lib/__mocks__/react-native-nitro-modules.d.ts +81 -0
  27. package/lib/__mocks__/react-native-nitro-modules.js +98 -0
  28. package/lib/__tests__/hooks.test.d.ts +1 -0
  29. package/lib/__tests__/hooks.test.js +175 -0
  30. package/lib/__tests__/inferenceRouting.test.d.ts +1 -0
  31. package/lib/__tests__/inferenceRouting.test.js +28 -0
  32. package/lib/__tests__/memoryTracker.test.d.ts +1 -0
  33. package/lib/__tests__/memoryTracker.test.js +74 -0
  34. package/lib/__tests__/modelFactory.test.d.ts +1 -0
  35. package/lib/__tests__/modelFactory.test.js +107 -0
  36. package/lib/__tests__/modelPath.test.d.ts +1 -0
  37. package/lib/__tests__/modelPath.test.js +12 -0
  38. package/lib/__tests__/modelRegistry.test.d.ts +1 -0
  39. package/lib/__tests__/modelRegistry.test.js +61 -0
  40. package/lib/hooks.d.ts +46 -0
  41. package/lib/hooks.js +159 -0
  42. package/lib/index.d.ts +142 -0
  43. package/lib/index.js +184 -0
  44. package/lib/inferenceRouting.d.ts +18 -0
  45. package/lib/inferenceRouting.js +63 -0
  46. package/lib/memoryTracker.d.ts +128 -0
  47. package/lib/memoryTracker.js +155 -0
  48. package/lib/modelFactory.d.ts +24 -0
  49. package/lib/modelFactory.js +128 -0
  50. package/lib/modelPath.d.ts +8 -0
  51. package/lib/modelPath.js +18 -0
  52. package/lib/modelRegistry.d.ts +55 -0
  53. package/lib/modelRegistry.js +82 -0
  54. package/lib/specs/LiteRTLM.nitro.d.ts +380 -0
  55. package/lib/specs/LiteRTLM.nitro.js +2 -0
  56. package/nitrogen/generated/.gitattributes +1 -0
  57. package/nitrogen/generated/android/LiteRTLM+autolinking.cmake +83 -0
  58. package/nitrogen/generated/android/LiteRTLM+autolinking.gradle +27 -0
  59. package/nitrogen/generated/android/LiteRTLMOnLoad.cpp +74 -0
  60. package/nitrogen/generated/android/LiteRTLMOnLoad.hpp +34 -0
  61. package/nitrogen/generated/android/c++/JBackend.hpp +61 -0
  62. package/nitrogen/generated/android/c++/JExecuteResult.hpp +86 -0
  63. package/nitrogen/generated/android/c++/JFunc_void_double.hpp +75 -0
  64. package/nitrogen/generated/android/c++/JFunc_void_std__string_bool.hpp +76 -0
  65. package/nitrogen/generated/android/c++/JGenerationStats.hpp +77 -0
  66. package/nitrogen/generated/android/c++/JHybridLiteRTLMSpec.cpp +371 -0
  67. package/nitrogen/generated/android/c++/JHybridLiteRTLMSpec.hpp +83 -0
  68. package/nitrogen/generated/android/c++/JHybridModelStoreSpec.cpp +101 -0
  69. package/nitrogen/generated/android/c++/JHybridModelStoreSpec.hpp +67 -0
  70. package/nitrogen/generated/android/c++/JLLMConfig.hpp +125 -0
  71. package/nitrogen/generated/android/c++/JMemoryUsage.hpp +69 -0
  72. package/nitrogen/generated/android/c++/JMessage.hpp +63 -0
  73. package/nitrogen/generated/android/c++/JModelFile.hpp +69 -0
  74. package/nitrogen/generated/android/c++/JMultimodalPart.hpp +78 -0
  75. package/nitrogen/generated/android/c++/JPartType.hpp +61 -0
  76. package/nitrogen/generated/android/c++/JRole.hpp +61 -0
  77. package/nitrogen/generated/android/c++/JToolCall.hpp +61 -0
  78. package/nitrogen/generated/android/c++/JToolDefinition.hpp +65 -0
  79. package/nitrogen/generated/android/c++/JToolResponse.hpp +61 -0
  80. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/Backend.kt +24 -0
  81. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/ExecuteResult.kt +61 -0
  82. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/Func_void_double.kt +80 -0
  83. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/Func_void_std__string_bool.kt +80 -0
  84. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/GenerationStats.kt +76 -0
  85. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLMSpec.kt +165 -0
  86. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/HybridModelStoreSpec.kt +76 -0
  87. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/LLMConfig.kt +106 -0
  88. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/LiteRTLMOnLoad.kt +35 -0
  89. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/MemoryUsage.kt +66 -0
  90. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/Message.kt +56 -0
  91. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/ModelFile.kt +66 -0
  92. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/MultimodalPart.kt +71 -0
  93. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/PartType.kt +24 -0
  94. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/Role.kt +24 -0
  95. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/ToolCall.kt +56 -0
  96. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/ToolDefinition.kt +61 -0
  97. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/ToolResponse.kt +56 -0
  98. package/nitrogen/generated/ios/LiteRTLM+autolinking.rb +62 -0
  99. package/nitrogen/generated/ios/LiteRTLM-Swift-Cxx-Bridge.cpp +98 -0
  100. package/nitrogen/generated/ios/LiteRTLM-Swift-Cxx-Bridge.hpp +585 -0
  101. package/nitrogen/generated/ios/LiteRTLM-Swift-Cxx-Umbrella.hpp +93 -0
  102. package/nitrogen/generated/ios/LiteRTLMAutolinking.mm +41 -0
  103. package/nitrogen/generated/ios/LiteRTLMAutolinking.swift +38 -0
  104. package/nitrogen/generated/ios/c++/HybridLiteRTLMSpecSwift.cpp +11 -0
  105. package/nitrogen/generated/ios/c++/HybridLiteRTLMSpecSwift.hpp +279 -0
  106. package/nitrogen/generated/ios/c++/HybridModelStoreSpecSwift.cpp +11 -0
  107. package/nitrogen/generated/ios/c++/HybridModelStoreSpecSwift.hpp +117 -0
  108. package/nitrogen/generated/ios/swift/Backend.swift +44 -0
  109. package/nitrogen/generated/ios/swift/ExecuteResult.swift +45 -0
  110. package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
  111. package/nitrogen/generated/ios/swift/Func_void_ExecuteResult.swift +46 -0
  112. package/nitrogen/generated/ios/swift/Func_void_double.swift +46 -0
  113. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  114. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  115. package/nitrogen/generated/ios/swift/Func_void_std__string_bool.swift +46 -0
  116. package/nitrogen/generated/ios/swift/GenerationStats.swift +54 -0
  117. package/nitrogen/generated/ios/swift/HybridLiteRTLMSpec.swift +75 -0
  118. package/nitrogen/generated/ios/swift/HybridLiteRTLMSpec_cxx.swift +516 -0
  119. package/nitrogen/generated/ios/swift/HybridModelStoreSpec.swift +59 -0
  120. package/nitrogen/generated/ios/swift/HybridModelStoreSpec_cxx.swift +203 -0
  121. package/nitrogen/generated/ios/swift/LLMConfig.swift +239 -0
  122. package/nitrogen/generated/ios/swift/MemoryUsage.swift +44 -0
  123. package/nitrogen/generated/ios/swift/Message.swift +34 -0
  124. package/nitrogen/generated/ios/swift/ModelFile.swift +44 -0
  125. package/nitrogen/generated/ios/swift/MultimodalPart.swift +101 -0
  126. package/nitrogen/generated/ios/swift/PartType.swift +44 -0
  127. package/nitrogen/generated/ios/swift/Role.swift +44 -0
  128. package/nitrogen/generated/ios/swift/ToolCall.swift +34 -0
  129. package/nitrogen/generated/ios/swift/ToolDefinition.swift +39 -0
  130. package/nitrogen/generated/ios/swift/ToolResponse.swift +34 -0
  131. package/nitrogen/generated/shared/c++/Backend.hpp +80 -0
  132. package/nitrogen/generated/shared/c++/ExecuteResult.hpp +94 -0
  133. package/nitrogen/generated/shared/c++/GenerationStats.hpp +103 -0
  134. package/nitrogen/generated/shared/c++/HybridLiteRTLMSpec.cpp +41 -0
  135. package/nitrogen/generated/shared/c++/HybridLiteRTLMSpec.hpp +109 -0
  136. package/nitrogen/generated/shared/c++/HybridModelStoreSpec.cpp +25 -0
  137. package/nitrogen/generated/shared/c++/HybridModelStoreSpec.hpp +71 -0
  138. package/nitrogen/generated/shared/c++/LLMConfig.hpp +134 -0
  139. package/nitrogen/generated/shared/c++/MemoryUsage.hpp +95 -0
  140. package/nitrogen/generated/shared/c++/Message.hpp +89 -0
  141. package/nitrogen/generated/shared/c++/ModelFile.hpp +95 -0
  142. package/nitrogen/generated/shared/c++/MultimodalPart.hpp +103 -0
  143. package/nitrogen/generated/shared/c++/PartType.hpp +80 -0
  144. package/nitrogen/generated/shared/c++/Role.hpp +80 -0
  145. package/nitrogen/generated/shared/c++/ToolCall.hpp +87 -0
  146. package/nitrogen/generated/shared/c++/ToolDefinition.hpp +91 -0
  147. package/nitrogen/generated/shared/c++/ToolResponse.hpp +87 -0
  148. package/package.json +115 -0
  149. package/react-native-litert-lm.podspec +62 -0
  150. package/react-native.config.js +16 -0
  151. package/scripts/download-ios-frameworks.sh +39 -0
  152. package/scripts/framework-source.js +46 -0
  153. package/scripts/postinstall.js +145 -0
  154. package/src/__mocks__/react-native-nitro-modules.ts +132 -0
  155. package/src/__tests__/hooks.test.ts +216 -0
  156. package/src/__tests__/inferenceRouting.test.ts +36 -0
  157. package/src/__tests__/memoryTracker.test.ts +87 -0
  158. package/src/__tests__/modelFactory.test.ts +153 -0
  159. package/src/__tests__/modelPath.test.ts +14 -0
  160. package/src/__tests__/modelRegistry.test.ts +73 -0
  161. package/src/hooks.ts +234 -0
  162. package/src/index.ts +214 -0
  163. package/src/inferenceRouting.ts +80 -0
  164. package/src/memoryTracker.ts +268 -0
  165. package/src/modelFactory.ts +163 -0
  166. package/src/modelPath.ts +16 -0
  167. package/src/modelRegistry.ts +104 -0
  168. 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
+ }