react-native-litert-lm 0.4.0 → 0.4.2

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 +330 -35
  5. package/ios/Tests/HybridLiteRTLMTests.swift +58 -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()
@@ -14,6 +14,7 @@ import os
14
14
  /// A stream context passed to the low-level C FFI callback to forward chunks safely to the JS thread.
15
15
  private class StreamContext {
16
16
  let userMessage: String
17
+ let historyUserContent: String
17
18
  let startTime: Date
18
19
  let onToken: (_ token: String, _ done: Bool) -> Void
19
20
  let promise: Promise<Void>
@@ -26,12 +27,14 @@ private class StreamContext {
26
27
 
27
28
  init(
28
29
  userMessage: String,
30
+ historyUserContent: String? = nil,
29
31
  startTime: Date,
30
32
  onToken: @escaping (_ token: String, _ done: Bool) -> Void,
31
33
  promise: Promise<Void>,
32
34
  parent: HybridLiteRTLM
33
35
  ) {
34
36
  self.userMessage = userMessage
37
+ self.historyUserContent = historyUserContent ?? userMessage
35
38
  self.startTime = startTime
36
39
  self.onToken = onToken
37
40
  self.promise = promise
@@ -117,7 +120,7 @@ public class HybridLiteRTLM: HybridLiteRTLMSpec_base, HybridLiteRTLMSpec_protoco
117
120
  }
118
121
 
119
122
  public func countTokens(text: String) throws -> Double {
120
- return try queue.sync {
123
+ return queue.sync {
121
124
  guard let engine = self.engine else {
122
125
  return -1.0
123
126
  }
@@ -406,42 +409,50 @@ public class HybridLiteRTLM: HybridLiteRTLMSpec_base, HybridLiteRTLMSpec_protoco
406
409
  ctx.onToken(remaining, false)
407
410
  }
408
411
  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))
412
+
413
+ // This callback fires on an engine-internal thread (the C API
414
+ // returns once the stream *starts*), so commit the shared
415
+ // lastStats/history and the conversation benchmark read — on
416
+ // the serial engine queue to avoid racing getStats()/getHistory().
417
+ // Resolving inside the same block guarantees JS observes the
418
+ // final turn before the promise settles.
419
+ ctx.parent.queue.async {
420
+ var completionTokens = Double(ctx.tokenCount)
421
+ var tokensPerSecond = 0.0
422
+ var ttft = 0.0
423
+
424
+ if let benchInfo = litert_lm_conversation_get_benchmark_info(ctx.parent.conversation) {
425
+ let numDecodeTurns = litert_lm_benchmark_info_get_num_decode_turns(benchInfo)
426
+ if numDecodeTurns > 0 {
427
+ let lastIdx = numDecodeTurns - 1
428
+ tokensPerSecond = litert_lm_benchmark_info_get_decode_tokens_per_sec_at(benchInfo, lastIdx)
429
+ completionTokens = Double(litert_lm_benchmark_info_get_decode_token_count_at(benchInfo, lastIdx))
430
+ }
431
+ ttft = litert_lm_benchmark_info_get_time_to_first_token(benchInfo)
432
+ litert_lm_benchmark_info_delete(benchInfo)
420
433
  }
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
434
+
435
+ let promptTokens = Double(ctx.userMessage.count) / 4.0
436
+ if completionTokens == 0.0 {
437
+ completionTokens = Double(ctx.fullResponse.count) / 4.0
438
+ }
439
+
440
+ ctx.parent.lastStats = GenerationStats(
441
+ promptTokens: promptTokens,
442
+ completionTokens: completionTokens,
443
+ totalTokens: promptTokens + completionTokens,
444
+ timeToFirstToken: ttft,
445
+ totalTime: totalTime,
446
+ tokensPerSecond: tokensPerSecond > 0.0 ? tokensPerSecond : (completionTokens / totalTime)
447
+ )
448
+
449
+ ctx.parent.history.append(Message(role: .user, content: ctx.userMessage))
450
+ ctx.parent.history.append(Message(role: .model, content: ctx.fullResponse))
451
+
452
+ ctx.onToken("", true)
453
+ ctx.promise.resolve()
454
+ Unmanaged<StreamContext>.fromOpaque(callbackData).release()
428
455
  }
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
456
  return
446
457
  }
447
458
 
@@ -542,7 +553,291 @@ public class HybridLiteRTLM: HybridLiteRTLMSpec_base, HybridLiteRTLMSpec_protoco
542
553
 
543
554
  return promise
544
555
  }
