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
@@ -0,0 +1,46 @@
1
+ /**
2
+ * framework-source.js
3
+ *
4
+ * Single source of truth for the prebuilt iOS framework artifact, shared by
5
+ * scripts/postinstall.js, scripts/download-ios-frameworks.sh, and
6
+ * scripts/check-framework-release.js, so the asset name, tag, and URL can never
7
+ * drift between the install path and the release-time guardrail.
8
+ *
9
+ * The artifact is Google's canonical LiteRT-LM release (NOT a per-version
10
+ * re-host of this package), pinned to the LiteRT-LM **engine** version
11
+ * (`litertLm.iosGitTag` in package.json), NOT this wrapper's npm version. So:
12
+ * - patch releases of this wrapper reuse the same framework (no re-upload),
13
+ * - there is no per-release asset for the maintainer to forget to upload,
14
+ * - the guardrail checks the exact URL consumers fetch.
15
+ *
16
+ * Override the host/asset (e.g. to point at a private mirror) via env vars:
17
+ * LITERT_FRAMEWORK_REPO (default: google-ai-edge/LiteRT-LM)
18
+ * LITERT_FRAMEWORK_ASSET (default: CLiteRTLM.xcframework.zip)
19
+ */
20
+
21
+ const path = require('path');
22
+ const packageJson = require('../package.json');
23
+
24
+ /** GitHub repo that hosts the framework release asset. */
25
+ const GITHUB_REPO = process.env.LITERT_FRAMEWORK_REPO || 'google-ai-edge/LiteRT-LM';
26
+
27
+ /** Release asset filename. Must match the file on the GitHub release. */
28
+ const ASSET_NAME = process.env.LITERT_FRAMEWORK_ASSET || 'CLiteRTLM.xcframework.zip';
29
+
30
+ /** Release tag the asset lives under — the LiteRT-LM engine git tag, e.g. "v0.12.0". */
31
+ const FRAMEWORK_TAG = packageJson.litertLm.iosGitTag;
32
+
33
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
34
+ const FRAMEWORKS_DIR = path.join(PACKAGE_ROOT, 'ios', 'Frameworks');
35
+
36
+ /** Fully-resolved download URL for the framework zip. */
37
+ const ASSET_URL = `https://github.com/${GITHUB_REPO}/releases/download/${FRAMEWORK_TAG}/${ASSET_NAME}`;
38
+
39
+ module.exports = {
40
+ GITHUB_REPO,
41
+ ASSET_NAME,
42
+ FRAMEWORK_TAG,
43
+ PACKAGE_ROOT,
44
+ FRAMEWORKS_DIR,
45
+ ASSET_URL,
46
+ };
@@ -5,10 +5,15 @@
5
5
  * Downloads prebuilt LiteRT-LM iOS frameworks from this package's GitHub
6
6
  * releases when consumers run `npm install react-native-litert-lm`.
7
7
  *
8
+ * The framework is intentionally NOT shipped inside the npm tarball (it is
9
+ * ~40MB and irrelevant to Android-only consumers). The asset name, tag, and
10
+ * URL come from scripts/framework-source.js so they stay in lockstep with the
11
+ * release-time guardrail (scripts/check-framework-release.js).
12
+ *
8
13
  * Skips download if:
9
14
  * - Not on macOS (iOS builds require macOS)
10
15
  * - Frameworks already exist
11
- * - CI environment with SKIP_IOS_FRAMEWORK_DOWNLOAD=1
16
+ * - SKIP_IOS_FRAMEWORK_DOWNLOAD=1 (e.g. Android-only / CI builds)
12
17
  */
13
18
 
14
19
  const { execSync } = require('child_process');
@@ -16,14 +21,12 @@ const fs = require('fs');
16
21
  const path = require('path');
17
22
  const https = require('https');
18
23
 
19
- const PACKAGE_JSON = require('../package.json');
20
- const PACKAGE_VERSION = PACKAGE_JSON.version;
21
- const GITHUB_REPO = 'hung-yueh/react-native-litert-lm';
22
- const ASSET_NAME = 'LiteRTLM-ios-frameworks.zip';
24
+ const { ASSET_URL, FRAMEWORKS_DIR, FRAMEWORK_TAG } = require('./framework-source');
23
25
 
