expo-local-llm 0.8.1 → 0.10.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 (88) hide show
  1. package/README.md +179 -270
  2. package/android/build.gradle +11 -4
  3. package/android/src/main/java/expo/modules/localllm/AndroidModel.kt +73 -0
  4. package/android/src/main/java/expo/modules/localllm/ConversationHistory.kt +50 -3
  5. package/android/src/main/java/expo/modules/localllm/Exceptions.kt +15 -3
  6. package/android/src/main/java/expo/modules/localllm/ExpoLocalLlmModule.kt +28 -35
  7. package/android/src/main/java/expo/modules/localllm/GenerationLifecycle.kt +76 -0
  8. package/android/src/main/java/expo/modules/localllm/ImageDecoder.kt +164 -0
  9. package/android/src/main/java/expo/modules/localllm/ImagePrompt.kt +86 -0
  10. package/android/src/main/java/expo/modules/localllm/LLMSession.kt +350 -125
  11. package/android/src/main/java/expo/modules/localllm/NativeTools.kt +229 -0
  12. package/android/src/main/java/expo/modules/localllm/Records.kt +21 -0
  13. package/android/src/main/java/expo/modules/localllm/SessionRuntime.kt +48 -0
  14. package/build/ExpoLocalLlm.types.d.ts +68 -4
  15. package/build/ExpoLocalLlm.types.d.ts.map +1 -1
  16. package/build/ExpoLocalLlm.types.js.map +1 -1
  17. package/build/ExpoLocalLlmModule.d.ts +7 -3
  18. package/build/ExpoLocalLlmModule.d.ts.map +1 -1
  19. package/build/ExpoLocalLlmModule.js.map +1 -1
  20. package/build/LLMSession.d.ts +5 -5
  21. package/build/LLMSession.d.ts.map +1 -1
  22. package/build/LLMSession.js +41 -4
  23. package/build/LLMSession.js.map +1 -1
  24. package/build/capabilities.d.ts +4 -1
  25. package/build/capabilities.d.ts.map +1 -1
  26. package/build/capabilities.js +71 -2
  27. package/build/capabilities.js.map +1 -1
  28. package/build/defineTool.d.ts.map +1 -1
  29. package/build/defineTool.js.map +1 -1
  30. package/build/errors.d.ts.map +1 -1
  31. package/build/errors.js +2 -0
  32. package/build/errors.js.map +1 -1
  33. package/build/index.d.ts +1 -1
  34. package/build/index.d.ts.map +1 -1
  35. package/build/index.js +1 -1
  36. package/build/index.js.map +1 -1
  37. package/build/useLocalLLM.d.ts +3 -3
  38. package/build/useLocalLLM.d.ts.map +1 -1
  39. package/build/useLocalLLM.js +61 -14
  40. package/build/useLocalLLM.js.map +1 -1
  41. package/build/validateSchema.d.ts +1 -0
  42. package/build/validateSchema.d.ts.map +1 -1
  43. package/build/validateSchema.js +59 -0
  44. package/build/validateSchema.js.map +1 -1
  45. package/build/validatedOutput.d.ts +4 -0
  46. package/build/validatedOutput.d.ts.map +1 -0
  47. package/build/validatedOutput.js +87 -0
  48. package/build/validatedOutput.js.map +1 -0
  49. package/ios/Exceptions.swift +20 -0
  50. package/ios/ExpoLocalLlmModule.swift +13 -4
  51. package/ios/FoundationModelBridge.swift +102 -18
  52. package/ios/LLMSession.swift +10 -4
  53. package/ios/Records.swift +162 -0
  54. package/package.json +23 -14
  55. package/src/ExpoLocalLlm.types.ts +77 -5
  56. package/src/ExpoLocalLlmModule.ts +12 -3
  57. package/src/LLMSession.ts +72 -8
  58. package/src/__tests__/ExpoLocalLlm.test.ts +1 -1
  59. package/src/__tests__/inference.typecheck.ts +77 -4
  60. package/src/__tests__/nativeApi.test.ts +747 -8
  61. package/src/__tests__/useLocalLLM.native.test.ts +357 -0
  62. package/src/capabilities.ts +87 -2
  63. package/src/errors.ts +2 -0
  64. package/src/index.ts +5 -1
  65. package/src/useLocalLLM.ts +77 -22
  66. package/src/validateSchema.ts +61 -1
  67. package/src/validatedOutput.ts +129 -0
  68. package/android/build/generated/source/buildConfig/debug/expo/modules/localllm/BuildConfig.java +0 -10
  69. package/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml +0 -7
  70. package/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json +0 -18
  71. package/android/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties +0 -6
  72. package/android/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json +0 -1
  73. package/android/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar +0 -0
  74. package/android/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt +0 -0
  75. package/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties +0 -1
  76. package/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml +0 -2
  77. package/android/build/intermediates/incremental/mergeDebugAssets/merger.xml +0 -2
  78. package/android/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml +0 -2
  79. package/android/build/intermediates/incremental/mergeDebugShaders/merger.xml +0 -2
  80. package/android/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt +0 -2
  81. package/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt +0 -7
  82. package/android/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml +0 -7
  83. package/android/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json +0 -1
  84. package/android/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt +0 -1
  85. package/android/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt +0 -1
  86. package/android/build/outputs/logs/manifest-merger-debug-report.txt +0 -14
  87. package/android/src/test/java/expo/modules/localllm/ConversationHistoryTest.kt +0 -96
  88. package/android/src/test/java/expo/modules/localllm/GenerationErrorsTest.kt +0 -48
