react-native-litert-lm 0.4.0 → 0.4.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 (35) hide show
  1. package/android/src/main/AndroidManifest.xml +3 -0
  2. package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLM.kt +117 -0
  3. package/android/src/test/java/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLMTest.kt +22 -0
  4. package/ios/HybridLiteRTLM.swift +321 -35
  5. package/ios/Tests/HybridLiteRTLMTests.swift +46 -0
  6. package/lib/__mocks__/react-native-nitro-modules.d.ts +4 -0
  7. package/lib/__mocks__/react-native-nitro-modules.js +10 -0
  8. package/lib/__tests__/modelFactory.test.js +16 -0
  9. package/lib/hooks.js +27 -3
  10. package/lib/index.d.ts +6 -0
  11. package/lib/index.js +7 -3
  12. package/lib/modelFactory.js +20 -0
  13. package/lib/specs/LiteRTLM.nitro.d.ts +16 -0
  14. package/nitrogen/generated/android/LiteRTLMOnLoad.cpp +2 -2
  15. package/nitrogen/generated/android/c++/JHybridLiteRTLMSpec.cpp +32 -2
  16. package/nitrogen/generated/android/c++/JHybridLiteRTLMSpec.hpp +2 -0
  17. package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLMSpec.kt +18 -0
  18. package/nitrogen/generated/ios/LiteRTLM-Swift-Cxx-Bridge.cpp +8 -8
  19. package/nitrogen/generated/ios/LiteRTLM-Swift-Cxx-Bridge.hpp +22 -22
  20. package/nitrogen/generated/ios/c++/HybridLiteRTLMSpecSwift.hpp +16 -0
  21. package/nitrogen/generated/ios/swift/HybridLiteRTLMSpec.swift +2 -0
  22. package/nitrogen/generated/ios/swift/HybridLiteRTLMSpec_cxx.swift +48 -0
  23. package/nitrogen/generated/shared/c++/HybridLiteRTLMSpec.cpp +2 -0
  24. package/nitrogen/generated/shared/c++/HybridLiteRTLMSpec.hpp +2 -0
  25. package/package.json +7 -4
  26. package/react-native-litert-lm.podspec +4 -2
  27. package/scripts/download-ios-frameworks.sh +4 -3
  28. package/scripts/framework-source.js +46 -0
  29. package/scripts/postinstall.js +39 -16
  30. package/src/__mocks__/react-native-nitro-modules.ts +10 -0
  31. package/src/__tests__/modelFactory.test.ts +28 -0
  32. package/src/hooks.ts +29 -7
  33. package/src/index.ts +7 -3
  34. package/src/modelFactory.ts +22 -0
  35. package/src/specs/LiteRTLM.nitro.ts +26 -0
@@ -12,5 +12,8 @@
12
12
  <uses-native-library
13
13
  android:name="libOpenCL.so"
14
14
  android:required="false" />
15
+ <uses-native-library
16
+ android:name="libvndksupport.so"
17
+ android:required="false" />
15
18
  </application>
16
19
  </manifest>
@@ -501,6 +501,75 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
501
501
  }
502
502
  }
503
503
 
