@dr33m/react-native-litert-lm 0.5.6 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/android/build.gradle +22 -1
  2. package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLM.kt +138 -43
  3. package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/LiteRTLMRegistry.kt +25 -6
  4. package/android/src/main/java/dev/litert/litertlm/LiteRTLMInitProvider.kt +4 -1
  5. package/cpp/include/README.md +12 -16
  6. package/cpp/include/litert_lm_engine.h +1359 -71
  7. package/ios/HybridLiteRTLM+Streaming.swift +9 -6
  8. package/ios/HybridLiteRTLM.swift +42 -2
  9. package/lib/__mocks__/react-native-nitro-modules.d.ts +2 -0
  10. package/lib/__mocks__/react-native-nitro-modules.js +1 -0
  11. package/lib/__tests__/modelFactory.test.js +19 -0
  12. package/lib/modelFactory.js +13 -0
  13. package/lib/specs/LiteRTLM.nitro.d.ts +17 -0
  14. package/nitrogen/generated/android/c++/JHybridLiteRTLMSpec.cpp +13 -0
  15. package/nitrogen/generated/android/c++/JHybridLiteRTLMSpec.hpp +1 -0
  16. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLMSpec.kt +4 -0
  17. package/nitrogen/generated/ios/c++/HybridLiteRTLMSpecSwift.hpp +6 -0
  18. package/nitrogen/generated/ios/swift/HybridLiteRTLMSpec.swift +1 -0
  19. package/nitrogen/generated/ios/swift/HybridLiteRTLMSpec_cxx.swift +11 -0
  20. package/nitrogen/generated/shared/c++/HybridLiteRTLMSpec.cpp +1 -0
  21. package/nitrogen/generated/shared/c++/HybridLiteRTLMSpec.hpp +1 -0
  22. package/package.json +4 -4
  23. package/react-native-litert-lm.podspec +4 -0
  24. package/src/__mocks__/react-native-nitro-modules.ts +1 -0
  25. package/src/__tests__/modelFactory.test.ts +21 -0
  26. package/src/modelFactory.ts +14 -0
  27. package/src/specs/LiteRTLM.nitro.ts +18 -0
@@ -55,11 +55,14 @@ extension HybridLiteRTLM {
55
55
  )
56
56
  let ptr = Unmanaged.passRetained(ctx).toOpaque()
57
57
 