556
+
557
+ public func sendMessageWithImageAsync(message: String, imagePath: String, onToken: @escaping (_ token: String, _ done: Bool) -> Void) throws -> Promise<Void> {
558
+ let promise = Promise<Void>()
559
+
560
+ queue.async {
561
+ guard let conversation = self.conversation else {
562
+ promise.reject(withError: NSError(domain: "LiteRTLM", code: 400, userInfo: [NSLocalizedDescriptionKey: "LiteRTLM: No model loaded. Call loadModel() first."]))
563
+ return
564
+ }
565
+
566
+ if !FileManager.default.fileExists(atPath: imagePath) {
567
+ promise.reject(withError: NSError(domain: "LiteRTLM", code: 404, userInfo: [NSLocalizedDescriptionKey: "Image file not found: \(imagePath)"]))
568
+ return
569
+ }
570
+
571
+ let msgJson = self.buildImageMessageJson(text: message, imagePath: imagePath)
572
+ let startTime = Date()
573
+
574
+ let historyUserContent = message + " [image: \(imagePath)]"
575
+ let context = StreamContext(
576
+ userMessage: message,
577
+ historyUserContent: historyUserContent,
578
+ startTime: startTime,
579
+ onToken: onToken,
580
+ promise: promise,
581
+ parent: self
582
+ )
583
+
584
+ let callbackData = Unmanaged.passRetained(context).toOpaque()
585
+
586
+ let callback: LiteRtLmStreamCallback = { callbackData, chunk, isFinal, errorMsg in
587
+ guard let callbackData = callbackData else { return }
588
+ let ctx = Unmanaged<StreamContext>.fromOpaque(callbackData).takeUnretainedValue()
589
+
590
+ if let errorMsg = errorMsg {
591
+ let errorStr = String(cString: errorMsg)
592
+ ctx.onToken("Error: \(errorStr)", true)
593
+ ctx.promise.reject(withError: NSError(domain: "LiteRTLM", code: 500, userInfo: [NSLocalizedDescriptionKey: errorStr]))
594
+ Unmanaged<StreamContext>.fromOpaque(callbackData).release()
595
+ return
596
+ }
597
+
598
+ if isFinal {
599
+ let endTime = Date()
600
+ let totalTime = endTime.timeIntervalSince(ctx.startTime)
601
+
602
+ let cleaned = ctx.parent.stripControlTokens(ctx.rawResponse)
603
+ var finalCleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
604
+ if !ctx.userMessage.isEmpty && finalCleaned.hasPrefix(ctx.userMessage) {
605
+ finalCleaned = String(finalCleaned.dropFirst(ctx.userMessage.count))
606
+ .trimmingCharacters(in: .whitespacesAndNewlines)
607
+ }
608
+
609
+ if finalCleaned.count > ctx.lastEmittedLength {
610
+ let startIdx = finalCleaned.index(finalCleaned.startIndex, offsetBy: ctx.lastEmittedLength)
611
+ let remaining = String(finalCleaned[startIdx...])
612
+ ctx.onToken(remaining, false)
613
+ }
614
+ ctx.fullResponse = finalCleaned
615
+
616
+ ctx.parent.queue.async {
617
+ var completionTokens = Double(ctx.tokenCount)
618
+ var tokensPerSecond = 0.0
619
+ var ttft = 0.0
620
+ if let benchInfo = litert_lm_conversation_get_benchmark_info(ctx.parent.conversation) {
621
+ let numDecodeTurns = litert_lm_benchmark_info_get_num_decode_turns(benchInfo)
622
+ if numDecodeTurns > 0 {
623
+ let lastIdx = numDecodeTurns - 1
624
+ tokensPerSecond = litert_lm_benchmark_info_get_decode_tokens_per_sec_at(benchInfo, lastIdx)
625
+ completionTokens = Double(litert_lm_benchmark_info_get_decode_token_count_at(benchInfo, lastIdx))
626
+ }
627
+ ttft = litert_lm_benchmark_info_get_time_to_first_token(benchInfo)
628
+ litert_lm_benchmark_info_delete(benchInfo)
629
+ }
630
+
631
+ let promptTokens = Double(ctx.userMessage.count) / 4.0
632
+ if completionTokens == 0.0 {
633
+ completionTokens = Double(ctx.fullResponse.count) / 4.0
634
+ }
635
+ ctx.parent.lastStats = GenerationStats(
636
+ promptTokens: promptTokens,
637
+ completionTokens: completionTokens,
638
+ totalTokens: promptTokens + completionTokens,
639
+ timeToFirstToken: ttft,
640
+ totalTime: totalTime,
641
+ tokensPerSecond: tokensPerSecond > 0.0 ? tokensPerSecond : (completionTokens / totalTime)
642
+ )
643
+ ctx.parent.history.append(Message(role: .user, content: ctx.historyUserContent))
644
+ ctx.parent.history.append(Message(role: .model, content: ctx.fullResponse))
645
+ ctx.onToken("", true)
646
+ ctx.promise.resolve()
647
+ Unmanaged<StreamContext>.fromOpaque(callbackData).release()
648
+ }
649
+ return
650
+ }
651
+
652
+ if let chunk = chunk {
653
+ let token = String(cString: chunk)
654
+ let raw: String
655
+ if token.hasPrefix("{") && token.contains("\"role\"") {
656
+ raw = ctx.parent.extractTextFromResponse(token)
657
+ } else {
658
+ raw = token
659
+ }
660
+
661
+ ctx.rawResponse += raw
662
+ let cleaned = ctx.parent.stripControlTokens(ctx.rawResponse)
663
+ .trimmingLeadingCharacters(in: .whitespacesAndNewlines)
664
+
665
+ var processed = cleaned
666
+ if !ctx.userMessage.isEmpty && processed.hasPrefix(ctx.userMessage) {
667
+ processed = String(processed.dropFirst(ctx.userMessage.count))
668
+ .trimmingLeadingCharacters(in: .whitespacesAndNewlines)
669
+ }
670
+
671
+ let safeLen = ctx.parent.safeEmitLength(processed)
672
+ if safeLen > ctx.lastEmittedLength {
673
+ let chars = Array(processed)
674
+ let newText = String(chars[ctx.lastEmittedLength..<safeLen])
675
+ ctx.lastEmittedLength = safeLen
676
+ ctx.tokenCount += 1
677
+ ctx.onToken(newText, false)
678
+ }
679
+ }
680
+ }
681
+
682
+ let status = litert_lm_conversation_send_message_stream(
683
+ conversation,
684
+ msgJson,
685
+ nil,
686
+ nil,
687
+ callback,
688
+ callbackData
689
+ )
690
+ if status != 0 {
691
+ Unmanaged<StreamContext>.fromOpaque(callbackData).release()
692
+ promise.reject(withError: NSError(domain: "LiteRTLM", code: Int(status), userInfo: [NSLocalizedDescriptionKey: "Failed to start streaming conversation."]))
693
+ }
694
+ }
695
+
696
+ return promise
697
+ }
545
698
 