504
+ override fun sendMessageWithImageAsync(message: String, imagePath: String, onToken: (String, Boolean) -> Unit): Promise<Unit> {
505
+ return Promise.parallel {
506
+ val latch = CountDownLatch(1)
507
+ val errorRef = AtomicReference<Throwable?>(null)
508
+
509
+ ensureLoaded()
510
+
511
+ Log.i(TAG, "sendMessageWithImageAsync: $message, path=$imagePath")
512
+
513
+ // Resize image to prevent OOM on high-resolution photos
514
+ val processedImagePath = resizeImageIfNeeded(imagePath)
515
+
516
+ val fullResponseBuilder = StringBuilder()
517
+
518
+ val listener = StreamingCallbackListener(
519
+ onToken = { token, done ->
520
+ onToken(token, done)
521
+ if (done) {
522
+ latch.countDown()
523
+ }
524
+ },
525
+ responseBuilder = fullResponseBuilder,
526
+ history = history,
527
+ userMessage = message,
528
+ onStatsReady = { stats -> lastStats = stats },
529
+ )
530
+
531
+ try {
532
+ val textContent = Content.Text(message)
533
+ val userMsg = LiteRTMessage.user(Contents.of(textContent, Content.ImageFile(processedImagePath)))
534
+
535
+ history.add(Message(Role.USER, "$message [Image]"))
536
+
537
+ conversation!!.sendMessageAsync(message = userMsg, callback = listener)
538
+ } catch (e: Exception) {
539
+ // Clean up temp resized image to prevent cache dir bloat
540
+ if (processedImagePath != imagePath) {
541
+ try {
542
+ java.io.File(processedImagePath).delete()
543
+ } catch (e: Exception) {
544
+ Log.w(TAG, "Failed to clean up temp image: ${e.message}")
545
+ }
546
+ }
547
+
548
+ Log.e(TAG, "Failed to initiate async multimodal generation", e)
549
+ errorRef.set(e)
550
+ onToken("Error: ${e.message}", true)
551
+ latch.countDown()
552
+ }
553
+
554
+ // Wait for completion or error
555
+ latch.await()
556
+
557
+ // Clean up temp resized image to prevent cache dir bloat
558
+ if (processedImagePath != imagePath) {
559
+ try {
560
+ java.io.File(processedImagePath).delete()
561
+ } catch (e: Exception) {
562
+ Log.w(TAG, "Failed to clean up temp image: ${e.message}")
563
+ }
564
+ }
565
+
566
+ val err = errorRef.get()
567
+ if (err != null) {
568
+ throw RuntimeException("Async multimodal inference failed: ${err.message}", err)
569
+ }
570
+ }
571
+ }
572
+
504
573
  override fun downloadModel(url: String, fileName: String, onProgress: ((Double) -> Unit)?): Promise<String> {
505
574
  return Promise.parallel {
506
575
  Log.i(TAG, "downloadModel: $url -> $fileName")
@@ -623,6 +692,54 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
623
692
  }
624
693
  }
625
694
 