package/README.md CHANGED
@@ -5,24 +5,38 @@
5
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
6
  [![License: MIT](https://img.shields.io/npm/l/expo-local-llm)](LICENSE)
7
7
 
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.
8
+ **On-device AI. No bundled model weights. No additional npm runtime dependencies.**
9
9
 
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.
10
+ Use system-provided models in Expo apps: 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 API key.
11
+
12
+ The full JavaScript API measures approximately **17.8 kB minified / 6.0 kB gzipped**, excluding external dependencies and native code.[^js-size]
13
+
14
+ Inference stays on the device, with no cloud fallback. Once the model is ready, generation works offline; initial model provisioning or downloads may require network access and device storage. Tool handlers you write may also access the network.
15
+
16
+ [^js-size]: Measured from the built package entry point with Bun 1.4.4: a minified browser-target ESM bundle, all public exports retained, packages external, and default `node:zlib` gzip compression. Sizes use decimal kB. This standalone JS measurement excludes Expo, React, React Native, native SDKs (including Android ML Kit), and model assets; it is not a Metro/Hermes or installed-app size measurement. Reproduce with `bun run build && bun run check:size`. CI enforces budgets of 18,000 minified bytes and 6,200 gzip bytes.
17
+
18
+ [Setup](#quick-start) · [Usage](#usage) · [API reference](docs/api.md) · [Troubleshooting](docs/troubleshooting.md) · [Changelog](CHANGELOG.md)
11
19
 
12
20
  ## At a glance
13
21
 
14
22
  | | iOS 26+ (Foundation Models) | Android (Gemini Nano) |
15
23
  |---|---|---|
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 |
24
+ | Text generation and streaming | ✅ Verified on device | ✅ Implemented |
25
+ | Image prompts | iOS 27+, Xcode 27+, and a ready vision-capable system model | ✅ Up to eight images; requires model vision support |
26
+ | Structured results (`generateObject`) | ✅ Native constraints + JS validation | ✅ JS schema validation and bounded repair |
27
+ | Native schema constraints | ✅ Runtime schemas | Unavailable for runtime schemas in ML Kit; automatic mode uses validation instead |
28
+ | Tool calling (validated primitive arguments) | ✅ Schema-constrained | ✅ Native tools; selected model must advertise function calling |
19
29
  | Stable error codes | ✅ | ✅ |
20
30
  | Model download | Managed by the OS | `downloadModel()` with progress |
21
31
 
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).
32
+ **Checkmarks indicate implemented features, not hardware certification.** Android session behavior is covered by automated tests, and real SDK image requests are tested on an emulator. Gemini Nano inference has not yet been validated on eligible hardware. See the [verification record](docs/release-verification.md) for evidence and remaining device checks.
33
+
34
+ Unsupported features fail with a clear error instead of degrading silently. `getCapabilities()` reports integration support, including the path selected by `objectGenerationMode`; `getModelReadiness()` reports selected-model readiness and tool support. `structuredOutput` continues to describe native constrained output only. See [how this library is verified](#how-this-library-is-verified).
23
35
 
24
36
  ## Quick start
25
37
 
38
+ Use an existing Expo app and a native development build; **Expo Go is not supported**. Generation needs Apple Intelligence on iOS 26+ or an eligible Gemini Nano device on Android. A successful build does not establish device eligibility. Check the [requirements](#requirements) before choosing a test device.
39
+
26
40
  ```bash
27
41
  npx expo install expo-local-llm expo-dev-client expo-build-properties
28
42
  ```
@@ -37,7 +51,7 @@ Set the deployment targets the module needs in `app.json`:
37
51
  "expo-build-properties",
38
52
  {
39
53
  "ios": { "deploymentTarget": "16.4" },
40
- "android": { "minSdkVersion": 26 }
54
+ "android": { "minSdkVersion": 26, "kotlinVersion": "2.3.0" }
41
55
  }
42
56
  ]
43
57
  ]
@@ -49,6 +63,8 @@ Build a development app, since stock Expo Go cannot load native modules:
49
63
 
50
64
  ```bash
51
65
  npx expo run:ios --device
66
+ # Or, for Android:
67
+ # npx expo run:android --device
52
68
  ```
53
69
 
54
70
  Then stream a response:
@@ -58,28 +74,49 @@ import { Button, Text } from 'react-native';
58
74
  import { useLocalLLM } from 'expo-local-llm';
59
75
 
60
76
  export function Summary({ notes }: { notes: string }) {
61
- const { availability, streamResponse, streamedText, error } = useLocalLLM({
77
+ const { availability, streamResponse, streamedText, isGenerating, error } = useLocalLLM({
62
78
  instructions: 'Summarise the text in two sentences.',
63
79
  });
64
80
 
65
- if (!streamResponse) return <Text>{error ?? `On-device AI: ${availability}`}</Text>;
81
+ async function summarise() {
82
+ if (!streamResponse || isGenerating) return;
83
+ try {
84
+ await streamResponse(notes);
85
+ } catch {
86
+ // The hook exposes the rejection through `error`, rendered below.
87
+ }
88
+ }
89
+
66
90
  return (
67
91
  <>
68
- <Button title="Summarise" onPress={() => streamResponse(notes)} />
69
- <Text>{streamedText}</Text>
92
+ <Text>Model: {availability}</Text>
93
+ <Button
94
+ title={isGenerating ? 'Summarising…' : 'Summarise'}
95
+ disabled={!streamResponse || isGenerating}
96
+ onPress={summarise}
97
+ />
98
+ <Text>{error ?? streamedText}</Text>
70
99
  </>
71
100
  );
72
101
  }
73
102
  ```
74
103
 
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.
104
+ `streamResponse` is `undefined` until the model is ready. `availability` distinguishes an ineligible device, disabled Apple Intelligence, a pending download, and other states. Android apps can expose the hook's `downloadModel()` when availability is `downloadRequired`; see [readiness and downloads](docs/troubleshooting.md#model-not-ready).
105
+
106
+ The hook retains conversation history between requests. Use `reset()` for a new conversation or [`generate()`](#one-shot-generation) for independent tasks.
107
+
108
+ ### Upgrading to 0.10.0
109
+
110
+ All Android apps now require **Kotlin 2.3.0+**, including text-only apps; set `kotlinVersion` as shown above or update your native Kotlin Gradle plugin. Rebuild your iOS or Android app to include the new native image and tool APIs. An EAS Update alone cannot install them.
111
+
112
+ Version 0.10.0 makes `generateObject()` automatic: iOS uses native constraints and Android uses validation with bounded repair. Pass `mode: 'constrained'` to require native constraints. Capability objects now require `objectGenerationMode`, and `experimental` is `false` for both integrations; device verification is documented separately. Hook/session `responseFormat: 'json'` remains iOS-only. See the [changelog](CHANGELOG.md).
76
113
 
77
114
  ## Requirements
78
115
 
79
116
  | | Requirement |
80
117
  |---|---|
81
118
  | 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`. |
119
+ | Android | `minSdkVersion` 26+ and Kotlin 2.3.0+ to compile ML Kit beta5. Generation needs an eligible Gemini Nano device; query readiness on the target device before offering generation. |
83
120
  | Expo SDK | 52+ peer range. The example app targets SDK 58 / React Native 0.88; the peer range is not a tested compatibility matrix. |
84
121
 
85
122
  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.
@@ -109,75 +146,69 @@ Then regenerate and rebuild the native app. This is an app-level build setting,
109
146
 
110
147
  ## Usage
111
148
 
112
- ### Stream text in a component
113
-
114
- ```tsx
115
- import { Text, View } from 'react-native';
116
- import { useLocalLLM } from 'expo-local-llm';
149
+ | Task | API | Session ownership |
150
+ |---|---|---|
151
+ | Stream into a React component or keep a conversation | `useLocalLLM()` | The hook creates and releases its session |
152
+ | Generate one independent response | `generate()` | Released after the request |
153
+ | Extract a typed object | `generateObject()` | Released after each attempt |
154
+ | Invoke your app's tools | `useLocalLLM({ tools })` | The hook dispatches handlers |
155
+ | Manage events and conversation lifetime yourself | `createLLMSession()` | You must call `release()` |
117
156
 
118
- function Chat() {
119
- const {
120
- availability,
121
- isGenerating,
122
- streamedText,
123
- error,
124
- respond,
125
- streamResponse,
126
- cancelStream,
127
- downloadModel,
128
- downloadProgress,
129
- } = useLocalLLM({
130
- instructions: 'You are a helpful assistant.',
131
- });
157
+ The [quick start](#quick-start) shows streaming with the hook. For all options and return types, see the [API reference](docs/api.md).
132
158
 
133
- // Methods become available after session creation and model readiness.
134
- if (!streamResponse) {
135
- return (
136
- <Text>
137
- {error ?? (availability === 'available'
138
- ? 'Preparing on-device AI...'
139
- : `On-device AI: ${availability}`)}
140
- </Text>
141
- );
142
- }
159
+ ### One-shot generation
143
160
 
144
- const handleSend = async (prompt: string) => {
145
- // Streaming — updates streamedText live, resolves with the final text
146
- const text = await streamResponse(prompt);
161
+ 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:
147
162
 
148
- // Or non-streaming:
149
- // const response = await respond(prompt);
150
- };
163
+ ```ts
164
+ import { generate } from 'expo-local-llm';
151
165
 
152
- return (
153
- <View>
154
- <Text>Status: {availability}</Text>
155
- {isGenerating && <Text>{streamedText}</Text>}
156
- </View>
157
- );
158
- }
166
+ const title = await generate('Suggest a short title for this note: ...', {
167
+ instructions: 'Reply with the title only.',
168
+ });
159
169
  ```
160
170
 
161
- ### One-shot generation
171
+ <a id="describe-an-image"></a>
162
172
 
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:
173
+ ### Describe or compare images
174
+
175
+ Pass a prompt object to `generate()`, `session.respond()`, `session.streamResponse()`, or the hook's `respond` / `streamResponse`:
164
176
 
165
177
  ```ts
166
178
  import { generate } from 'expo-local-llm';
167
179
 
168
- const title = await generate('Suggest a short title for this note: ...', {
169
- instructions: 'Reply with the title only.',
180
+ // photoUri is a local file:// URI, for example from your app's cache.
181
+ const description = await generate({
182
+ text: 'Describe the photo in one sentence.',
183
+ images: [{ uri: photoUri, label: 'photo' }],
184
+ });
185
+ ```
186
+
187
+ Attach up to **eight local images** with unique labels. For a comparison:
188
+
189
+ ```ts
190
+ // Both URIs point to local files already available to your app.
191
+ const comparison = await generate({
192
+ text: 'What changed between the before and after photos?',
193
+ images: [
194
+ { uri: beforePhotoUri, label: 'before' },
195
+ { uri: afterPhotoUri, label: 'after' },
196
+ ],
170
197
  });
171
198
  ```
172
199
 
173
- ### Extract typed data (iOS)
200
+ iOS images require **iOS 27+, Xcode 27+, and a ready vision-capable model**. Android image support depends on the selected model. Use `getCapabilities().images` to inspect support; a ready text model does not guarantee vision support.
201
+
202
+ Images must use local `file://` URIs; download remote images first. iOS retains completed image turns in session history. Android retains images during the current request and its tool turns, but you must attach them again on a later request. See [image requirements, capabilities, and errors](docs/images.md) for URI rules, limits, and cancellation behavior.
174
203
 
175
- `generateObject()` infers the result type from the schema, constrains the model's decoding to it, and validates the JSON before returning it:
204
+ ### Extract typed data
205
+
206
+ `generateObject()` infers the result type from the schema and automatically selects the strongest available path. iOS uses native constrained decoding. Android generates text, validates every field in JavaScript, and makes at most one repair request after invalid JSON or a schema mismatch. Check `objectGenerationMode` to inspect the selected path:
176
207
 
177
208
  ```ts
178
209
  import { generateObject, getCapabilities } from 'expo-local-llm';
179
210
 
180
- if (getCapabilities().structuredOutputMode === 'constrained') {
211
+ if (getCapabilities().objectGenerationMode !== 'unsupported') {
181
212
  const result = await generateObject('Classify: "Can we move the meeting to Friday?"', {
182
213
  instructions: 'Classify the message by its primary intent.',
183
214
  schema: {
@@ -188,7 +219,7 @@ if (getCapabilities().structuredOutputMode === 'constrained') {
188
219
  }
189
220
  ```
190
221
 
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.
222
+ A supported capability does not mean the model is ready, so handle rejected calls. Provider failures never switch paths or trigger repair. Exhausted schema repair rejects with `StructuredOutputValidationError`.
192
223
 
193
224
  <p align="center">
194
225
  <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" />
@@ -196,9 +227,37 @@ A supported capability does not mean the model is ready, so handle rejected call
196
227
 
197
228
  *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`.*
198
229
 
230
+ ### Choose the structured-output path explicitly
231
+
232
+ The default `mode: 'auto'` is portable. Request `mode: 'constrained'` to require native constraints and reject when unavailable, or `mode: 'validated'` to force ordinary text generation plus complete JSON validation. Validated mode does **not** constrain decoding. `structuredOutput` and `structuredOutputMode` describe native constraints; `objectGenerationMode` describes automatic routing.
233
+
234
+ ```ts
235
+ import { generateObject, getCapabilities, getModelReadiness } from 'expo-local-llm';
236
+
237
+ const controller = new AbortController();
238
+ if (getCapabilities().text && (await getModelReadiness('default')).availability === 'available') {
239
+ const result = await generateObject('Classify: "Can we meet on Friday?"', {
240
+ mode: 'validated',
241
+ repairAttempts: 1, // default; choose 0 to disable repair
242
+ signal: controller.signal,
243
+ androidModel: 'default',
244
+ schema: {
245
+ intent: { type: 'string', enum: ['scheduling', 'question', 'other'] },
246
+ dates: { type: 'array', items: { type: 'string' } },
247
+ },
248
+ });
249
+ // result.intent: 'scheduling' | 'question' | 'other'; result.dates: string[]
250
+ }
251
+ // Call controller.abort() from your cancel action while the call is pending.
252
+ ```
253
+
254
+ Only JSON parse or schema failures permit one additional request. Each attempt uses a fresh session with the original task, schema, instructions, generation options, selected model, and image attachment records. Native errors, refusals, guardrails, cancellation, context limits, and availability failures propagate without repair. Exhausted or disabled repair rejects with `StructuredOutputValidationError`; the helper never extracts JSON from prose, coerces values, inserts fields, or removes extra fields. Validation checks structure, not factual correctness. Both platforms still need device testing of validated mode (automatic on Android or explicit).
255
+
256
+ Automatic and validated modes are available only in one-shot `generateObject()`. They do not change sessions, hooks, JSON streaming, or Android's rejection of native `responseFormat: 'json'`.
257
+
199
258
  ### Structured output in the hook
200
259
 
201
- In JSON mode the hook streams a partial object, then publishes the validated result:
260
+ On iOS, JSON mode in the hook streams a partial object, then publishes the validated result. Android callers should use one-shot [`generateObject()`](#extract-typed-data); the hook does not support automatic or validated JSON generation.
202
261
 
203
262
  ```tsx
204
263
  const { streamResponse, streamedObject, completedObject } = useLocalLLM({
@@ -219,66 +278,61 @@ await streamResponse?.('Give me a pasta recipe');
219
278
 
220
279
  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.
221
280
 
222
- ### Tool calling (iOS)
281
+ ### Tool calling
282
+
283
+ Use `defineTool()` to type a handler from its parameters and `useLocalLLM()` to dispatch calls. This example looks up local sample data; replace the handler with your app's data access:
223
284
 
224
285
  ```tsx
225
- import { Text, View } from 'react-native';
286
+ import { Button, Text } from 'react-native';
226
287
  import { defineTool, useLocalLLM } from 'expo-local-llm';
227
288
 
228
- const getWeather = defineTool({
229
- name: 'getWeather',
230
- description: 'Get the current weather for a city',
289
+ const stock = new Map([['trail-shoes', 12], ['rain-jacket', 0]]);
290
+ const lookupStock = defineTool({
291
+ name: 'lookupStock',
292
+ description: 'Look up the available quantity for a product SKU',
231
293
  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());
294
+ sku: { type: 'string', description: 'Product SKU, such as trail-shoes' },
241
295
  },
296
+ handler: async ({ sku }) => JSON.stringify({ sku, quantity: stock.get(sku) ?? null }),
242
297
  });
243
298
 
244
- function WeatherChat() {
245
- const { streamResponse, streamedText, activeToolCalls, error } = useLocalLLM({
246
- instructions: 'You are a helpful weather assistant.',
247
- tools: [getWeather],
299
+ export function InventoryAssistant() {
300
+ const { streamResponse, streamedText, isGenerating, activeToolCalls, error } = useLocalLLM({
301
+ instructions: 'Use lookupStock to answer inventory questions.',
302
+ androidModel: 'previewFast', // Explicit Android preview selection; ignored on iOS.
303
+ tools: [lookupStock],
248
304
  });
249
305
 
306
+ async function checkStock() {
307
+ if (!streamResponse || isGenerating) return;
308
+ try {
309
+ await streamResponse('How many trail-shoes are available?');
310
+ } catch {
311
+ // The hook exposes failures through `error`.
312
+ }
313
+ }
314
+
250
315
  return (
251
- <View>
252
- {activeToolCalls.length > 0 && (
253
- <Text>Using {activeToolCalls.map((c) => c.toolName).join(', ')}...</Text>
254
- )}
255
- <Text>{streamedText}</Text>
256
- </View>
316
+ <>
317
+ <Button title="Check stock" onPress={checkStock} disabled={!streamResponse || isGenerating} />
318
+ {activeToolCalls.length > 0 && <Text>Checking inventory…</Text>}
319
+ <Text>{error ?? streamedText}</Text>
320
+ </>
257
321
  );
258
322
  }
259
323
  ```
260
324
 
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>`.
325
+ Android tool calling requires a selected model that advertises function calling. Check `await getModelReadiness('previewFast')`; both `availability === 'available'` and `tools === 'available'` are needed. Preview access requires eligible hardware and AICore Developer Preview enrollment. There is no automatic fallback to another model.
262
326
 
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`.
327
+ The model chooses whether to call a tool. Handlers must validate application-specific values and permissions; valid arguments alone do not authorize an action. Return strings, using `JSON.stringify()` for structured results. See [tool requirements, execution limits, and cancellation](docs/tool-calling.md).
264
328
 
265
329
  ### Handle errors
266
330
 
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()`.
331
+ Generation failures and the library's own checks carry a stable `code` (see [Error codes](docs/api.md#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
332
 
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:
333
+ Cancellation through validated mode's `AbortSignal` instead rejects with `error.name === 'AbortError'`, without a library error code. Handle it separately from generation failures.
270
334
 
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
- ```
335
+ A conversation can fail with `ERR_CONTEXT_WINDOW_EXCEEDED`. Offer a user-triggered `reset()` action for the hook, or start a new session with a summary. Reset clears conversation history; it is not an automatic retry.
282
336
 
283
337
  ```ts
284
338
  import { generate, getLocalLLMErrorCode } from 'expo-local-llm';
@@ -287,177 +341,31 @@ try {
287
341
  await generate('Summarise my notes');
288
342
  } catch (error) {
289
343
  if (error instanceof Error && getLocalLLMErrorCode(error) === 'ERR_RATE_LIMITED') {
290
- // The system is throttling on-device generation; retry later.
344
+ // Show a retry-later message in your app.
345
+ } else {
346
+ throw error;
291
347
  }
292
348
  }
293
349
  ```
294
350
 
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
- ```
351
+ For an Effect wrapper, see [Using with Effect](docs/effect.md).
326
352
 
327
353
  ## API reference
328
354
 
329
- ### `getCapabilities()`
330
-
331
- 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.
332
-
333
- The function does not download models, start inference, or opt the app into a cloud service.
334
-
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.
336
-
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.
338
-
339
- ### `getModuleDiagnostics()`
340
-
341
- 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.
342
-
343
- ```ts
344
- import { getModuleDiagnostics } from 'expo-local-llm';
345
-
346
- const diagnostics = getModuleDiagnostics();
347
- if (!diagnostics.available) {
348
- console.warn(diagnostics.message);
349
- }
350
- ```
351
-
352
- ### `useLocalLLM(options?)`
353
-
354
- #### Options
355
-
356
- | Option | Type | Description |
357
- |--------|------|-------------|
358
- | `instructions` | `string` | System instructions for the session |
359
- | `options.temperature` | `number` | Sampling temperature |
360
- | `options.maxTokens` | `number` | Max output tokens (`maxOutputTokens` on Android; `maximumResponseTokens` on iOS). Native SDK/model limits apply. |
361
- | `options.topK` | `number` | Top-K sampling (maps to `.random(top:)` on iOS) |
362
- | `tools` | `ToolDefinition[]` | Tools the model can invoke (iOS 26+ only) |
363
- | `toolTimeout` | `number` | Seconds before an unresolved tool call times out (default 30) |
364
- | `responseFormat` | `"text" \| "json"` | Set to `"json"` for structured JSON output; requires `schema` |
365
- | `schema` | `Schema` | Schema for structured output (used with `responseFormat: "json"`); see [Structured output in the hook](#structured-output-in-the-hook) |
366
- | `includeSchemaInPrompt` | `boolean` | iOS 26+: include the schema definition in the prompt. Default `true`. Set `false` when you've front-loaded few-shot examples. |
355
+ The [complete API reference](docs/api.md) covers configuration, return types, events, errors, and session ownership.
367
356
 
368
- #### Returns
369
-
370
- | Property | Type | Description |
371
- |----------|------|-------------|
372
- | `availability` | `ModelAvailability` | Current model status |
373
- | `isGenerating` | `boolean` | Whether a generation is in progress |
374
- | `streamedText` | `string` | Accumulated text from current text stream |
375
- | `streamedJSON` | `string` | Current partial JSON from a schema-driven stream |
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. |
378
- | `downloadProgress` | `number \| null` | Download progress 0-1 (Android only) |
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 |
381
- | `activeToolCalls` | `ActiveToolCall[]` | In-flight tool calls (`{ callId, toolName }`) |
382
- | `respond` | `(prompt: string) => Promise<string>` | Non-streaming generation. `undefined` when unavailable. |
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. |
384
- | `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`. |
385
- | `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. |
386
- | `reset` | `() => void` | Clear the conversation transcript, keeping instructions/tools/schema/options. Also cancels any in-flight stream. `undefined` only when there is no session. |
387
- | `downloadModel` | `() => Promise<void>` | Trigger model download (Android). `undefined` when unavailable. |
388
-
389
- ### `generate(prompt, config?)`
390
-
391
- One-shot stateless generation — creates a session, responds once, and releases it. Takes the
392
- same generation config as `useLocalLLM`/`createLLMSession`, except tool handlers are not supported. Use it for classification or extraction
393
- calls where conversation history between calls is unwanted. Throws when the module or model
394
- is unavailable (check `ExpoLocalLlmModule.getAvailability()` first if you need to gate).
395
-
396
- 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.
397
-
398
- ### `generateObject(prompt, config)`
399
-
400
- 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.
401
-
402
- 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.
403
-
404
- `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.
405
-
406
- ### `createLLMSession(config?)`
407
-
408
- Creates a manually owned conversation. Release it when finished:
409
-
410
- ```ts
411
- import { createLLMSession } from 'expo-local-llm';
412
-
413
- const session = createLLMSession({ instructions: 'Keep answers concise.' });
414
- try {
415
- const text = await session.respond('Suggest a short title for a gardening journal.');
416
- } finally {
417
- session.release();
418
- }
419
- ```
420
-
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.
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
-
452
- ### `ModelAvailability`
453
-
454
- `'available' | 'notEnabled' | 'notReady' | 'notEligible' | 'downloadRequired' | 'downloading' | 'unknown' | 'moduleUnavailable'`
455
-
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.
457
-
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.
357
+ | API | Reference |
358
+ |---|---|
359
+ | <a id="getcapabilities"></a>`getCapabilities()` | [Integration and image capabilities](docs/api.md#getcapabilities) |
360
+ | <a id="getmodelreadinessandroidmodel-androidmodel"></a>`getModelReadiness()` | [Selected-model readiness](docs/api.md#getmodelreadinessandroidmodel-androidmodel) |
361
+ | <a id="getmodulediagnostics"></a>`getModuleDiagnostics()` | [Native module diagnostics](docs/api.md#getmodulediagnostics) |
362
+ | <a id="uselocalllmoptions"></a>`useLocalLLM()` | [Options and return values](docs/api.md#uselocalllmoptions) |
363
+ | <a id="generateprompt-config"></a>`generate()` | [Independent text generation](docs/api.md#generateprompt-config) |
364
+ | <a id="generateobjectprompt-config"></a>`generateObject()` | [Constrained and validated modes](docs/api.md#generateobjectprompt-config) |
365
+ | <a id="createllmsessionconfig"></a>`createLLMSession()` | [Manual session ownership](docs/api.md#createllmsessionconfig) |
366
+ | <a id="error-codes"></a>Error codes | [Generation and validation failures](docs/api.md#error-codes) |
367
+ | <a id="modelavailability"></a>`ModelAvailability` | [Readiness states](docs/api.md#modelavailability) |
368
+ | <a id="validateschemaschema-and-validatestructuredoutputvalue-schema"></a>Schema validators | [Validate schemas and objects](docs/api.md#validateschemaschema-and-validatestructuredoutputvalue-schema) |
461
369
 
462
370
  ## Platform differences and limitations
463
371
 
@@ -467,18 +375,19 @@ Android readiness is checked asynchronously and may initially be `unknown`. Pref
467
375
  | Conversation history | Kept by the native session | Replayed in the prompt: the last 10 exchanges, with role markers in conversation text neutralised |
468
376
  | Model availability | Built into the OS | May need `downloadModel()` |
469
377
  | Token limits | OS and model dependent | SDK and model dependent |
378
+ | Image prompts | Up to eight local images per request on iOS 27+ with a ready vision-capable model and an Xcode 27+ build; retained in native history | Up to eight local images per request; device model support is unknown |
470
379
 
471
380
  - **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
381
  - **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
382
  - **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.
383
+ - **Android model requirements.** The Gemini Nano SDK is in beta and its API may change. Tools require selected-model feature support. Default `generateObject()` uses text generation plus JS validation and bounded repair. Native `responseFormat: 'json'` and explicit `mode: 'constrained'` remain unsupported because ML Kit only accepts compiled Kotlin types for constrained output, with no public runtime-schema entrypoint for arbitrary JS schemas.
475
384
  - **Readiness gates generation.** `respond` and `streamResponse` need a session and `availability === 'available'`; cancelling and resetting work whenever a session exists.
476
385
 
477
386
  ## How this library is verified
478
387
 
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.
388
+ - **Code changes:** 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
389
  - **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.
390
+ - **On devices:** the [release verification record](docs/release-verification.md) lists the device/OS, package revision, and results for each recorded run, and leaves untested scenarios marked as pending rather than assumed.
482
391
 
483
392
  ## Choosing this library
484
393
 
@@ -495,7 +404,7 @@ The [Expo Modules API](https://docs.expo.dev/modules/overview/) fits OS-provided
495
404
  - **Swift and Kotlin** — both implementations use a consistent module definition style, without adding a separate inference engine.
496
405
  - **Expo build workflows** — autolinking integrates with prebuild, local development builds, and EAS Build.
497
406
 
498
- 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.
407
+ Expo supplies the native integration and build workflow. Model behavior and device eligibility still come from the platform backend.
499
408
 
500
409
  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.
501
410