expo-local-llm 0.6.0 → 0.8.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 (87) hide show
  1. package/README.md +292 -110
  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 +9 -3
  22. package/android/src/main/java/expo/modules/localllm/ConversationHistory.kt +14 -8
  23. package/android/src/main/java/expo/modules/localllm/Exceptions.kt +16 -2
  24. package/android/src/main/java/expo/modules/localllm/ExpoLocalLlmModule.kt +46 -18
  25. package/android/src/main/java/expo/modules/localllm/GenerationErrors.kt +30 -0
  26. package/android/src/main/java/expo/modules/localllm/LLMSession.kt +310 -84
  27. package/android/src/test/java/expo/modules/localllm/ConversationHistoryTest.kt +96 -0
  28. package/android/src/test/java/expo/modules/localllm/GenerationErrorsTest.kt +48 -0
  29. package/build/ExpoLocalLlm.types.d.ts +142 -12
  30. package/build/ExpoLocalLlm.types.d.ts.map +1 -1
  31. package/build/ExpoLocalLlm.types.js.map +1 -1
  32. package/build/ExpoLocalLlmModule.d.ts +2 -0
  33. package/build/ExpoLocalLlmModule.d.ts.map +1 -1
  34. package/build/ExpoLocalLlmModule.js +7 -1
  35. package/build/ExpoLocalLlmModule.js.map +1 -1
  36. package/build/LLMSession.d.ts +8 -4
  37. package/build/LLMSession.d.ts.map +1 -1
  38. package/build/LLMSession.js +37 -2
  39. package/build/LLMSession.js.map +1 -1
  40. package/build/capabilities.d.ts +4 -0
  41. package/build/capabilities.d.ts.map +1 -0
  42. package/build/capabilities.js +64 -0
  43. package/build/capabilities.js.map +1 -0
  44. package/build/defineTool.d.ts +19 -0
  45. package/build/defineTool.d.ts.map +1 -0
  46. package/build/defineTool.js +20 -0
  47. package/build/defineTool.js.map +1 -0
  48. package/build/errors.d.ts +25 -0
  49. package/build/errors.d.ts.map +1 -0
  50. package/build/errors.js +54 -0
  51. package/build/errors.js.map +1 -0
  52. package/build/index.d.ts +5 -2
  53. package/build/index.d.ts.map +1 -1
  54. package/build/index.js +5 -2
  55. package/build/index.js.map +1 -1
  56. package/build/useLocalLLM.d.ts +15 -5
  57. package/build/useLocalLLM.d.ts.map +1 -1
  58. package/build/useLocalLLM.js +222 -45
  59. package/build/useLocalLLM.js.map +1 -1
  60. package/build/validateSchema.d.ts +14 -1
  61. package/build/validateSchema.d.ts.map +1 -1
  62. package/build/validateSchema.js +126 -0
  63. package/build/validateSchema.js.map +1 -1
  64. package/expo-module.config.json +2 -1
  65. package/ios/DynamicTool.swift +67 -57
  66. package/ios/Exceptions.swift +16 -10
  67. package/ios/ExpoLocalLlmModule.swift +5 -5
  68. package/ios/FoundationModelBridge.swift +90 -36
  69. package/ios/GenerationErrorCodes.swift +107 -0
  70. package/ios/GenerationErrorMapper.swift +27 -0
  71. package/ios/LLMSession.swift +528 -136
  72. package/package.json +21 -7
  73. package/src/ExpoLocalLlm.types.ts +175 -14
  74. package/src/ExpoLocalLlmModule.ts +7 -1
  75. package/src/LLMSession.ts +59 -4
  76. package/src/__tests__/ExpoLocalLlm.test.ts +32 -5
  77. package/src/__tests__/inference.typecheck.ts +119 -0
  78. package/src/__tests__/nativeApi.test.ts +253 -0
  79. package/src/__tests__/useLocalLLM.native.test.ts +944 -0
  80. package/src/__tests__/useLocalLLM.test.ts +25 -4
  81. package/src/__tests__/validateSchema.test.ts +94 -1
  82. package/src/capabilities.ts +79 -0
  83. package/src/defineTool.ts +23 -0
  84. package/src/errors.ts +57 -0
  85. package/src/index.ts +12 -1
  86. package/src/useLocalLLM.ts +289 -56
  87. package/src/validateSchema.ts +139 -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, so `getCapabilities()` reports Android as `experimental: true`. |
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) => {
@@ -87,27 +152,93 @@ function Chat() {
87
152
  }
88
153
  ```
89
154
 
155
+ ### Handle errors
156
+
157
+ Every error from this library carries a stable `code` (see [Error codes](#error-codes)), so you can respond to *why* generation failed rather than parsing OS messages. The hook exposes it as `errorCode`; for a caught error, use `getLocalLLMErrorCode()`.
158
+
159
+ Foundation Models has a small context window, so a long conversation eventually fails with `ERR_CONTEXT_WINDOW_EXCEEDED`. The library never drops turns silently; decide what your app should do:
160
+
161
+ ```tsx
162
+ const { errorCode, reset, streamResponse } = useLocalLLM({ instructions });
163
+
164
+ if (errorCode === 'ERR_CONTEXT_WINDOW_EXCEEDED') {
165
+ // Start over, or create a new session seeded with a summary of the chat.
166
+ reset?.();
167
+ }
168
+ if (errorCode === 'ERR_GUARDRAIL_VIOLATION' || errorCode === 'ERR_REFUSAL') {
169
+ // The model declined; ask the person to rephrase.
170
+ }
171
+ ```
172
+
173
+ ```ts
174
+ import { generate, getLocalLLMErrorCode } from 'expo-local-llm';
175
+
176
+ try {
177
+ await generate(prompt);
178
+ } catch (error) {
179
+ if (error instanceof Error && getLocalLLMErrorCode(error) === 'ERR_RATE_LIMITED') {
180
+ // The system is throttling on-device generation; retry later.
181
+ }
182
+ }
183
+ ```
184
+
185
+ #### Using with Effect
186
+
187
+ The library has no runtime dependencies, so it does not ship [Effect](https://effect.website) bindings. The error codes map directly onto a tagged error, so a wrapper takes a few lines:
188
+
189
+ ```ts
190
+ import { Data, Effect } from 'effect';
191
+ import { generate, getLocalLLMErrorCode, type LocalLLMErrorCode } from 'expo-local-llm';
192
+
193
+ class LocalLLMFailure extends Data.TaggedError('LocalLLMFailure')<{
194
+ code: LocalLLMErrorCode | null;
195
+ message: string;
196
+ }> {}
197
+
198
+ const generateEffect = (prompt: string) =>
199
+ Effect.tryPromise({
200
+ try: () => generate(prompt),
201
+ catch: (error) =>
202
+ new LocalLLMFailure({
203
+ code: error instanceof Error ? getLocalLLMErrorCode(error) : null,
204
+ message: error instanceof Error ? error.message : 'Generation failed',
205
+ }),
206
+ });
207
+
208
+ // Recover from one failure by its code.
209
+ const answer = generateEffect('Summarise my notes').pipe(
210
+ Effect.catchIf(
211
+ (failure) => failure.code === 'ERR_RATE_LIMITED',
212
+ () => Effect.succeed('Try again in a moment.'),
213
+ ),
214
+ );
215
+ ```
216
+
90
217
  ### Tool Calling (iOS 26+)
91
218
 
92
219
  ```tsx
93
- import { useLocalLLM } from 'expo-local-llm';
220
+ import { defineTool, useLocalLLM } from 'expo-local-llm';
221
+
222
+ const getWeather = defineTool({
223
+ name: 'getWeather',
224
+ description: 'Get the current weather for a city',
225
+ parameters: {
226
+ city: { type: 'string', description: 'The city name' },
227
+ unit: { type: 'string', description: 'Temperature unit', enum: ['celsius', 'fahrenheit'] },
228
+ },
229
+ // Typed from `parameters`: city is a string, unit is 'celsius' | 'fahrenheit'.
230
+ handler: async ({ city, unit }) => {
231
+ const res = await fetch(
232
+ `https://api.example.com/weather?city=${encodeURIComponent(city)}&unit=${unit}`
233
+ );
234
+ return JSON.stringify(await res.json());
235
+ },
236
+ });
94
237
 
95
238
  function WeatherChat() {
96
239
  const { streamResponse, streamedText, activeToolCalls, error } = useLocalLLM({
97
240
  instructions: 'You are a helpful weather assistant.',
98
- tools: [
99
- {
100
- name: 'getWeather',
101
- description: 'Get the current weather for a city',
102
- parameters: {
103
- city: { type: 'string', description: 'The city name' },
104
- },
105
- handler: async (args) => {
106
- const res = await fetch(`https://api.example.com/weather?city=${args.city}`);
107
- return JSON.stringify(await res.json());
108
- },
109
- },
110
- ],
241
+ tools: [getWeather],
111
242
  });
112
243
 
113
244
  return (
@@ -121,11 +252,15 @@ function WeatherChat() {
121
252
  }
122
253
  ```
123
254
 
255
+ `defineTool()` infers the handler's argument types from `parameters`; it returns the tool unchanged. Tools written inline in `tools` still work, with arguments typed as `Record<string, string | number | boolean>`.
256
+
257
+ Tool arguments use the same constrained decoding as structured output: the model fills in a schema built from `parameters`, so every declared parameter arrives with its declared type and any `enum` is respected. Validate values whose meaning matters (ranges, formats) as you would any input. If a handler throws or times out, the turn rejects with `ERR_TOOL_FAILED`.
258
+
124
259
  ### Structured Output
125
260
 
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.
261
+ iOS 26+ uses Apple's constrained decoding (`DynamicGenerationSchema`) for schema-driven output.
262
+ Successful generation produces structured JSON; requests can still fail or be refused.
263
+ Android structured output is unsupported and rejected explicitly, rather than silently generating ordinary text.
129
264
 
130
265
  <p align="center">
131
266
  <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" />
@@ -134,7 +269,7 @@ guidance.
134
269
  *Real device output: the model fills in a recipe schema with `difficulty` constrained to `easy | medium | hard`. The streaming demo lives at `example/App.tsx`.*
135
270
 
136
271
  ```tsx
137
- const { respond } = useLocalLLM({
272
+ const { respond, completedObject } = useLocalLLM({
138
273
  responseFormat: 'json',
139
274
  schema: {
140
275
  name: { type: 'string', description: 'Recipe name' },
@@ -155,9 +290,15 @@ const { respond } = useLocalLLM({
155
290
  },
156
291
  });
157
292
 
158
- const recipe = JSON.parse(await respond('Give me a pasta recipe'));
293
+ if (respond) {
294
+ await respond('Give me a pasta recipe');
295
+ }
296
+ // completedObject is typed from the schema and validated against it:
297
+ // { name: string; ingredients: string[]; steps: string[]; difficulty: 'easy' | 'medium' | 'hard' } | null
159
298
  ```
160
299
 
300
+ In JSON mode, `respond` and `streamResponse` validate the final JSON against the schema and reject with `StructuredOutputValidationError` (`ERR_STRUCTURED_OUTPUT_INVALID`) if it does not match.
301
+
161
302
  For one-shot structured calls (classification, extraction) where you don't want conversation
162
303
  history accumulating between calls, use `generate()` instead of a session:
163
304
 
@@ -172,18 +313,22 @@ const json = await generate('Classify this message: "how was my sleep?"', {
172
313
  ```
173
314
 
174
315
  Streaming with a schema yields progressively-filled snapshots via `streamedJSON` (the current
175
- partial as a JSON string) and `streamedObject` (the parsed value):
316
+ partial as a JSON string) and `streamedObject` (the parsed value, typed with every field optional).
317
+ When the stream completes, the validated result lands in `completedObject`:
176
318
 
177
319
  ```tsx
178
- const { streamResponse, streamedObject } = useLocalLLM({
320
+ const { streamResponse, streamedObject, completedObject } = useLocalLLM({
179
321
  responseFormat: 'json',
180
322
  schema: { /* ... */ },
181
323
  });
182
324
 
183
- await streamResponse('Give me a pasta recipe');
184
- // streamedObject updates as the model fills in fields
325
+ await streamResponse?.('Give me a pasta recipe');
326
+ // streamedObject updates as the model fills in fields;
327
+ // completedObject is set once the whole object has validated.
185
328
  ```
186
329
 
330
+ A cancelled stream keeps its partial `streamedObject` and leaves `completedObject` null.
331
+
187
332
  #### Schema validation
188
333
 
189
334
  `createLLMSession` validates the schema before crossing to native and throws `SchemaInvalidError`
@@ -203,6 +348,29 @@ if (!result.ok) {
203
348
 
204
349
  ## API
205
350
 
351
+ ### `getCapabilities()`
352
+
353
+ 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.
354
+
355
+ The function does not download models, start inference, or opt the app into a cloud service.
356
+
357
+ Returns `platform`, `backend`, `status`, boolean `text`/`streaming`/`tools`/`structuredOutput` flags, `structuredOutputMode` (`'constrained'` or `'unsupported'`), and `experimental`. `experimental` is `true` on Android until Gemini Nano generation has been validated on real hardware; gate production features on it if you need verified behavior.
358
+
359
+ Capability `status` is `'available'`, `'moduleUnavailable'`, `'unsupportedOS'`, or `'unsupportedPlatform'`. Here `'available'` means the integration is supported; use the hook's `availability` for current model readiness.
360
+
361
+ ### `getModuleDiagnostics()`
362
+
363
+ 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.
364
+
365
+ ```ts
366
+ import { getModuleDiagnostics } from 'expo-local-llm';
367
+
368
+ const diagnostics = getModuleDiagnostics();
369
+ if (!diagnostics.available) {
370
+ console.warn(diagnostics.message);
371
+ }
372
+ ```
373
+
206
374
  ### `useLocalLLM(options?)`
207
375
 
208
376
  #### Options
@@ -211,11 +379,11 @@ if (!result.ok) {
211
379
  |--------|------|-------------|
212
380
  | `instructions` | `string` | System instructions for the session |
213
381
  | `options.temperature` | `number` | Sampling temperature |
214
- | `options.maxTokens` | `number` | Max output tokens (capped at 256 on Android; maps to `maximumResponseTokens` on iOS) |
382
+ | `options.maxTokens` | `number` | Max output tokens (`maxOutputTokens` on Android; `maximumResponseTokens` on iOS). Native SDK/model limits apply. |
215
383
  | `options.topK` | `number` | Top-K sampling (maps to `.random(top:)` on iOS) |
216
384
  | `tools` | `ToolDefinition[]` | Tools the model can invoke (iOS 26+ only) |
217
385
  | `toolTimeout` | `number` | Seconds before an unresolved tool call times out (default 30) |
218
- | `responseFormat` | `"text" \| "json"` | Set to `"json"` for structured JSON output |
386
+ | `responseFormat` | `"text" \| "json"` | Set to `"json"` for structured JSON output; requires `schema` |
219
387
  | `schema` | `Schema` | Schema for structured output (used with `responseFormat: "json"`) — see Structured Output section |
220
388
  | `includeSchemaInPrompt` | `boolean` | iOS 26+: include the schema definition in the prompt. Default `true`. Set `false` when you've front-loaded few-shot examples. |
221
389
 
@@ -227,113 +395,127 @@ if (!result.ok) {
227
395
  | `isGenerating` | `boolean` | Whether a generation is in progress |
228
396
  | `streamedText` | `string` | Accumulated text from current text stream |
229
397
  | `streamedJSON` | `string` | Current partial JSON from a schema-driven stream |
230
- | `streamedObject` | `unknown` | Parsed value of `streamedJSON` (or `null` before any partial) |
398
+ | `streamedObject` | `DeepPartial<InferSchema<schema>> \| null` | Parsed value of `streamedJSON` (or `null` before any partial). Fields may be missing until generated. |
399
+ | `completedObject` | `InferSchema<schema> \| null` | The finished JSON-mode response, validated against the schema. `null` while generating and after a cancel. |
231
400
  | `downloadProgress` | `number \| null` | Download progress 0-1 (Android only) |
232
401
  | `error` | `string \| null` | Last error message |
402
+ | `errorCode` | `LocalLLMErrorCode \| null` | Code of the last error (see [Error codes](#error-codes)); `null` when there is none or it came from your own code |
233
403
  | `activeToolCalls` | `ActiveToolCall[]` | In-flight tool calls (`{ callId, toolName }`) |
234
404
  | `respond` | `(prompt: string) => Promise<string>` | Non-streaming generation. `undefined` when unavailable. |
235
- | `session` | `LLMSession \| null` | The native session object. `null` when unavailable. |
405
+ | `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
406
  | `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. |
407
+ | `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
408
  | `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
409
  | `downloadModel` | `() => Promise<void>` | Trigger model download (Android). `undefined` when unavailable. |
240
410
 
241
411
  ### `generate(prompt, config?)`
242
412
 
243
413
  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
414
+ same generation config as `useLocalLLM`/`createLLMSession`, except tool handlers are not supported. Use it for classification or extraction
245
415
  calls where conversation history between calls is unwanted. Throws when the module or model
246
416
  is unavailable (check `ExpoLocalLlmModule.getAvailability()` first if you need to gate).
247
417
 
418
+ 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.
419
+
420
+ ### `generateObject(prompt, config)`
421
+
422
+ 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.
423
+
424
+ 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.
425
+
426
+ `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.
427
+
428
+ ### `createLLMSession(config?)`
429
+
430
+ Creates a manually owned conversation. Release it when finished:
431
+
432
+ ```ts
433
+ import { createLLMSession } from 'expo-local-llm';
434
+
435
+ const session = createLLMSession({ instructions: 'Keep answers concise.' });
436
+ try {
437
+ const text = await session.respond('Suggest a short title for a gardening journal.');
438
+ } finally {
439
+ session.release();
440
+ }
441
+ ```
442
+
443
+ 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.
444
+
445
+ ### Error codes
446
+
447
+ Errors thrown or rejected by the library, and `streamError` events, carry one of these codes. Messages come from the OS and may change; codes do not.
448
+
449
+ On iOS 27, Foundation Models reports some failures with error types that only the iOS 27 SDK can name. Build with Xcode 27 to classify all of them. An app built with Xcode 26.4 still gets codes for the iOS 26 error types, but reports the newer ones as `ERR_GENERATION_FAILED`.
450
+
451
+ | Code | Meaning |
452
+ |------|---------|
453
+ | `ERR_CONTEXT_WINDOW_EXCEEDED` | The conversation no longer fits the model's context window. Reset or start a new session. |
454
+ | `ERR_GUARDRAIL_VIOLATION` | Safety guardrails blocked the prompt or response. |
455
+ | `ERR_REFUSAL` | The model declined to answer. |
456
+ | `ERR_UNSUPPORTED_LANGUAGE` | The prompt language or device locale is not supported. |
457
+ | `ERR_UNSUPPORTED_REQUEST` | The request uses a capability the model or helper cannot honour. |
458
+ | `ERR_RATE_LIMITED` | The system is throttling generation (quota, background use, load). Retry later. |
459
+ | `ERR_MODEL_UNAVAILABLE` | Model assets are missing or the device cannot run the model right now. |
460
+ | `ERR_DECODING_FAILED` | Output could not be decoded into the requested structure. |
461
+ | `ERR_TIMEOUT` | Generation exceeded the system's time limit (iOS 27+). |
462
+ | `ERR_TOOL_FAILED` | A tool handler threw, timed out, or was removed mid-call. |
463
+ | `ERR_GENERATION_FAILED` | Any other generation failure. |
464
+ | `ERR_SESSION_BUSY` | The session already has a generation in progress. |
465
+ | `ERR_SESSION_INVALID` | The session was released or is not ready. |
466
+ | `ERR_NOT_SUPPORTED` | On-device generation is not available on this OS version. |
467
+ | `ERR_RESPONSE_FORMAT_NOT_SUPPORTED` | Structured output is not supported on this platform. |
468
+ | `ERR_TOOL_NOT_SUPPORTED` | Tool calling is not supported on this platform. |
469
+ | `ERR_MODEL_DOWNLOAD_FAILED` | Android: downloading Gemini Nano failed. |
470
+ | `ERR_SCHEMA_INVALID` | A schema is malformed (`SchemaInvalidError`). |
471
+ | `ERR_STRUCTURED_OUTPUT_INVALID` | Generated JSON did not match the schema (`StructuredOutputValidationError`). |
472
+ | `ERR_UNAVAILABLE` | The native module is missing from this build. |
473
+
248
474
  ### `ModelAvailability`
249
475
 
250
- `'available' | 'notEnabled' | 'notReady' | 'notEligible' | 'downloadRequired' | 'downloading' | 'unknown'`
476
+ `'available' | 'notEnabled' | 'notReady' | 'notEligible' | 'downloadRequired' | 'downloading' | 'unknown' | 'moduleUnavailable'`
477
+
478
+ 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
479
 
252
480
  ## Platform Asymmetries
253
481
 
254
482
  | Concern | iOS | Android |
255
483
  |---------|-----|---------|
256
484
  | System instructions | Native `LanguageModelSession(instructions:)` | Prepended in prompt text |
257
- | Output tokens | ~4K context, generous output | 256 max |
485
+ | Token limits | OS/model-dependent | SDK/model-dependent |
258
486
  | Model availability | Built-in to OS | May need download |
259
- | Session history | Native session maintains it | `ConversationHistory` class |
487
+ | Session history | Native session maintains it | Replayed in the prompt; last 10 exchanges, with role markers in conversation text neutralised |
260
488
  | Streaming | `AsyncSequence` | Kotlin `Flow` |
261
489
 
262
490
  ## Known Limitations
263
491
 
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.
492
+ - Events belong to their session. Use a separate session for independent conversations.
493
+ - A session accepts one generation at a time; overlapping requests reject. After cancellation, await the original generation promise before starting another request.
494
+ - **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 (`getCapabilities().experimental` is `true`).
495
+ - **Context window**: neither platform trims history to fit the model's context. iOS rejects with `ERR_CONTEXT_WINDOW_EXCEEDED` when the transcript is too long; Android keeps the last 10 exchanges. Call `reset()` or start a new session when that happens.
266
496
  - **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'`.
497
+ - Generation methods (`respond`, `streamResponse`) require a session and `availability === 'available'`. Cancellation remains available while a session exists.
268
498
  - 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`.
499
+ - Structured output on Android is unsupported. iOS 26+ uses constrained decoding via `DynamicGenerationSchema`.
271
500
 
272
501
  ## Why an Expo module?
273
502
 
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 August 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`, and — new as of July 2026 — `@react-native-ai/adk`, which
300
- wraps Google's Agent Development Kit to run on-device Gemini Nano (or cloud
301
- Gemini) on Android. It covers text generation, tool calling, embeddings,
302
- transcription, and speech synthesis.
303
-
304
- `expo-local-llm` takes a narrower approach:
305
-
306
- | | `expo-local-llm` | React Native AI |
307
- |---|---|---|
308
- | **Surface** | React hook (`useLocalLLM`) and a native session object | Vercel AI SDK provider (`generateText`, `streamText`, `embed`, `transcribe`, `speech`) |
309
- | **iOS model** | Apple Foundation Models (system-provided) | Apple Foundation Models, Llama (via llama.rn), MLC LLM |
310
- | **Android model** | Gemini Nano (system-provided) | Gemini Nano (via ADK/ML Kit GenAI), cloud Gemini, Llama, MLC |
311
- | **Capabilities** | Text generation, tool calling, constrained JSON output | Text, tool calling, embeddings, transcription, speech synthesis, image input (ADK) |
312
- | **Structured output** | Constrained decoding on iOS; instruction-based on Android | JSON mode via `responseMimeType` on ADK (no schema constraints); MLC on iOS |
313
- | **Model management** | None — the OS handles it | Built-in for Apple/ADK; download/prepare for Llama/MLC |
314
- | **Bundle size impact** | Near zero | Depends on which provider you install + model weights (ADK pulls in Google GenAI libraries) |
315
- | **DevTools** | None | AI SDK Profiler via Rozenite (OpenTelemetry spans) |
316
- | **Install path** | One `expo install`, autolinks | Per-package install; ADK needs New Architecture, `minSdkVersion` 26, and packaging excludes |
317
- | **Dependencies** | None | Vercel AI SDK (v6 as of v0.12) and per-provider runtimes |
318
-
319
- **Choose `expo-local-llm` if** you want the simplest path to on-device LLM in
320
- an Expo app and are happy using whatever model the OS provides (Apple Foundation
321
- Models on iOS, Gemini Nano on Android). There's nothing to configure, no weights
322
- to bundle, and it works on both platforms with a single install.
323
-
324
- **Choose React Native AI if** you need any of:
325
- - Vercel AI SDK compatibility (drop-in replacement for cloud-LLM apps)
326
- - Capabilities beyond text generation (embeddings, transcription, speech synthesis, image input)
327
- - Specific models (Llama 3.2, Phi-3, Mistral, Qwen) on either iOS or Android
328
- - A cloud Gemini fallback behind the same API as on-device Gemini Nano
329
- - AI SDK Profiler DevTools for tracing
330
-
331
- The biggest change since June 2026: React Native AI's `@react-native-ai/adk`
332
- package closed the Android gap — it now offers system-provided Gemini Nano too,
333
- with tool calling and multimodal input. The remaining differences are setup
334
- weight (ADK requires New Architecture, `minSdkVersion` 26, and Gradle packaging
335
- tweaks vs. a single `expo install` here) and API shape (Vercel AI SDK functions
336
- vs. a React hook with a session object).
503
+ The [Expo Modules API](https://docs.expo.dev/modules/overview/) fits OS-provided AI APIs well:
504
+
505
+ - **Native session objects** — Expo's `SharedObject` and class DSL expose instance methods and session-scoped events directly to JavaScript.
506
+ - **Lifecycle integration** — native cleanup hooks support cancellation and resource release. `generate()` releases its session automatically; consumers of `createLLMSession()` must call `release()` when finished.
507
+ - **Swift and Kotlin** — both implementations use a consistent module definition style, without adding a separate inference engine.
508
+ - **Expo build workflows** — autolinking integrates with prebuild, local development builds, and EAS Build.
509
+
510
+ 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.
511
+
512
+ 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.
513
+
514
+ ## Choosing this library
515
+
516
+ 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.
517
+
518
+ 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.
337
519
 
338
520
  ## Built with expo-local-llm
339
521
 
@@ -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>