expo-local-llm 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +260 -168
  2. package/android/build/intermediates/incremental/mergeDebugAssets/merger.xml +1 -1
  3. package/android/build.gradle +4 -2
  4. package/android/proguard-rules.pro +8 -0
  5. package/android/src/main/java/expo/modules/localllm/ConversationHistory.kt +12 -3
  6. package/android/src/main/java/expo/modules/localllm/Exceptions.kt +6 -2
  7. package/android/src/main/java/expo/modules/localllm/GenerationErrors.kt +30 -0
  8. package/android/src/main/java/expo/modules/localllm/LLMSession.kt +17 -7
  9. package/android/src/test/java/expo/modules/localllm/ConversationHistoryTest.kt +21 -0
  10. package/android/src/test/java/expo/modules/localllm/GenerationErrorsTest.kt +48 -0
  11. package/build/ExpoLocalLlm.types.d.ts +90 -10
  12. package/build/ExpoLocalLlm.types.d.ts.map +1 -1
  13. package/build/ExpoLocalLlm.types.js.map +1 -1
  14. package/build/LLMSession.d.ts.map +1 -1
  15. package/build/LLMSession.js +5 -22
  16. package/build/LLMSession.js.map +1 -1
  17. package/build/capabilities.d.ts.map +1 -1
  18. package/build/capabilities.js +2 -0
  19. package/build/capabilities.js.map +1 -1
  20. package/build/defineTool.d.ts +19 -0
  21. package/build/defineTool.d.ts.map +1 -0
  22. package/build/defineTool.js +20 -0
  23. package/build/defineTool.js.map +1 -0
  24. package/build/errors.d.ts +25 -0
  25. package/build/errors.d.ts.map +1 -0
  26. package/build/errors.js +54 -0
  27. package/build/errors.js.map +1 -0
  28. package/build/index.d.ts +2 -0
  29. package/build/index.d.ts.map +1 -1
  30. package/build/index.js +2 -0
  31. package/build/index.js.map +1 -1
  32. package/build/useLocalLLM.d.ts +15 -5
  33. package/build/useLocalLLM.d.ts.map +1 -1
  34. package/build/useLocalLLM.js +60 -23
  35. package/build/useLocalLLM.js.map +1 -1
  36. package/build/validateSchema.d.ts +9 -1
  37. package/build/validateSchema.d.ts.map +1 -1
  38. package/build/validateSchema.js +28 -0
  39. package/build/validateSchema.js.map +1 -1
  40. package/ios/DynamicTool.swift +26 -40
  41. package/ios/Exceptions.swift +0 -6
  42. package/ios/ExpoLocalLlmModule.swift +1 -1
  43. package/ios/GenerationErrorCodes.swift +107 -0
  44. package/ios/GenerationErrorMapper.swift +27 -0
  45. package/ios/LLMSession.swift +30 -18
  46. package/package.json +14 -12
  47. package/src/ExpoLocalLlm.types.ts +106 -13
  48. package/src/LLMSession.ts +7 -24
  49. package/src/__tests__/inference.typecheck.ts +72 -2
  50. package/src/__tests__/nativeApi.test.ts +54 -0
  51. package/src/__tests__/useLocalLLM.native.test.ts +166 -0
  52. package/src/capabilities.ts +2 -0
  53. package/src/defineTool.ts +23 -0
  54. package/src/errors.ts +57 -0
  55. package/src/index.ts +2 -0
  56. package/src/useLocalLLM.ts +113 -29
  57. package/src/validateSchema.ts +34 -1
package/README.md CHANGED
@@ -1,53 +1,33 @@
1
1
  # expo-local-llm
