expo-local-llm 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +164 -80
  2. package/android/build/generated/source/buildConfig/debug/expo/modules/localllm/BuildConfig.java +10 -0
  3. package/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml +7 -0
  4. package/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json +18 -0
  5. package/android/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties +6 -0
  6. package/android/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json +1 -0
  7. package/android/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar +0 -0
  8. package/android/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt +0 -0
  9. package/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties +1 -0
  10. package/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml +2 -0
  11. package/android/build/intermediates/incremental/mergeDebugAssets/merger.xml +2 -0
  12. package/android/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml +2 -0
  13. package/android/build/intermediates/incremental/mergeDebugShaders/merger.xml +2 -0
  14. package/android/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt +2 -0
  15. package/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt +7 -0
  16. package/android/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml +7 -0
  17. package/android/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json +1 -0
  18. package/android/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt +1 -0
  19. package/android/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt +1 -0
  20. package/android/build/outputs/logs/manifest-merger-debug-report.txt +14 -0
  21. package/android/build.gradle +6 -1
  22. package/android/src/main/java/expo/modules/localllm/ConversationHistory.kt +5 -8
  23. package/android/src/main/java/expo/modules/localllm/Exceptions.kt +10 -0
  24. package/android/src/main/java/expo/modules/localllm/ExpoLocalLlmModule.kt +46 -18
  25. package/android/src/main/java/expo/modules/localllm/LLMSession.kt +303 -83
  26. package/android/src/test/java/expo/modules/localllm/ConversationHistoryTest.kt +75 -0
  27. package/build/ExpoLocalLlm.types.d.ts +61 -4
  28. package/build/ExpoLocalLlm.types.d.ts.map +1 -1
  29. package/build/ExpoLocalLlm.types.js.map +1 -1
  30. package/build/ExpoLocalLlmModule.d.ts +2 -0
  31. package/build/ExpoLocalLlmModule.d.ts.map +1 -1
  32. package/build/ExpoLocalLlmModule.js +7 -1
  33. package/build/ExpoLocalLlmModule.js.map +1 -1
  34. package/build/LLMSession.d.ts +8 -4
  35. package/build/LLMSession.d.ts.map +1 -1
  36. package/build/LLMSession.js +57 -3
  37. package/build/LLMSession.js.map +1 -1
  38. package/build/capabilities.d.ts +4 -0
  39. package/build/capabilities.d.ts.map +1 -0
  40. package/build/capabilities.js +62 -0
  41. package/build/capabilities.js.map +1 -0
  42. package/build/index.d.ts +3 -2
  43. package/build/index.d.ts.map +1 -1
  44. package/build/index.js +3 -2
  45. package/build/index.js.map +1 -1
  46. package/build/useLocalLLM.d.ts.map +1 -1
  47. package/build/useLocalLLM.js +177 -35
  48. package/build/useLocalLLM.js.map +1 -1
  49. package/build/validateSchema.d.ts +5 -0
  50. package/build/validateSchema.d.ts.map +1 -1
  51. package/build/validateSchema.js +98 -0
  52. package/build/validateSchema.js.map +1 -1
  53. package/expo-module.config.json +2 -1
  54. package/ios/DynamicTool.swift +46 -22
  55. package/ios/Exceptions.swift +22 -10
  56. package/ios/ExpoLocalLlmModule.swift +4 -4
  57. package/ios/FoundationModelBridge.swift +90 -36
  58. package/ios/LLMSession.swift +513 -133
  59. package/package.json +15 -3
  60. package/src/ExpoLocalLlm.types.ts +79 -3
  61. package/src/ExpoLocalLlmModule.ts +7 -1
  62. package/src/LLMSession.ts +79 -5
  63. package/src/__tests__/ExpoLocalLlm.test.ts +32 -5
  64. package/src/__tests__/inference.typecheck.ts +49 -0
  65. package/src/__tests__/nativeApi.test.ts +199 -0
  66. package/src/__tests__/useLocalLLM.native.test.ts +778 -0
  67. package/src/__tests__/useLocalLLM.test.ts +25 -4
  68. package/src/__tests__/validateSchema.test.ts +94 -1
  69. package/src/capabilities.ts +77 -0
  70. package/src/index.ts +10 -1
  71. package/src/useLocalLLM.ts +191 -40
  72. package/src/validateSchema.ts +106 -1
package/README.md CHANGED
@@ -1,22 +1,47 @@
1
1
  # expo-local-llm
2
2
 