24
- const SCRIPT_DIR = __dirname;
25
- const PACKAGE_ROOT = path.resolve(SCRIPT_DIR, '..');
26
- const FRAMEWORKS_DIR = path.join(PACKAGE_ROOT, 'ios', 'Frameworks');
26
+ /** ZIP local-file-header magic ("PK\x03\x04"). Guards against truncated downloads / HTML error pages. */
27
+ const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
28
+ /** A valid framework zip is well over this; anything smaller is certainly an error page. */
29
+ const MIN_VALID_BYTES = 1024 * 1024; // 1 MB
27
30
 
28
31
  function log(msg) {
29
32
  console.log(`[react-native-litert-lm] ${msg}`);
@@ -74,25 +77,46 @@ function downloadFile(url, destPath, maxRedirects = 5) {
74
77
  const file = fs.createWriteStream(destPath);
75
78
  res.pipe(file);
76
79
  file.on('finish', () => {
77
- file.close();
78
- resolve();
80
+ file.close(resolve);
79
81
  });
80
82
  file.on('error', reject);
81
83
  }).on('error', reject);
82
84
  });
83
85
  }
84
86
 
87
+ /**
88
+ * Verify the downloaded file is a non-trivial ZIP before we trust it.
89
+ * Catches GitHub HTML error pages and truncated downloads that would
90
+ * otherwise fail cryptically at `unzip` or, worse, link a corrupt framework.
91
+ */
92
+ function assertValidZip(zipPath) {
93
+ const { size } = fs.statSync(zipPath);
94
+ if (size < MIN_VALID_BYTES) {
95
+ throw new Error(`downloaded asset is only ${size} bytes — expected a multi-MB framework zip (likely an error page or truncated download)`);
96
+ }
97
+
98
+ const header = Buffer.alloc(4);
99
+ const fd = fs.openSync(zipPath, 'r');
100
+ try {
101
+ fs.readSync(fd, header, 0, 4, 0);
102
+ } finally {
103
+ fs.closeSync(fd);
104
+ }
105
+ if (!header.equals(ZIP_MAGIC)) {
106
+ throw new Error('downloaded asset is not a valid ZIP (bad magic bytes)');
107
+ }
108
+ }
109
+
85
110
  async function main() {
86
111
  if (shouldSkip()) return;
87
112
 
88
- const releaseUrl = `https://github.com/${GITHUB_REPO}/releases/download/v${PACKAGE_VERSION}/${ASSET_NAME}`;
89
-
90
- log(`Downloading iOS frameworks from: ${releaseUrl}`);
113
+ log(`Downloading iOS frameworks (engine ${FRAMEWORK_TAG}) from: ${ASSET_URL}`);
91
114
 
92
- const tmpZip = path.join(PACKAGE_ROOT, '.ios-frameworks-tmp.zip');
115
+ const tmpZip = path.join(path.dirname(FRAMEWORKS_DIR), '.ios-frameworks-tmp.zip');
93
116
 
94
117
  try {
95
- await downloadFile(releaseUrl, tmpZip);
118
+ await downloadFile(ASSET_URL, tmpZip);
119
+ assertValidZip(tmpZip);
96
120
 
97
121
  // Extract
98
122
  fs.mkdirSync(FRAMEWORKS_DIR, { recursive: true });
@@ -111,7 +135,6 @@ async function main() {
111
135
  log('Run: ./scripts/download-ios-frameworks.sh to download manually.');
112
136
 
113
137
  // Fail fast on macOS so users discover the problem now, not at Xcode link time.
114
- // Skip SKIP_IOS_FRAMEWORK_DOWNLOAD is already checked above.
115
138
  if (process.platform === 'darwin') {
116
139
  log('Set SKIP_IOS_FRAMEWORK_DOWNLOAD=1 to suppress this error (e.g. Android-only builds).');
117
140
  process.exit(1);
@@ -15,6 +15,16 @@ export const mockLiteRTLM = {
15
15
  onToken("token", true);
16
16
  return Promise.resolve();
17
17
  }),
18
+ sendMessageWithImageAsync: jest.fn((msg, imagePath, onToken) => {
19
+ onToken("Mock vision ", false);
20
+ onToken("token", true);
21
+ return Promise.resolve();
22
+ }),
23
+ sendMessageWithAudioAsync: jest.fn((msg, audioPath, onToken) => {
24
+ onToken("Mock audio ", false);
25
+ onToken("token", true);
26
+ return Promise.resolve();
27
+ }),
18
28
  getHistory: jest.fn(() => []),
19
29
  resetConversation: jest.fn(),
20
30
  getStats: jest.fn(() => ({
@@ -56,6 +56,34 @@ describe('modelFactory Security & Proxy Unit Tests', () => {
56
56
  expect(mockLiteRTLM.getMemoryUsage).toHaveBeenCalled();
57
57
  });
58
58
 
59
+ it('should successfully proxy sendMessageWithImageAsync and record memory metrics when done', async () => {
60
+ const onToken = jest.fn();
61
+ await llm.sendMessageWithImageAsync("Vision prompt", "/path/to/image.jpg", onToken);
62
+
63
+ expect(onToken).toHaveBeenCalledWith("Mock vision ", false);
64
+ expect(onToken).toHaveBeenCalledWith("token", true);
65
+ expect(mockLiteRTLM.sendMessageWithImageAsync).toHaveBeenCalledWith(
66
+ "Vision prompt",
67
+ "/path/to/image.jpg",
68
+ expect.any(Function)
69
+ );
70
+ expect(mockLiteRTLM.getMemoryUsage).toHaveBeenCalled();
71
+ });
72
+
73
+ it('should successfully proxy sendMessageWithAudioAsync and record memory metrics when done', async () => {
74
+ const onToken = jest.fn();
75
+ await llm.sendMessageWithAudioAsync("Audio prompt", "/path/to/audio.wav", onToken);
76
+
77
+ expect(onToken).toHaveBeenCalledWith("Mock audio ", false);
78
+ expect(onToken).toHaveBeenCalledWith("token", true);
79
+ expect(mockLiteRTLM.sendMessageWithAudioAsync).toHaveBeenCalledWith(
80
+ "Audio prompt",
81
+ "/path/to/audio.wav",
82
+ expect.any(Function)
83
+ );
84
+ expect(mockLiteRTLM.getMemoryUsage).toHaveBeenCalled();
85
+ });
86
+
59
87
  it('should successfully access memoryTracker and getSnapshots when memory tracking is enabled', () => {
60
88
  expect(llm.memoryTracker).toBeDefined();
61
89
  expect(llm.memoryTracker?.getCapacity()).toBe(256);
package/src/hooks.ts CHANGED
@@ -64,7 +64,8 @@ export function useModel(
64
64
  const [isGenerating, setIsGenerating] = useState(false);
65
65
  const [downloadProgress, setDownloadProgress] = useState(0);
66
66
  const [error, setError] = useState<string | null>(null);
67
- const [memorySummary, setMemorySummary] = useState<MemoryTrackerSummary | null>(null);
67
+ const [memorySummary, setMemorySummary] =
68
+ useState<MemoryTrackerSummary | null>(null);
68
69
 
69
70
  // Destructure config into primitive values for stable dependency arrays.
70
71
  // This prevents infinite re-render loops when consumers pass inline config
@@ -78,6 +79,11 @@ export function useModel(
78
79
  const temperature = config?.temperature;
79
80
  const topK = config?.topK;
80
81
  const topP = config?.topP;
82
+ const validate = config?.validate;
83
+ const multimodal = config?.multimodal;
84
+ const tools = config?.tools;
85
+ const enableSpeculativeDecoding = config?.enableSpeculativeDecoding;
86
+ const toolsKey = tools ? JSON.stringify(tools) : undefined;
81
87
 
82
88
  // Build a stable config object from the destructured primitives
83
89
  const nativeConfig = useMemo<LLMConfig>(
@@ -88,8 +94,25 @@ export function useModel(
88
94
  ...(temperature !== undefined && { temperature }),
89
95
  ...(topK !== undefined && { topK }),
90
96
  ...(topP !== undefined && { topP }),
97
+ ...(validate !== undefined && { validate }),
98
+ ...(multimodal !== undefined && { multimodal }),
99
+ ...(tools !== undefined && { tools }),
100
+ ...(enableSpeculativeDecoding !== undefined && {
101
+ enableSpeculativeDecoding,
102
+ }),
91
103
  }),
92
- [backend, systemPrompt, maxTokens, temperature, topK, topP],
104
+ [
105
+ backend,
106
+ systemPrompt,
107
+ maxTokens,
108
+ temperature,
109
+ topK,
110
+ topP,
111
+ validate,
112
+ multimodal,
113
+ toolsKey,
114
+ enableSpeculativeDecoding,
115
+ ],
93
116
  );
94
117
 
95
118
  /**
@@ -165,16 +188,15 @@ export function useModel(
165
188
  return new Promise<string>((resolve, reject) => {
166
189
  let fullResponse = "";
167
190
  try {
168
- modelRef.current?.sendMessageAsync(
169
- prompt,
170
- (token: string, done: boolean) => {
191
+ modelRef.current
192
+ ?.sendMessageAsync(prompt, (token: string, done: boolean) => {
171
193
  fullResponse += token;
172
194
  if (done) {
173
195
  refreshMemorySummary();
174
196
  resolve(fullResponse);
175
197
  }
176
- },
177
- ).catch(reject);
198
+ })
199
+ .catch(reject);
178
200
  } catch (e: any) {
179
201
  reject(e);
180
202
  }
package/src/index.ts CHANGED
@@ -151,6 +151,12 @@ export function checkBackendSupport(backend: Backend): string | undefined {
151
151
  * Check if multimodal features (image/audio) are supported on the current platform.
152
152
  * Returns an error message if not supported, undefined if OK.
153
153
  *
154
+ * Both iOS (v0.12.0 CLiteRTLM xcframework) and Android (LiteRT-LM SDK) ship the
155
+ * vision/audio executor ops, so there is no platform-level block. Whether a
156
+ * given call succeeds depends on the **loaded model**: only multimodal models
157
+ * (e.g. Gemma 3n) bundle the vision/audio executors. Pass `multimodal: true` to
158
+ * `loadModel` for such models, or rely on filename sniffing ("3n"/"gemma3").
159
+ *
154
160
  * @returns Error message if multimodal is not supported, undefined if OK
155
161
  *
156
162
  * @example
@@ -165,9 +171,7 @@ export function checkBackendSupport(backend: Backend): string | undefined {
165
171
  * ```
166
172
  */
167
173
  export function checkMultimodalSupport(): string | undefined {
168
- if (Platform.OS === "ios") {
169
- return "Multimodal (image/audio) is not available on iOS. The XCFramework lacks compiled vision and audio executor ops.";
170
- }
174
+ // Supported on both platforms with a multimodal model loaded.
171
175
  return undefined;
172
176
  }
173
177
 
@@ -132,6 +132,28 @@ export function createLLM(options?: {
132
132
  };
133
133
  }
134
134
 
135
+ if (prop === "sendMessageWithImageAsync") {
136
+ return (message: string, imagePath: string, onToken: (token: string, done: boolean) => void) => {
137
+ return original.call(target, message, imagePath, (token: string, done: boolean) => {
138
+ onToken(token, done);
139
+ if (done) {
140
+ recordMemorySnapshot();
141
+ }
142
+ });
143
+ };
144
+ }
145
+
146
+ if (prop === "sendMessageWithAudioAsync") {
147
+ return (message: string, audioPath: string, onToken: (token: string, done: boolean) => void) => {
148
+ return original.call(target, message, audioPath, (token: string, done: boolean) => {
149
+ onToken(token, done);
150
+ if (done) {
151
+ recordMemorySnapshot();
152
+ }
153
+ });
154
+ };
155
+ }
156
+
135
157
  if (SNAPSHOT_TRIGGERS.has(prop as string)) {
136
158
  return async (...args: any[]) => {
137
159
  const result = await original.apply(target, args);
@@ -226,6 +226,19 @@ export interface LiteRTLM extends HybridObject<{
226
226
  */
227
227
  sendMessageWithImage(message: string, imagePath: string): Promise<string>;
228
228
 
229
+ /**
230
+ * Send a text message with an image and get a streaming response.
231
+ * Tokens are delivered via callback as they are generated.
232
+ * @param message User message text.
233
+ * @param imagePath Absolute path to an image file.
234
+ * @param onToken Callback invoked for each token (token, isDone).
235
+ */
236
+ sendMessageWithImageAsync(
237
+ message: string,
238
+ imagePath: string,
239
+ onToken: (token: string, done: boolean) => void,
240
+ ): Promise<void>;
241
+
229
242
  /**
230
243
  * Download a model file from a URL.
231
244
  * @param url URL to download from.
@@ -253,6 +266,19 @@ export interface LiteRTLM extends HybridObject<{
253
266
  */
254
267
  sendMessageWithAudio(message: string, audioPath: string): Promise<string>;
255
268
 
269
+ /**
270
+ * Send a text message with audio and get a streaming response.
271
+ * Tokens are delivered via callback as they are generated.
272
+ * @param message User message text.
273
+ * @param audioPath Absolute path to an audio file (WAV).
274
+ * @param onToken Callback invoked for each token (token, isDone).
275
+ */
276
+ sendMessageWithAudioAsync(
277
+ message: string,
278
+ audioPath: string,
279
+ onToken: (token: string, done: boolean) => void,
280
+ ): Promise<void>;
281
+
256
282
  /**
257
283
  * Send a unified multimodal message containing text and/or zero-copy binary buffers.
258
284
  * @param parts The message content parts (text, image, and/or audio).