699
+ public func sendMessageWithAudioAsync(message: String, audioPath: String, onToken: @escaping (_ token: String, _ done: Bool) -> Void) throws -> Promise<Void> {
700
+ let promise = Promise<Void>()
701
+
702
+ queue.async {
703
+ guard let conversation = self.conversation else {
704
+ promise.reject(withError: NSError(domain: "LiteRTLM", code: 400, userInfo: [NSLocalizedDescriptionKey: "LiteRTLM: No model loaded. Call loadModel() first."]))
705
+ return
706
+ }
707
+
708
+ if !FileManager.default.fileExists(atPath: audioPath) {
709
+ promise.reject(withError: NSError(domain: "LiteRTLM", code: 404, userInfo: [NSLocalizedDescriptionKey: "Audio file not found: \(audioPath)"]))
710
+ return
711
+ }
712
+
713
+ let msgJson = self.buildAudioMessageJson(text: message, audioPath: audioPath)
714
+ let startTime = Date()
715
+
716
+ let historyUserContent = message + " [audio: \(audioPath)]"
717
+ let context = StreamContext(
718
+ userMessage: message,
719
+ historyUserContent: historyUserContent,
720
+ startTime: startTime,
721
+ onToken: onToken,
722
+ promise: promise,
723
+ parent: self
724
+ )
725
+
726
+ let callbackData = Unmanaged.passRetained(context).toOpaque()
727
+
728
+ let callback: LiteRtLmStreamCallback = { callbackData, chunk, isFinal, errorMsg in
729
+ guard let callbackData = callbackData else { return }
730
+ let ctx = Unmanaged<StreamContext>.fromOpaque(callbackData).takeUnretainedValue()
731
+
732
+ if let errorMsg = errorMsg {
733
+ let errorStr = String(cString: errorMsg)
734
+ ctx.onToken("Error: \(errorStr)", true)
735
+ ctx.promise.reject(withError: NSError(domain: "LiteRTLM", code: 500, userInfo: [NSLocalizedDescriptionKey: errorStr]))
736
+ Unmanaged<StreamContext>.fromOpaque(callbackData).release()
737
+ return
738
+ }
739
+
740
+ if isFinal {
741
+ let endTime = Date()
742
+ let totalTime = endTime.timeIntervalSince(ctx.startTime)
743
+
744
+ let cleaned = ctx.parent.stripControlTokens(ctx.rawResponse)
745
+ var finalCleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
746
+ if !ctx.userMessage.isEmpty && finalCleaned.hasPrefix(ctx.userMessage) {
747
+ finalCleaned = String(finalCleaned.dropFirst(ctx.userMessage.count))
748
+ .trimmingCharacters(in: .whitespacesAndNewlines)
749
+ }
750
+
751
+ if finalCleaned.count > ctx.lastEmittedLength {
752
+ let startIdx = finalCleaned.index(finalCleaned.startIndex, offsetBy: ctx.lastEmittedLength)
753
+ let remaining = String(finalCleaned[startIdx...])
754
+ ctx.onToken(remaining, false)
755
+ }
756
+ ctx.fullResponse = finalCleaned
757
+
758
+ ctx.parent.queue.async {
759
+ var completionTokens = Double(ctx.tokenCount)
760
+ var tokensPerSecond = 0.0
761
+ var ttft = 0.0
762
+ if let benchInfo = litert_lm_conversation_get_benchmark_info(ctx.parent.conversation) {
763
+ let numDecodeTurns = litert_lm_benchmark_info_get_num_decode_turns(benchInfo)
764
+ if numDecodeTurns > 0 {
765
+ let lastIdx = numDecodeTurns - 1
766
+ tokensPerSecond = litert_lm_benchmark_info_get_decode_tokens_per_sec_at(benchInfo, lastIdx)
767
+ completionTokens = Double(litert_lm_benchmark_info_get_decode_token_count_at(benchInfo, lastIdx))
768
+ }
769
+ ttft = litert_lm_benchmark_info_get_time_to_first_token(benchInfo)
770
+ litert_lm_benchmark_info_delete(benchInfo)
771
+ }
772
+
773
+ let promptTokens = Double(ctx.userMessage.count) / 4.0
774
+ if completionTokens == 0.0 {
775
+ completionTokens = Double(ctx.fullResponse.count) / 4.0
776
+ }
777
+ ctx.parent.lastStats = GenerationStats(
778
+ promptTokens: promptTokens,
779
+ completionTokens: completionTokens,
780
+ totalTokens: promptTokens + completionTokens,
781
+ timeToFirstToken: ttft,
782
+ totalTime: totalTime,
783
+ tokensPerSecond: tokensPerSecond > 0.0 ? tokensPerSecond : (completionTokens / totalTime)
784
+ )
785
+ ctx.parent.history.append(Message(role: .user, content: ctx.historyUserContent))
786
+ ctx.parent.history.append(Message(role: .model, content: ctx.fullResponse))
787
+ ctx.onToken("", true)
788
+ ctx.promise.resolve()
789
+ Unmanaged<StreamContext>.fromOpaque(callbackData).release()
790
+ }
791
+ return
792
+ }
793
+
794
+ if let chunk = chunk {
795
+ let token = String(cString: chunk)
796
+ let raw: String
797
+ if token.hasPrefix("{") && token.contains("\"role\"") {
798
+ raw = ctx.parent.extractTextFromResponse(token)
799
+ } else {
800
+ raw = token
801
+ }
802
+
803
+ ctx.rawResponse += raw
804
+ let cleaned = ctx.parent.stripControlTokens(ctx.rawResponse)
805
+ .trimmingLeadingCharacters(in: .whitespacesAndNewlines)
806
+
807
+ var processed = cleaned
808
+ if !ctx.userMessage.isEmpty && processed.hasPrefix(ctx.userMessage) {
809
+ processed = String(processed.dropFirst(ctx.userMessage.count))
810
+ .trimmingLeadingCharacters(in: .whitespacesAndNewlines)
811
+ }
812
+
813
+ let safeLen = ctx.parent.safeEmitLength(processed)
814
+ if safeLen > ctx.lastEmittedLength {
815
+ let chars = Array(processed)
816
+ let newText = String(chars[ctx.lastEmittedLength..<safeLen])
817
+ ctx.lastEmittedLength = safeLen
818
+ ctx.tokenCount += 1
819
+ ctx.onToken(newText, false)
820
+ }
821
+ }
822
+ }
823
+
824
+ let status = litert_lm_conversation_send_message_stream(
825
+ conversation,
826
+ msgJson,
827
+ nil,
828
+ nil,
829
+ callback,
830
+ callbackData
831
+ )
832
+ if status != 0 {
833
+ Unmanaged<StreamContext>.fromOpaque(callbackData).release()
834
+ promise.reject(withError: NSError(domain: "LiteRTLM", code: Int(status), userInfo: [NSLocalizedDescriptionKey: "Failed to start streaming conversation."]))
835
+ }
836
+ }
837
+
838
+ return promise
839
+ }
840
+
546
841
  public func sendMessageWithAudio(message: String, audioPath: String) throws -> Promise<String> {
547
842
  let promise = Promise<String>()
548
843
 
@@ -53,6 +53,64 @@ class HybridLiteRTLMTests: XCTestCase {
53
53
  }
54
54
  }