3
- Expo module for on-device LLM inference. Wraps Apple Foundation Models (iOS 26+) and Gemini Nano via ML Kit (Android).
3
+ On-device intelligence for Expo apps, using the models provided by the operating system. Generate text, extract structured data, and build streaming interfaces without distributing model weights or writing native code.
4
+
5
+ Wraps Apple Foundation Models (iOS 26+) and Gemini Nano (Android). Inference stays on device; any network access inside your tool handlers is controlled by your app. There is no automatic cloud fallback.
4
6
 
5
7
  ## Platform Requirements
6
8
 
7
9
  | Platform | Requirement |
8
10
  |----------|-------------|
9
11
  | iOS | iOS 26+ with Apple Intelligence enabled. Loads on iOS 16.4+ without crashing (returns `notEligible`). Host project must target iOS 16.4+. |
10
- | Android | Device with Gemini Nano support (Pixel 8+, Galaxy S25+). Model may require download. |
11
- | Expo SDK | 52+ (peer requirement). Tested against SDK 56 and SDK 57 (React Native 0.86). |
12
+ | Android | Host app uses `minSdkVersion` 26+. Inference requires a Gemini Nano–capable device; most Android devices, including most mid-range phones, report `notEligible`. Build, module loading, eligibility, and unsupported-feature handling are device-verified; generation is not yet validated on Gemini Nano hardware. |
13
+ | Expo SDK | 52+ (peer range); the example targets SDK 57 / React Native 0.86. A peer range is not a tested compatibility matrix. |
14
+
15
+ **A native development build is required. Stock Expo Go cannot load this module.** A simulator can compile the module and, when the host Mac has Apple Intelligence enabled and ready, run Apple Foundation Models for real inference; physical hardware is still needed for latency and thermal measurements. See the [release verification matrix](docs/release-verification.md).
16
+
17
+ For setup failures, unavailable models, and unsupported features, see [troubleshooting](docs/troubleshooting.md).
18
+
19
+ ### Xcode 27 requires scene support
20
+
21
+ Apps built with the iOS 27 SDK must adopt the UIKit scene lifecycle, or UIKit stops them at launch before any JavaScript runs. Expo SDK 57 apps opt in through `expo-build-properties` (Expo `57.0.23`+, `expo-build-properties` `57.0.20`+):
22
+
23
+ ```json
24
+ {
25
+ "expo": {
26
+ "plugins": [
27
+ [
28
+ "expo-build-properties",
29
+ { "ios": { "deploymentTarget": "16.4", "enableSceneSupport": true } }
30
+ ]
31
+ ]
32
+ }
33
+ }
34
+ ```
35
+
36
+ Then regenerate and rebuild the native app. This is an app-level build setting, not something this library can enable for you.
12
37
 
13
38
  ## Installation
14
39
 
15
40
  ```bash
16
- npm install expo-local-llm
41
+ npx expo install expo-local-llm expo-dev-client
17
42
  ```
18
43
 