2
2
 
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.
3
+ [![npm](https://img.shields.io/npm/v/expo-local-llm)](https://www.npmjs.com/package/expo-local-llm)
4
+ [![CI](https://github.com/GijungKim/expo-local-llm/actions/workflows/ci.yml/badge.svg)](https://github.com/GijungKim/expo-local-llm/actions/workflows/ci.yml)
5
+ [![Native compile](https://github.com/GijungKim/expo-local-llm/actions/workflows/native.yml/badge.svg)](https://github.com/GijungKim/expo-local-llm/actions/workflows/native.yml)
6
+ [![License: MIT](https://img.shields.io/npm/l/expo-local-llm)](LICENSE)
4
7
 
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.
8
+ On-device language models for Expo apps, using the models the operating system already ships: Apple Foundation Models on iOS 26+ and Gemini Nano on Android. Generate text, stream it into a component, extract typed objects and call tools, with no model weights to bundle, no API key, and no network connection needed to generate.
6
9
 
7
- ## Platform Requirements
10
+ Inference stays on the device. There is no cloud fallback; network access happens only inside tool handlers you write. The package has no runtime dependencies.
8
11
 
9
- | Platform | Requirement |
10
- |----------|-------------|
11
- | iOS | iOS 26+ with Apple Intelligence enabled. Loads on iOS 16.4+ without crashing (returns `notEligible`). Host project must target iOS 16.4+. |
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. |
12
+ ## At a glance
14
13
 
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).
14
+ | | iOS 26+ (Foundation Models) | Android (Gemini Nano) |
15
+ |---|---|---|
16
+ | Text generation and streaming | ✅ Verified on device | ⚠️ Experimental: builds and reports eligibility; generation not yet validated on Gemini Nano hardware |
17
+ | Structured output (typed, schema-constrained) | ✅ Constrained decoding, validated in JS | ❌ Rejected explicitly |
18
+ | Tool calling (schema-constrained arguments) | ✅ | ❌ Rejected explicitly |
19
+ | Stable error codes | ✅ | ✅ |
20
+ | Model download | Managed by the OS | `downloadModel()` with progress |
16
21
 
17
- For setup failures, unavailable models, and unsupported features, see [troubleshooting](docs/troubleshooting.md).
22
+ Unsupported features fail with a clear error instead of degrading silently. `getCapabilities()` reports all of this at runtime, including `experimental: true` on Android. See [how this library is verified](#how-this-library-is-verified).
18
23
 
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.
37
-
38
- ## Installation
39
-
40
- ```bash
41
- npx expo install expo-local-llm expo-dev-client
42
- ```
43
-
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/):
24
+ ## Quick start
45
25
 
46
26
  ```bash
47
- npx expo install expo-build-properties
27
+ npx expo install expo-local-llm expo-dev-client expo-build-properties
48
28
  ```
49
29
 
50
- In `app.json`, add the plugin with the deployment target:
30
+ Set the deployment targets the module needs in `app.json`:
51
31
 
52
32
  ```json
53
33
  {
@@ -65,43 +45,69 @@ In `app.json`, add the plugin with the deployment target:
65
45
  }
66
46
  ```
67
47
 
68
- Build and install your development app on a supported physical device:
48
+ Build a development app, since stock Expo Go cannot load native modules:
69
49
 
70
50
  ```bash
71
51
  npx expo run:ios --device
72
- # or
73
- npx expo run:android --device
74
52
  ```
75
53
 
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.)
54
+ Then stream a response:
77
55
 
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`.
56
+ ```tsx
57
+ import { Button, Text } from 'react-native';
58
+ import { useLocalLLM } from 'expo-local-llm';
79
59
 
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.
60
+ export function Summary({ notes }: { notes: string }) {
61
+ const { availability, streamResponse, streamedText, error } = useLocalLLM({
62
+ instructions: 'Summarise the text in two sentences.',
63
+ });
81
64
 
82
- ## Usage
65
+ if (!streamResponse) return <Text>{error ?? `On-device AI: ${availability}`}</Text>;
66
+ return (
67
+ <>
68
+ <Button title="Summarise" onPress={() => streamResponse(notes)} />
69
+ <Text>{streamedText}</Text>
70
+ </>
71
+ );
72
+ }
73
+ ```
83
74
 
84
- ### Extract typed data (iOS 26+)
75
+ `streamResponse` is `undefined` until the model is ready, so the component shows why when it isn't: Apple Intelligence switched off, the model still downloading, or an ineligible device.
85
76
 
86
- For classification and extraction, use `generateObject()`. It infers the result type from your schema and validates the generated JSON before returning it:
77
+ ## Requirements
87
78
 
88
- ```ts
89
- import { generateObject, getCapabilities } from 'expo-local-llm';
79
+ | | Requirement |
80
+ |---|---|
81
+ | iOS | iOS 26+ with Apple Intelligence enabled to generate. The module loads on iOS 16.4+ and reports `notEligible` below 26. The host project must target iOS 16.4+, and building needs Xcode 26.4 or newer. |
82
+ | Android | `minSdkVersion` 26+. Generation needs a Gemini Nano–capable device; most Android phones, including most mid-range ones, report `notEligible`. |
83
+ | Expo SDK | 52+ peer range. The example app targets SDK 58 / React Native 0.88; the peer range is not a tested compatibility matrix. |
90
84
 
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'
85
+ A native development build is required, built locally or with EAS Build. Installing or upgrading the package needs a new binary; EAS Update cannot add native code. An iPhone simulator can run Foundation Models when the host Mac has Apple Intelligence enabled; latency and thermals still need real hardware.
86
+
87
+ If the project targets an iOS version below 16.4, the build fails with `module 'ExpoLocalLlm' has a minimum deployment target of iOS 16.4`. The 16.4 floor is for compilation only; generation still needs iOS 26.
88
+
89
+ For setup failures, unavailable models and unsupported features, see [troubleshooting](docs/troubleshooting.md).
90
+
91
+ ### Xcode 27 requires scene support
92
+
93
+ 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 58 apps adopt it by default, so there is nothing to set. Expo SDK 57 apps opt in through `expo-build-properties` (Expo `57.0.23`+, `expo-build-properties` `57.0.20`+):
94
+
95
+ ```json
96
+ {
97
+ "expo": {
98
+ "plugins": [
99
+ [
100
+ "expo-build-properties",
101
+ { "ios": { "deploymentTarget": "16.4", "enableSceneSupport": true } }
102
+ ]
103
+ ]
104
+ }
101
105
  }
102
106
  ```
103
107
 
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.
108
+ Then regenerate and rebuild the native app. This is an app-level build setting, not something this library can enable for you.
109
+
110
+ ## Usage
105
111
 
106
112
  ### Stream text in a component
107
113
 
@@ -152,46 +158,37 @@ function Chat() {
152
158
  }
153
159
  ```
154
160
 
155
- ### Tool Calling (iOS 26+)
161
+ ### One-shot generation
156
162
 
157
- ```tsx
158
- import { useLocalLLM } from 'expo-local-llm';
163
+ For a single call with no conversation history, such as classifying or rewriting one message, use `generate()`. It creates a session, responds once and releases it:
159
164
 
160
- function WeatherChat() {
161
- const { streamResponse, streamedText, activeToolCalls, error } = useLocalLLM({
162
- instructions: 'You are a helpful weather assistant.',
163
- tools: [
164
- {
165
- name: 'getWeather',
166
- description: 'Get the current weather for a city',
167
- parameters: {
168
- city: { type: 'string', description: 'The city name' },
169
- },
170
- handler: async (args) => {
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)}`);
173
- return JSON.stringify(await res.json());
174
- },
175
- },
176
- ],
177
- });
165
+ ```ts
166
+ import { generate } from 'expo-local-llm';
178
167
 
179
- return (
180
- <View>
181
- {activeToolCalls.length > 0 && (
182
- <Text>Using {activeToolCalls.map((c) => c.toolName).join(', ')}...</Text>
183
- )}
184
- <Text>{streamedText}</Text>
185
- </View>
186
- );
187
- }
168
+ const title = await generate('Suggest a short title for this note: ...', {
169
+ instructions: 'Reply with the title only.',
170
+ });
188
171
  ```
189
172
 
190
- ### Structured Output
173
+ ### Extract typed data (iOS)
191
174
 
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.
175
+ `generateObject()` infers the result type from the schema, constrains the model's decoding to it, and validates the JSON before returning it:
176
+
177
+ ```ts
178
+ import { generateObject, getCapabilities } from 'expo-local-llm';
179
+
180
+ if (getCapabilities().structuredOutputMode === 'constrained') {
181
+ const result = await generateObject('Classify: "Can we move the meeting to Friday?"', {
182
+ instructions: 'Classify the message by its primary intent.',
183
+ schema: {
184
+ intent: { type: 'string', enum: ['scheduling', 'question', 'other'] },
185
+ },
186
+ });
187
+ // result.intent: 'scheduling' | 'question' | 'other'
188
+ }
189
+ ```
190
+
191
+ A supported capability does not mean the model is ready, so handle rejected calls. A mismatch rejects with `StructuredOutputValidationError`; output is never repaired silently.
195
192
 
196
193
  <p align="center">
197
194
  <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" />
@@ -199,77 +196,135 @@ Android structured output is unsupported and rejected explicitly, rather than si
199
196
 
200
197
  *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`.*
201
198
 
199
+ ### Structured output in the hook
200
+
201
+ In JSON mode the hook streams a partial object, then publishes the validated result:
202
+
202
203
  ```tsx
203
- const { respond } = useLocalLLM({
204
+ const { streamResponse, streamedObject, completedObject } = useLocalLLM({
204
205
  responseFormat: 'json',
205
206
  schema: {
206
207
  name: { type: 'string', description: 'Recipe name' },
207
- ingredients: {
208
- type: 'array',
209
- items: { type: 'string' },
210
- description: 'List of ingredients',
211
- },
212
- steps: {
213
- type: 'array',
214
- items: { type: 'string' },
215
- description: 'Cooking steps',
216
- },
217
- difficulty: {
218
- type: 'string',
219
- enum: ['easy', 'medium', 'hard'],
220
- },
208
+ ingredients: { type: 'array', items: { type: 'string' } },
209
+ difficulty: { type: 'string', enum: ['easy', 'medium', 'hard'] },
221
210
  },
222
211
  });
223
212
 
224
- if (respond) {
225
- const recipe = JSON.parse(await respond('Give me a pasta recipe'));
226
- }
213
+ await streamResponse?.('Give me a pasta recipe');
214
+ // streamedObject: fields appear as the model writes them (every field optional)
215
+ // completedObject: { name: string; ingredients: string[]; difficulty: 'easy' | 'medium' | 'hard' } | null
227
216
  ```
228
217
 
229
- For one-shot structured calls (classification, extraction) where you don't want conversation
230
- history accumulating between calls, use `generate()` instead of a session:
218
+ `respond` and `streamResponse` validate the final JSON and reject with `StructuredOutputValidationError` (`ERR_STRUCTURED_OUTPUT_INVALID`) if it does not match. A cancelled stream keeps its partial `streamedObject` and leaves `completedObject` null. `streamedJSON` holds the raw partial JSON.
231
219
 
232
- ```ts
233
- import { generate } from 'expo-local-llm';
234
-
235
- const json = await generate('Classify this message: "how was my sleep?"', {
236
- instructions: 'Classify what the message asks for.',
237
- responseFormat: 'json',
238
- schema: { topic: { type: 'string', enum: ['sleep', 'heart', 'none'] } },
239
- });
240
- ```
220
+ Schemas are validated before they reach native code; a malformed one throws `SchemaInvalidError` with per-field paths. Call `validateSchema()` yourself when building schemas at runtime.
241
221
 
242
- Streaming with a schema yields progressively-filled snapshots via `streamedJSON` (the current
243
- partial as a JSON string) and `streamedObject` (the parsed value):
222
+ ### Tool calling (iOS)
244
223
 
245
224
  ```tsx
246
- const { streamResponse, streamedObject } = useLocalLLM({
247
- responseFormat: 'json',
248
- schema: { /* ... */ },
225
+ import { Text, View } from 'react-native';
226
+ import { defineTool, useLocalLLM } from 'expo-local-llm';
227
+
228
+ const getWeather = defineTool({
229
+ name: 'getWeather',
230
+ description: 'Get the current weather for a city',
231
+ parameters: {
232
+ city: { type: 'string', description: 'The city name' },
233
+ unit: { type: 'string', description: 'Temperature unit', enum: ['celsius', 'fahrenheit'] },
234
+ },
235
+ // Typed from `parameters`: city is a string, unit is 'celsius' | 'fahrenheit'.
236
+ handler: async ({ city, unit }) => {
237
+ const res = await fetch(
238
+ `https://api.example.com/weather?city=${encodeURIComponent(city)}&unit=${unit}`
239
+ );
240
+ return JSON.stringify(await res.json());
241
+ },
249
242
  });
250
243
 
251
- await streamResponse?.('Give me a pasta recipe');
252
- // streamedObject updates as the model fills in fields
244
+ function WeatherChat() {
245
+ const { streamResponse, streamedText, activeToolCalls, error } = useLocalLLM({
246
+ instructions: 'You are a helpful weather assistant.',
247
+ tools: [getWeather],
248
+ });
249
+
250
+ return (
251
+ <View>
252
+ {activeToolCalls.length > 0 && (
253
+ <Text>Using {activeToolCalls.map((c) => c.toolName).join(', ')}...</Text>
254
+ )}
255
+ <Text>{streamedText}</Text>
256
+ </View>
257
+ );
258
+ }
253
259
  ```
254
260
 
255
- #### Schema validation
261
+ `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>`.
262
+
263
+ 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`.
264
+
265
+ ### Handle errors
266
+
267
+ Generation failures and the library's own checks carry 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()`.
268
+
269
+ Foundation Models has a small context window, so a long conversation eventually fails with `ERR_CONTEXT_WINDOW_EXCEEDED`. On iOS the library never drops turns to make room (Android replays only the last 10 exchanges); decide what your app should do:
256
270
 
257
- `createLLMSession` validates the schema before crossing to native and throws `SchemaInvalidError`
258
- with per-field paths if anything is malformed (missing `items`, `enum` on the wrong type, unknown
259
- `type`, etc.). You can also call `validateSchema()` directly when building schemas at runtime:
271
+ ```tsx
272
+ const { errorCode, reset } = useLocalLLM({ instructions: 'You are a helpful assistant.' });
273
+
274
+ if (errorCode === 'ERR_CONTEXT_WINDOW_EXCEEDED') {
275
+ // Start over, or create a new session seeded with a summary of the chat.
276
+ reset?.();
277
+ }
278
+ if (errorCode === 'ERR_GUARDRAIL_VIOLATION' || errorCode === 'ERR_REFUSAL') {
279
+ // The model declined; ask the person to rephrase.
280
+ }
281
+ ```
260
282
 
261
283
  ```ts
262
- import { validateSchema } from 'expo-local-llm';
284
+ import { generate, getLocalLLMErrorCode } from 'expo-local-llm';
263
285
 
264
- const result = validateSchema(mySchema);
265
- if (!result.ok) {
266
- for (const err of result.errors) {
267
- console.warn(`${err.path}: ${err.message}`);
286
+ try {
287
+ await generate('Summarise my notes');
288
+ } catch (error) {
289
+ if (error instanceof Error && getLocalLLMErrorCode(error) === 'ERR_RATE_LIMITED') {
290
+ // The system is throttling on-device generation; retry later.
268
291
  }
269
292
  }
270
293
  ```
271
294
 
272
- ## API
295
+ #### Using with Effect
296
+
297
+ 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:
298
+
299
+ ```ts
300
+ import { Data, Effect } from 'effect';
301
+ import { generate, getLocalLLMErrorCode, type LocalLLMErrorCode } from 'expo-local-llm';
302
+
303
+ class LocalLLMFailure extends Data.TaggedError('LocalLLMFailure')<{
304
+ code: LocalLLMErrorCode | null;
305
+ message: string;
306
+ }> {}
307
+
308
+ const generateEffect = (prompt: string) =>
309
+ Effect.tryPromise({
310
+ try: () => generate(prompt),
311
+ catch: (error) =>
312
+ new LocalLLMFailure({
313
+ code: error instanceof Error ? getLocalLLMErrorCode(error) : null,
314
+ message: error instanceof Error ? error.message : 'Generation failed',
315
+ }),
316
+ });
317
+
318
+ // Recover from one failure by its code.
319
+ const answer = generateEffect('Summarise my notes').pipe(
320
+ Effect.catchIf(
321
+ (failure) => failure.code === 'ERR_RATE_LIMITED',
322
+ () => Effect.succeed('Try again in a moment.'),
323
+ ),
324
+ );
325
+ ```
326
+
327
+ ## API reference
273
328
 
274
329
  ### `getCapabilities()`
275
330
 
@@ -277,7 +332,7 @@ Reports which features the loaded platform integration implements. Use this to d
277
332
 
278
333
  The function does not download models, start inference, or opt the app into a cloud service.
279
334
 
280
- Returns `platform`, `backend`, `status`, boolean `text`/`streaming`/`tools`/`structuredOutput` flags, and `structuredOutputMode` (`'constrained'` or `'unsupported'`).
335
+ 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.
281
336
 
282
337
  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
338
 
@@ -307,7 +362,7 @@ if (!diagnostics.available) {
307
362
  | `tools` | `ToolDefinition[]` | Tools the model can invoke (iOS 26+ only) |
308
363
  | `toolTimeout` | `number` | Seconds before an unresolved tool call times out (default 30) |
309
364
  | `responseFormat` | `"text" \| "json"` | Set to `"json"` for structured JSON output; requires `schema` |
310
- | `schema` | `Schema` | Schema for structured output (used with `responseFormat: "json"`) — see Structured Output section |
365
+ | `schema` | `Schema` | Schema for structured output (used with `responseFormat: "json"`); see [Structured output in the hook](#structured-output-in-the-hook) |
311
366
  | `includeSchemaInPrompt` | `boolean` | iOS 26+: include the schema definition in the prompt. Default `true`. Set `false` when you've front-loaded few-shot examples. |
312
367
 
313
368
  #### Returns
@@ -318,9 +373,11 @@ if (!diagnostics.available) {
318
373
  | `isGenerating` | `boolean` | Whether a generation is in progress |
319
374
  | `streamedText` | `string` | Accumulated text from current text stream |
320
375
  | `streamedJSON` | `string` | Current partial JSON from a schema-driven stream |
321
- | `streamedObject` | `unknown` | Parsed value of `streamedJSON` (or `null` before any partial) |
376
+ | `streamedObject` | `DeepPartial<InferSchema<schema>> \| null` | Parsed value of `streamedJSON` (or `null` before any partial). Fields may be missing until generated. |
377
+ | `completedObject` | `InferSchema<schema> \| null` | The finished JSON-mode response, validated against the schema. `null` while generating and after a cancel. |
322
378
  | `downloadProgress` | `number \| null` | Download progress 0-1 (Android only) |
323
379
  | `error` | `string \| null` | Last error message |
380
+ | `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 |
324
381
  | `activeToolCalls` | `ActiveToolCall[]` | In-flight tool calls (`{ callId, toolName }`) |
325
382
  | `respond` | `(prompt: string) => Promise<string>` | Non-streaming generation. `undefined` when unavailable. |
326
383
  | `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. |
@@ -363,33 +420,73 @@ try {
363
420
 
364
421
  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
422
 
423
+ ### Error codes
424
+
425
+ 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.
426
+
427
+ 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`.
428
+
429
+ | Code | Meaning |
430
+ |------|---------|
431
+ | `ERR_CONTEXT_WINDOW_EXCEEDED` | The conversation no longer fits the model's context window. Reset or start a new session. |
432
+ | `ERR_GUARDRAIL_VIOLATION` | Safety guardrails blocked the prompt or response. |
433
+ | `ERR_REFUSAL` | The model declined to answer. |
434
+ | `ERR_UNSUPPORTED_LANGUAGE` | The prompt language or device locale is not supported. |
435
+ | `ERR_UNSUPPORTED_REQUEST` | The request uses a capability the model or helper cannot honour. |
436
+ | `ERR_RATE_LIMITED` | The system is throttling generation (quota, background use, load). Retry later. |
437
+ | `ERR_MODEL_UNAVAILABLE` | Model assets are missing or the device cannot run the model right now. |
438
+ | `ERR_DECODING_FAILED` | Output could not be decoded into the requested structure. |
439
+ | `ERR_TIMEOUT` | Generation exceeded the system's time limit (iOS 27+). |
440
+ | `ERR_TOOL_FAILED` | A tool handler threw or timed out, or the hook had no handler for the tool. |
441
+ | `ERR_GENERATION_FAILED` | Any other generation failure. |
442
+ | `ERR_SESSION_BUSY` | The session already has a generation in progress. |
443
+ | `ERR_SESSION_INVALID` | The session was released or is not ready. |
444
+ | `ERR_NOT_SUPPORTED` | On-device generation is not available on this OS version. |
445
+ | `ERR_RESPONSE_FORMAT_NOT_SUPPORTED` | Structured output is not supported on this platform. |
446
+ | `ERR_TOOL_NOT_SUPPORTED` | Tool calling is not supported on this platform. |
447
+ | `ERR_MODEL_DOWNLOAD_FAILED` | Android: downloading Gemini Nano failed. |
448
+ | `ERR_SCHEMA_INVALID` | A schema is malformed (`SchemaInvalidError`). |
449
+ | `ERR_STRUCTURED_OUTPUT_INVALID` | Generated JSON did not match the schema (`StructuredOutputValidationError`). |
450
+ | `ERR_UNAVAILABLE` | The native module is missing from this build. |
451
+
366
452
  ### `ModelAvailability`
367
453
 
368
454
  `'available' | 'notEnabled' | 'notReady' | 'notEligible' | 'downloadRequired' | 'downloading' | 'unknown' | 'moduleUnavailable'`
369
455
 
370
456
  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.
371
457
 
372
- ## Platform Asymmetries
458
+ ### `validateSchema(schema)` and `validateStructuredOutput(value, schema)`
459
+
460
+ `validateSchema` checks a schema and returns `{ ok: true }` or `{ ok: false, errors }` with field paths (missing `items`, `enum` on the wrong type, unknown `type`). `validateStructuredOutput` checks an already-parsed value against a valid schema the same way.
461
+
462
+ ## Platform differences and limitations
373
463
 
374
464
  | Concern | iOS | Android |
375
- |---------|-----|---------|
376
- | System instructions | Native `LanguageModelSession(instructions:)` | Prepended in prompt text |
377
- | Token limits | OS/model-dependent | SDK/model-dependent |
378
- | Model availability | Built-in to OS | May need download |
379
- | Session history | Native session maintains it | `ConversationHistory` class |
380
- | Streaming | `AsyncSequence` | Kotlin `Flow` |
465
+ |---|---|---|
466
+ | System instructions | Native `LanguageModelSession(instructions:)` | Prepended to the prompt |
467
+ | Conversation history | Kept by the native session | Replayed in the prompt: the last 10 exchanges, with role markers in conversation text neutralised |
468
+ | Model availability | Built into the OS | May need `downloadModel()` |
469
+ | Token limits | OS and model dependent | SDK and model dependent |
470
+
471
+ - **Context window.** Neither platform trims history to fit the model's context; Android only caps it at 10 exchanges. iOS rejects with `ERR_CONTEXT_WINDOW_EXCEEDED`; call `reset()` or start a new session.
472
+ - **One generation per session.** Overlapping requests reject with `ERR_SESSION_BUSY`. After cancelling, await the original promise before starting another request. Use separate sessions for independent conversations; events belong to their session.
473
+ - **Guardrails.** Apple's model refuses some categories of prompt, such as interpreting personal health data. A refusal arrives as `ERR_GUARDRAIL_VIOLATION` or `ERR_REFUSAL`.
474
+ - **Android is experimental.** The Gemini Nano SDK is in beta and its API may change. Structured output and tools are rejected at session creation.
475
+ - **Readiness gates generation.** `respond` and `streamResponse` need a session and `availability === 'available'`; cancelling and resetting work whenever a session exists.
381
476
 
382
- ## Known Limitations
477
+ ## How this library is verified
383
478
 
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.
387
- - **iOS**: Apple's Foundation Model may refuse certain categories of prompts (e.g. personal health data interpretation) due to built-in safety guardrails.
388
- - Generation methods (`respond`, `streamResponse`) require a session and `availability === 'available'`. Cancellation remains available while a session exists.
389
- - Tool calling is iOS 26+ only. Android will throw at session creation if tools are passed.
390
- - Structured output on Android is unsupported. iOS 26+ uses constrained decoding via `DynamicGenerationSchema`.
479
+ - **Every pull request:** lint, unit tests, type-level tests, and a compile of a freshly generated Expo SDK 58 app on both platforms, with the iOS build using Xcode 26.4, the oldest supported toolchain. Android also runs JVM tests of the prompt assembly and error mapping.
480
+ - **Against the real model:** [`tools/model-check`](tools/model-check/README.md) runs the library's Foundation Models code on a Mac with Apple Intelligence: constrained tool arguments, error-code mapping and schema-constrained output. The release checklist runs it before each release.
481
+ - **On devices:** the [release verification record](docs/release-verification.md) lists what has been checked on an iPhone 15 Pro, a simulator and a Galaxy A17, and leaves untested scenarios marked as pending rather than assumed.
391
482
 
392
- ## Why an Expo module?
483
+ ## Choosing this library
484
+
485
+ 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.
486
+
487
+ 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.
488
+
489
+ ### Why an Expo module?
393
490
 
394
491
  The [Expo Modules API](https://docs.expo.dev/modules/overview/) fits OS-provided AI APIs well:
395
492
 
@@ -402,16 +499,11 @@ Expo does not improve model quality or remove device eligibility requirements. I
402
499
 
403
500
  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
501
 
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.
410
-
411
- ## Built with expo-local-llm
502
+ ## Used by
412
503
 
413
- - [PulseID](https://apps.apple.com/us/app/pulseid-heart-rate-camera/id6754331991) — Heart rate camera
414
- - [Teamfit Tactics](https://apps.apple.com/us/app/teamfit-tactics-tft-fitness/id6757195493) — TFT fitness
504
+ - [Angular Native](https://github.com/ng-native/ng-native): wraps it as the `LanguageModel` service in `@ng-native/expo`
505
+ - [PulseID](https://apps.apple.com/us/app/pulseid-heart-rate-camera/id6754331991): heart rate camera
506
+ - [Teamfit Tactics](https://apps.apple.com/us/app/teamfit-tactics-tft-fitness/id6757195493): TFT fitness
415
507
 
416
508
  *Using this library? Open a PR to add your app.*
417
509
 
@@ -1,2 +1,2 @@
1
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>
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></merger>
@@ -5,7 +5,8 @@ plugins {
5
5
  }
6
6
 
7
7
  group = 'expo.modules.localllm'
8
- version = '0.1.0'
8
+ // Single source of truth: the npm package version.
9
+ version = new groovy.json.JsonSlurper().parse(file('../package.json')).version
9
10
 
10
11
  android {
11
12
  namespace "expo.modules.localllm"
@@ -14,7 +15,8 @@ android {
14
15
  defaultConfig {
15
16
  minSdk 26
16
17
  versionCode 1
17
- versionName "0.1.0"
18
+ versionName project.version
19
+ consumerProguardFiles 'proguard-rules.pro'
18
20
  }
19
21
 
20
22
  compileOptions {
@@ -0,0 +1,8 @@
1
+ # Consumer rules applied to apps that minify release builds (the default from
2
+ # Expo SDK 58). ML Kit discovers its components by instantiating the
3
+ # registrars named in its manifest metadata through reflection. ML Kit's own
4
+ # rules do not keep their no-argument constructors, so R8 removes them and
5
+ # ML Kit never initialises: availability checks then fail on every device.
6
+ -keep class * implements com.google.firebase.components.ComponentRegistrar {
7
+ <init>();
8
+ }
@@ -3,12 +3,21 @@ package expo.modules.localllm
3
3
  class ConversationHistory(private val systemInstructions: String?) {
4
4
  private data class Message(val role: String, val content: String)
5
5
 
6
+ private companion object {
7
+ // Gemini Nano receives one flat prompt, so roles exist only as these
8
+ // markers. Neutralise them in conversation text so a message cannot
9
+ // impersonate the system instructions or another turn.
10
+ val roleMarker = Regex("""\[\s*(system|user|assistant)\s*]""", RegexOption.IGNORE_CASE)
11
+
12
+ fun neutralise(content: String): String = content.replace(roleMarker, "($1)")
13
+ }
14
+
6
15
  private val messages = mutableListOf<Message>()
7
16
  private val maxExchanges = 10
8
17
 
9
18
  fun addExchange(user: String, assistant: String) {
10
- messages.add(Message("User", user))
11
- messages.add(Message("Assistant", assistant))
19
+ messages.add(Message("User", neutralise(user)))
20
+ messages.add(Message("Assistant", neutralise(assistant)))
12
21
  trimHistory()
13
22
  }
14
23
 
@@ -25,7 +34,7 @@ class ConversationHistory(private val systemInstructions: String?) {
25
34
  for (msg in messages) {
26
35
  sb.appendLine("[${msg.role}] ${msg.content}")
27
36
  }
28
- sb.appendLine("[User] $addingUserMessage")
37
+ sb.appendLine("[User] ${neutralise(addingUserMessage)}")
29
38
  return sb.toString().trim()
30
39
  }
31
40