695
+ override fun sendMessageWithAudioAsync(message: String, audioPath: String, onToken: (String, Boolean) -> Unit): Promise<Unit> {
696
+ return Promise.parallel {
697
+ val latch = CountDownLatch(1)
698
+ val errorRef = AtomicReference<Throwable?>(null)
699
+
700
+ ensureLoaded()
701
+
702
+ Log.i(TAG, "sendMessageWithAudioAsync: $message, path=$audioPath")
703
+
704
+ val fullResponseBuilder = StringBuilder()
705
+
706
+ val listener = StreamingCallbackListener(
707
+ onToken = { token, done ->
708
+ onToken(token, done)
709
+ if (done) {
710
+ latch.countDown()
711
+ }
712
+ },
713
+ responseBuilder = fullResponseBuilder,
714
+ history = history,
715
+ userMessage = message,
716
+ onStatsReady = { stats -> lastStats = stats },
717
+ )
718
+
719
+ try {
720
+ val userMsg = LiteRTMessage.user(Contents.of(
721
+ Content.Text(message),
722
+ Content.AudioFile(audioPath)
723
+ ))
724
+
725
+ history.add(Message(Role.USER, "$message [Audio]"))
726
+
727
+ conversation!!.sendMessageAsync(message = userMsg, callback = listener)
728
+ } catch (e: Exception) {
729
+ Log.e(TAG, "Failed to initiate async audio generation", e)
730
+ errorRef.set(e)
731
+ onToken("Error: ${e.message}", true)
732
+ latch.countDown()
733
+ }
734
+
735
+ latch.await()
736
+ val err = errorRef.get()
737
+ if (err != null) {
738
+ throw RuntimeException("Async audio inference failed: ${err.message}", err)
739
+ }
740
+ }
741
+ }
742
+
626
743
  override fun sendMessageWithAudio(message: String, audioPath: String): Promise<String> {
627
744
  return Promise.parallel {
628
745
  ensureLoaded()
@@ -69,6 +69,28 @@ class HybridLiteRTLMTest {
69
69
  assertTrue(mem.availableMemoryBytes >= 0.0)
70
70
  }
71
71
 
72
+ @Test
73
+ fun testSendMessageWithImageAsyncRejectsWithoutModel() {
74
+ val promise = bridge.sendMessageWithImageAsync("hello", "/tmp/image.jpg") { _, _ -> }
75
+ assertNotNull("Promise should not be null", promise)
76
+ assertTrue("Promise should be completed", promise.isCompleted)
77
+ assertNotNull("Promise should have rejected without model", promise.error)
78
+ val errMsg = promise.error!!.message ?: promise.error!!.cause?.message ?: ""
79
+ assertTrue("Expected no-model error, got: $errMsg",
80
+ errMsg.contains("No model loaded"))
81
+ }
82
+
83
+ @Test
84
+ fun testSendMessageWithAudioAsyncRejectsWithoutModel() {
85
+ val promise = bridge.sendMessageWithAudioAsync("hello", "/tmp/audio.wav") { _, _ -> }
86
+ assertNotNull("Promise should not be null", promise)
87
+ assertTrue("Promise should be completed", promise.isCompleted)
88
+ assertNotNull("Promise should have rejected without model", promise.error)
89
+ val errMsg = promise.error!!.message ?: promise.error!!.cause?.message ?: ""
90
+ assertTrue("Expected no-model error, got: $errMsg",
91
+ errMsg.contains("No model loaded"))
92
+ }
93
+
72
94
  @Test
73
95
  fun testAndroidInitialStats() {
74
96
  val stats = bridge.getStats()
@@ -117,7 +117,7 @@ public class HybridLiteRTLM: HybridLiteRTLMSpec_base, HybridLiteRTLMSpec_protoco
117
117
  }
118
118
 
119
119
  public func countTokens(text: String) throws -> Double {
120
- return try queue.sync {
120
+ return queue.sync {
121
121
  guard let engine = self.engine else {
122
122
  return -1.0
123
123
  }
@@ -406,42 +406,50 @@ public class HybridLiteRTLM: HybridLiteRTLMSpec_base, HybridLiteRTLMSpec_protoco
406
406
  ctx.onToken(remaining, false)
407
407
  }
408
408
  ctx.fullResponse = finalCleaned
409
-
410
- var completionTokens = Double(ctx.tokenCount)
411
- var tokensPerSecond = 0.0
412
- var ttft = 0.0
413
-
414
- if let benchInfo = litert_lm_conversation_get_benchmark_info(ctx.parent.conversation) {
415
- let numDecodeTurns = litert_lm_benchmark_info_get_num_decode_turns(benchInfo)
416
- if numDecodeTurns > 0 {
417
- let lastIdx = numDecodeTurns - 1
418
- tokensPerSecond = litert_lm_benchmark_info_get_decode_tokens_per_sec_at(benchInfo, lastIdx)
419
- completionTokens = Double(litert_lm_benchmark_info_get_decode_token_count_at(benchInfo, lastIdx))
409
+
410
+ // This callback fires on an engine-internal thread (the C API
411
+ // returns once the stream *starts*), so commit the shared
412
+ // lastStats/history and the conversation benchmark read — on
413
+ // the serial engine queue to avoid racing getStats()/getHistory().
414
+ // Resolving inside the same block guarantees JS observes the
415
+ // final turn before the promise settles.
416
+ ctx.parent.queue.async {
417
+ var completionTokens = Double(ctx.tokenCount)
418
+ var tokensPerSecond = 0.0
419
+ var ttft = 0.0
420
+
421
+ if let benchInfo = litert_lm_conversation_get_benchmark_info(ctx.parent.conversation) {
422
+ let numDecodeTurns = litert_lm_benchmark_info_get_num_decode_turns(benchInfo)
423
+ if numDecodeTurns > 0 {
424
+ let lastIdx = numDecodeTurns - 1
425
+ tokensPerSecond = litert_lm_benchmark_info_get_decode_tokens_per_sec_at(benchInfo, lastIdx)
426
+ completionTokens = Double(litert_lm_benchmark_info_get_decode_token_count_at(benchInfo, lastIdx))
427
+ }
428
+ ttft = litert_lm_benchmark_info_get_time_to_first_token(benchInfo)
429
+ litert_lm_benchmark_info_delete(benchInfo)
420
430
  }
421
- ttft = litert_lm_benchmark_info_get_time_to_first_token(benchInfo)
422
- litert_lm_benchmark_info_delete(benchInfo)
423
- }
424
-
425
- let promptTokens = Double(ctx.userMessage.count) / 4.0
426
- if completionTokens == 0.0 {
427
- completionTokens = Double(ctx.fullResponse.count) / 4.0
431
+
432
+ let promptTokens = Double(ctx.userMessage.count) / 4.0
433
+ if completionTokens == 0.0 {
434
+ completionTokens = Double(ctx.fullResponse.count) / 4.0
435
+ }
436
+
437
+ ctx.parent.lastStats = GenerationStats(
438
+ promptTokens: promptTokens,
439
+ completionTokens: completionTokens,
440
+ totalTokens: promptTokens + completionTokens,
441
+ timeToFirstToken: ttft,
442
+ totalTime: totalTime,
443
+ tokensPerSecond: tokensPerSecond > 0.0 ? tokensPerSecond : (completionTokens / totalTime)
444
+ )
445
+
446
+ ctx.parent.history.append(Message(role: .user, content: ctx.userMessage))
447
+ ctx.parent.history.append(Message(role: .model, content: ctx.fullResponse))
448
+
449
+ ctx.onToken("", true)
450
+ ctx.promise.resolve()
451
+ Unmanaged<StreamContext>.fromOpaque(callbackData).release()
428
452
  }
429
-
430
- ctx.parent.lastStats = GenerationStats(
431
- promptTokens: promptTokens,
432
- completionTokens: completionTokens,
433
- totalTokens: promptTokens + completionTokens,
434
- timeToFirstToken: ttft,
435
- totalTime: totalTime,
436
- tokensPerSecond: tokensPerSecond > 0.0 ? tokensPerSecond : (completionTokens / totalTime)
437
- )
438
-
439
- ctx.parent.history.append(Message(role: .user, content: ctx.userMessage))
440
- ctx.parent.history.append(Message(role: .model, content: ctx.fullResponse))
441
-
442
- ctx.onToken("", true)
443
- ctx.promise.resolve()
444
- Unmanaged<StreamContext>.fromOpaque(callbackData).release()
445
453
  return
446
454
  }
447
455
 
@@ -542,7 +550,285 @@ public class HybridLiteRTLM: HybridLiteRTLMSpec_base, HybridLiteRTLMSpec_protoco
542
550
 
543
551
  return promise
544
552
  }
553
+
554
+ public func sendMessageWithImageAsync(message: String, imagePath: String, onToken: @escaping (_ token: String, _ done: Bool) -> Void) throws -> Promise<Void> {
555
+ let promise = Promise<Void>()
556
+
557
+ queue.async {
558
+ guard let conversation = self.conversation else {
559
+ promise.reject(withError: NSError(domain: "LiteRTLM", code: 400, userInfo: [NSLocalizedDescriptionKey: "LiteRTLM: No model loaded. Call loadModel() first."]))
560
+ return
561
+ }
562
+
563
+ if !FileManager.default.fileExists(atPath: imagePath) {
564
+ promise.reject(withError: NSError(domain: "LiteRTLM", code: 404, userInfo: [NSLocalizedDescriptionKey: "Image file not found: \(imagePath)"]))
565
+ return
566
+ }
567
+
568
+ let msgJson = self.buildImageMessageJson(text: message, imagePath: imagePath)
569
+ let startTime = Date()
570
+
571
+ let historyUserContent = message + " [image: \(imagePath)]"
572
+ let context = StreamContext(
573
+ userMessage: message,
574
+ startTime: startTime,
575
+ onToken: onToken,
576
+ promise: promise,
577
+ parent: self
578
+ )
579
+
580
+ let callbackData = Unmanaged.passRetained(context).toOpaque()
581
+
582
+ let callback: LiteRtLmStreamCallback = { callbackData, chunk, isFinal, errorMsg in
583
+ guard let callbackData = callbackData else { return }
584
+ let ctx = Unmanaged<StreamContext>.fromOpaque(callbackData).takeUnretainedValue()
585
+
586
+ if let errorMsg = errorMsg {
587
+ let errorStr = String(cString: errorMsg)
588
+ ctx.onToken("Error: \(errorStr)", true)
589
+ ctx.promise.reject(withError: NSError(domain: "LiteRTLM", code: 500, userInfo: [NSLocalizedDescriptionKey: errorStr]))
590
+ Unmanaged<StreamContext>.fromOpaque(callbackData).release()
591
+ return
592
+ }
593
+
594
+ if isFinal {
595
+ let endTime = Date()
596
+ let totalTime = endTime.timeIntervalSince(ctx.startTime)
597
+
598
+ let cleaned = ctx.parent.stripControlTokens(ctx.rawResponse)
599
+ var finalCleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
600
+ if !ctx.userMessage.isEmpty && finalCleaned.hasPrefix(ctx.userMessage) {
601
+ finalCleaned = String(finalCleaned.dropFirst(ctx.userMessage.count))
602
+ .trimmingCharacters(in: .whitespacesAndNewlines)
603
+ }
604
+
605
+ if finalCleaned.count > ctx.lastEmittedLength {
606
+ let startIdx = finalCleaned.index(finalCleaned.startIndex, offsetBy: ctx.lastEmittedLength)
607
+ let remaining = String(finalCleaned[startIdx...])
608
+ ctx.onToken(remaining, false)
609
+ }
610
+ ctx.fullResponse = finalCleaned
611
+
612
+ var completionTokens = Double(ctx.tokenCount)
613
+ var tokensPerSecond = 0.0
614
+ var ttft = 0.0
615
+ if let benchInfo = litert_lm_conversation_get_benchmark_info(ctx.parent.conversation) {
616
+ let numDecodeTurns = litert_lm_benchmark_info_get_num_decode_turns(benchInfo)
617
+ if numDecodeTurns > 0 {
618
+ let lastIdx = numDecodeTurns - 1
619
+ tokensPerSecond = litert_lm_benchmark_info_get_decode_tokens_per_sec_at(benchInfo, lastIdx)
620
+ completionTokens = Double(litert_lm_benchmark_info_get_decode_token_count_at(benchInfo, lastIdx))
621
+ }
622
+ ttft = litert_lm_benchmark_info_get_time_to_first_token(benchInfo)
623
+ litert_lm_benchmark_info_delete(benchInfo)
624
+ }
625
+
626
+ let promptTokens = Double(ctx.userMessage.count) / 4.0
627
+ if completionTokens == 0.0 {
628
+ completionTokens = Double(ctx.fullResponse.count) / 4.0
629
+ }
630
+ ctx.parent.lastStats = GenerationStats(
631
+ promptTokens: promptTokens,
632
+ completionTokens: completionTokens,
633
+ totalTokens: promptTokens + completionTokens,
634
+ timeToFirstToken: ttft,
635
+ totalTime: totalTime,
636
+ tokensPerSecond: tokensPerSecond > 0.0 ? tokensPerSecond : (completionTokens / totalTime)
637
+ )
638
+ ctx.parent.history.append(Message(role: .user, content: historyUserContent))
639
+ ctx.parent.history.append(Message(role: .model, content: ctx.fullResponse))
640
+ ctx.onToken("", true)
641
+ ctx.promise.resolve()
642
+ Unmanaged<StreamContext>.fromOpaque(callbackData).release()
643
+ return
644
+ }
645
+
646
+ if let chunk = chunk {
647
+ let token = String(cString: chunk)
648
+ let raw: String
649
+ if token.hasPrefix("{") && token.contains("\"role\"") {
650
+ raw = ctx.parent.extractTextFromResponse(token)
651
+ } else {
652
+ raw = token
653
+ }
654
+
655
+ ctx.rawResponse += raw
656
+ let cleaned = ctx.parent.stripControlTokens(ctx.rawResponse)
657
+ .trimmingLeadingCharacters(in: .whitespacesAndNewlines)
658
+
659
+ var processed = cleaned
660
+ if !ctx.userMessage.isEmpty && processed.hasPrefix(ctx.userMessage) {
661
+ processed = String(processed.dropFirst(ctx.userMessage.count))
662
+ .trimmingLeadingCharacters(in: .whitespacesAndNewlines)
663
+ }
664
+
665
+ let safeLen = ctx.parent.safeEmitLength(processed)
666
+ if safeLen > ctx.lastEmittedLength {
667
+ let chars = Array(processed)
668
+ let newText = String(chars[ctx.lastEmittedLength..<safeLen])
669
+ ctx.lastEmittedLength = safeLen
670
+ ctx.tokenCount += 1
671
+ ctx.onToken(newText, false)
672
+ }
673
+ }
674
+ }
675
+
676
+ let status = litert_lm_conversation_send_message_stream(
677
+ conversation,
678
+ msgJson,
679
+ nil,
680
+ nil,
681
+ callback,
682
+ callbackData
683
+ )
684
+ if status != 0 {
685
+ Unmanaged<StreamContext>.fromOpaque(callbackData).release()
686
+ promise.reject(withError: NSError(domain: "LiteRTLM", code: Int(status), userInfo: [NSLocalizedDescriptionKey: "Failed to start streaming conversation."]))
687
+ }
688
+ }
689
+
690
+ return promise
691
+ }
545
692
 
693
+ public func sendMessageWithAudioAsync(message: String, audioPath: String, onToken: @escaping (_ token: String, _ done: Bool) -> Void) throws -> Promise<Void> {
694
+ let promise = Promise<Void>()
695
+
696
+ queue.async {
697
+ guard let conversation = self.conversation else {
698
+ promise.reject(withError: NSError(domain: "LiteRTLM", code: 400, userInfo: [NSLocalizedDescriptionKey: "LiteRTLM: No model loaded. Call loadModel() first."]))
699
+ return
700
+ }
701
+
702
+ if !FileManager.default.fileExists(atPath: audioPath) {
703
+ promise.reject(withError: NSError(domain: "LiteRTLM", code: 404, userInfo: [NSLocalizedDescriptionKey: "Audio file not found: \(audioPath)"]))
704
+ return
705
+ }
706
+
707
+ let msgJson = self.buildAudioMessageJson(text: message, audioPath: audioPath)
708
+ let startTime = Date()
709
+
710
+ let historyUserContent = message + " [audio: \(audioPath)]"
711
+ let context = StreamContext(
712
+ userMessage: message,
713
+ startTime: startTime,
714
+ onToken: onToken,
715
+ promise: promise,
716
+ parent: self
717
+ )
718
+
719
+ let callbackData = Unmanaged.passRetained(context).toOpaque()
720
+
721
+ let callback: LiteRtLmStreamCallback = { callbackData, chunk, isFinal, errorMsg in
722
+ guard let callbackData = callbackData else { return }
723
+ let ctx = Unmanaged<StreamContext>.fromOpaque(callbackData).takeUnretainedValue()
724
+
725
+ if let errorMsg = errorMsg {
726
+ let errorStr = String(cString: errorMsg)
727
+ ctx.onToken("Error: \(errorStr)", true)
728
+ ctx.promise.reject(withError: NSError(domain: "LiteRTLM", code: 500, userInfo: [NSLocalizedDescriptionKey: errorStr]))
729
+ Unmanaged<StreamContext>.fromOpaque(callbackData).release()
730
+ return
731
+ }
732
+
733
+ if isFinal {
734
+ let endTime = Date()
735
+ let totalTime = endTime.timeIntervalSince(ctx.startTime)
736
+
737
+ let cleaned = ctx.parent.stripControlTokens(ctx.rawResponse)
738
+ var finalCleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
739
+ if !ctx.userMessage.isEmpty && finalCleaned.hasPrefix(ctx.userMessage) {
740
+ finalCleaned = String(finalCleaned.dropFirst(ctx.userMessage.count))
741
+ .trimmingCharacters(in: .whitespacesAndNewlines)
742
+ }
743
+
744
+ if finalCleaned.count > ctx.lastEmittedLength {
745
+ let startIdx = finalCleaned.index(finalCleaned.startIndex, offsetBy: ctx.lastEmittedLength)
746
+ let remaining = String(finalCleaned[startIdx...])
747
+ ctx.onToken(remaining, false)
748
+ }
749
+ ctx.fullResponse = finalCleaned
750
+
751
+ var completionTokens = Double(ctx.tokenCount)
752
+ var tokensPerSecond = 0.0
753
+ var ttft = 0.0
754
+ if let benchInfo = litert_lm_conversation_get_benchmark_info(ctx.parent.conversation) {
755
+ let numDecodeTurns = litert_lm_benchmark_info_get_num_decode_turns(benchInfo)
756
+ if numDecodeTurns > 0 {
757
+ let lastIdx = numDecodeTurns - 1
758
+ tokensPerSecond = litert_lm_benchmark_info_get_decode_tokens_per_sec_at(benchInfo, lastIdx)
759
+ completionTokens = Double(litert_lm_benchmark_info_get_decode_token_count_at(benchInfo, lastIdx))
760
+ }
761
+ ttft = litert_lm_benchmark_info_get_time_to_first_token(benchInfo)
762
+ litert_lm_benchmark_info_delete(benchInfo)
763
+ }
764
+
765
+ let promptTokens = Double(ctx.userMessage.count) / 4.0
766
+ if completionTokens == 0.0 {
767
+ completionTokens = Double(ctx.fullResponse.count) / 4.0
768
+ }
769
+ ctx.parent.lastStats = GenerationStats(
770
+ promptTokens: promptTokens,
771
+ completionTokens: completionTokens,
772
+ totalTokens: promptTokens + completionTokens,
773
+ timeToFirstToken: ttft,
774
+ totalTime: totalTime,
775
+ tokensPerSecond: tokensPerSecond > 0.0 ? tokensPerSecond : (completionTokens / totalTime)
776
+ )
777
+ ctx.parent.history.append(Message(role: .user, content: historyUserContent))
778
+ ctx.parent.history.append(Message(role: .model, content: ctx.fullResponse))
779
+ ctx.onToken("", true)
780
+ ctx.promise.resolve()
781
+ Unmanaged<StreamContext>.fromOpaque(callbackData).release()
782
+ return
783
+ }
784
+
785
+ if let chunk = chunk {
786
+ let token = String(cString: chunk)
787
+ let raw: String
788
+ if token.hasPrefix("{") && token.contains("\"role\"") {
789
+ raw = ctx.parent.extractTextFromResponse(token)
790
+ } else {
791
+ raw = token
792
+ }
793
+
794
+ ctx.rawResponse += raw
795
+ let cleaned = ctx.parent.stripControlTokens(ctx.rawResponse)
796
+ .trimmingLeadingCharacters(in: .whitespacesAndNewlines)
797
+
798
+ var processed = cleaned
799
+ if !ctx.userMessage.isEmpty && processed.hasPrefix(ctx.userMessage) {
800
+ processed = String(processed.dropFirst(ctx.userMessage.count))
801
+ .trimmingLeadingCharacters(in: .whitespacesAndNewlines)
802
+ }
803
+
804
+ let safeLen = ctx.parent.safeEmitLength(processed)
805
+ if safeLen > ctx.lastEmittedLength {
806
+ let chars = Array(processed)
807
+ let newText = String(chars[ctx.lastEmittedLength..<safeLen])
808
+ ctx.lastEmittedLength = safeLen
809
+ ctx.tokenCount += 1
810
+ ctx.onToken(newText, false)
811
+ }
812
+ }
813
+ }
814
+
815
+ let status = litert_lm_conversation_send_message_stream(
816
+ conversation,
817
+ msgJson,
818
+ nil,
819
+ nil,
820
+ callback,
821
+ callbackData
822
+ )
823
+ if status != 0 {
824
+ Unmanaged<StreamContext>.fromOpaque(callbackData).release()
825
+ promise.reject(withError: NSError(domain: "LiteRTLM", code: Int(status), userInfo: [NSLocalizedDescriptionKey: "Failed to start streaming conversation."]))
826
+ }
827
+ }
828
+
829
+ return promise
830
+ }
831
+
546
832
  public func sendMessageWithAudio(message: String, audioPath: String) throws -> Promise<String> {
547
833
  let promise = Promise<String>()
548
834
 
@@ -53,6 +53,52 @@ class HybridLiteRTLMTests: XCTestCase {
53
53
  }
54
54
  }
55
55
 
56
+ func testSendMessageWithImageAsyncRejectsWithoutModel() async throws {
57
+ do {
58
+ let promise = try bridge.sendMessageWithImageAsync(message: "hello", imagePath: "/tmp/image.jpg") { _, _ in }
59
+ _ = try await promise.await()
60
+ XCTFail("Should have failed without model")
61
+ } catch {
62
+ let nsError = error as NSError
63
+ XCTAssertEqual(nsError.domain, "LiteRTLM")
64
+ XCTAssertEqual(nsError.code, 400)
65
+ }
66
+ }
67
+
68
+ func testSendMessageWithAudioAsyncRejectsWithoutModel() async throws {
69
+ do {
70
+ let promise = try bridge.sendMessageWithAudioAsync(message: "hello", audioPath: "/tmp/audio.wav") { _, _ in }
71
+ _ = try await promise.await()
72
+ XCTFail("Should have failed without model")
73
+ } catch {
74
+ let nsError = error as NSError
75
+ XCTAssertEqual(nsError.domain, "LiteRTLM")
76
+ XCTAssertEqual(nsError.code, 400)
77
+ }
78
+ }
79
+
80
+ func testSendMessageWithImageAsyncRejectsFileNotFound() async throws {
81
+ do {
82
+ let promise = try bridge.sendMessageWithImageAsync(message: "hello", imagePath: "/nonexistent/image.jpg") { _, _ in }
83
+ _ = try await promise.await()
84
+ XCTFail("Should have failed without model")
85
+ } catch {
86
+ let nsError = error as NSError
87
+ XCTAssertEqual(nsError.domain, "LiteRTLM")
88
+ }
89
+ }
90
+
91
+ func testSendMessageWithAudioAsyncRejectsFileNotFound() async throws {
92
+ do {
93
+ let promise = try bridge.sendMessageWithAudioAsync(message: "hello", audioPath: "/nonexistent/audio.wav") { _, _ in }
94
+ _ = try await promise.await()
95
+ XCTFail("Should have failed without model")
96
+ } catch {
97
+ let nsError = error as NSError
98
+ XCTAssertEqual(nsError.domain, "LiteRTLM")
99
+ }
100
+ }
101
+
56
102
  func testInitialStats() {
57
103
  XCTAssertNoThrow(try bridge.getStats())
58
104
  if let stats = try? bridge.getStats() {
@@ -8,6 +8,8 @@ export declare const mockLiteRTLM: {
8
8
  sendMessageWithAudio: jest.Mock<any, any, any>;
9
9
  sendMultimodalMessage: jest.Mock<any, any, any>;
10
10
  sendMessageAsync: jest.Mock<Promise<void>, [msg: any, onToken: any], any>;
11
+ sendMessageWithImageAsync: jest.Mock<Promise<void>, [msg: any, imagePath: any, onToken: any], any>;
12
+ sendMessageWithAudioAsync: jest.Mock<Promise<void>, [msg: any, audioPath: any, onToken: any], any>;
11
13
  getHistory: jest.Mock<never[], [], any>;
12
14
  resetConversation: jest.Mock<any, any, any>;
13
15
  getStats: jest.Mock<{
@@ -38,6 +40,8 @@ export declare const NitroModules: {
38
40
  sendMessageWithAudio: jest.Mock<any, any, any>;
39
41
  sendMultimodalMessage: jest.Mock<any, any, any>;
40
42
  sendMessageAsync: jest.Mock<Promise<void>, [msg: any, onToken: any], any>;
43
+ sendMessageWithImageAsync: jest.Mock<Promise<void>, [msg: any, imagePath: any, onToken: any], any>;
44
+ sendMessageWithAudioAsync: jest.Mock<Promise<void>, [msg: any, audioPath: any, onToken: any], any>;
41
45
  getHistory: jest.Mock<never[], [], any>;
42
46
  resetConversation: jest.Mock<any, any, any>;
43
47
  getStats: jest.Mock<{
@@ -18,6 +18,16 @@ exports.mockLiteRTLM = {
18
18
  onToken("token", true);
19
19
  return Promise.resolve();
20
20
  }),
21
+ sendMessageWithImageAsync: jest.fn((msg, imagePath, onToken) => {
22
+ onToken("Mock vision ", false);
23
+ onToken("token", true);
24
+ return Promise.resolve();
25
+ }),
26
+ sendMessageWithAudioAsync: jest.fn((msg, audioPath, onToken) => {
27
+ onToken("Mock audio ", false);
28
+ onToken("token", true);
29
+ return Promise.resolve();
30
+ }),
21
31
  getHistory: jest.fn(() => []),
22
32
  resetConversation: jest.fn(),
23
33
  getStats: jest.fn(() => ({
@@ -41,6 +41,22 @@ describe('modelFactory Security & Proxy Unit Tests', () => {
41
41
  expect(react_native_nitro_modules_1.mockLiteRTLM.sendMessageAsync).toHaveBeenCalled();
42
42
  expect(react_native_nitro_modules_1.mockLiteRTLM.getMemoryUsage).toHaveBeenCalled();
43
43
  });
44
+ it('should successfully proxy sendMessageWithImageAsync and record memory metrics when done', async () => {
45
+ const onToken = jest.fn();
46
+ await llm.sendMessageWithImageAsync("Vision prompt", "/path/to/image.jpg", onToken);
47
+ expect(onToken).toHaveBeenCalledWith("Mock vision ", false);
48
+ expect(onToken).toHaveBeenCalledWith("token", true);
49
+ expect(react_native_nitro_modules_1.mockLiteRTLM.sendMessageWithImageAsync).toHaveBeenCalledWith("Vision prompt", "/path/to/image.jpg", expect.any(Function));
50
+ expect(react_native_nitro_modules_1.mockLiteRTLM.getMemoryUsage).toHaveBeenCalled();
51
+ });
52
+ it('should successfully proxy sendMessageWithAudioAsync and record memory metrics when done', async () => {
53
+ const onToken = jest.fn();
54
+ await llm.sendMessageWithAudioAsync("Audio prompt", "/path/to/audio.wav", onToken);
55
+ expect(onToken).toHaveBeenCalledWith("Mock audio ", false);
56
+ expect(onToken).toHaveBeenCalledWith("token", true);
57
+ expect(react_native_nitro_modules_1.mockLiteRTLM.sendMessageWithAudioAsync).toHaveBeenCalledWith("Audio prompt", "/path/to/audio.wav", expect.any(Function));
58
+ expect(react_native_nitro_modules_1.mockLiteRTLM.getMemoryUsage).toHaveBeenCalled();
59
+ });
44
60
  it('should successfully access memoryTracker and getSnapshots when memory tracking is enabled', () => {
45
61
  expect(llm.memoryTracker).toBeDefined();
46
62
  expect(llm.memoryTracker?.getCapacity()).toBe(256);