58
- let cb: LiteRtLmStreamCallback = { ptr, chunk, isFinal, errorMsg in
59
- guard let ptr = ptr else { return }
58
+ // v0.15: the callback receives an opaque LiteRtLmStreamChunk; text,
59
+ // finality, and errors are read through accessors. The chunk (and any
60
+ // string it returns) is only valid for the duration of the call.
61
+ let cb: LiteRtLmStreamCallback = { ptr, chunk in
62
+ guard let ptr = ptr, let chunk = chunk else { return }
60
63
  let ctx = Unmanaged<ExecuteStreamContext>.fromOpaque(ptr).takeUnretainedValue()
61
64
 
62
- if let errorMsg = errorMsg {
65
+ if let errorMsg = litert_lm_stream_chunk_get_error(chunk) {
63
66
  let msg = String(cString: errorMsg)
64
67
  ctx.onToken("Error: \(msg)", true)
65
68
  ctx.cleanup()
@@ -69,13 +72,13 @@ extension HybridLiteRTLM {
69
72
  return
70
73
  }
71
74
 
72
- if isFinal {
75
+ if litert_lm_stream_chunk_is_final(chunk) {
73
76
  ctx.parent.finalizeExecuteStream(ctx: ctx, streamPtr: ptr)
74
77
  return
75
78
  }
76
79
 
77
- if let chunk = chunk {
78
- ctx.parent.emitExecuteStreamChunk(ctx: ctx, chunk: chunk)
80
+ if let text = litert_lm_stream_chunk_get_text(chunk) {
81
+ ctx.parent.emitExecuteStreamChunk(ctx: ctx, chunk: text)
79
82
  }
80
83
  }
81
84
 
@@ -92,6 +92,26 @@ public class HybridLiteRTLM: HybridLiteRTLMSpec_base, HybridLiteRTLMSpec_protoco
92
92
  }
93
93
  }
94
94
 
95
+ public func resetConversationWith(messages: [Message]) throws {
96
+ queue.sync {
97
+ history = messages
98
+ lastStats = GenerationStats(
99
+ promptTokens: 0.0,
100
+ completionTokens: 0.0,
101
+ totalTokens: 0.0,
102
+ timeToFirstToken: 0.0,
103
+ totalTime: 0.0,
104
+ tokensPerSecond: 0.0
105
+ )
106
+ if isLoaded && engine != nil {
107
+ // Recreating the conversation is what actually frees the old KV
108
+ // cache; the seed is replayed into the new one without
109
+ // triggering generation.
110
+ createNewConversation(initialMessages: messages)
111
+ }
112
+ }
113
+ }
114
+
95
115
  public func getStats() throws -> GenerationStats {
96
116
  return queue.sync { lastStats }
97
117
  }
@@ -219,7 +239,9 @@ public class HybridLiteRTLM: HybridLiteRTLMSpec_base, HybridLiteRTLMSpec_protoco
219
239
  var rawEngine: OpaquePointer? = nil
220
240
 
221
241
  // Set LiteRT C Log Level to WARNING (2) for clean production output
222
- litert_lm_set_min_log_level(2)
242
+ // v0.15: takes a typed LiteRtLmLogSeverity (previously a raw int,
243
+ // where 2 was INFO — WARNING is the intended level).
244
+ litert_lm_set_min_log_level(kLiteRtLmLogSeverityWarning)
223
245
 
224
246
  // Creation helper with scoped FFI pointer lifetime
225
247
  let createEngine = { (main: String, vision: String?, audio: String?) -> OpaquePointer? in
@@ -393,7 +415,7 @@ public class HybridLiteRTLM: HybridLiteRTLMSpec_base, HybridLiteRTLMSpec_protoco
393
415
 
394
416
  // MARK: - Internal Engine Helpers
395
417
 
396
- private func createNewConversation() {
418
+ private func createNewConversation(initialMessages: [Message] = []) {
397
419
  guard let engine = self.engine else { return }
398
420
 
399
421
  if let oldConv = self.conversation {
@@ -446,6 +468,24 @@ public class HybridLiteRTLM: HybridLiteRTLMSpec_base, HybridLiteRTLMSpec_protoco
446
468
  }
447
469
  }
448
470
 
471
+ if !initialMessages.isEmpty {
472
+ let payload = initialMessages.map { msg -> [String: String] in
473
+ let role: String
474
+ switch msg.role {
475
+ case .model: role = "model"
476
+ case .system: role = "system"
477
+ default: role = "user"
478
+ }
479
+ return ["role": role, "content": msg.content]
480
+ }
481
+ if let data = try? JSONSerialization.data(withJSONObject: payload, options: []),
482
+ let jsonString = String(data: data, encoding: .utf8) {
483
+ jsonString.withCString { messagesC in
484
+ litert_lm_conversation_config_set_messages(convConfig, messagesC)
485
+ }
486
+ }
487
+ }
488
+
449
489
  self.conversation = litert_lm_conversation_create(engine, convConfig)
450
490
  }
451
491
 
@@ -14,6 +14,7 @@ export declare const mockLiteRTLM: {
14
14
  sendMessageWithAudioAsync: jest.Mock<Promise<void>, [msg: string, audioPath: string, onToken: (token: string, done: boolean) => void], any>;
15
15
  getHistory: jest.Mock<never[], [], any>;
16
16
  resetConversation: jest.Mock<any, any, any>;
17
+ resetConversationWith: jest.Mock<any, any, any>;
17
18
  getStats: jest.Mock<{
18
19
  promptTokens: number;
19
20
  completionTokens: number;
@@ -54,6 +55,7 @@ export declare const NitroModules: {
54
55
  sendMessageWithAudioAsync: jest.Mock<Promise<void>, [msg: string, audioPath: string, onToken: (token: string, done: boolean) => void], any>;
55
56
  getHistory: jest.Mock<never[], [], any>;
56
57
  resetConversation: jest.Mock<any, any, any>;
58
+ resetConversationWith: jest.Mock<any, any, any>;
57
59
  getStats: jest.Mock<{
58
60
  promptTokens: number;
59
61
  completionTokens: number;
@@ -56,6 +56,7 @@ exports.mockLiteRTLM = {
56
56
  sendMessageWithAudioAsync: jest.fn((msg, audioPath, onToken) => mockExecute([{ type: "text", text: msg }, { type: "audio", path: audioPath }], onToken).then(() => { })),
57
57
  getHistory: jest.fn(() => []),
58
58
  resetConversation: jest.fn(),
59
+ resetConversationWith: jest.fn(),
59
60
  getStats: jest.fn(() => ({
60
61
  promptTokens: 10,
61
62
  completionTokens: 20,
@@ -33,6 +33,25 @@ describe('modelFactory Security & Proxy Unit Tests', () => {
33
33
  expect(react_native_nitro_modules_1.mockLiteRTLM.resetConversation).toHaveBeenCalled();
34
34
  expect(react_native_nitro_modules_1.mockLiteRTLM.getMemoryUsage).toHaveBeenCalled();
35
35
  });
36
+ it('should successfully proxy resetConversationWith and record memory metrics', async () => {
37
+ const seed = [{ role: 'user', content: 'earlier turn' }];
38
+ await llm.resetConversationWith(seed);
39
+ expect(react_native_nitro_modules_1.mockLiteRTLM.resetConversationWith).toHaveBeenCalledWith(seed);
40
+ expect(react_native_nitro_modules_1.mockLiteRTLM.getMemoryUsage).toHaveBeenCalled();
41
+ });
42
+ it('should report resetConversationWith as absent on older native builds', () => {
43
+ // The app feature-detects this method, so the proxy must not hand back a
44
+ // wrapper the native side cannot service.
45
+ const original = react_native_nitro_modules_1.mockLiteRTLM.resetConversationWith;
46
+ // @ts-expect-error deliberately removing the method to simulate old native
47
+ delete react_native_nitro_modules_1.mockLiteRTLM.resetConversationWith;
48
+ try {
49
+ expect((0, modelFactory_1.createLLM)().resetConversationWith).toBeUndefined();
50
+ }
51
+ finally {
52
+ react_native_nitro_modules_1.mockLiteRTLM.resetConversationWith = original;
53
+ }
54
+ });
36
55
  it('should successfully proxy sendMessageAsync and record memory metrics when done', async () => {
37
56
  const onToken = jest.fn();
38
57
  await llm.sendMessageAsync("Async prompt", onToken);
@@ -95,6 +95,19 @@ function createLLM(options) {
95
95
  return result;
96
96
  };
97
97
  }
98
+ if (prop === "resetConversationWith") {
99
+ // Absent on native builds older than this JS wrapper, and callers
100
+ // feature-detect it — so fall through rather than handing back a
101
+ // wrapper that would fail at the bridge.
102
+ if (typeof target.resetConversationWith !== "function") {
103
+ return undefined;
104
+ }
105
+ return (messages) => {
106
+ const result = target.resetConversationWith(messages);
107
+ recordMemorySnapshot();
108
+ return result;
109
+ };
110
+ }
98
111
  if (prop === "sendToolResponse") {
99
112
  return (responses, onToken) => {
100
113
  if (onToken) {
@@ -313,6 +313,23 @@ export interface LiteRTLM extends HybridObject<{
313
313
  * Clear the conversation context and start fresh.
314
314
  */
315
315
  resetConversation(): void;
316
+ /**
317
+ * Clear the conversation and re-seed it with prior turns.
318
+ *
319
+ * The engine allocates one KV cache per conversation, sized by
320
+ * `maxContextTokens`. Long tool-calling sessions fill it, and overflowing it
321
+ * aborts the process rather than raising a catchable error. Recreating the
322
+ * conversation frees the cache, but `resetConversation()` alone also throws
323
+ * away the thread.
324
+ *
325
+ * This seeds the fresh conversation with `messages` as prior context, so a
326
+ * caller can drop the oldest turns and keep the recent ones. The system
327
+ * prompt and tools configured at `loadModel` are reapplied automatically.
328
+ * No generation is triggered and no reply is produced.
329
+ *
330
+ * @param messages Prior turns, oldest first.
331
+ */
332
+ resetConversationWith(messages: Message[]): void;
316
333
  /**
317
334
  * Check if a model is loaded and ready for inference.
318
335
  */
@@ -288,6 +288,19 @@ namespace margelo::nitro::litertlm {
288
288
  static const auto method = _javaPart->javaClassStatic()->getMethod<void()>("resetConversation");
289
289
  method(_javaPart);
290
290
  }
291
+ void JHybridLiteRTLMSpec::resetConversationWith(const std::vector<Message>& messages) {
292
+ static const auto method = _javaPart->javaClassStatic()->getMethod<void(jni::alias_ref<jni::JArrayClass<JMessage>> /* messages */)>("resetConversationWith");
293
+ method(_javaPart, [&](auto&& __input) {
294
+ size_t __size = __input.size();
295
+ jni::local_ref<jni::JArrayClass<JMessage>> __array = jni::JArrayClass<JMessage>::newArray(__size);
296
+ for (size_t __i = 0; __i < __size; __i++) {
297
+ const auto& __element = __input[__i];
298
+ auto __elementJni = JMessage::fromCpp(__element);
299
+ __array->setElement(__i, *__elementJni);
300
+ }
301
+ return __array;
302
+ }(messages));
303
+ }
291
304
  bool JHybridLiteRTLMSpec::isReady() {
292
305
  static const auto method = _javaPart->javaClassStatic()->getMethod<jboolean()>("isReady");
293
306
  auto __result = method(_javaPart);
@@ -66,6 +66,7 @@ namespace margelo::nitro::litertlm {
66
66
  std::shared_ptr<Promise<void>> sendMessageAsync(const std::string& message, const std::function<void(const std::string& /* token */, bool /* done */)>& onToken) override;
67
67
  std::vector<Message> getHistory() override;
68
68
  void resetConversation() override;
69
+ void resetConversationWith(const std::vector<Message>& messages) override;
69
70
  bool isReady() override;
70
71
  GenerationStats getStats() override;
71
72
  double countTokens(const std::string& text) override;
@@ -98,6 +98,10 @@ abstract class HybridLiteRTLMSpec: HybridObject() {
98
98
  @Keep
99
99
  abstract fun resetConversation(): Unit
100
100
 
101
+ @DoNotStrip
102
+ @Keep
103
+ abstract fun resetConversationWith(messages: Array<Message>): Unit
104
+
101
105
  @DoNotStrip
102
106
  @Keep
103
107
  abstract fun isReady(): Boolean
@@ -206,6 +206,12 @@ namespace margelo::nitro::litertlm {
206
206
  std::rethrow_exception(__result.error());
207
207
  }
208
208
  }
209
+ inline void resetConversationWith(const std::vector<Message>& messages) override {
210
+ auto __result = _swiftPart.resetConversationWith(messages);
211
+ if (__result.hasError()) [[unlikely]] {
212
+ std::rethrow_exception(__result.error());
213
+ }
214
+ }
209
215
  inline bool isReady() override {
210
216
  auto __result = _swiftPart.isReady();
211
217
  if (__result.hasError()) [[unlikely]] {
@@ -25,6 +25,7 @@ public protocol HybridLiteRTLMSpec_protocol: HybridObject {
25
25
  func sendMessageAsync(message: String, onToken: @escaping (_ token: String, _ done: Bool) -> Void) throws -> Promise<Void>
26
26
  func getHistory() throws -> [Message]
27
27
  func resetConversation() throws -> Void
28
+ func resetConversationWith(messages: [Message]) throws -> Void
28
29
  func isReady() throws -> Bool
29
30
  func getStats() throws -> GenerationStats
30
31
  func countTokens(text: String) throws -> Double
@@ -370,6 +370,17 @@ open class HybridLiteRTLMSpec_cxx {
370
370
  }
371
371
  }
372
372
 
373
+ @inline(__always)
374
+ public final func resetConversationWith(messages: bridge.std__vector_Message_) -> bridge.Result_void_ {
375
+ do {
376
+ try self.__implementation.resetConversationWith(messages: messages.map({ __item in __item }))
377
+ return bridge.create_Result_void_()
378
+ } catch (let __error) {
379
+ let __exceptionPtr = __error.toCpp()
380
+ return bridge.create_Result_void_(__exceptionPtr)
381
+ }
382
+ }
383
+
373
384
  @inline(__always)
374
385
  public final func isReady() -> bridge.Result_bool_ {
375
386
  do {
@@ -26,6 +26,7 @@ namespace margelo::nitro::litertlm {
26
26
  prototype.registerHybridMethod("sendMessageAsync", &HybridLiteRTLMSpec::sendMessageAsync);
27
27
  prototype.registerHybridMethod("getHistory", &HybridLiteRTLMSpec::getHistory);
28
28
  prototype.registerHybridMethod("resetConversation", &HybridLiteRTLMSpec::resetConversation);
29
+ prototype.registerHybridMethod("resetConversationWith", &HybridLiteRTLMSpec::resetConversationWith);
29
30
  prototype.registerHybridMethod("isReady", &HybridLiteRTLMSpec::isReady);
30
31
  prototype.registerHybridMethod("getStats", &HybridLiteRTLMSpec::getStats);
31
32
  prototype.registerHybridMethod("countTokens", &HybridLiteRTLMSpec::countTokens);
@@ -90,6 +90,7 @@ namespace margelo::nitro::litertlm {
90
90
  virtual std::shared_ptr<Promise<void>> sendMessageAsync(const std::string& message, const std::function<void(const std::string& /* token */, bool /* done */)>& onToken) = 0;
91
91
  virtual std::vector<Message> getHistory() = 0;
92
92
  virtual void resetConversation() = 0;
93
+ virtual void resetConversationWith(const std::vector<Message>& messages) = 0;
93
94
  virtual bool isReady() = 0;
94
95
  virtual GenerationStats getStats() = 0;
95
96
  virtual double countTokens(const std::string& text) = 0;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dr33m/react-native-litert-lm",
3
- "version": "0.5.6",
3
+ "version": "0.6.1",
4
4
  "litertLm": {
5
- "version": "0.12.0",
6
- "androidMavenVersion": "0.12.0",
7
- "iosGitTag": "v0.12.0"
5
+ "version": "0.15.0",
6
+ "androidMavenVersion": "0.15.0",
7
+ "iosGitTag": "v0.15.0"
8
8
  },
9
9
  "description": "High-performance LLM inference for React Native using LiteRT-LM. Optimized for Gemma 4 and other on-device language models.",
10
10
  "license": "MIT",
@@ -37,6 +37,10 @@ Pod::Spec.new do |s|
37
37
  '"$(PODS_TARGET_SRCROOT)/nitrogen/generated/ios"',
38
38
  ].join(' '),
39
39
  'OTHER_LDFLAGS' => '$(inherited) -ObjC',
40
+ # CLiteRTLM.xcframework has no x86_64 simulator slice (ARM-only policy);
41
+ # without this, generic simulator builds compile both archs and the
42
+ # CLiteRTLM module resolves for neither.
43
+ 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'x86_64',
40
44
  }
41
45
 
42
46
  # Load nitrogen autolinking
@@ -81,6 +81,7 @@ export const mockLiteRTLM = {
81
81
  ),
82
82
  getHistory: jest.fn(() => []),
83
83
  resetConversation: jest.fn(),
84
+ resetConversationWith: jest.fn(),
84
85
  getStats: jest.fn(() => ({
85
86
  promptTokens: 10,
86
87
  completionTokens: 20,
@@ -49,6 +49,27 @@ describe('modelFactory Security & Proxy Unit Tests', () => {
49
49
  expect(mockLiteRTLM.getMemoryUsage).toHaveBeenCalled();
50
50
  });
51
51
 
52
+ it('should successfully proxy resetConversationWith and record memory metrics', async () => {
53
+ const seed = [{ role: 'user', content: 'earlier turn' }];
54
+ await llm.resetConversationWith(seed as never);
55
+
56
+ expect(mockLiteRTLM.resetConversationWith).toHaveBeenCalledWith(seed);
57
+ expect(mockLiteRTLM.getMemoryUsage).toHaveBeenCalled();
58
+ });
59
+
60
+ it('should report resetConversationWith as absent on older native builds', () => {
61
+ // The app feature-detects this method, so the proxy must not hand back a
62
+ // wrapper the native side cannot service.
63
+ const original = mockLiteRTLM.resetConversationWith;
64
+ // @ts-expect-error deliberately removing the method to simulate old native
65
+ delete mockLiteRTLM.resetConversationWith;
66
+ try {
67
+ expect(createLLM().resetConversationWith).toBeUndefined();
68
+ } finally {
69
+ mockLiteRTLM.resetConversationWith = original;
70
+ }
71
+ });
72
+
52
73
  it('should successfully proxy sendMessageAsync and record memory metrics when done', async () => {
53
74
  const onToken = jest.fn();
54
75
  await llm.sendMessageAsync("Async prompt", onToken);
@@ -130,6 +130,20 @@ export function createLLM(options?: {
130
130
  };
131
131
  }
132
132
 
133
+ if (prop === "resetConversationWith") {
134
+ // Absent on native builds older than this JS wrapper, and callers
135
+ // feature-detect it — so fall through rather than handing back a
136
+ // wrapper that would fail at the bridge.
137
+ if (typeof (target as any).resetConversationWith !== "function") {
138
+ return undefined;
139
+ }
140
+ return (messages: unknown[]) => {
141
+ const result = (target as any).resetConversationWith(messages);
142
+ recordMemorySnapshot();
143
+ return result;
144
+ };
145
+ }
146
+
133
147
  if (prop === "sendToolResponse") {
134
148
  return (responses: any[], onToken?: TokenCallback) => {
135
149
  if (onToken) {
@@ -364,6 +364,24 @@ export interface LiteRTLM extends HybridObject<{
364
364
  */
365
365
  resetConversation(): void;
366
366
 
367
+ /**
368
+ * Clear the conversation and re-seed it with prior turns.
369
+ *
370
+ * The engine allocates one KV cache per conversation, sized by
371
+ * `maxContextTokens`. Long tool-calling sessions fill it, and overflowing it
372
+ * aborts the process rather than raising a catchable error. Recreating the
373
+ * conversation frees the cache, but `resetConversation()` alone also throws
374
+ * away the thread.
375
+ *
376
+ * This seeds the fresh conversation with `messages` as prior context, so a
377
+ * caller can drop the oldest turns and keep the recent ones. The system
378
+ * prompt and tools configured at `loadModel` are reapplied automatically.
379
+ * No generation is triggered and no reply is produced.
380
+ *
381
+ * @param messages Prior turns, oldest first.
382
+ */
383
+ resetConversationWith(messages: Message[]): void;
384
+
367
385
  /**
368
386
  * Check if a model is loaded and ready for inference.
369
387
  */