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.
- package/README.md +260 -168
- package/android/build/intermediates/incremental/mergeDebugAssets/merger.xml +1 -1
- package/android/build.gradle +4 -2
- package/android/proguard-rules.pro +8 -0
- package/android/src/main/java/expo/modules/localllm/ConversationHistory.kt +12 -3
- package/android/src/main/java/expo/modules/localllm/Exceptions.kt +6 -2
- package/android/src/main/java/expo/modules/localllm/GenerationErrors.kt +30 -0
- package/android/src/main/java/expo/modules/localllm/LLMSession.kt +17 -7
- package/android/src/test/java/expo/modules/localllm/ConversationHistoryTest.kt +21 -0
- package/android/src/test/java/expo/modules/localllm/GenerationErrorsTest.kt +48 -0
- package/build/ExpoLocalLlm.types.d.ts +90 -10
- package/build/ExpoLocalLlm.types.d.ts.map +1 -1
- package/build/ExpoLocalLlm.types.js.map +1 -1
- package/build/LLMSession.d.ts.map +1 -1
- package/build/LLMSession.js +5 -22
- package/build/LLMSession.js.map +1 -1
- package/build/capabilities.d.ts.map +1 -1
- package/build/capabilities.js +2 -0
- package/build/capabilities.js.map +1 -1
- package/build/defineTool.d.ts +19 -0
- package/build/defineTool.d.ts.map +1 -0
- package/build/defineTool.js +20 -0
- package/build/defineTool.js.map +1 -0
- package/build/errors.d.ts +25 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +54 -0
- package/build/errors.js.map +1 -0
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/build/useLocalLLM.d.ts +15 -5
- package/build/useLocalLLM.d.ts.map +1 -1
- package/build/useLocalLLM.js +60 -23
- package/build/useLocalLLM.js.map +1 -1
- package/build/validateSchema.d.ts +9 -1
- package/build/validateSchema.d.ts.map +1 -1
- package/build/validateSchema.js +28 -0
- package/build/validateSchema.js.map +1 -1
- package/ios/DynamicTool.swift +26 -40
- package/ios/Exceptions.swift +0 -6
- package/ios/ExpoLocalLlmModule.swift +1 -1
- package/ios/GenerationErrorCodes.swift +107 -0
- package/ios/GenerationErrorMapper.swift +27 -0
- package/ios/LLMSession.swift +30 -18
- package/package.json +14 -12
- package/src/ExpoLocalLlm.types.ts +106 -13
- package/src/LLMSession.ts +7 -24
- package/src/__tests__/inference.typecheck.ts +72 -2
- package/src/__tests__/nativeApi.test.ts +54 -0
- package/src/__tests__/useLocalLLM.native.test.ts +166 -0
- package/src/capabilities.ts +2 -0
- package/src/defineTool.ts +23 -0
- package/src/errors.ts +57 -0
- package/src/index.ts +2 -0
- package/src/useLocalLLM.ts +113 -29
- package/src/validateSchema.ts +34 -1
package/README.md
CHANGED
|
@@ -1,53 +1,33 @@
|
|
|
1
1
|
# expo-local-llm
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/expo-local-llm)
|
|
4
|
+
[](https://github.com/GijungKim/expo-local-llm/actions/workflows/ci.yml)
|
|
5
|
+
[](https://github.com/GijungKim/expo-local-llm/actions/workflows/native.yml)
|
|
6
|
+
[](LICENSE)
|
|
4
7
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
54
|
+
Then stream a response:
|
|
77
55
|
|
|
78
|
-
|
|
56
|
+
```tsx
|
|
57
|
+
import { Button, Text } from 'react-native';
|
|
58
|
+
import { useLocalLLM } from 'expo-local-llm';
|
|
79
59
|
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
+
## Requirements
|
|
87
78
|
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
161
|
+
### One-shot generation
|
|
156
162
|
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
###
|
|
173
|
+
### Extract typed data (iOS)
|
|
191
174
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 {
|
|
204
|
+
const { streamResponse, streamedObject, completedObject } = useLocalLLM({
|
|
204
205
|
responseFormat: 'json',
|
|
205
206
|
schema: {
|
|
206
207
|
name: { type: 'string', description: 'Recipe name' },
|
|
207
|
-
ingredients: {
|
|
208
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
partial as a JSON string) and `streamedObject` (the parsed value):
|
|
222
|
+
### Tool calling (iOS)
|
|
244
223
|
|
|
245
224
|
```tsx
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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 {
|
|
284
|
+
import { generate, getLocalLLMErrorCode } from 'expo-local-llm';
|
|
263
285
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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,
|
|
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"`)
|
|
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` | `
|
|
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
|
-
|
|
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
|
|
377
|
-
|
|
|
378
|
-
| Model availability | Built
|
|
379
|
-
|
|
|
380
|
-
|
|
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
|
-
##
|
|
477
|
+
## How this library is verified
|
|
383
478
|
|
|
384
|
-
-
|
|
385
|
-
-
|
|
386
|
-
- **
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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
|
-
- [
|
|
414
|
-
- [
|
|
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:.*:<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:.*:<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
|
|
2
|
+
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<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:.*:<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>
|
package/android/build.gradle
CHANGED
|
@@ -5,7 +5,8 @@ plugins {
|
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
group = 'expo.modules.localllm'
|
|
8
|
-
|
|
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
|
|
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
|
|