19
- This module requires **iOS 16.4+** as a compile target. Expo SDK 56+ defaults to 16.4 (the same as the module's floor), but if you're on an older SDK or have customized the deployment target, raise it via [`expo-build-properties`](https://docs.expo.dev/versions/latest/sdk/build-properties/):
44
+ This module requires **iOS 16.4+** and **Android API 26+** deployment targets. If your app targets an earlier version, raise the relevant target via [`expo-build-properties`](https://docs.expo.dev/versions/latest/sdk/build-properties/):
20
45
 
21
46
  ```bash
22
47
  npx expo install expo-build-properties
@@ -30,24 +55,58 @@ In `app.json`, add the plugin with the deployment target:
30
55
  "plugins": [
31
56
  [
32
57
  "expo-build-properties",
33
- { "ios": { "deploymentTarget": "16.4" } }
58
+ {
59
+ "ios": { "deploymentTarget": "16.4" },
60
+ "android": { "minSdkVersion": 26 }
61
+ }
34
62
  ]
35
63
  ]
36
64
  }
37
65
  }
38
66
  ```
39
67
 
40
- Then prebuild:
68
+ Build and install your development app on a supported physical device:
41
69
 
42
70
  ```bash
43
- npx expo prebuild --clean
71
+ npx expo run:ios --device
72
+ # or
73
+ npx expo run:android --device
44
74
  ```
45
75
 
46
76
  > If your project targets an iOS version below 16.4, you'll hit `compiling for iOS X.Y, but module 'ExpoLocalLlm' has a minimum deployment target of iOS 16.4` at build time. (Apple Intelligence itself still requires iOS 26+ at runtime — the 16.4 floor is just for compilation; the module returns `notEligible` on iOS 16.4–25.)
47
77
 
78
+ The iOS native build requires an Xcode SDK containing Foundation Models; the SDK 57 example requires Xcode 26.4 or newer. `expo run` generates native projects if they do not exist. For projects using Continuous Native Generation, regenerate and rebuild after changing native dependencies or native app configuration. Keep custom native changes in config plugins before using `prebuild --clean`.
79
+
80
+ EAS Build is also supported as a build workflow: use a development profile with a compatible native toolchain. Installing this package or upgrading its native implementation requires a new binary; EAS Update cannot add native code. JavaScript-only changes can use the existing binary when they remain compatible with its native API.
81
+
48
82
  ## Usage
49
83
 
84
+ ### Extract typed data (iOS 26+)
85
+
86
+ For classification and extraction, use `generateObject()`. It infers the result type from your schema and validates the generated JSON before returning it:
87
+
88
+ ```ts
89
+ import { generateObject, getCapabilities } from 'expo-local-llm';
90
+
91
+ const capabilities = getCapabilities();
92
+ // Capability support and current model readiness are separate checks.
93
+ if (capabilities.structuredOutputMode === 'constrained') {
94
+ const result = await generateObject('Classify: "Can we move the meeting to Friday?"', {
95
+ instructions: 'Classify the message by its primary intent.',
96
+ schema: {
97
+ intent: { type: 'string', enum: ['scheduling', 'question', 'other'] },
98
+ },
99
+ });
100
+ // result.intent: 'scheduling' | 'question' | 'other'
101
+ }
102
+ ```
103
+
104
+ Handle rejected requests in your app: supported capabilities do not imply that the model is downloaded, enabled, or ready. `generateObject()` rejects invalid JSON or schema mismatches with `StructuredOutputValidationError`; it never repairs output silently or falls back to cloud inference.
105
+
106
+ ### Stream text in a component
107
+
50
108
  ```tsx
109
+ import { Text, View } from 'react-native';
51
110
  import { useLocalLLM } from 'expo-local-llm';
52
111
 
53
112
  function Chat() {
@@ -65,9 +124,15 @@ function Chat() {
65
124
  instructions: 'You are a helpful assistant.',
66
125
  });
67
126
 
68
- // Methods are undefined when module is unavailable (optionality pattern)
127
+ // Methods become available after session creation and model readiness.
69
128
  if (!streamResponse) {
70
- return <Text>On-device LLM not available</Text>;
129
+ return (
130
+ <Text>
131
+ {error ?? (availability === 'available'
132
+ ? 'Preparing on-device AI...'
133
+ : `On-device AI: ${availability}`)}
134
+ </Text>
135
+ );
71
136
  }
72
137
 
73
138
  const handleSend = async (prompt: string) => {
@@ -103,7 +168,8 @@ function WeatherChat() {
103
168
  city: { type: 'string', description: 'The city name' },
104
169
  },
105
170
  handler: async (args) => {
106
- const res = await fetch(`https://api.example.com/weather?city=${args.city}`);
171
+ if (typeof args.city !== 'string') throw new Error('city must be a string');
172
+ const res = await fetch(`https://api.example.com/weather?city=${encodeURIComponent(args.city)}`);
107
173
  return JSON.stringify(await res.json());
108
174
  },
109
175
  },
@@ -123,9 +189,9 @@ function WeatherChat() {
123
189
 
124
190
  ### Structured Output
125
191
 
126
- iOS 26+ uses Apple's constrained decoding (`DynamicGenerationSchema`) — output is guaranteed to be
127
- a structurally-valid JSON object matching the schema. Android falls back to instruction-based
128
- guidance.
192
+ iOS 26+ uses Apple's constrained decoding (`DynamicGenerationSchema`) for schema-driven output.
193
+ Successful generation produces structured JSON; requests can still fail or be refused.
194
+ Android structured output is unsupported and rejected explicitly, rather than silently generating ordinary text.
129
195
 
130
196
  <p align="center">
131
197
  <img src="https://raw.githubusercontent.com/GijungKim/expo-local-llm/main/docs/structured-output.png" alt="Structured output demo — recipe JSON with constrained 'difficulty' enum on iPhone" width="320" />
@@ -155,7 +221,9 @@ const { respond } = useLocalLLM({
155
221
  },
156
222
  });
157
223
 
158
- const recipe = JSON.parse(await respond('Give me a pasta recipe'));
224
+ if (respond) {
225
+ const recipe = JSON.parse(await respond('Give me a pasta recipe'));
226
+ }
159
227
  ```
160
228
 
161
229
  For one-shot structured calls (classification, extraction) where you don't want conversation
@@ -180,7 +248,7 @@ const { streamResponse, streamedObject } = useLocalLLM({
180
248
  schema: { /* ... */ },
181
249
  });
182
250
 
183
- await streamResponse('Give me a pasta recipe');
251
+ await streamResponse?.('Give me a pasta recipe');
184
252
  // streamedObject updates as the model fills in fields
185
253
  ```
186
254
 
@@ -203,6 +271,29 @@ if (!result.ok) {
203
271
 
204
272
  ## API
205
273
 
274
+ ### `getCapabilities()`
275
+
276
+ Reports which features the loaded platform integration implements. Use this to decide whether to offer text, streaming, tools, or structured output. Check model availability separately before starting generation. A supported capability does not mean the OS model is currently ready.
277
+
278
+ The function does not download models, start inference, or opt the app into a cloud service.
279
+
280
+ Returns `platform`, `backend`, `status`, boolean `text`/`streaming`/`tools`/`structuredOutput` flags, and `structuredOutputMode` (`'constrained'` or `'unsupported'`).
281
+
282
+ Capability `status` is `'available'`, `'moduleUnavailable'`, `'unsupportedOS'`, or `'unsupportedPlatform'`. Here `'available'` means the integration is supported; use the hook's `availability` for current model readiness.
283
+
284
+ ### `getModuleDiagnostics()`
285
+
286
+ Reports whether native code loaded, with `available`, `reason`, `message`, and `setupInstructions`. A missing module is a build/setup problem, distinct from a device that cannot run the model.
287
+
288
+ ```ts
289
+ import { getModuleDiagnostics } from 'expo-local-llm';
290
+
291
+ const diagnostics = getModuleDiagnostics();
292
+ if (!diagnostics.available) {
293
+ console.warn(diagnostics.message);
294
+ }
295
+ ```
296
+
206
297
  ### `useLocalLLM(options?)`
207
298
 
208
299
  #### Options
@@ -211,11 +302,11 @@ if (!result.ok) {
211
302
  |--------|------|-------------|
212
303
  | `instructions` | `string` | System instructions for the session |
213
304
  | `options.temperature` | `number` | Sampling temperature |
214
- | `options.maxTokens` | `number` | Max output tokens (capped at 256 on Android; maps to `maximumResponseTokens` on iOS) |
305
+ | `options.maxTokens` | `number` | Max output tokens (`maxOutputTokens` on Android; `maximumResponseTokens` on iOS). Native SDK/model limits apply. |
215
306
  | `options.topK` | `number` | Top-K sampling (maps to `.random(top:)` on iOS) |
216
307
  | `tools` | `ToolDefinition[]` | Tools the model can invoke (iOS 26+ only) |
217
308
  | `toolTimeout` | `number` | Seconds before an unresolved tool call times out (default 30) |
218
- | `responseFormat` | `"text" \| "json"` | Set to `"json"` for structured JSON output |
309
+ | `responseFormat` | `"text" \| "json"` | Set to `"json"` for structured JSON output; requires `schema` |
219
310
  | `schema` | `Schema` | Schema for structured output (used with `responseFormat: "json"`) — see Structured Output section |
220
311
  | `includeSchemaInPrompt` | `boolean` | iOS 26+: include the schema definition in the prompt. Default `true`. Set `false` when you've front-loaded few-shot examples. |
221
312
 
@@ -232,97 +323,90 @@ if (!result.ok) {
232
323
  | `error` | `string \| null` | Last error message |
233
324
  | `activeToolCalls` | `ActiveToolCall[]` | In-flight tool calls (`{ callId, toolName }`) |
234
325
  | `respond` | `(prompt: string) => Promise<string>` | Non-streaming generation. `undefined` when unavailable. |
235
- | `session` | `LLMSession \| null` | The native session object. `null` when unavailable. |
326
+ | `session` | `LLMSession \| null` | The native session object. `null` before creation or when unavailable. Use the hook's generation methods for hook-managed state and tool dispatch. |
236
327
  | `streamResponse` | `(prompt: string) => Promise<string>` | Streaming generation (updates `streamedText`). Resolves with the final text at stream end, or the partial text if cancelled; rejects on stream failure. `undefined` when model not `available`. |
237
- | `cancelStream` | `() => Promise<void>` | Cancel the active stream. Actually stops inference (not just token delivery); the pending `streamResponse` promise resolves with the partial text. `undefined` when unavailable. |
328
+ | `cancelStream` | `() => Promise<void>` | Cancel the active stream; the pending `streamResponse` promise resolves with the partial text. Available whenever a session exists, even if model readiness changes. |
238
329
  | `reset` | `() => void` | Clear the conversation transcript, keeping instructions/tools/schema/options. Also cancels any in-flight stream. `undefined` only when there is no session. |
239
330
  | `downloadModel` | `() => Promise<void>` | Trigger model download (Android). `undefined` when unavailable. |
240
331
 
241
332
  ### `generate(prompt, config?)`
242
333
 
243
334
  One-shot stateless generation — creates a session, responds once, and releases it. Takes the
244
- same config as `useLocalLLM`/`createLLMSession`. Use it for classification or extraction
335
+ same generation config as `useLocalLLM`/`createLLMSession`, except tool handlers are not supported. Use it for classification or extraction
245
336
  calls where conversation history between calls is unwanted. Throws when the module or model
246
337
  is unavailable (check `ExpoLocalLlmModule.getAvailability()` first if you need to gate).
247
338
 
339
+ Use `useLocalLLM()` for automatic tool dispatch. Low-level `createLLMSession()` consumers must subscribe to `toolCall` and resolve or reject each call themselves; passing handlers in its configuration does not install those listeners.
340
+
341
+ ### `generateObject(prompt, config)`
342
+
343
+ One-shot structured generation with a required `schema`. Returns an object whose type is inferred from the schema, after validating every required field, primitive type, enum, array item, and nested object. Unexpected properties are rejected. Use inline schema literals or `as const satisfies Schema` to preserve enum literal types.
344
+
345
+ This helper requires constrained structured output support (currently iOS 26+). It releases its session on success and failure. Generation errors and model refusals still reject the call; schema validation establishes structure, not factual accuracy.
346
+
347
+ `validateStructuredOutput(value, schema)` is also available for checking an already-parsed value against a valid schema. It returns `{ ok: true }` or `{ ok: false, errors }` with field paths.
348
+
349
+ ### `createLLMSession(config?)`
350
+
351
+ Creates a manually owned conversation. Release it when finished:
352
+
353
+ ```ts
354
+ import { createLLMSession } from 'expo-local-llm';
355
+
356
+ const session = createLLMSession({ instructions: 'Keep answers concise.' });
357
+ try {
358
+ const text = await session.respond('Suggest a short title for a gardening journal.');
359
+ } finally {
360
+ session.release();
361
+ }
362
+ ```
363
+
364
+ Low-level `respond(prompt, requestId?)` and `streamResponse(prompt, requestId?)` accept an optional request identifier. Generation events carry `requestId` so consumers can discard queued events from cancelled or reset requests. Omit it to let native code generate an identifier; `useLocalLLM()` manages identifiers and filtering automatically. Use a distinct identifier for each invocation.
365
+
248
366
  ### `ModelAvailability`
249
367
 
250
- `'available' | 'notEnabled' | 'notReady' | 'notEligible' | 'downloadRequired' | 'downloading' | 'unknown'`
368
+ `'available' | 'notEnabled' | 'notReady' | 'notEligible' | 'downloadRequired' | 'downloading' | 'unknown' | 'moduleUnavailable'`
369
+
370
+ Android readiness is checked asynchronously and may initially be `unknown`. Prefer `useLocalLLM()` for reactive readiness; low-level consumers should listen for the module's `availabilityChange` event instead of treating the first synchronous status read as final.
251
371
 
252
372
  ## Platform Asymmetries
253
373
 
254
374
  | Concern | iOS | Android |
255
375
  |---------|-----|---------|
256
376
  | System instructions | Native `LanguageModelSession(instructions:)` | Prepended in prompt text |
257
- | Output tokens | ~4K context, generous output | 256 max |
377
+ | Token limits | OS/model-dependent | SDK/model-dependent |
258
378
  | Model availability | Built-in to OS | May need download |
259
379
  | Session history | Native session maintains it | `ConversationHistory` class |
260
380
  | Streaming | `AsyncSequence` | Kotlin `Flow` |
261
381
 
262
382
  ## Known Limitations
263
383
 
264
- - Only one active stream at a time. Events are not scoped to session ID.
265
- - **Android**: Gemini Nano SDK is in beta. API surface may change — not yet validated on device.
384
+ - Events belong to their session. Use a separate session for independent conversations.
385
+ - A session accepts one generation at a time; overlapping requests reject. After cancellation, await the original generation promise before starting another request.
386
+ - **Android**: Gemini Nano SDK is in beta and its API surface may change. Build, module loading, eligibility, and unsupported-feature rejection are device-verified; generation has not been validated on Gemini Nano hardware.
266
387
  - **iOS**: Apple's Foundation Model may refuse certain categories of prompts (e.g. personal health data interpretation) due to built-in safety guardrails.
267
- - Methods (`respond`, `streamResponse`, `cancelStream`) are only defined when `availability === 'available'`.
388
+ - Generation methods (`respond`, `streamResponse`) require a session and `availability === 'available'`. Cancellation remains available while a session exists.
268
389
  - Tool calling is iOS 26+ only. Android will throw at session creation if tools are passed.
269
- - Structured output on Android uses instruction-based JSON guidance. iOS 26+ uses constrained
270
- decoding via `DynamicGenerationSchema`.
390
+ - Structured output on Android is unsupported. iOS 26+ uses constrained decoding via `DynamicGenerationSchema`.
271
391
 
272
392
  ## Why an Expo module?
273
393
 
274
- This is built as an [Expo Module](https://docs.expo.dev/modules/overview/), not
275
- a bare React Native library. Here's why:
276
-
277
- - **`expo install` and go** — no manual Xcode/Gradle linking, no `pod install`
278
- surprises. Works with `npx expo prebuild` and managed workflow out of the box.
279
- - **SharedObject lifecycle** — `LLMSession` extends Expo's `SharedObject`, which
280
- handles native memory management, event subscriptions, and cleanup
281
- automatically when the JS object is garbage collected. In bare RN you'd wire
282
- this yourself with `NativeEventEmitter` and manual release calls.
283
- - **Class DSL** — the native module exposes `LLMSession` as a first-class JS
284
- object with instance methods (`session.respond()`, `session.streamResponse()`)
285
- rather than flat module-level functions with session IDs. This is an Expo
286
- Modules API feature that doesn't exist in the classic RN bridge.
287
- - **Cross-platform parity** — Expo's Kotlin DSL mirrors the Swift DSL, so the
288
- iOS and Android modules have the same structure. Adding Android tool calling
289
- later means implementing the same interface, not building a separate bridge.
290
-
291
- If you're on bare React Native without Expo, this module won't work — you'd need
292
- to add `expo-modules-core` as a dependency or use a different library.
293
-
294
- ## How is this different from React Native AI? (as of June 2026)
295
-
296
- [React Native AI](https://github.com/callstackincubator/ai) (formerly `@callstack/ai`)
297
- is a Vercel AI SDK-compatible collection of on-device AI primitives, modularized
298
- into per-backend packages: `@react-native-ai/apple`, `@react-native-ai/llama`,
299
- `@react-native-ai/mlc`. It covers text generation, embeddings, transcription, and
300
- speech synthesis.
301
-
302
- `expo-local-llm` takes a narrower approach:
303
-
304
- | | `expo-local-llm` | React Native AI |
305
- |---|---|---|
306
- | **Surface** | React hook (`useLocalLLM`) and a native session object | Vercel AI SDK provider (`generateText`, `streamText`, `embed`, `transcribe`, `speech`) |
307
- | **iOS model** | Apple Foundation Models (system-provided) | Apple Foundation Models, Llama (via llama.rn), MLC LLM |
308
- | **Android model** | Gemini Nano (system-provided) | Llama, MLC (no built-in OS model) |
309
- | **Capabilities** | Text generation, tool calling, constrained JSON output | Text, embeddings, transcription, speech synthesis |
310
- | **Model management** | None — the OS handles it | Built-in for Apple; download/prepare for Llama/MLC |
311
- | **Bundle size impact** | Near zero | Depends on which provider you install + model weights |
312
- | **DevTools** | None | AI SDK Profiler via Rozenite (OpenTelemetry spans) |
313
- | **Install path** | One `expo install`, autolinks | Per-package install (Apple provider autolinks; Llama/MLC need extra setup) |
314
- | **Dependencies** | None | Vercel AI SDK (v6) and per-provider runtimes |
315
-
316
- **Choose `expo-local-llm` if** you want the simplest path to on-device LLM in
317
- an Expo app and are happy using whatever model the OS provides (Apple Foundation
318
- Models on iOS, Gemini Nano on Android). There's nothing to configure, no weights
319
- to bundle, and it works on both platforms.
320
-
321
- **Choose React Native AI if** you need any of:
322
- - Vercel AI SDK compatibility (drop-in replacement for cloud-LLM apps)
323
- - Capabilities beyond text generation (embeddings, transcription, speech synthesis)
324
- - Specific models (Llama 3.2, Phi-3, Mistral, Qwen) on either iOS or Android
325
- - AI SDK Profiler DevTools for tracing
394
+ The [Expo Modules API](https://docs.expo.dev/modules/overview/) fits OS-provided AI APIs well:
395
+
396
+ - **Native session objects** — Expo's `SharedObject` and class DSL expose instance methods and session-scoped events directly to JavaScript.
397
+ - **Lifecycle integration** — native cleanup hooks support cancellation and resource release. `generate()` releases its session automatically; consumers of `createLLMSession()` must call `release()` when finished.
398
+ - **Swift and Kotlin** — both implementations use a consistent module definition style, without adding a separate inference engine.
399
+ - **Expo build workflows** — autolinking integrates with prebuild, local development builds, and EAS Build.
400
+
401
+ Expo does not improve model quality or remove device eligibility requirements. Its native-call performance is comparable to React Native Turbo Modules; autolinking is not unique to Expo. The benefit is a cohesive integration and maintenance experience.
402
+
403
+ Existing React Native apps can use this library after [installing Expo Modules support](https://docs.expo.dev/bare/installing-expo-modules/). Expo-first does not mean Expo-only.
404
+
405
+ ## Choosing this library
406
+
407
+ Choose `expo-local-llm` for a small, Expo-first API around system-provided language models: short text generation, classification, extraction, and streaming interfaces on supported devices. You do not select or distribute model weights.
408
+
409
+ If you need downloadable models, embeddings, speech, or an AI SDK provider interface, evaluate a broader runtime such as [React Native AI](https://github.com/callstackincubator/ai). Check its current provider documentation for platform requirements. This library does not implement those capabilities or automatically route requests to a cloud model.
326
410
 
327
411
  ## Built with expo-local-llm
328
412
 
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Automatically generated file. DO NOT MODIFY
3
+ */
4
+ package expo.modules.localllm;
5
+
6
+ public final class BuildConfig {
7
+ public static final boolean DEBUG = Boolean.parseBoolean("true");
8
+ public static final String LIBRARY_PACKAGE_NAME = "expo.modules.localllm";
9
+ public static final String BUILD_TYPE = "debug";
10
+ }
@@ -0,0 +1,7 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
3
+ package="expo.modules.localllm" >
4
+
5
+ <uses-sdk android:minSdkVersion="26" />
6
+
7
+ </manifest>
@@ -0,0 +1,18 @@
1
+ {
2
+ "version": 3,
3
+ "artifactType": {
4
+ "type": "AAPT_FRIENDLY_MERGED_MANIFESTS",
5
+ "kind": "Directory"
6
+ },
7
+ "applicationId": "expo.modules.localllm",
8
+ "variantName": "debug",
9
+ "elements": [
10
+ {
11
+ "type": "SINGLE",
12
+ "filters": [],
13
+ "attributes": [],
14
+ "outputFile": "AndroidManifest.xml"
15
+ }
16
+ ],
17
+ "elementType": "File"
18
+ }
@@ -0,0 +1,6 @@
1
+ aarFormatVersion=1.0
2
+ aarMetadataVersion=1.0
3
+ minCompileSdk=1
4
+ minCompileSdkExtension=0
5
+ minAndroidGradlePluginVersion=1.0.0
6
+ coreLibraryDesugaringEnabled=false
@@ -0,0 +1,2 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <merger version="3"><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/swoleknee/chingu/expo-local-llm/android/src/main/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main" generated-set="main$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/swoleknee/chingu/expo-local-llm/android/src/main/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="debug$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/swoleknee/chingu/expo-local-llm/android/src/debug/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="debug" generated-set="debug$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/swoleknee/chingu/expo-local-llm/android/src/debug/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/swoleknee/chingu/expo-local-llm/android/build/generated/res/resValues/debug"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated" generated-set="generated$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/swoleknee/chingu/expo-local-llm/android/build/generated/res/resValues/debug"/></dataSet><mergedItems/></merger>
@@ -0,0 +1,2 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/swoleknee/chingu/expo-local-llm/example/node_modules/expo-local-llm/android/src/main/assets"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/swoleknee/chingu/expo-local-llm/example/node_modules/expo-local-llm/android/src/debug/assets"/></dataSet><dataSet config="generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/swoleknee/chingu/expo-local-llm/example/node_modules/expo-local-llm/android/build/intermediates/shader_assets/debug/compileDebugShaders/out"/></dataSet></merger>
@@ -0,0 +1,2 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/swoleknee/chingu/expo-local-llm/example/node_modules/expo-local-llm/android/src/main/jniLibs"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/swoleknee/chingu/expo-local-llm/example/node_modules/expo-local-llm/android/src/debug/jniLibs"/></dataSet></merger>
@@ -0,0 +1,2 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/swoleknee/chingu/expo-local-llm/example/node_modules/expo-local-llm/android/src/main/shaders"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/swoleknee/chingu/expo-local-llm/example/node_modules/expo-local-llm/android/src/debug/shaders"/></dataSet></merger>
@@ -0,0 +1,2 @@
1
+ R_DEF: Internal format may change without notice
2
+ local
@@ -0,0 +1,7 @@
1
+ 1<?xml version="1.0" encoding="utf-8"?>
2
+ 2<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3
+ 3 package="expo.modules.localllm" >
4
+ 4
5
+ 5 <uses-sdk android:minSdkVersion="26" />
6
+ 6
7
+ 7</manifest>
@@ -0,0 +1,7 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
3
+ package="expo.modules.localllm" >
4
+
5
+ <uses-sdk android:minSdkVersion="26" />
6
+
7
+ </manifest>
@@ -0,0 +1,14 @@
1
+ -- Merging decision tree log ---
2
+ manifest
3
+ ADDED from /Users/swoleknee/chingu/expo-local-llm/android/src/main/AndroidManifest.xml:1:1-2:12
4
+ INJECTED from /Users/swoleknee/chingu/expo-local-llm/android/src/main/AndroidManifest.xml:1:1-2:12
5
+ package
6
+ INJECTED from /Users/swoleknee/chingu/expo-local-llm/android/src/main/AndroidManifest.xml
7
+ uses-sdk
8
+ INJECTED from /Users/swoleknee/chingu/expo-local-llm/android/src/main/AndroidManifest.xml reason: use-sdk injection requested
9
+ INJECTED from /Users/swoleknee/chingu/expo-local-llm/android/src/main/AndroidManifest.xml
10
+ INJECTED from /Users/swoleknee/chingu/expo-local-llm/android/src/main/AndroidManifest.xml
11
+ android:targetSdkVersion
12
+ INJECTED from /Users/swoleknee/chingu/expo-local-llm/android/src/main/AndroidManifest.xml
13
+ android:minSdkVersion
14
+ INJECTED from /Users/swoleknee/chingu/expo-local-llm/android/src/main/AndroidManifest.xml
@@ -32,5 +32,10 @@ android {
32
32
  }
33
33
 
34
34
  dependencies {
35
- implementation 'com.google.ai.edge.localagent:localagent:0.1.0'
35
+ // beta3+ uses Kotlin 2.3 metadata and stdlib, which Expo 57's Kotlin 2.1
36
+ // compiler cannot consume. beta2 is the newest release with supported 2.2
37
+ // metadata and its declared Kotlin 1.8 runtime dependency remains intact.
38
+ implementation 'com.google.mlkit:genai-prompt:1.0.0-beta2'
39
+
40
+ testImplementation 'junit:junit:4.13.2'
36
41
  }
@@ -6,13 +6,9 @@ class ConversationHistory(private val systemInstructions: String?) {
6
6
  private val messages = mutableListOf<Message>()
7
7
  private val maxExchanges = 10
8
8
 
9
- fun addUserMessage(content: String) {
10
- messages.add(Message("User", content))
11
- trimHistory()
12
- }
13
-
14
- fun addAssistantMessage(content: String) {
15
- messages.add(Message("Assistant", content))
9
+ fun addExchange(user: String, assistant: String) {
10
+ messages.add(Message("User", user))
11
+ messages.add(Message("Assistant", assistant))
16
12
  trimHistory()
17
13
  }
18
14
 
@@ -20,7 +16,7 @@ class ConversationHistory(private val systemInstructions: String?) {
20
16
  messages.clear()
21
17
  }
22
18
 
23
- fun buildPrompt(): String {
19
+ fun buildPrompt(addingUserMessage: String): String {
24
20
  val sb = StringBuilder()
25
21
  if (!systemInstructions.isNullOrBlank()) {
26
22
  sb.appendLine("[System] $systemInstructions")
@@ -29,6 +25,7 @@ class ConversationHistory(private val systemInstructions: String?) {
29
25
  for (msg in messages) {
30
26
  sb.appendLine("[${msg.role}] ${msg.content}")
31
27
  }
28
+ sb.appendLine("[User] $addingUserMessage")
32
29
  return sb.toString().trim()
33
30
  }
34
31
 
@@ -11,5 +11,15 @@ class SessionInvalidException :
11
11
  class StreamException(message: String) :
12
12
  CodedException("ERR_STREAM", message, null)
13
13
 
14
+ class SessionBusyException :
15
+ CodedException("ERR_SESSION_BUSY", "This LLM session already has a generation in progress", null)
16
+
17
+ class ResponseFormatNotSupportedException :
18
+ CodedException(
19
+ "ERR_RESPONSE_FORMAT_NOT_SUPPORTED",
20
+ "Structured JSON output is not supported on Android yet",
21
+ null
22
+ )
23
+
14
24
  class ToolNotSupportedException :
15
25
  CodedException("ERR_TOOL_NOT_SUPPORTED", "Tool calling is not supported on Android yet", null)