55
55
 
56
+ func testSendMessageAsyncRejectsWithoutModel() async throws {
57
+ do {
58
+ let promise = try bridge.sendMessageAsync(message: "hello") { _, _ 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 testSendMessageWithImageAsyncRejectsWithoutModel() async throws {
69
+ do {
70
+ let promise = try bridge.sendMessageWithImageAsync(message: "hello", imagePath: "/tmp/image.jpg") { _, _ 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 testSendMessageWithAudioAsyncRejectsWithoutModel() async throws {
81
+ do {
82
+ let promise = try bridge.sendMessageWithAudioAsync(message: "hello", audioPath: "/tmp/audio.wav") { _, _ 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
+ XCTAssertEqual(nsError.code, 400)
89
+ }
90
+ }
91
+
92
+ func testSendMessageWithImageAsyncRejectsFileNotFound() async throws {
93
+ do {
94
+ let promise = try bridge.sendMessageWithImageAsync(message: "hello", imagePath: "/nonexistent/image.jpg") { _, _ in }
95
+ _ = try await promise.await()
96
+ XCTFail("Should have failed without model")
97
+ } catch {
98
+ let nsError = error as NSError
99
+ XCTAssertEqual(nsError.domain, "LiteRTLM")
100
+ }
101
+ }
102
+
103
+ func testSendMessageWithAudioAsyncRejectsFileNotFound() async throws {
104
+ do {
105
+ let promise = try bridge.sendMessageWithAudioAsync(message: "hello", audioPath: "/nonexistent/audio.wav") { _, _ in }
106
+ _ = try await promise.await()
107
+ XCTFail("Should have failed without model")
108
+ } catch {
109
+ let nsError = error as NSError
110
+ XCTAssertEqual(nsError.domain, "LiteRTLM")
111
+ }
112
+ }
113
+
56
114
  func testInitialStats() {
57
115
  XCTAssertNoThrow(try bridge.getStats())
58
116
  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<{