diff --git a/.cspell-wordlist.txt b/.cspell-wordlist.txt
index 964521ab13..33558db948 100644
--- a/.cspell-wordlist.txt
+++ b/.cspell-wordlist.txt
@@ -1,4 +1,6 @@
multimodal
+subvariant
+Subvariant
swmansion
executorch
RNET
@@ -314,3 +316,4 @@ Partitioner
denoised
ttfa
TTFA
+agentic
\ No newline at end of file
diff --git a/apps/nlp/app/index.tsx b/apps/nlp/app/index.tsx
index 2e0e024076..4020c1956a 100644
--- a/apps/nlp/app/index.tsx
+++ b/apps/nlp/app/index.tsx
@@ -20,6 +20,9 @@ export default function Home() {
router.navigate('privacy-filter/')}>
Privacy Filter
+ router.navigate('llm/')}>
+ LLM
+
);
diff --git a/apps/nlp/app/llm/index.tsx b/apps/nlp/app/llm/index.tsx
new file mode 100644
index 0000000000..688886115e
--- /dev/null
+++ b/apps/nlp/app/llm/index.tsx
@@ -0,0 +1,351 @@
+import React, { useRef, useState, type ComponentRef } from 'react';
+import {
+ View,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ ScrollView,
+ StyleSheet,
+ ActivityIndicator,
+ KeyboardAvoidingView,
+ Platform,
+ Alert,
+ Image as RNImage,
+} from 'react-native';
+import { Skia } from '@shopify/react-native-skia';
+import {
+ useLLMChatSession,
+ models,
+ type ChatMessage,
+ type GenerationStats,
+ cv,
+} from 'react-native-executorch';
+import ScreenWrapper from '../../components/ScreenWrapper';
+import { getImage, skImageToBuffer } from '../../utils';
+
+const MODEL = models.llm.LFM2_5_350M;
+const SYSTEM_PROMPT = 'You are a helpful multimodal assistant by Liquid AI.';
+const INITIAL_MESSAGES: ChatMessage[] = [{ role: 'system', content: SYSTEM_PROMPT }];
+const GENERATION_CONFIG = { temperature: 0.7, maxNewTokens: 512, echo: false };
+
+type Turn = {
+ role: 'user' | 'assistant';
+ content: string;
+ imageUri?: string;
+ stats?: GenerationStats;
+};
+
+function formatStats(stats: GenerationStats): string {
+ const decodeMs = stats.inferenceEndMs - stats.firstTokenMs;
+ const tokensPerSec = (stats.numGeneratedTokens / decodeMs) * 1000;
+ const totalMs = stats.inferenceEndMs - stats.inferenceStartMs;
+ const ttftMs = stats.firstTokenMs - stats.inferenceStartMs;
+ return (
+ `${stats.numGeneratedTokens} tokens · ` +
+ `${tokensPerSec.toFixed(1)} tok/s · ` +
+ `${ttftMs.toFixed(0)}ms ttft · ` +
+ `${(totalMs / 1000).toFixed(2)}s`
+ );
+}
+
+function LLMContent() {
+ const { isReady, downloadProgress, error, sendMessage, stop } = useLLMChatSession(MODEL, {
+ initialMessages: INITIAL_MESSAGES,
+ generationConfig: GENERATION_CONFIG,
+ });
+
+ const [input, setInput] = useState('');
+ const [turns, setTurns] = useState([]);
+ const [streamingResponse, setStreamingResponse] = useState(null);
+
+ const scrollRef = useRef>(null);
+ const isGenerating = streamingResponse !== null;
+
+ const [attachedImage, setAttachedImage] = useState<{
+ uri: string;
+ name: string;
+ buffer: cv.ImageBuffer;
+ } | null>(null);
+
+ const handlePickGalleryImage = async () => {
+ try {
+ const uri = await getImage(false);
+ if (!uri) return;
+
+ const skData = await Skia.Data.fromURI(uri);
+ if (!skData) throw new Error('Failed to read image file');
+ const skImage = Skia.Image.MakeImageFromEncoded(skData);
+ if (!skImage) throw new Error('Failed to decode image');
+
+ const buffer = skImageToBuffer(skImage);
+ setAttachedImage({ uri, buffer, name: `Photo (${skImage.width()}x${skImage.height()})` });
+ } catch (err: any) {
+ Alert.alert('Error', err?.message || 'Failed to select gallery image');
+ }
+ };
+
+ const handleSend = async () => {
+ const textMessage = input.trim();
+ if ((!textMessage && !attachedImage) || !sendMessage || isGenerating) return;
+
+ const currentImage = attachedImage;
+ setAttachedImage(null);
+ setInput('');
+ setStreamingResponse('');
+
+ setTurns((prev) => [
+ ...prev,
+ { role: 'user', content: textMessage, imageUri: currentImage?.uri },
+ ]);
+
+ try {
+ let payload;
+ if (currentImage) {
+ payload = [
+ { kind: 'image' as const, image: currentImage.buffer },
+ textMessage || "What's in this image?",
+ ];
+ } else {
+ payload = textMessage;
+ }
+
+ const { response, stats } = await sendMessage(payload, (token) => {
+ setStreamingResponse((prev) => (prev !== null ? prev + token : token));
+ });
+ setTurns((prev) => [...prev, { role: 'assistant', content: response as string, stats }]);
+ } finally {
+ setStreamingResponse(null);
+ }
+ };
+
+ if (error) {
+ return (
+
+ Failed to load model
+ {error.message}
+
+ );
+ }
+
+ if (!isReady) {
+ return (
+
+
+
+ {downloadProgress < 100
+ ? `Downloading model… ${downloadProgress.toFixed(0)}%`
+ : 'Loading model into memory…'}
+
+
+ );
+ }
+
+ return (
+
+ scrollRef.current?.scrollToEnd({ animated: false })}
+ >
+ {turns.length === 0 && streamingResponse === null && (
+ Ask LFM 2.5 VL anything or attach an image.
+ )}
+ {turns.map((turn, idx) => (
+
+
+ {turn.imageUri && (
+
+ )}
+
+ {turn.content || '…'}
+
+
+ {turn.stats && {formatStats(turn.stats)}}
+
+ ))}
+ {streamingResponse !== null && (
+
+
+ {streamingResponse || '…'}
+
+
+ )}
+
+
+ {attachedImage && (
+
+
+
+
+ {attachedImage.name}
+
+
+ setAttachedImage(null)}
+ >
+ ✕
+
+
+ )}
+
+
+
+ 📷
+
+
+ {isGenerating ? (
+ stop?.()}>
+ Stop
+
+ ) : (
+
+ Send
+
+ )}
+
+
+ );
+}
+
+export default function LLMScreen() {
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: '#f8f9fa' },
+ centered: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
+ loadingText: { marginTop: 16, fontSize: 15, color: '#495057', fontWeight: '600' },
+ errorTitle: { fontSize: 16, fontWeight: '700', color: '#e03131', marginBottom: 8 },
+ errorBody: { fontSize: 13, color: '#868e96', textAlign: 'center' },
+ messages: { flex: 1 },
+ messagesContent: { padding: 16, paddingBottom: 8 },
+ placeholder: { textAlign: 'center', color: '#adb5bd', marginTop: 40, fontSize: 14 },
+ turn: { marginBottom: 12 },
+ bubble: {
+ maxWidth: '85%',
+ borderRadius: 16,
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ },
+ userBubble: { alignSelf: 'flex-end', backgroundColor: '#0070f3' },
+ assistantBubble: {
+ alignSelf: 'flex-start',
+ backgroundColor: '#fff',
+ borderWidth: 1,
+ borderColor: '#e9ecef',
+ },
+ userText: { color: '#fff', fontSize: 15, lineHeight: 21 },
+ assistantText: { color: '#212529', fontSize: 15, lineHeight: 21 },
+ turnThumbnail: {
+ width: 160,
+ height: 120,
+ borderRadius: 8,
+ marginBottom: 8,
+ backgroundColor: '#e9ecef',
+ },
+ statsLine: {
+ alignSelf: 'flex-start',
+ marginTop: 4,
+ marginLeft: 4,
+ fontSize: 11,
+ color: '#adb5bd',
+ },
+ attachmentBar: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ backgroundColor: '#fff',
+ borderTopWidth: 1,
+ borderTopColor: '#e9ecef',
+ gap: 12,
+ },
+ attachmentPreview: {
+ width: 40,
+ height: 40,
+ borderRadius: 6,
+ backgroundColor: '#e9ecef',
+ },
+ attachmentInfo: { flex: 1 },
+ attachmentName: { fontSize: 13, fontWeight: '500', color: '#212529' },
+ removeAttachmentButton: {
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ borderRadius: 12,
+ backgroundColor: '#f1f3f5',
+ },
+ removeAttachmentText: { fontSize: 13, fontWeight: '700', color: '#868e96' },
+ inputRow: {
+ flexDirection: 'row',
+ alignItems: 'flex-end',
+ padding: 12,
+ gap: 8,
+ borderTopWidth: 1,
+ borderTopColor: '#e9ecef',
+ backgroundColor: '#fff',
+ },
+ galleryButton: {
+ width: 44,
+ height: 44,
+ backgroundColor: '#f1f3f5',
+ borderRadius: 22,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ galleryButtonText: { fontSize: 18 },
+ input: {
+ flex: 1,
+ backgroundColor: '#f1f3f5',
+ borderRadius: 20,
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ fontSize: 15,
+ color: '#212529',
+ maxHeight: 120,
+ },
+ sendButton: {
+ backgroundColor: '#0070f3',
+ borderRadius: 20,
+ paddingHorizontal: 18,
+ height: 44,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ sendButtonDisabled: { backgroundColor: '#a3cdff' },
+ stopButton: { backgroundColor: '#e03131' },
+ sendButtonText: { color: '#fff', fontSize: 15, fontWeight: '600' },
+});
diff --git a/apps/nlp/components/ModelPicker.tsx b/apps/nlp/components/ModelPicker.tsx
new file mode 100644
index 0000000000..ae3893eb2c
--- /dev/null
+++ b/apps/nlp/components/ModelPicker.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import { View, Text, ScrollView, Pressable, StyleSheet } from 'react-native';
+
+export type ModelOption = {
+ label: string;
+ value: any;
+ labels?: any;
+};
+
+interface ModelPickerProps {
+ label: string;
+ options: ModelOption[];
+ selectedValue: any;
+ onValueChange: (value: any) => void;
+}
+
+export function ModelPicker({ label, options, selectedValue, onValueChange }: ModelPickerProps) {
+ return (
+
+ {label}
+
+ {options.map((option, index) => {
+ const isSelected = option.value === selectedValue;
+ return (
+ onValueChange(option.value)}
+ >
+
+ {option.label}
+
+
+ );
+ })}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ width: '100%',
+ marginBottom: 16,
+ paddingHorizontal: 4,
+ },
+ label: {
+ fontSize: 12,
+ fontWeight: '700',
+ color: '#666',
+ textTransform: 'uppercase',
+ marginBottom: 8,
+ letterSpacing: 0.5,
+ },
+ scrollContainer: {
+ flexDirection: 'row',
+ gap: 8,
+ },
+ chip: {
+ backgroundColor: '#e0e0e0',
+ paddingHorizontal: 14,
+ paddingVertical: 8,
+ borderRadius: 20,
+ },
+ activeChip: {
+ backgroundColor: '#000',
+ },
+ chipText: {
+ fontSize: 13,
+ color: '#333',
+ fontWeight: '500',
+ },
+ activeChipText: {
+ color: '#fff',
+ fontWeight: '600',
+ },
+});
diff --git a/apps/nlp/package.json b/apps/nlp/package.json
index d35e45a6af..50394f8c65 100644
--- a/apps/nlp/package.json
+++ b/apps/nlp/package.json
@@ -3,6 +3,12 @@
"version": "1.0.0",
"main": "expo-router/entry",
"react-native-executorch": {
+ "backends": [
+ "xnnpack",
+ "coreml",
+ "mlx",
+ "vulkan"
+ ],
"features": [
"tokenizer",
"textEmbeddings",
@@ -21,9 +27,12 @@
"dependencies": {
"@react-navigation/drawer": "^7.9.4",
"@react-navigation/native": "^7.2.2",
+ "@shopify/react-native-skia": "2.6.2",
"expo": "~56.0.9",
"expo-build-properties": "~56.0.17",
"expo-constants": "~56.0.17",
+ "expo-image-manipulator": "~56.0.18",
+ "expo-image-picker": "~56.0.18",
"expo-linking": "~56.0.13",
"expo-router": "~56.2.9",
"react": "19.2.3",
diff --git a/apps/nlp/utils.ts b/apps/nlp/utils.ts
new file mode 100644
index 0000000000..a8271596c4
--- /dev/null
+++ b/apps/nlp/utils.ts
@@ -0,0 +1,75 @@
+import { Alert } from 'react-native';
+import * as ImagePicker from 'expo-image-picker';
+import { ImageManipulator, SaveFormat } from 'expo-image-manipulator';
+import type { SkImage } from '@shopify/react-native-skia';
+import type { cv } from 'react-native-executorch';
+
+/**
+ * Converts a Skia image into the raw RGBA/HWC image buffer that
+ * react-native-executorch vision tasks accept. Throws if the pixel data cannot
+ * be read.
+ * @param image - The Skia image to read pixels from.
+ * @returns The RGBA/HWC image buffer with its `data`, `width`, `height`,
+ * `format`, and `layout`.
+ */
+export const skImageToBuffer = (image: SkImage): cv.ImageBuffer => {
+ const pixels = image.readPixels();
+ if (!pixels) {
+ throw new Error('Failed to read pixels from image');
+ }
+ if (!(pixels instanceof Uint8Array)) {
+ throw new Error('Expected Uint8Array from readPixels');
+ }
+ return {
+ data: pixels,
+ width: image.width(),
+ height: image.height(),
+ format: 'rgba' as const,
+ layout: 'hwc' as const,
+ };
+};
+
+export const getImage = async (
+ useCamera: boolean,
+ targetWidth = 800
+): Promise => {
+ const permissionResult = useCamera
+ ? await ImagePicker.requestCameraPermissionsAsync()
+ : await ImagePicker.requestMediaLibraryPermissionsAsync();
+
+ if (!permissionResult.granted) {
+ Alert.alert(
+ 'Permission Required',
+ useCamera
+ ? 'Permission to access camera is required!'
+ : 'Permission to access camera roll is required!'
+ );
+ return;
+ }
+
+ const options: ImagePicker.ImagePickerOptions = {
+ mediaTypes: ['images'],
+ allowsEditing: false,
+ quality: 1,
+ };
+
+ const pickerResult = useCamera
+ ? await ImagePicker.launchCameraAsync(options)
+ : await ImagePicker.launchImageLibraryAsync(options);
+
+ if (pickerResult.canceled || !pickerResult.assets[0]) {
+ return;
+ }
+
+ const asset = pickerResult.assets[0];
+ let imageRef = await ImageManipulator.manipulate(asset.uri).renderAsync();
+
+ if (imageRef.width > targetWidth) {
+ imageRef = await ImageManipulator.manipulate(asset.uri)
+ .resize({ width: targetWidth })
+ .renderAsync();
+ }
+
+ const result = await imageRef.saveAsync({ format: SaveFormat.PNG });
+ return result.uri;
+};
diff --git a/packages/react-native-executorch/android/CMakeLists.txt b/packages/react-native-executorch/android/CMakeLists.txt
index 0212e869f8..3030946c35 100644
--- a/packages/react-native-executorch/android/CMakeLists.txt
+++ b/packages/react-native-executorch/android/CMakeLists.txt
@@ -27,6 +27,7 @@ file(GLOB CORE_SOURCES ${CPP_DIR}/core/*.cpp)
file(GLOB MATH_SOURCES ${CPP_DIR}/extensions/math/*.cpp)
file(GLOB NLP_SOURCES ${CPP_DIR}/extensions/nlp/*.cpp)
file(GLOB SPEECH_SOURCES ${CPP_DIR}/extensions/speech/*.cpp)
+file(GLOB LLM_SOURCES ${CPP_DIR}/extensions/llm/*.cpp)
file(GLOB OPENCV_SOURCES ${CPP_DIR}/extensions/cv/*.cpp)
set(RNE_SOURCES
@@ -35,6 +36,7 @@ set(RNE_SOURCES
${MATH_SOURCES}
${NLP_SOURCES}
${SPEECH_SOURCES}
+ ${LLM_SOURCES}
cpp-adapter.cpp
)
diff --git a/packages/react-native-executorch/cpp/RnExecutorch.cpp b/packages/react-native-executorch/cpp/RnExecutorch.cpp
index df1bcb9d4d..98fb8c97ff 100644
--- a/packages/react-native-executorch/cpp/RnExecutorch.cpp
+++ b/packages/react-native-executorch/cpp/RnExecutorch.cpp
@@ -1,6 +1,7 @@
#include "RnExecutorch.h"
#include "core/install.h"
+#include "extensions/llm/install.h"
#include "extensions/math/install.h"
#include "extensions/nlp/install.h"
#include "extensions/speech/install.h"
@@ -22,6 +23,7 @@ void install(jsi::Runtime &jsiRuntime) {
rnexecutorch::extensions::math::install(jsiRuntime, module);
rnexecutorch::extensions::nlp::install(jsiRuntime, module);
rnexecutorch::extensions::speech::install(jsiRuntime, module);
+ rnexecutorch::extensions::llm::install(jsiRuntime, module);
jsiRuntime.global().setProperty(jsiRuntime, "__rnexecutorch_jsi__", std::move(module));
}
diff --git a/packages/react-native-executorch/cpp/extensions/llm/install.cpp b/packages/react-native-executorch/cpp/extensions/llm/install.cpp
new file mode 100644
index 0000000000..f30a2bd80a
--- /dev/null
+++ b/packages/react-native-executorch/cpp/extensions/llm/install.cpp
@@ -0,0 +1,14 @@
+#include "install.h"
+#include "llm_runner.h"
+
+namespace rnexecutorch::extensions::llm {
+namespace jsi = facebook::jsi;
+
+void install(facebook::jsi::Runtime &rt, facebook::jsi::Object &module) {
+ jsi::Object llmModule = jsi::Object(rt);
+
+ install_createLLMRunner(rt, llmModule);
+
+ module.setProperty(rt, "llm", llmModule);
+}
+} // namespace rnexecutorch::extensions::llm
diff --git a/packages/react-native-executorch/cpp/extensions/llm/install.h b/packages/react-native-executorch/cpp/extensions/llm/install.h
new file mode 100644
index 0000000000..89d3de5e6b
--- /dev/null
+++ b/packages/react-native-executorch/cpp/extensions/llm/install.h
@@ -0,0 +1,7 @@
+#pragma once
+
+#include
+
+namespace rnexecutorch::extensions::llm {
+void install(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
+} // namespace rnexecutorch::extensions::llm
diff --git a/packages/react-native-executorch/cpp/extensions/llm/llm_runner.cpp b/packages/react-native-executorch/cpp/extensions/llm/llm_runner.cpp
new file mode 100644
index 0000000000..ce32532593
--- /dev/null
+++ b/packages/react-native-executorch/cpp/extensions/llm/llm_runner.cpp
@@ -0,0 +1,344 @@
+// Upstream ExecuTorch annotates experimental LLM APIs (MultimodalRunner, Stats,
+// load_tokenizer, create_multimodal_runner) with `ET_EXPERIMENTAL`, which
+// expands to `[[deprecated("...")]]`. We suppress -Wdeprecated-declarations so
+// Clang does not fail builds on ExecuTorch's experimental API tags.
+#ifdef __clang__
+#pragma clang diagnostic ignored "-Wdeprecated-declarations"
+#endif
+
+#include "llm_runner.h"
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "core/conversions.h"
+#include "core/error.h"
+#include "core/tensor_helpers.h"
+
+namespace rnexecutorch::extensions::llm {
+namespace jsi = facebook::jsi;
+namespace error = rnexecutorch::core::error;
+namespace tensor = rnexecutorch::core::tensor;
+namespace conversions = rnexecutorch::core::conversions;
+
+using rnexecutorch::core::types::DType;
+
+namespace {
+jsi::Object statsToJSI(jsi::Runtime &rt, const executorch::extension::llm::Stats &stats) {
+ jsi::Object obj(rt);
+ obj.setProperty(rt, "numPromptTokens", static_cast(stats.num_prompt_tokens));
+ obj.setProperty(rt, "numGeneratedTokens", static_cast(stats.num_generated_tokens));
+ obj.setProperty(rt, "firstTokenMs", static_cast(stats.first_token_ms));
+ obj.setProperty(rt, "inferenceStartMs", static_cast(stats.inference_start_ms));
+ obj.setProperty(rt, "inferenceEndMs", static_cast(stats.inference_end_ms));
+ obj.setProperty(rt, "modelLoadStartMs", static_cast(stats.model_load_start_ms));
+ obj.setProperty(rt, "modelLoadEndMs", static_cast(stats.model_load_end_ms));
+ return obj;
+}
+
+std::vector parsePrompt(
+ jsi::Runtime &rt,
+ const std::string &ctx,
+ const jsi::Value &value,
+ const std::vector &supportedModalities) {
+
+ if (value.isString()) {
+ return {executorch::extension::llm::MultimodalInput(conversions::asType(rt, ctx, value))};
+ }
+
+ auto arr = conversions::asType(rt, ctx, value);
+ size_t len = arr.length(rt);
+
+ std::vector inputs;
+ inputs.reserve(len);
+
+ for (size_t i = 0; i < len; ++i) {
+ auto elem = arr.getValueAtIndex(rt, i);
+ std::string itemCtx = std::format("{}[{}]", ctx, i);
+
+ if (elem.isString()) {
+ inputs.emplace_back(conversions::asType(rt, itemCtx, elem));
+ continue;
+ }
+ auto mediaObj = conversions::asType(rt, itemCtx, elem);
+ auto kind = conversions::getRequiredProperty(rt, itemCtx, mediaObj, "kind");
+
+ if (std::ranges::find(supportedModalities, kind) == supportedModalities.end()) {
+ throw error::InvalidArgument(std::format("{}: Modality '{}' is not supported "
+ "by this runner instance",
+ itemCtx, kind));
+ }
+
+ if (kind == "image") {
+ auto tensorJs = conversions::getRequiredProperty(rt, itemCtx, mediaObj, "image");
+ auto tensorHost = tensor::fromJs(rt, itemCtx, tensorJs, DType::float32, {"C", "H", "W"});
+ auto tensorLock = tensor::tryLockShared(rt, itemCtx, tensorHost);
+
+ const auto &shape = tensorHost->shape_;
+ const auto C = shape[0];
+ const auto H = shape[1];
+ const auto W = shape[2];
+
+ std::vector data(tensorHost->numel_);
+ std::memcpy(data.data(), tensorHost->data_.get(), tensorHost->numel_ * sizeof(float));
+ inputs.emplace_back(executorch::extension::llm::Image(std::move(data), W, H, C));
+ } else if (kind == "audio") {
+ auto tensorJs = conversions::getRequiredProperty(rt, itemCtx, mediaObj, "audio");
+ auto tensorHost = tensor::fromJs(rt, itemCtx, tensorJs, DType::float32, {"batch", "n_bins", "n_frames"});
+ auto tensorLock = tensor::tryLockShared(rt, itemCtx, tensorHost);
+
+ const auto &shape = tensorHost->shape_;
+ const auto batchSize = shape[0];
+ const auto nBins = shape[1];
+ const auto nFrames = shape[2];
+
+ std::vector data(tensorHost->numel_);
+ std::memcpy(data.data(), tensorHost->data_.get(), tensorHost->numel_ * sizeof(float));
+ inputs.emplace_back(executorch::extension::llm::Audio(std::move(data), batchSize, nBins, nFrames));
+ } else {
+ throw error::InvalidArgument(std::format("{}: Unsupported media kind '{}'", itemCtx, kind));
+ }
+ }
+
+ return inputs;
+}
+} // namespace
+
+LLMRunnerHostObject::LLMRunnerHostObject(const std::string &modelPath,
+ const std::string &tokenizerPath,
+ const std::vector &modalities)
+ : modelPath_(modelPath),
+ tokenizerPath_(tokenizerPath),
+ modalities_(modalities) {
+
+ auto tokenizer = executorch::extension::llm::load_tokenizer(tokenizerPath);
+ if (!tokenizer) {
+ throw error::LoadFailed(std::format("LLMRunner: Failed to load runner tokenizer at path: {}", tokenizerPath));
+ }
+
+ if (modalities_.empty()) {
+ runner_ = executorch::extension::llm::create_text_llm_runner(modelPath, std::move(tokenizer));
+ } else {
+ runner_ = executorch::extension::llm::create_multimodal_runner(modelPath, std::move(tokenizer));
+ }
+
+ if (!runner_) {
+ throw error::LoadFailed("LLMRunner: Failed to create llm runner");
+ }
+
+ auto loadError = runner_->load();
+ if (loadError != executorch::runtime::Error::Ok) {
+ std::string errorMsg = executorch::runtime::to_string(loadError);
+ throw error::LoadFailed(std::format("LLMRunner: Failed to load model: {}", errorMsg), loadError);
+ }
+}
+
+jsi::Value LLMRunnerHostObject::get(jsi::Runtime &rt, const jsi::PropNameID &name) {
+ auto nameStr = name.utf8(rt);
+
+ if (nameStr == "modelPath") {
+ return jsi::String::createFromUtf8(rt, modelPath_);
+ }
+
+ if (nameStr == "tokenizerPath") {
+ return jsi::String::createFromUtf8(rt, tokenizerPath_);
+ }
+
+ if (nameStr == "modalities") {
+ return conversions::toJsiArray(rt, modalities_);
+ }
+
+ if (nameStr == "generate") {
+ auto self = shared_from_this();
+ auto fnBody = [self](jsi::Runtime &rt, const jsi::Value & /*thisVal*/, const jsi::Value *args, size_t count) -> jsi::Value {
+ if (count < 1) {
+ throw error::InvalidArgument("LLMRunner.generate: Usage: generate(prompt, config?, onToken?)");
+ }
+
+ executorch::extension::llm::GenerationConfig config;
+ if (count > 1 && !args[1].isUndefined() && !args[1].isNull()) {
+ auto configObj = conversions::asType(rt, "LLMRunner.generate: config", args[1]);
+ if (auto echoOpt = conversions::getOptionalProperty(rt, "LLMRunner.generate: config", configObj, "echo")) {
+ config.echo = *echoOpt;
+ }
+ if (auto ignoreEosOpt = conversions::getOptionalProperty(rt, "LLMRunner.generate: config", configObj, "ignoreEos")) {
+ config.ignore_eos = *ignoreEosOpt;
+ }
+ if (auto maxNewTokensOpt = conversions::getOptionalProperty(rt, "LLMRunner.generate: config", configObj, "maxNewTokens")) {
+ config.max_new_tokens = *maxNewTokensOpt;
+ }
+ if (auto tempOpt = conversions::getOptionalProperty(rt, "LLMRunner.generate: config", configObj, "temperature")) {
+ config.temperature = *tempOpt;
+ }
+ }
+
+ std::function tokenCallback;
+ if (count > 2 && !args[2].isUndefined() && !args[2].isNull()) {
+ auto tokenFn = std::make_shared(conversions::asType(rt, "LLMRunner.generate: onToken", args[2]));
+ tokenCallback = [&rt, tokenFn](const std::string &token) {
+ tokenFn->call(rt, jsi::String::createFromUtf8(rt, token));
+ };
+ }
+
+ auto finalStats = std::make_shared();
+ auto statsCallback = [finalStats](const executorch::extension::llm::Stats &stats) {
+ finalStats->num_prompt_tokens = stats.num_prompt_tokens;
+ finalStats->num_generated_tokens = stats.num_generated_tokens;
+ finalStats->first_token_ms = stats.first_token_ms;
+ finalStats->inference_start_ms = stats.inference_start_ms;
+ finalStats->inference_end_ms = stats.inference_end_ms;
+ finalStats->model_load_start_ms = stats.model_load_start_ms;
+ finalStats->model_load_end_ms = stats.model_load_end_ms;
+ finalStats->aggregate_sampling_time_ms = stats.aggregate_sampling_time_ms;
+ };
+
+ // Hold the lock for the whole call so dispose() cannot free the
+ // runner mid-generation (dispose blocks on this lock until we
+ // return). try_to_lock: only one prefill/generate may run at a
+ // time, so fail fast instead of queuing. stop() is lock-free and
+ // can still interrupt us.
+ std::unique_lock lock(self->mutex_, std::try_to_lock);
+ if (!lock.owns_lock()) {
+ throw error::ResourceBusy("LLMRunner.generate: Runner is already in use");
+ }
+ if (!self->runner_) {
+ throw error::ResourceDisposed("LLMRunner.generate: Runner has been disposed");
+ }
+
+ auto genError = executorch::runtime::Error::Ok;
+ if (self->modalities_.empty()) {
+ auto prompt = conversions::asType(rt, "LLMRunner.generate: prompt", args[0]);
+ genError = self->runner_->generate(prompt, config, tokenCallback, statsCallback);
+ } else {
+ auto inputs = parsePrompt(rt, "LLMRunner.generate", args[0], self->modalities_);
+ auto *multimodalRunner = dynamic_cast(self->runner_.get());
+ if (multimodalRunner == nullptr) {
+ throw error::InvalidArgument("LLMRunner.generate: Runner instance is not a multimodal model");
+ }
+ genError = multimodalRunner->generate(inputs, config, tokenCallback, statsCallback);
+ }
+
+ if (genError != executorch::runtime::Error::Ok) {
+ std::string errorMsg = executorch::runtime::to_string(genError);
+ throw error::ExecutionFailed(std::format("LLMRunner.generate: Failed to generate: {}", errorMsg), genError);
+ }
+
+ return statsToJSI(rt, *finalStats);
+ };
+ return jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, "generate"), 1, error::guarded(fnBody));
+ }
+
+ if (nameStr == "prefill") {
+ auto self = shared_from_this();
+ auto fnBody = [self](jsi::Runtime &rt, const jsi::Value & /*thisVal*/, const jsi::Value *args, size_t count) -> jsi::Value {
+ if (count < 1) {
+ throw error::InvalidArgument("LLMRunner.prefill: Usage: prefill(prompt)");
+ }
+
+ // Lock held for the whole call, same as generate().
+ std::unique_lock lock(self->mutex_, std::try_to_lock);
+ if (!lock.owns_lock()) {
+ throw error::ResourceBusy("LLMRunner.prefill: Runner is already in use");
+ }
+ if (!self->runner_) {
+ throw error::ResourceDisposed("LLMRunner.prefill: Runner has been disposed");
+ }
+
+ auto inputs = parsePrompt(rt, "LLMRunner.prefill", args[0], self->modalities_);
+ auto result = self->runner_->prefill(inputs);
+
+ if (!result.ok()) {
+ std::string errorMsg = executorch::runtime::to_string(result.error());
+ throw error::ExecutionFailed(std::format("LLMRunner.prefill: Failed: {}", errorMsg), result.error());
+ }
+
+ return jsi::Value::undefined();
+ };
+ return jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, "prefill"), 1, error::guarded(fnBody));
+ }
+
+ if (nameStr == "stop") {
+ auto self = shared_from_this();
+ auto fnBody = [self](jsi::Runtime & /*rt*/, const jsi::Value & /*thisVal*/, const jsi::Value * /*args*/, size_t /*count*/) -> jsi::Value {
+ // Intentionally no mutex here: stop() is designed to be called
+ // concurrently to interrupt an in-progress generate(). Taking the
+ // lock would block until generate() finishes, defeating the point.
+ // runner_ is only cleared by dispose() on this same (JS) thread,
+ // so reading it lock-free here is safe.
+ if (!self->runner_) {
+ throw error::ResourceDisposed("LLMRunner.stop: Runner has been disposed");
+ }
+ self->runner_->stop();
+ return jsi::Value::undefined();
+ };
+ return jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, "stop"), 0, error::guarded(fnBody));
+ }
+
+ if (nameStr == "dispose") {
+ auto self = shared_from_this();
+ auto fnBody = [self](jsi::Runtime & /*rt*/, const jsi::Value & /*thisVal*/, const jsi::Value * /*args*/, size_t count) -> jsi::Value {
+ if (count != 0) {
+ throw error::InvalidArgument("dispose: Usage: dispose()");
+ }
+
+ // Signal stop before locking so any in-progress generate() exits
+ // quickly; we then block on the lock until it returns and clear
+ // the runner, which frees the model. Idempotent: a second
+ // dispose() finds a null runner_ and is a no-op.
+ if (self->runner_) {
+ self->runner_->stop();
+ }
+
+ std::unique_lock lock(self->mutex_);
+ self->runner_ = nullptr;
+
+ return jsi::Value::undefined();
+ };
+ return jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, "dispose"), 0, error::guarded(fnBody));
+ }
+
+ return jsi::Value::undefined();
+}
+
+std::vector LLMRunnerHostObject::getPropertyNames(jsi::Runtime &rt) {
+ std::vector properties;
+ properties.push_back(jsi::PropNameID::forAscii(rt, "modelPath"));
+ properties.push_back(jsi::PropNameID::forAscii(rt, "tokenizerPath"));
+ properties.push_back(jsi::PropNameID::forAscii(rt, "modalities"));
+ properties.push_back(jsi::PropNameID::forAscii(rt, "prefill"));
+ properties.push_back(jsi::PropNameID::forAscii(rt, "generate"));
+ properties.push_back(jsi::PropNameID::forAscii(rt, "stop"));
+ properties.push_back(jsi::PropNameID::forAscii(rt, "dispose"));
+ return properties;
+}
+
+void install_createLLMRunner(jsi::Runtime &rt, jsi::Object &module) {
+ const auto *name = "createLLMRunner";
+ auto fnBody = [](jsi::Runtime &rt, const jsi::Value & /*thisVal*/, const jsi::Value *args, size_t count) -> jsi::Value {
+ if (count < 2 || count > 3) {
+ throw error::InvalidArgument("createLLMRunner: Usage: createLLMRunner(modelPath, tokenizerPath, modalities?)");
+ }
+
+ auto modelPath = conversions::asType(rt, "createLLMRunner: modelPath", args[0]);
+ auto tokenizerPath = conversions::asType(rt, "createLLMRunner: tokenizerPath", args[1]);
+
+ std::vector modalities;
+ if (count > 2 && !args[2].isNull() && !args[2].isUndefined()) {
+ modalities = conversions::asVector(rt, "createLLMRunner: modalities", args[2]);
+ }
+
+ auto runnerInstance = std::make_shared(modelPath, tokenizerPath, std::move(modalities));
+ return jsi::Object::createFromHostObject(rt, runnerInstance);
+ };
+
+ module.setProperty(rt, name, jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, name), 2, error::guarded(fnBody)));
+}
+} // namespace rnexecutorch::extensions::llm
diff --git a/packages/react-native-executorch/cpp/extensions/llm/llm_runner.h b/packages/react-native-executorch/cpp/extensions/llm/llm_runner.h
new file mode 100644
index 0000000000..74e0dbf5b0
--- /dev/null
+++ b/packages/react-native-executorch/cpp/extensions/llm/llm_runner.h
@@ -0,0 +1,66 @@
+#pragma once
+
+// Upstream ExecuTorch annotates experimental LLM APIs (TextLLMRunner, Stats,
+// etc.) with `ET_EXPERIMENTAL`, which expands to `[[deprecated("This API is
+// experimental...")]]`. We suppress -Wdeprecated-declarations so Clang does not
+// fail builds on ExecuTorch's experimental API tags.
+#ifdef __clang__
+#pragma clang diagnostic ignored "-Wdeprecated-declarations"
+#endif
+
+#include
+#include
+#include
+#include
+
+#include
+
+#include
+
+namespace rnexecutorch::extensions::llm {
+/**
+ * JSI HostObject wrapping an ExecuTorch LLM runner instance
+ * (`executorch::extension::llm::IRunner`).
+ *
+ * Exposes methods to JavaScript for prefilling prompt context, generating token
+ * stream continuations, interrupting generation, and releasing native model
+ * memory.
+ */
+class LLMRunnerHostObject : public facebook::jsi::HostObject,
+ public std::enable_shared_from_this {
+public:
+ /**
+ * Constructs an LLMRunnerHostObject by loading an ExecuTorch model binary
+ * and tokenizer.
+ *
+ * @param modelPath Absolute file system path to the `.pte` LLM model
+ * binary.
+ * @param tokenizerPath Absolute file system path to the local tokenizer
+ * configuration file (e.g. `tokenizer.json`).
+ * @param modalities Vector of supported input modality names (e.g.
+ * `{"image"}`).
+ * @throws core::error::RnExecuTorchException with code LoadFailed if
+ * loading the model or tokenizer fails.
+ */
+ LLMRunnerHostObject(const std::string &modelPath,
+ const std::string &tokenizerPath,
+ const std::vector &modalities);
+
+ facebook::jsi::Value get(facebook::jsi::Runtime &rt, const facebook::jsi::PropNameID &name) override;
+ std::vector getPropertyNames(facebook::jsi::Runtime &rt) override;
+
+private:
+ /** Owning pointer to the underlying ExecuTorch IRunner instance. */
+ std::unique_ptr runner_;
+ /** Mutex guarding concurrent access to prefill and generation operations. */
+ std::mutex mutex_;
+ /** File system path to the loaded `.pte` model binary. */
+ std::string modelPath_;
+ /** File system path to the loaded tokenizer configuration file. */
+ std::string tokenizerPath_;
+ /** List of supported non-text input modalities. */
+ std::vector modalities_;
+};
+
+void install_createLLMRunner(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
+} // namespace rnexecutorch::extensions::llm
diff --git a/packages/react-native-executorch/package.json b/packages/react-native-executorch/package.json
index 2eb9dc91cb..c15604ccea 100644
--- a/packages/react-native-executorch/package.json
+++ b/packages/react-native-executorch/package.json
@@ -83,6 +83,9 @@
"publishConfig": {
"registry": "https://registry.npmjs.org/"
},
+ "dependencies": {
+ "@huggingface/jinja": "^0.5.9"
+ },
"peerDependencies": {
"react": "*",
"react-native": "*",
diff --git a/packages/react-native-executorch/src/extensions/llm/chatPreprocessor.ts b/packages/react-native-executorch/src/extensions/llm/chatPreprocessor.ts
new file mode 100644
index 0000000000..e6437cad32
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/llm/chatPreprocessor.ts
@@ -0,0 +1,201 @@
+import { Template } from '@huggingface/jinja';
+
+import { tensor, type Tensor } from '../../core/tensor';
+import { RnExecuTorchError } from '../../core/error';
+import type { ImageBuffer } from '../cv';
+import { createImagePreprocessor, type ImagePreprocessorOptions } from '../cv/tasks/preprocessing';
+import type { Modality, Prompt, MediaInput } from './llmRunner';
+
+/**
+ * High-level media payload input for chat turns.
+ * @category Types
+ */
+export type ChatMediaInput =
+ | { readonly kind: 'image'; readonly image: ImageBuffer }
+ | { readonly kind: 'audio'; readonly audio: unknown };
+
+/**
+ * Interleaved text and media content for a chat turn.
+ * @category Types
+ */
+export type ChatMessageContent = string | readonly (string | ChatMediaInput)[];
+
+/**
+ * Conversation turn representing system, user, or assistant messages.
+ * @category Types
+ */
+export type ChatMessage = {
+ /** Conversation role ('system', 'user', or 'assistant'). */
+ readonly role: 'system' | 'user' | 'assistant';
+ /** Message content string or interleaved text and media payloads. */
+ readonly content: ChatMessageContent;
+};
+
+/**
+ * Image preprocessing and sentinel token config for vision-language LLMs.
+ * @category Types
+ */
+export type LLMImagePreprocessorConfig = {
+ /** Sentinel token delimiters inserted into Jinja prompts. */
+ readonly token: { readonly start: string; readonly end: string };
+ /** Image preprocessing options (normalization, resize mode, interpolation). */
+ readonly preprocessorOpts: ImagePreprocessorOptions;
+ /** Fixed target shape expected by native LLM `[C, H, W]`. */
+ readonly targetShape: readonly [number, number, number];
+};
+
+/**
+ * Audio preprocessing and sentinel token config for audio-language LLMs.
+ * @category Types
+ */
+export type LLMAudioPreprocessorConfig = {
+ /** Sentinel token delimiters inserted into Jinja prompts. */
+ readonly token: { readonly start: string; readonly end: string };
+};
+
+/**
+ * Preprocessor configuration for media modalities.
+ * @category Types
+ */
+export type LLMMediaPreprocessorConfig = {
+ readonly image?: LLMImagePreprocessorConfig;
+ readonly audio?: LLMAudioPreprocessorConfig;
+};
+
+/**
+ * Options for instantiating a ChatPreprocessor.
+ * @category Types
+ */
+export type ChatPreprocessorConfig = {
+ readonly chatTemplate: string;
+ readonly bosToken?: string;
+ readonly modalities?: readonly Modality[];
+ readonly preprocessorConfig?: LLMMediaPreprocessorConfig;
+};
+
+/**
+ * Turn preprocessing options for ChatPreprocessor.
+ * @category Types
+ */
+export type ChatProcessOptions = {
+ /**
+ * Whether this is the initial turn in the conversation (prepends BOS token if
+ * true). Defaults to `false`.
+ */
+ readonly isFirstTurn?: boolean;
+ /**
+ * Whether to append the assistant generation prompt (e.g.
+ * `<|im_start|>assistant\n`). Defaults to `true`.
+ */
+ readonly addGenerationPrompt?: boolean;
+};
+
+/**
+ * Handles Jinja template formatting and media tensor preprocessing for chat turns.
+ * @category Typescript API
+ * @param config Preprocessor configuration including template and preprocessor settings.
+ * @returns Object with process and dispose methods.
+ */
+export function createChatPreprocessor(config: ChatPreprocessorConfig) {
+ const { chatTemplate, modalities, preprocessorConfig, bosToken = '' } = config;
+ const template = new Template(chatTemplate);
+
+ let imgPreprocessor: ReturnType | undefined;
+ let imgShape: [number, number, number] | undefined;
+
+ if (preprocessorConfig?.image !== undefined) {
+ imgShape = [...preprocessorConfig.image.targetShape];
+ imgPreprocessor = createImagePreprocessor(preprocessorConfig.image.preprocessorOpts, imgShape);
+ }
+
+ const tensors: Tensor[] = [];
+
+ const dispose = () => {
+ tensors.forEach((t) => t.dispose());
+ imgPreprocessor?.dispose();
+ };
+
+ const process = (message: ChatMessage, opts?: ChatProcessOptions): Prompt => {
+ const isFirstTurn = opts?.isFirstTurn ?? false;
+ const addGenerationPrompt = opts?.addGenerationPrompt ?? true;
+
+ if (typeof message.content === 'string') {
+ /* eslint-disable camelcase */
+ return template.render({
+ bos_token: isFirstTurn ? bosToken : '',
+ add_generation_prompt: addGenerationPrompt,
+ messages: [{ role: message.role, content: message.content }],
+ });
+ /* eslint-enable */
+ }
+
+ const mediaInputs: MediaInput[] = [];
+ let syntheticContent = '';
+
+ for (const item of message.content) {
+ if (typeof item === 'string') {
+ syntheticContent += item;
+ continue;
+ }
+
+ if (!modalities || !modalities.includes(item.kind)) {
+ throw RnExecuTorchError(
+ 'INVALID_ARGUMENT',
+ `Modality '${item.kind}' is not supported by this model instance.`
+ );
+ }
+
+ if (item.kind === 'image') {
+ if (!preprocessorConfig?.image || !imgPreprocessor || !imgShape) {
+ throw RnExecuTorchError(
+ 'INVALID_ARGUMENT',
+ 'Received image input but no image preprocessorConfig was provided.'
+ );
+ }
+ const tokenStart = preprocessorConfig.image.token.start;
+ const tokenEnd = preprocessorConfig.image.token.end;
+ const index = mediaInputs.length;
+
+ syntheticContent += `${tokenStart}\uFFFC__ET_MEDIA_${index}__${tokenEnd}`;
+
+ const tImage = tensor('float32', imgShape);
+ tensors.push(tImage);
+ imgPreprocessor.process(item.image).copyTo(tImage);
+
+ mediaInputs.push({ kind: 'image', image: tImage });
+ }
+
+ if (item.kind === 'audio') {
+ throw RnExecuTorchError('INVALID_ARGUMENT', 'Audio input not yet supported');
+ }
+ }
+
+ /* eslint-disable camelcase */
+ const renderedContent = template.render({
+ bos_token: isFirstTurn ? bosToken : '',
+ add_generation_prompt: addGenerationPrompt,
+ messages: [{ role: message.role, content: syntheticContent }],
+ });
+ /* eslint-enable */
+
+ const regex = /\uFFFC__ET_MEDIA_(\d+)__/g;
+ const prompt: (string | MediaInput)[] = [];
+
+ let match: RegExpExecArray | null;
+ let lastIndex = 0;
+ while ((match = regex.exec(renderedContent)) !== null) {
+ const textChunk = renderedContent.slice(lastIndex, match.index);
+ if (textChunk.length > 0) prompt.push(textChunk);
+
+ prompt.push(mediaInputs[parseInt(match[1]!, 10)]!);
+ lastIndex = regex.lastIndex;
+ }
+
+ const tail = renderedContent.slice(lastIndex);
+ if (tail.length > 0) prompt.push(tail);
+
+ return prompt;
+ };
+
+ return { dispose, process };
+}
diff --git a/packages/react-native-executorch/src/extensions/llm/index.ts b/packages/react-native-executorch/src/extensions/llm/index.ts
new file mode 100644
index 0000000000..8b338b7712
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/llm/index.ts
@@ -0,0 +1,3 @@
+export * from './llmRunner';
+export * from './chatPreprocessor';
+export * from './tokenizerConfig';
diff --git a/packages/react-native-executorch/src/extensions/llm/llmRunner.ts b/packages/react-native-executorch/src/extensions/llm/llmRunner.ts
new file mode 100644
index 0000000000..dd5246e242
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/llm/llmRunner.ts
@@ -0,0 +1,126 @@
+import type { Tensor } from '../../core/tensor';
+import { rnexecutorchJsi } from '../../native/bridge';
+
+declare const llmRunnerBrand: unique symbol;
+
+/**
+ * Configuration options for LLM text generation.
+ * @category Types
+ */
+export type GenerationConfig = {
+ /** Whether to echo the prompt in the generated output. */
+ readonly echo?: boolean;
+ /** Whether to ignore EOS tokens during generation. */
+ readonly ignoreEos?: boolean;
+ /** Maximum number of new tokens to generate. */
+ readonly maxNewTokens?: number;
+ /** Sampling temperature for token selection. */
+ readonly temperature?: number;
+};
+
+/**
+ * Execution and performance statistics for a generation call.
+ * @category Types
+ */
+export type GenerationStats = {
+ /** Number of tokens in the input prompt. */
+ readonly numPromptTokens: number;
+ /** Number of newly generated tokens. */
+ readonly numGeneratedTokens: number;
+ /** Time elapsed in milliseconds to generate the first token. */
+ readonly firstTokenMs: number;
+ /** Timestamp in milliseconds when inference started. */
+ readonly inferenceStartMs: number;
+ /** Timestamp in milliseconds when inference completed. */
+ readonly inferenceEndMs: number;
+ /** Timestamp in milliseconds when model loading started. */
+ readonly modelLoadStartMs: number;
+ /** Timestamp in milliseconds when model loading completed. */
+ readonly modelLoadEndMs: number;
+};
+
+/**
+ * Low-level non-text media input tensor payloads.
+ * @category Types
+ */
+export type MediaInput =
+ | { readonly kind: 'image'; readonly image: Tensor }
+ | { readonly kind: 'audio'; readonly audio: Tensor };
+
+/**
+ * Supported non-text input modality keys (e.g. `'image'`, `'audio'`).
+ * @category Types
+ */
+export type Modality = MediaInput['kind'];
+
+/**
+ * Text or interleaved multimodal prompt input for a low-level LLM runner.
+ * @category Types
+ */
+export type Prompt = string | readonly (string | MediaInput)[];
+
+/**
+ * Handle to a native ExecuTorch LLM runner.
+ * @category Types
+ */
+export type LLMRunner = {
+ /** Path to the local model file. */
+ readonly modelPath: string;
+ /** Path to the local tokenizer configuration file. */
+ readonly tokenizerPath: string;
+ /** List of supported non-text input modalities for this runner (e.g. `['image']`). */
+ readonly modalities: readonly Modality[];
+
+ /**
+ * Disposes the native LLM runner and releases the loaded model memory.
+ */
+ dispose(): void;
+
+ /**
+ * Interrupts and stops any active generation call on this runner.
+ */
+ stop(): void;
+
+ /**
+ * Prefills the runner with a prompt to build up the KV cache.
+ * @param prompt The prefill text or multimodal prompt.
+ */
+ prefill(prompt: Prompt): void;
+
+ /**
+ * Generates text continuation from a prompt.
+ * @param prompt The text or multimodal prompt to generate continuation for.
+ * @param config Generation configuration options.
+ * @param onToken Callback function triggered whenever a new token is generated.
+ * @returns Generation performance statistics.
+ */
+ generate(
+ prompt: Prompt,
+ config?: GenerationConfig,
+ onToken?: (token: string) => void
+ ): GenerationStats;
+
+ /**
+ * Prevents plain JS objects from being cast as LLMRunners.
+ * @internal
+ */
+ readonly [llmRunnerBrand]: never;
+};
+
+/**
+ * Creates a native ExecuTorch LLM runner instance.
+ * @category Typescript API
+ * @param modelPath Path to the local `.pte` model file.
+ * @param tokenizerPath Path to the local tokenizer configuration file (e.g. `tokenizer.json`).
+ * @param modalities List of supported input non-text modalities (e.g. `['image']`).
+ * Defaults to text-only.
+ * @returns A native LLMRunner instance.
+ */
+export function createLLMRunner(
+ modelPath: string,
+ tokenizerPath: string,
+ modalities?: readonly Modality[]
+): LLMRunner {
+ 'worklet';
+ return rnexecutorchJsi.llm.createLLMRunner(modelPath, tokenizerPath, modalities ?? []);
+}
diff --git a/packages/react-native-executorch/src/extensions/llm/tasks/llmChatSession.ts b/packages/react-native-executorch/src/extensions/llm/tasks/llmChatSession.ts
new file mode 100644
index 0000000000..1283cf8346
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/llm/tasks/llmChatSession.ts
@@ -0,0 +1,225 @@
+import { scheduleOnRN, type WorkletRuntime } from 'react-native-worklets';
+import RNBlobUtil from 'react-native-blob-util';
+
+import { wrapAsync } from '../../../core/runtime';
+import {
+ createLLMRunner,
+ type LLMRunner,
+ type GenerationConfig,
+ type GenerationStats,
+ type Modality,
+ type Prompt,
+} from '../llmRunner';
+import { parseTokenizerConfig } from '../tokenizerConfig';
+import {
+ createChatPreprocessor,
+ type ChatMediaInput,
+ type ChatMessageContent,
+ type ChatMessage,
+ type LLMImagePreprocessorConfig,
+ type LLMAudioPreprocessorConfig,
+ type LLMMediaPreprocessorConfig,
+} from '../chatPreprocessor';
+
+export type {
+ GenerationConfig,
+ GenerationStats,
+ Modality,
+ ChatMediaInput,
+ ChatMessageContent,
+ ChatMessage,
+ LLMImagePreprocessorConfig,
+ LLMAudioPreprocessorConfig,
+ LLMMediaPreprocessorConfig,
+};
+
+/**
+ * Model configuration required to instantiate an LLM chat session.
+ * @category Types
+ */
+export type LLMModel = {
+ /** Local path or remote URL of the `.pte` model file. */
+ readonly modelPath: string;
+ /** Local path or remote URL of the `tokenizer.json` file. */
+ readonly tokenizerPath: string;
+ /** Local path or remote URL of the `tokenizer_config.json` file. */
+ readonly tokenizerConfigPath: string;
+ /** Supported input non-text modalities (e.g. `['image']`). */
+ readonly modalities?: readonly Modality[];
+ /** Media preprocessor configuration. */
+ readonly preprocessorConfig?: LLMMediaPreprocessorConfig;
+};
+
+/**
+ * Options for configuring an LLM chat session.
+ * @category Types
+ */
+export type LLMChatSessionOptions = {
+ /** Initial conversation history to prefill into the model KV cache. */
+ readonly initialMessages?: readonly ChatMessage[];
+ /** Default generation configuration options. */
+ readonly generationConfig?: GenerationConfig;
+ /** Additional stop tokens that interrupt token generation. */
+ readonly stopTokens?: readonly string[];
+};
+
+/**
+ * Generation result returned by an LLM chat turn.
+ * @category Types
+ */
+export type LLMGenerationResult = {
+ /** The generated assistant response text. */
+ readonly response: ChatMessageContent;
+ /** Generation performance statistics. */
+ readonly stats: GenerationStats;
+};
+
+/**
+ * Handle to an active LLM chat session.
+ * @category Types
+ */
+export type LLMChatSession = {
+ /**
+ * Interrupts and stops any active token generation call.
+ */
+ stop(): void;
+
+ /**
+ * Releases native model memory and preprocessor resources.
+ */
+ dispose(): void;
+
+ /**
+ * Returns the read-only conversation message history.
+ */
+ getHistory(): readonly ChatMessage[];
+
+ /**
+ * Sends a user message or chat turn to the model and generates a response.
+ * @param message Message string, media payload array, or ChatMessage object.
+ * @param onToken Callback fired on the RN thread for each decoded token.
+ * @param genConfig Generation options overriding session defaults.
+ * @returns A promise resolving to the response and generation stats.
+ */
+ sendMessage(
+ message: ChatMessageContent | ChatMessage,
+ onToken?: (token: string) => void,
+ genConfig?: GenerationConfig
+ ): Promise;
+};
+
+function generateChatTurnWorklet(
+ runner: LLMRunner,
+ prompt: Prompt,
+ options: {
+ readonly genConfig: GenerationConfig;
+ readonly stopTokens: readonly string[];
+ readonly onToken?: (token: string) => void;
+ }
+): LLMGenerationResult {
+ 'worklet';
+ const { genConfig, stopTokens, onToken } = options;
+
+ let response = '';
+
+ const callback = (token: string) => {
+ if (stopTokens.includes(token)) return;
+ response += token;
+ if (onToken) scheduleOnRN(onToken, token);
+ };
+
+ const stats = runner.generate(prompt, genConfig, callback);
+
+ return { response, stats };
+}
+
+/**
+ * Instantiates an LLM chat session using background thread execution.
+ * @category Typescript API
+ * @param config Model configuration containing model, tokenizer, and tokenizer config paths.
+ * @param options Custom generation and state options.
+ * @param runtime The worklet runtime thread to run native generation on.
+ * @returns A Promise resolving to an LLMChatSession instance.
+ */
+export async function createLLMChatSession(
+ config: LLMModel,
+ options?: LLMChatSessionOptions,
+ runtime?: WorkletRuntime
+): Promise {
+ const { modelPath, tokenizerPath, tokenizerConfigPath, modalities, preprocessorConfig } = config;
+
+ const initialMessages = options?.initialMessages ?? [];
+ const defaultGenerationConfig = options?.generationConfig;
+
+ // Read and parse tokenizer_config.json
+ const tokenizerConfigStr = await RNBlobUtil.fs.readFile(tokenizerConfigPath, 'utf8');
+ const tokenizerConfig = parseTokenizerConfig(JSON.parse(tokenizerConfigStr));
+ const { chatTemplate, bosToken, eosToken } = tokenizerConfig;
+
+ // Prepare chat preprocessor
+ const chatPreprocessor = createChatPreprocessor({
+ chatTemplate,
+ bosToken,
+ modalities,
+ preprocessorConfig,
+ });
+ const stopTokens = [...(options?.stopTokens ?? []), ...(eosToken ? [eosToken] : [])];
+
+ // Prepare runner
+ const history: ChatMessage[] = [];
+ const runner = await wrapAsync(createLLMRunner, runtime)(modelPath, tokenizerPath, modalities);
+ const prefill = wrapAsync(runner.prefill, runtime);
+
+ // Prefill initial messages
+ for (const msg of initialMessages) {
+ await prefill(
+ chatPreprocessor.process(msg, {
+ isFirstTurn: history.length === 0,
+ addGenerationPrompt: false,
+ })
+ );
+ history.push(msg);
+ }
+
+ const stop = () => runner.stop();
+ const dispose = () => {
+ runner.dispose();
+ chatPreprocessor.dispose();
+ };
+
+ const generateChatTurn = wrapAsync(generateChatTurnWorklet, runtime);
+
+ const sendMessage = async (
+ message: ChatMessageContent | ChatMessage,
+ onToken?: (token: string) => void,
+ genConfig?: GenerationConfig
+ ): Promise => {
+ let input: ChatMessage;
+ if (typeof message === 'object' && 'role' in message) {
+ input = message;
+ } else {
+ input = { role: 'user', content: message };
+ }
+
+ const prompt = chatPreprocessor.process(input, {
+ isFirstTurn: history.length === 0,
+ addGenerationPrompt: true,
+ });
+
+ history.push(input);
+
+ const opts = { genConfig: { ...defaultGenerationConfig, ...genConfig }, stopTokens, onToken };
+ const { response, stats } = await generateChatTurn(runner, prompt, opts);
+
+ history.push({ role: 'assistant', content: response });
+
+ return { response, stats };
+ };
+
+ return {
+ stop,
+ dispose,
+ sendMessage,
+ getHistory: () => [...history],
+ };
+}
diff --git a/packages/react-native-executorch/src/extensions/llm/tokenizerConfig.ts b/packages/react-native-executorch/src/extensions/llm/tokenizerConfig.ts
new file mode 100644
index 0000000000..ce07ca4101
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/llm/tokenizerConfig.ts
@@ -0,0 +1,48 @@
+import { RnExecuTorchError } from '../../core/error';
+
+/**
+ * Model chat template configuration resolved from tokenizer config file.
+ * @category Types
+ */
+export type TokenizerChatConfig = {
+ readonly chatTemplate: string;
+ readonly bosToken?: string;
+ readonly eosToken?: string;
+};
+
+function resolveToken(token: unknown): string | undefined {
+ if (typeof token === 'string') return token;
+ if (token && typeof token === 'object' && typeof (token as any).content === 'string') {
+ return (token as any).content;
+ }
+ return undefined;
+}
+
+/**
+ * Parses raw JSON configuration from `tokenizer_config.json` into a normalized format.
+ * @category Utils
+ * @param config Raw JSON object from tokenizer_config.json.
+ * @returns A parsed TokenizerChatConfig object.
+ */
+export function parseTokenizerConfig(config: any): TokenizerChatConfig {
+ let chatTemplate = config?.chat_template;
+
+ // Some models ship multiple named templates as `[{ name, template }]`.
+ if (Array.isArray(chatTemplate)) {
+ const entry = chatTemplate.find((t) => t?.name === 'default') ?? chatTemplate[0];
+ chatTemplate = entry?.template;
+ }
+
+ if (typeof chatTemplate !== 'string') {
+ throw RnExecuTorchError(
+ 'LOAD_FAILED',
+ 'tokenizer_config.json does not contain a string `chat_template`'
+ );
+ }
+
+ return {
+ chatTemplate,
+ bosToken: resolveToken(config?.bos_token),
+ eosToken: resolveToken(config?.eos_token),
+ };
+}
diff --git a/packages/react-native-executorch/src/fetcher/fetcher.ts b/packages/react-native-executorch/src/fetcher/fetcher.ts
index 8bbc9f963a..8af28c4108 100644
--- a/packages/react-native-executorch/src/fetcher/fetcher.ts
+++ b/packages/react-native-executorch/src/fetcher/fetcher.ts
@@ -1,20 +1,11 @@
/* eslint-disable no-bitwise */
import { Platform } from 'react-native';
import RNBlobUtil from 'react-native-blob-util';
-import * as telemetry from './telemetry';
import { RnExecuTorchError } from '../core/error';
const IS_ANDROID = Platform.OS === 'android';
-
-// Persistent, per-app directory where downloaded model assets are cached.
-// iOS: internal DocumentDir (not CacheDir) so the OS won't evict large models
-// between runs and force a costly re-download.
-// Android: the app-private EXTERNAL files dir (getExternalFilesDir), so the
-// system DownloadManager can write there and same-volume moves stay cheap
-// even for multi-GB files. Falls back to DocumentDir if unmounted.
-const ANDROID_DIRECTORY = RNBlobUtil.fs.dirs.SDCardDir || RNBlobUtil.fs.dirs.DocumentDir;
-const RNE_DIRECTORY = IS_ANDROID
- ? `${ANDROID_DIRECTORY}/react-native-executorch`
+const BASE_DIR = IS_ANDROID
+ ? `${RNBlobUtil.fs.dirs.SDCardDir || RNBlobUtil.fs.dirs.DocumentDir}/react-native-executorch`
: `${RNBlobUtil.fs.dirs.DocumentDir}/react-native-executorch`;
/**
@@ -24,10 +15,7 @@ const RNE_DIRECTORY = IS_ANDROID
export interface DownloadOptions {
/** Called with overall progress in `[0, 1]` as bytes arrive. */
onProgress?: (progress: number) => void;
- /**
- * Aborts the download. On iOS the bytes fetched so far are kept on disk so a
- * later {@link download} of the same source resumes instead of restarting.
- */
+ /** Aborts the download. */
signal?: AbortSignal;
/**
* Re-downloads every remote source even when it is already cached, replacing
@@ -37,353 +25,92 @@ export interface DownloadOptions {
forceDownload?: boolean;
}
-// djb2 — cheap, dependency-free hash used to derive a stable cache key per URL.
const djb2 = (s: string): number => {
let h = 5381;
- for (let i = 0; i < s.length; i++) {
- h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0;
- }
+ for (let i = 0; i < s.length; i++) h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0;
return h;
};
const isRemote = (source: string): boolean => /^https?:\/\//.test(source);
function cachePathFor(url: string): string {
- const urlWithoutQuery = url.split('?')[0]!;
- const basename = urlWithoutQuery.split('/').pop() || 'model';
- // Hash guards against basename collisions across different repos/versions.
- return `${RNE_DIRECTORY}/${djb2(urlWithoutQuery)}_${basename}`;
-}
-
-async function fileSize(path: string): Promise {
- try {
- const stat = await RNBlobUtil.fs.stat(path);
- return Number(stat.size) || 0;
- } catch {
- return 0;
- }
+ const cleanUrl = url.split('?')[0]!;
+ const filename = cleanUrl.split('/').pop() || 'model';
+ return `${BASE_DIR}/${djb2(cleanUrl)}_${filename}`;
}
-// Best-effort remote content length via a HEAD request; 0 when unknown.
-async function remoteSize(url: string): Promise {
+async function isDownloaded(destPath: string): Promise {
try {
- const res = await fetch(url, { method: 'HEAD' });
- const len = res.headers.get('content-length');
- return len ? Number(len) : 0;
+ const stat = await RNBlobUtil.fs.stat(destPath);
+ return Number(stat.size) > 0;
} catch {
- return 0;
+ return false;
}
}
-// Raised when a download is cancelled through its `signal`. Internal: callers
-// match on the DOWNLOAD_ABORTED code via isRnExecuTorchError.
-const abortError = () => RnExecuTorchError('DOWNLOAD_ABORTED', 'The download was aborted.');
-
-type OnBytes = (received: number, total: number) => void;
-
-interface DownloadUrlCallbacks {
- // Reports absolute received/total bytes for this file, including any bytes
- // already present from a previous partial download.
- onBytes?: OnBytes;
- signal?: AbortSignal;
- forceDownload?: boolean;
-}
-
-// A download that is currently running, shared by every caller that asked for
-// the same URL while it was in flight.
-interface InFlightDownload {
- promise: Promise;
- // Progress is fanned out to every joined caller.
- listeners: Set;
- // Drives the underlying request; aborted only once every caller has left.
- controller: AbortController;
- callers: number;
-}
-
-const inFlight = new Map();
-
-// Downloads a single remote file into the cache, dispatching to the
-// platform-appropriate backend. Returns the local path.
-//
-// Concurrent calls for the same URL share one request: without this, both would
-// miss the cache check, double-count the download in telemetry, and write the
-// same temporary file — on Android the second one's opening `unlink` would
-// delete the first one's partially downloaded data.
-async function downloadUrl(url: string, cb: DownloadUrlCallbacks): Promise {
- if (cb.signal?.aborted) throw abortError();
-
- const dest = cachePathFor(url);
-
- if (cb.forceDownload) {
- // Drop the cached copy so the checks below fall through to a real fetch.
- // A download already in flight hasn't written `dest` yet, so joining it
- // still yields freshly fetched bytes.
- await RNBlobUtil.fs.unlink(dest).catch(() => {});
- } else if (await RNBlobUtil.fs.exists(dest)) {
- // Cache hit — nothing to download.
- const size = await fileSize(dest);
- cb.onBytes?.(size, size);
- return dest;
- }
-
- // Skip an entry whose last caller just left: it is already unwinding, so
- // joining it would hand this caller someone else's cancellation.
- const existing = inFlight.get(url);
- if (existing && !existing.controller.signal.aborted) return joinDownload(existing, cb);
-
- const entry: InFlightDownload = {
- promise: Promise.resolve(''),
- listeners: new Set(),
- controller: new AbortController(),
- callers: 0,
- };
- entry.promise = startDownload(url, dest, entry).finally(() => {
- inFlight.delete(url);
- });
- inFlight.set(url, entry);
-
- return joinDownload(entry, cb);
-}
-
-// Runs the actual request, fanning progress out to everyone who joined.
-async function startDownload(url: string, dest: string, entry: InFlightDownload): Promise {
- // Count this actual (non-cached) fetch once, no matter how many callers share it.
- telemetry.triggerHuggingFaceDownloadCounter(url);
- telemetry.triggerDownloadEvent(url);
-
- await RNBlobUtil.fs.mkdir(RNE_DIRECTORY).catch(() => {});
-
- const cb: DownloadUrlCallbacks = {
- signal: entry.controller.signal,
- onBytes: (received, total) => {
- for (const listener of entry.listeners) listener(received, total);
- },
- };
-
- return IS_ANDROID
- ? downloadUrlViaAndroidDownloadManager(url, dest, cb)
- : downloadUrlViaIosStream(url, dest, cb);
-}
-
-// Attaches one caller to a shared download. The caller's own signal only
-// detaches it — the request itself is cancelled once the last caller leaves.
-function joinDownload(entry: InFlightDownload, cb: DownloadUrlCallbacks): Promise {
- entry.callers += 1;
- if (cb.onBytes) entry.listeners.add(cb.onBytes);
-
- return new Promise((resolve, reject) => {
- const onAbort = () => {
- if (cb.onBytes) entry.listeners.delete(cb.onBytes);
- entry.callers -= 1;
- if (entry.callers === 0) entry.controller.abort();
- reject(abortError());
- };
- cb.signal?.addEventListener('abort', onAbort);
-
- entry.promise.then(resolve, reject).finally(() => {
- cb.signal?.removeEventListener('abort', onAbort);
- });
- });
-}
-
-// Android backend: the system DownloadManager streams to app-private external
-// storage. Unlike blob-util's in-process reader it handles files larger than
-// 2 GB, keeps downloading while the app is in the background or killed, and
-// resumes across transient network drops on its own — so no manual Range logic.
-async function downloadUrlViaAndroidDownloadManager(
+// Download a single file URL directly to disk
+async function downloadFile(
url: string,
- dest: string,
- cb: DownloadUrlCallbacks
+ destPath: string,
+ onBytes?: (recv: number, tot: number) => void,
+ signal?: AbortSignal
): Promise {
- const tmp = `${dest}.downloading`;
- await RNBlobUtil.fs.unlink(tmp).catch(() => {});
+ if (signal?.aborted) throw RnExecuTorchError('DOWNLOAD_ABORTED', 'The download was aborted.');
- if (cb.signal?.aborted) throw abortError();
-
- const task = RNBlobUtil.config({
- addAndroidDownloads: {
- useDownloadManager: true,
- path: tmp,
- notification: false,
- mediaScannable: false,
- mime: 'application/octet-stream',
- },
- }).fetch('GET', url);
+ await RNBlobUtil.fs.mkdir(BASE_DIR).catch(() => {});
+ const tmpPath = `${destPath}.tmp`;
+ await RNBlobUtil.fs.unlink(tmpPath).catch(() => {});
+ const task = RNBlobUtil.config({ path: tmpPath, fileCache: true }).fetch('GET', url);
const onAbort = () => task.cancel();
- cb.signal?.addEventListener('abort', onAbort);
+ signal?.addEventListener('abort', onAbort);
- // DownloadManager reports total as -1 until the size is known; forward the
- // received byte count regardless so byte-weighted progress still advances.
- task.progress({ count: 100 }, (received, total) => {
+ task.progress({ count: 50 }, (received, total) => {
const recv = Number(received);
const tot = Number(total);
- cb.onBytes?.(recv, tot > 0 ? tot : recv);
+ if (recv > 0) onBytes?.(recv, tot > 0 ? tot : 0);
});
- try {
- await task;
- } catch (e) {
- await RNBlobUtil.fs.unlink(tmp).catch(() => {});
- throw cb.signal?.aborted ? abortError() : e;
- } finally {
- cb.signal?.removeEventListener('abort', onAbort);
- }
-
- // DownloadManager doesn't surface an HTTP status; an empty file means failure.
- const size = await fileSize(tmp);
- if (size <= 0) {
- await RNBlobUtil.fs.unlink(tmp).catch(() => {});
- throw RnExecuTorchError('DOWNLOAD_FAILED', `Download of ${url} failed (empty response).`);
- }
- await RNBlobUtil.fs.mv(tmp, dest);
- return dest;
-}
-
-// iOS backend: blob-util streams via the iOS URL session straight to disk.
-// Interrupted downloads resume from a `.partial` file via an HTTP Range request.
-// `canResume` is set to `false` on an internal retry to avoid recursing forever
-// if partial-file assembly ever fails.
-async function downloadUrlViaIosStream(
- url: string,
- dest: string,
- cb: DownloadUrlCallbacks,
- canResume = true
-): Promise {
- const part = `${dest}.partial`;
- if (!canResume) await RNBlobUtil.fs.unlink(part).catch(() => {});
- const offset = canResume ? await fileSize(part) : 0;
-
- // Resumed byte ranges land in a separate chunk file that we append onto the
- // partial; fresh downloads stream straight into the partial.
- const target = offset > 0 ? `${dest}.chunk` : part;
- await RNBlobUtil.fs.unlink(target).catch(() => {}); // clear any stale chunk
-
- const headers: Record = {};
- if (offset > 0) headers.Range = `bytes=${offset}-`;
-
- if (cb.signal?.aborted) throw abortError();
-
- const task = RNBlobUtil.config({ path: target, fileCache: true }).fetch('GET', url, headers);
- const onAbort = () => task.cancel();
- cb.signal?.addEventListener('abort', onAbort);
-
- task.progress({ count: 20 }, (received, total) => {
- const recv = Number(received);
- const tot = Number(total);
- cb.onBytes?.(offset + recv, offset + tot);
- });
-
- let status: number;
try {
const res = await task;
- status = res.info().status;
- } catch (e) {
- // Network drop / cancel. Keep the fresh partial for a future resume, but
- // discard a resumed chunk — its offset assumptions may not hold.
- if (offset > 0) await RNBlobUtil.fs.unlink(target).catch(() => {});
- throw cb.signal?.aborted ? abortError() : e;
- } finally {
- cb.signal?.removeEventListener('abort', onAbort);
- }
-
- try {
- if (status === 416) {
- // Range not satisfiable — the partial already holds the whole file.
- await RNBlobUtil.fs.unlink(target).catch(() => {});
- } else if (status >= 400) {
- await RNBlobUtil.fs.unlink(target).catch(() => {});
+ if (res.info().status >= 400 || !(await isDownloaded(tmpPath))) {
+ await RNBlobUtil.fs.unlink(tmpPath).catch(() => {});
throw RnExecuTorchError(
'DOWNLOAD_FAILED',
- `Download of ${url} failed with HTTP status ${status}.`
+ `Download of ${url} failed with status ${res.info().status}.`
);
- } else if (offset > 0) {
- if (status === 206) {
- // Server honored the range: append the new bytes onto the partial.
- await RNBlobUtil.fs.appendFile(part, `file://${target}`, 'uri');
- await RNBlobUtil.fs.unlink(target).catch(() => {});
- } else {
- // Server ignored the range (200) and re-sent the whole file: replace.
- await RNBlobUtil.fs.unlink(part).catch(() => {});
- await RNBlobUtil.fs.mv(target, part);
- }
}
- await RNBlobUtil.fs.mv(part, dest);
- return dest;
- } catch (assemblyErr) {
- // A `4xx` we deliberately threw must propagate as-is.
- if (status >= 400 && status !== 416) throw assemblyErr;
- // Assembling the partial failed (e.g. append unsupported). Wipe and retry
- // once as a plain full download so correctness never depends on resume.
- await RNBlobUtil.fs.unlink(part).catch(() => {});
- await RNBlobUtil.fs.unlink(target).catch(() => {});
- if (canResume) return downloadUrlViaIosStream(url, dest, cb, false);
- throw assemblyErr;
+ await RNBlobUtil.fs.mv(tmpPath, destPath);
+ return destPath;
+ } catch (err) {
+ await RNBlobUtil.fs.unlink(tmpPath).catch(() => {});
+ if (signal?.aborted) throw RnExecuTorchError('DOWNLOAD_ABORTED', 'The download was aborted.');
+ throw err;
+ } finally {
+ signal?.removeEventListener('abort', onAbort);
}
}
-// Plain data containers we recurse into. Anything else (numbers, functions,
-// typed arrays, class instances) is left alone.
-function isPlainObject(value: unknown): value is Record {
- if (typeof value !== 'object' || value === null) return false;
- const proto = Object.getPrototypeOf(value);
- return proto === Object.prototype || proto === null;
-}
-
-/**
- * Walks a value and collects every distinct remote URL sitting on a string
- * leaf. Deduplicating here means a URL repeated across fields is fetched once.
- * @param node The value to walk.
- * @returns The set of remote URLs referenced by `node`.
- */
-function collectRemoteSources(node: unknown): Set {
- const out = new Set();
-
- const visit = (current: unknown): void => {
- if (typeof current === 'string') {
- if (isRemote(current)) out.add(current);
- } else if (Array.isArray(current)) {
- for (const item of current) visit(item);
- } else if (isPlainObject(current)) {
- for (const value of Object.values(current)) visit(value);
- }
- };
-
- visit(node);
+// Collect all remote URLs inside a string, array, or nested config object
+function collectUrls(node: unknown, out = new Set()): Set {
+ if (typeof node === 'string') {
+ if (isRemote(node)) out.add(node);
+ } else if (Array.isArray(node)) {
+ for (const item of node) collectUrls(item, out);
+ } else if (typeof node === 'object' && node !== null) {
+ for (const val of Object.values(node)) collectUrls(val, out);
+ }
return out;
}
-/**
- * Rebuilds a value with every resolved URL replaced by its local path.
- * Branches containing no resolved URL keep their original reference, so an
- * untouched config comes back as-is and stays stable across React renders.
- * @typeParam T The shape of the value being rewritten.
- * @param node The value to rewrite.
- * @param resolved Map of remote URL to downloaded local path.
- * @returns `node` with resolved URLs swapped for local paths.
- */
-function substituteRemoteSources(node: T, resolved: ReadonlyMap): T {
- if (typeof node === 'string') {
- return (resolved.get(node) ?? node) as T;
- }
- if (Array.isArray(node)) {
- let changed = false;
- const next = node.map((item) => {
- const mapped = substituteRemoteSources(item, resolved);
- changed ||= mapped !== item;
- return mapped;
- });
- return (changed ? next : node) as T;
- }
- if (isPlainObject(node)) {
- let changed = false;
+// Replace remote URLs in config object with local downloaded file paths
+function replaceUrls(node: T, resolved: Map): T {
+ if (typeof node === 'string') return (resolved.get(node) ?? node) as T;
+ if (Array.isArray(node)) return node.map((item) => replaceUrls(item, resolved)) as T;
+ if (typeof node === 'object' && node !== null) {
const next: Record = {};
- for (const [key, value] of Object.entries(node)) {
- const mapped = substituteRemoteSources(value, resolved);
- changed ||= mapped !== value;
- next[key] = mapped;
- }
- return (changed ? next : node) as T;
+ for (const [k, v] of Object.entries(node)) next[k] = replaceUrls(v, resolved);
+ return next as T;
}
return node;
}
@@ -392,23 +119,6 @@ function substituteRemoteSources(node: T, resolved: ReadonlyMap` factory:
- *
- * ```ts
- * const model = await download(models.classification.EFFICIENTNET_V2_S.XNNPACK_FP32);
- * const { classify, dispose } = await createClassifier(model);
- * ```
- *
- * Downloads go to a persistent cache: on Android via the system DownloadManager
- * (handles multi-GB files and continues in the background), on iOS via a
- * streaming request that resumes an interrupted download from where it stopped.
- * When a config references several files, overall progress is weighted by their
- * byte sizes so a large model isn't reported the same as a tiny tokenizer.
* @category Utils
* @typeParam T The shape of the value being resolved.
* @param source A URL, a local path, or any nested object/array holding them.
@@ -416,44 +126,71 @@ function substituteRemoteSources(node: T, resolved: ReadonlyMap(source: T, options: DownloadOptions = {}): Promise {
- const urls = [...collectRemoteSources(source)];
-
- // Nothing to fetch — every source is already a local path.
+ const urls = [...collectUrls(source)];
if (urls.length === 0) {
options.onProgress?.(1);
return source;
}
- // Weight overall progress by real byte sizes so a 1 GB model isn't treated
- // like a 1 MB tokenizer. When any HEAD fails we fall back to equal weighting.
- const sizes = await Promise.all(urls.map(remoteSize));
- const haveAllSizes = sizes.every((size) => size > 0);
- const total = haveAllSizes ? sizes.reduce((a, b) => a + b, 0) : urls.length;
-
+ const resolved = new Map();
const received = new Array(urls.length).fill(0);
- const report = () => {
+ const totals = new Array(urls.length).fill(0);
+
+ // Fast-path: pre-fill cached files
+ for (let i = 0; i < urls.length; i++) {
+ const url = urls[i]!;
+ const destPath = cachePathFor(url);
+ if (!options.forceDownload && (await isDownloaded(destPath))) {
+ const stat = await RNBlobUtil.fs.stat(destPath);
+ const sz = Number(stat.size);
+ received[i] = sz;
+ totals[i] = sz;
+ resolved.set(url, destPath);
+ }
+ }
+
+ // If all files are cached, return immediately with 100% progress
+ if (resolved.size === urls.length) {
+ options.onProgress?.(1);
+ return replaceUrls(source, resolved);
+ }
+
+ const updateProgress = () => {
if (!options.onProgress) return;
- const sum = received.reduce((a, b) => a + b, 0);
- options.onProgress(total > 0 ? Math.min(sum / total, 1) : 0);
+ const sumReceived = received.reduce((a, b) => a + b, 0);
+ const sumTotals = totals.reduce((a, b) => a + b, 0);
+ options.onProgress(sumTotals > 0 ? Math.min(sumReceived / sumTotals, 1) : 0);
};
- const resolved = new Map();
+ // Initial progress update for any pre-cached files in bundle
+ updateProgress();
+
+ // Download uncached files concurrently
await Promise.all(
- urls.map((url, i) =>
- // Telemetry fires inside downloadUrl, only for genuine (non-cached) fetches.
- downloadUrl(url, {
- signal: options.signal,
- forceDownload: options.forceDownload,
- onBytes: (recv, tot) => {
- received[i] = haveAllSizes ? recv : tot > 0 ? recv / tot : 0;
- report();
+ urls.map(async (url, i) => {
+ if (resolved.has(url)) return;
+
+ const destPath = cachePathFor(url);
+ const downloadedPath = await downloadFile(
+ url,
+ destPath,
+ (recv, tot) => {
+ received[i] = recv;
+ if (tot > 0) totals[i] = tot;
+ updateProgress();
},
- }).then((path) => {
- resolved.set(url, path);
- })
- )
+ options.signal
+ );
+
+ const stat = await RNBlobUtil.fs.stat(downloadedPath);
+ const finalSize = Number(stat.size);
+ received[i] = finalSize;
+ totals[i] = finalSize;
+ resolved.set(url, downloadedPath);
+ updateProgress();
+ })
);
options.onProgress?.(1);
- return substituteRemoteSources(source, resolved);
+ return replaceUrls(source, resolved);
}
diff --git a/packages/react-native-executorch/src/hooks/useLLMChatSession.ts b/packages/react-native-executorch/src/hooks/useLLMChatSession.ts
new file mode 100644
index 0000000000..5d3bde5176
--- /dev/null
+++ b/packages/react-native-executorch/src/hooks/useLLMChatSession.ts
@@ -0,0 +1,40 @@
+import { useModel } from './useModel';
+import { useResourceDownload, type ResourceOptions } from './useResourceDownload';
+import {
+ createLLMChatSession,
+ type LLMModel,
+ type LLMChatSessionOptions,
+} from '../extensions/llm/tasks/llmChatSession';
+
+/**
+ * React hook to load and run an LLM chat session model.
+ *
+ * This hook manages downloading (if they are remote URLs) and loading the `.pte` model
+ * file, `tokenizer.json`, and `tokenizer_config.json`, tracking download progress and errors,
+ * and cleaning up native memory when the component unmounts or configuration changes.
+ * @category Hooks
+ * @param config The LLM model configuration.
+ * @param options Chat session options and load/caching options. See {@link ResourceOptions}.
+ * @returns An object containing the session's loading state, error, download progress,
+ * and chat functions.
+ */
+export function useLLMChatSession(
+ config: LLMModel,
+ options?: LLMChatSessionOptions & ResourceOptions
+) {
+ const { resource, downloadProgress, downloadError } = useResourceDownload(config, options);
+ const { model: session, error } = useModel(
+ (res) => createLLMChatSession(res, options),
+ resource ?? null
+ );
+
+ return {
+ isReady: !!session,
+ error: downloadError || error,
+ downloadProgress,
+ resource,
+ sendMessage: session?.sendMessage,
+ getHistory: session?.getHistory,
+ stop: session?.stop,
+ };
+}
diff --git a/packages/react-native-executorch/src/index.ts b/packages/react-native-executorch/src/index.ts
index 0865295145..d7cb910926 100644
--- a/packages/react-native-executorch/src/index.ts
+++ b/packages/react-native-executorch/src/index.ts
@@ -6,6 +6,7 @@ export * from './hooks/useInstanceSegmenter';
export * from './hooks/useKeypointDetector';
export * from './hooks/useObjectDetector';
export * from './hooks/useTokenizer';
+export * from './hooks/useLLMChatSession';
export * from './hooks/useTextEmbedder';
export * from './hooks/usePrivacyFilter';
export * from './hooks/useImageEmbedder';
@@ -32,6 +33,7 @@ export * from './extensions/cv/tasks/keypointDetection';
export * from './extensions/cv/tasks/objectDetection';
export * from './extensions/cv/tasks/imageEmbedding';
export * from './extensions/cv/tasks/sdxsTextToImage';
+export * from './extensions/llm/tasks/llmChatSession';
export * from './extensions/nlp/tasks/tokenization';
export * from './extensions/nlp/tasks/textEmbedding';
export * from './extensions/nlp/tasks/privacyFilter';
@@ -50,6 +52,7 @@ export * as schema from './core/schema';
export * as math from './extensions/math';
export * as cv from './extensions/cv';
+export * as llm from './extensions/llm';
export * as nlp from './extensions/nlp';
export * as speech from './extensions/speech';
diff --git a/packages/react-native-executorch/src/models.ts b/packages/react-native-executorch/src/models.ts
index fe9155015e..5a1866c50e 100644
--- a/packages/react-native-executorch/src/models.ts
+++ b/packages/react-native-executorch/src/models.ts
@@ -14,6 +14,7 @@ import {
type WhisperSttModel,
WHISPER_LANGUAGES,
} from './extensions/speech/tasks/whisperSpeechToText';
+import type { LLMModel } from './extensions/llm/tasks/llmChatSession';
import {
IMAGENET_NORM,
IMAGENET1K_LABELS,
@@ -816,6 +817,269 @@ const PRIVACY_FILTER_NEMOTRON_MLX_INT8: PrivacyFilterModel', end: '<|image_end|>' },
+ targetShape: [3, 512, 512] as const,
+ preprocessorOpts: {
+ resizeMode: 'letterbox' as const,
+ interpolation: 'linear' as const,
+ normalizeOpts: { alpha: 1.0, beta: 0.0 },
+ },
+ },
+};
+const LFM2_5_VL_450M_XNNPACK_8DA4W: LLMModel = {
+ modelPath: `${LFM2_5_BASE_URL}/vl_450m/xnnpack/lfm_2_5_vl_450m_xnnpack_8da4w.pte`,
+ tokenizerPath: `${LFM2_5_BASE_URL}/vl_450m/tokenizer.json`,
+ tokenizerConfigPath: `${LFM2_5_BASE_URL}/vl_450m/tokenizer_config.json`,
+ modalities: ['image'],
+ preprocessorConfig: LFM2_5_VL_PREPROCESSOR_CONFIG,
+};
+const LFM2_5_VL_450M_MLX_INT4: LLMModel = {
+ modelPath: `${LFM2_5_BASE_URL}/vl_450m/mlx/lfm_2_5_vl_450m_mlx_int4.pte`,
+ tokenizerPath: `${LFM2_5_BASE_URL}/vl_450m/tokenizer.json`,
+ tokenizerConfigPath: `${LFM2_5_BASE_URL}/vl_450m/tokenizer_config.json`,
+ modalities: ['image'],
+ preprocessorConfig: LFM2_5_VL_PREPROCESSOR_CONFIG,
+};
+const LFM2_5_VL_1_6B_XNNPACK_8DA4W: LLMModel = {
+ modelPath: `${LFM2_5_BASE_URL}/vl_1_6b/xnnpack/lfm_2_5_vl_1_6b_xnnpack_8da4w.pte`,
+ tokenizerPath: `${LFM2_5_BASE_URL}/vl_1_6b/tokenizer.json`,
+ tokenizerConfigPath: `${LFM2_5_BASE_URL}/vl_1_6b/tokenizer_config.json`,
+ modalities: ['image'],
+ preprocessorConfig: LFM2_5_VL_PREPROCESSOR_CONFIG,
+};
+
+const BIELIK_V3_1_5B_BASE_URL = `${BASE_URL}-bielik-v3.0/${VERSION_TAG}`;
+
+const BIELIK_V3_1_5B_XNNPACK_8DA4W: LLMModel = {
+ modelPath: `${BIELIK_V3_1_5B_BASE_URL}/xnnpack/bielik_v3_0_1_5b_xnnpack_8da4w.pte`,
+ tokenizerPath: `${BIELIK_V3_1_5B_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${BIELIK_V3_1_5B_BASE_URL}/tokenizer_config.json`,
+};
+const BIELIK_V3_1_5B_XNNPACK_FP16: LLMModel = {
+ modelPath: `${BIELIK_V3_1_5B_BASE_URL}/xnnpack/bielik_v3_0_1_5b_xnnpack_fp16.pte`,
+ tokenizerPath: `${BIELIK_V3_1_5B_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${BIELIK_V3_1_5B_BASE_URL}/tokenizer_config.json`,
+};
+
+const LLAMA3_2_BASE_URL = `${BASE_URL}-llama-3.2/${VERSION_TAG}`;
+
+const LLAMA3_2_3B_SPINQUANT: LLMModel = {
+ modelPath: `${LLAMA3_2_BASE_URL}/3b/xnnpack/llama_3_2_3b_xnnpack_spinquant.pte`,
+ tokenizerPath: `${LLAMA3_2_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${LLAMA3_2_BASE_URL}/tokenizer_config.json`,
+};
+const LLAMA3_2_3B_BF16: LLMModel = {
+ modelPath: `${LLAMA3_2_BASE_URL}/3b/xnnpack/llama_3_2_3b_xnnpack_bf16.pte`,
+ tokenizerPath: `${LLAMA3_2_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${LLAMA3_2_BASE_URL}/tokenizer_config.json`,
+};
+const LLAMA3_2_1B_SPINQUANT: LLMModel = {
+ modelPath: `${LLAMA3_2_BASE_URL}/1b/xnnpack/llama_3_2_1b_xnnpack_spinquant.pte`,
+ tokenizerPath: `${LLAMA3_2_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${LLAMA3_2_BASE_URL}/tokenizer_config.json`,
+};
+const LLAMA3_2_1B_BF16: LLMModel = {
+ modelPath: `${LLAMA3_2_BASE_URL}/1b/xnnpack/llama_3_2_1b_xnnpack_bf16.pte`,
+ tokenizerPath: `${LLAMA3_2_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${LLAMA3_2_BASE_URL}/tokenizer_config.json`,
+};
+
+const SMOLLM2_BASE_URL = `${BASE_URL}-smolLm-2/${VERSION_TAG}`;
+
+const SMOLLM2_135M_8DA4W: LLMModel = {
+ modelPath: `${SMOLLM2_BASE_URL}/135m/xnnpack/smollm2_135m_xnnpack_8da4w.pte`,
+ tokenizerPath: `${SMOLLM2_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${SMOLLM2_BASE_URL}/tokenizer_config.json`,
+};
+const SMOLLM2_135M_BF16: LLMModel = {
+ modelPath: `${SMOLLM2_BASE_URL}/135m/xnnpack/smollm2_135m_xnnpack_bf16.pte`,
+ tokenizerPath: `${SMOLLM2_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${SMOLLM2_BASE_URL}/tokenizer_config.json`,
+};
+const SMOLLM2_360M_8DA4W: LLMModel = {
+ modelPath: `${SMOLLM2_BASE_URL}/360m/xnnpack/smollm2_360m_xnnpack_8da4w.pte`,
+ tokenizerPath: `${SMOLLM2_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${SMOLLM2_BASE_URL}/tokenizer_config.json`,
+};
+const SMOLLM2_360M_BF16: LLMModel = {
+ modelPath: `${SMOLLM2_BASE_URL}/360m/xnnpack/smollm2_360m_xnnpack_bf16.pte`,
+ tokenizerPath: `${SMOLLM2_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${SMOLLM2_BASE_URL}/tokenizer_config.json`,
+};
+const SMOLLM2_1_7B_8DA4W: LLMModel = {
+ modelPath: `${SMOLLM2_BASE_URL}/1_7b/xnnpack/smollm2_1_7b_xnnpack_8da4w.pte`,
+ tokenizerPath: `${SMOLLM2_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${SMOLLM2_BASE_URL}/tokenizer_config.json`,
+};
+const SMOLLM2_1_7B_BF16: LLMModel = {
+ modelPath: `${SMOLLM2_BASE_URL}/1_7b/xnnpack/smollm2_1_7b_xnnpack_bf16.pte`,
+ tokenizerPath: `${SMOLLM2_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${SMOLLM2_BASE_URL}/tokenizer_config.json`,
+};
+
+const HAMMER2_1_BASE_URL = `${BASE_URL}-hammer-2.1/${VERSION_TAG}`;
+
+const HAMMER2_1_0_5B_XNNPACK_8DA4W: LLMModel = {
+ modelPath: `${HAMMER2_1_BASE_URL}/0_5b/xnnpack/hammer_2_1_0_5b_xnnpack_8da4w.pte`,
+ tokenizerPath: `${HAMMER2_1_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${HAMMER2_1_BASE_URL}/tokenizer_config.json`,
+};
+const HAMMER2_1_0_5B_XNNPACK_BF16: LLMModel = {
+ modelPath: `${HAMMER2_1_BASE_URL}/0_5b/xnnpack/hammer_2_1_0_5b_xnnpack_bf16.pte`,
+ tokenizerPath: `${HAMMER2_1_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${HAMMER2_1_BASE_URL}/tokenizer_config.json`,
+};
+const HAMMER2_1_1_5B_XNNPACK_8DA4W: LLMModel = {
+ modelPath: `${HAMMER2_1_BASE_URL}/1_5b/xnnpack/hammer_2_1_1_5b_xnnpack_8da4w.pte`,
+ tokenizerPath: `${HAMMER2_1_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${HAMMER2_1_BASE_URL}/tokenizer_config.json`,
+};
+const HAMMER2_1_1_5B_XNNPACK_BF16: LLMModel = {
+ modelPath: `${HAMMER2_1_BASE_URL}/1_5b/xnnpack/hammer_2_1_1_5b_xnnpack_bf16.pte`,
+ tokenizerPath: `${HAMMER2_1_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${HAMMER2_1_BASE_URL}/tokenizer_config.json`,
+};
+const HAMMER2_1_3B_XNNPACK_8DA4W: LLMModel = {
+ modelPath: `${HAMMER2_1_BASE_URL}/3b/xnnpack/hammer_2_1_3b_xnnpack_8da4w.pte`,
+ tokenizerPath: `${HAMMER2_1_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${HAMMER2_1_BASE_URL}/tokenizer_config.json`,
+};
+const HAMMER2_1_3B_XNNPACK_BF16: LLMModel = {
+ modelPath: `${HAMMER2_1_BASE_URL}/3b/xnnpack/hammer_2_1_3b_xnnpack_bf16.pte`,
+ tokenizerPath: `${HAMMER2_1_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${HAMMER2_1_BASE_URL}/tokenizer_config.json`,
+};
+
+const PHI4_MINI_BASE_URL = `${BASE_URL}-phi-4-mini/${VERSION_TAG}`;
+
+const PHI4_MINI_XNNPACK_8DA4W: LLMModel = {
+ modelPath: `${PHI4_MINI_BASE_URL}/xnnpack/phi_4_mini_xnnpack_8da4w.pte`,
+ tokenizerPath: `${PHI4_MINI_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${PHI4_MINI_BASE_URL}/tokenizer_config.json`,
+};
+const PHI4_MINI_XNNPACK_BF16: LLMModel = {
+ modelPath: `${PHI4_MINI_BASE_URL}/xnnpack/phi_4_mini_xnnpack_bf16.pte`,
+ tokenizerPath: `${PHI4_MINI_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${PHI4_MINI_BASE_URL}/tokenizer_config.json`,
+};
+
+const QWEN2_5_BASE_URL = `${BASE_URL}-qwen-2.5/${VERSION_TAG}`;
+
+const QWEN2_5_0_5B_XNNPACK_8DA4W: LLMModel = {
+ modelPath: `${QWEN2_5_BASE_URL}/0_5b/xnnpack/qwen_2_5_0_5b_xnnpack_8da4w.pte`,
+ tokenizerPath: `${QWEN2_5_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${QWEN2_5_BASE_URL}/tokenizer_config.json`,
+};
+const QWEN2_5_0_5B_XNNPACK_BF16: LLMModel = {
+ modelPath: `${QWEN2_5_BASE_URL}/0_5b/xnnpack/qwen_2_5_0_5b_xnnpack_bf16.pte`,
+ tokenizerPath: `${QWEN2_5_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${QWEN2_5_BASE_URL}/tokenizer_config.json`,
+};
+const QWEN2_5_1_5B_XNNPACK_8DA4W: LLMModel = {
+ modelPath: `${QWEN2_5_BASE_URL}/1_5b/xnnpack/qwen_2_5_1_5b_xnnpack_8da4w.pte`,
+ tokenizerPath: `${QWEN2_5_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${QWEN2_5_BASE_URL}/tokenizer_config.json`,
+};
+const QWEN2_5_1_5B_XNNPACK_BF16: LLMModel = {
+ modelPath: `${QWEN2_5_BASE_URL}/1_5b/xnnpack/qwen_2_5_1_5b_xnnpack_bf16.pte`,
+ tokenizerPath: `${QWEN2_5_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${QWEN2_5_BASE_URL}/tokenizer_config.json`,
+};
+const QWEN2_5_3B_XNNPACK_8DA4W: LLMModel = {
+ modelPath: `${QWEN2_5_BASE_URL}/3b/xnnpack/qwen_2_5_3b_xnnpack_8da4w.pte`,
+ tokenizerPath: `${QWEN2_5_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${QWEN2_5_BASE_URL}/tokenizer_config.json`,
+};
+const QWEN2_5_3B_XNNPACK_BF16: LLMModel = {
+ modelPath: `${QWEN2_5_BASE_URL}/3b/xnnpack/qwen_2_5_3b_xnnpack_bf16.pte`,
+ tokenizerPath: `${QWEN2_5_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${QWEN2_5_BASE_URL}/tokenizer_config.json`,
+};
+
+const GEMMA4_BASE_URL = `${BASE_URL}-gemma-4/${VERSION_TAG}`;
+
+const GEMMA4_E2B_XNNPACK_8DA4W: LLMModel = {
+ modelPath: `${GEMMA4_BASE_URL}/e2b/xnnpack/gemma_4_e2b_xnnpack_8da4w.pte`,
+ tokenizerPath: `${GEMMA4_BASE_URL}/e2b/tokenizer.json`,
+ tokenizerConfigPath: `${GEMMA4_BASE_URL}/e2b/tokenizer_config.json`,
+};
+const GEMMA4_E2B_MLX_INT4: LLMModel = {
+ modelPath: `${GEMMA4_BASE_URL}/e2b/mlx/gemma4_e2b_mlx_int4.pte`,
+ tokenizerPath: `${GEMMA4_BASE_URL}/e2b/tokenizer.json`,
+ tokenizerConfigPath: `${GEMMA4_BASE_URL}/e2b/tokenizer_config.json`,
+};
+
+const QWEN3_BASE_URL = `${BASE_URL}-qwen-3/${VERSION_TAG}`;
+
+const QWEN3_0_6B_XNNPACK_8DA4W: LLMModel = {
+ modelPath: `${QWEN3_BASE_URL}/0_6b/xnnpack/qwen_3_0_6b_xnnpack_8da4w.pte`,
+ tokenizerPath: `${QWEN3_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${QWEN3_BASE_URL}/tokenizer_config.json`,
+};
+const QWEN3_0_6B_XNNPACK_BF16: LLMModel = {
+ modelPath: `${QWEN3_BASE_URL}/0_6b/xnnpack/qwen_3_0_6b_xnnpack_bf16.pte`,
+ tokenizerPath: `${QWEN3_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${QWEN3_BASE_URL}/tokenizer_config.json`,
+};
+const QWEN3_1_7B_XNNPACK_8DA4W: LLMModel = {
+ modelPath: `${QWEN3_BASE_URL}/1_7b/xnnpack/qwen_3_1_7b_xnnpack_8da4w.pte`,
+ tokenizerPath: `${QWEN3_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${QWEN3_BASE_URL}/tokenizer_config.json`,
+};
+const QWEN3_1_7B_XNNPACK_BF16: LLMModel = {
+ modelPath: `${QWEN3_BASE_URL}/1_7b/xnnpack/qwen_3_1_7b_xnnpack_bf16.pte`,
+ tokenizerPath: `${QWEN3_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${QWEN3_BASE_URL}/tokenizer_config.json`,
+};
+const QWEN3_4B_XNNPACK_8DA4W: LLMModel = {
+ modelPath: `${QWEN3_BASE_URL}/4b/xnnpack/qwen_3_4b_xnnpack_8da4w.pte`,
+ tokenizerPath: `${QWEN3_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${QWEN3_BASE_URL}/tokenizer_config.json`,
+};
+const QWEN3_4B_XNNPACK_BF16: LLMModel = {
+ modelPath: `${QWEN3_BASE_URL}/4b/xnnpack/qwen_3_4b_xnnpack_bf16.pte`,
+ tokenizerPath: `${QWEN3_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${QWEN3_BASE_URL}/tokenizer_config.json`,
+};
+
/**
* Registry of pre-configured ExecuTorch models.
*
@@ -1281,6 +1545,203 @@ export const models = {
ALL_MINILM_L6_V2: ALL_MINILM_L6_V2_TOKENIZER,
},
+ /**
+ * Generative Large Language Models (LLMs) for instruction following,
+ * chat, text generation, and reasoning.
+ */
+ llm: {
+ /**
+ * Liquid AI LFM 2.5 1.2B general-purpose text model. Excellent for complex
+ * on-device reasoning, instruction following, and fast multi-turn chat.
+ */
+ LFM2_5_1_2B: {
+ ...LFM2_5_1_2B_XNNPACK_8DA4W,
+ XNNPACK_8DA4W: LFM2_5_1_2B_XNNPACK_8DA4W,
+ XNNPACK_FP16: LFM2_5_1_2B_XNNPACK_FP16,
+ MLX_INT4: LFM2_5_1_2B_MLX_INT4,
+ },
+ /**
+ * Liquid AI LFM 2.5 350M ultra-compact text model. Best for low-latency text
+ * completion, quick responses, and resource-constrained devices.
+ */
+ LFM2_5_350M: {
+ ...LFM2_5_350M_XNNPACK_8DA4W,
+ XNNPACK_8DA4W: LFM2_5_350M_XNNPACK_8DA4W,
+ XNNPACK_FP16: LFM2_5_350M_XNNPACK_FP16,
+ MLX_INT4: LFM2_5_350M_MLX_INT4,
+ },
+ /**
+ * Liquid AI LFM 2.5 450M vision-language model. Optimized for real-time
+ * visual QA, image description, and low-latency multimodal chat.
+ */
+ LFM2_5_VL_450M: {
+ ...LFM2_5_VL_450M_XNNPACK_8DA4W,
+ XNNPACK_8DA4W: LFM2_5_VL_450M_XNNPACK_8DA4W,
+ MLX_INT4: LFM2_5_VL_450M_MLX_INT4,
+ },
+ /**
+ * Liquid AI LFM 2.5 1.6B vision-language model. Higher quality visual
+ * understanding, detailed image analysis, and complex multimodal tasks.
+ */
+ LFM2_5_VL_1_6B: {
+ ...LFM2_5_VL_1_6B_XNNPACK_8DA4W,
+ XNNPACK_8DA4W: LFM2_5_VL_1_6B_XNNPACK_8DA4W,
+ },
+ /**
+ * Bielik v3 1.5B Polish & English language model. Fine-tuned specifically for
+ * native Polish fluency, grammar, and bilingual translation.
+ */
+ BIELIK_V3_1_5B: {
+ ...BIELIK_V3_1_5B_XNNPACK_8DA4W,
+ XNNPACK_8DA4W: BIELIK_V3_1_5B_XNNPACK_8DA4W,
+ XNNPACK_FP16: BIELIK_V3_1_5B_XNNPACK_FP16,
+ },
+ /**
+ * Meta Llama 3.2 1B multilingual text model. Ideal for lightweight mobile
+ * chat, summary generation, and multilingual prompt processing.
+ */
+ LLAMA3_2_1B: {
+ ...LLAMA3_2_1B_SPINQUANT,
+ XNNPACK_SPINQUANT: LLAMA3_2_1B_SPINQUANT,
+ XNNPACK_BF16: LLAMA3_2_1B_BF16,
+ },
+ /**
+ * Meta Llama 3.2 3B multilingual text model. Strong instruction following,
+ * detailed content creation, and high-precision text reasoning.
+ */
+ LLAMA3_2_3B: {
+ ...LLAMA3_2_3B_SPINQUANT,
+ XNNPACK_SPINQUANT: LLAMA3_2_3B_SPINQUANT,
+ XNNPACK_BF16: LLAMA3_2_3B_BF16,
+ },
+ /**
+ * Hugging Face SmolLM2 135M sub-parameter model. Best for micro-footprint
+ * background tasks, simple text tagging, and instant autocomplete.
+ */
+ SMOLLM2_135M: {
+ ...SMOLLM2_135M_8DA4W,
+ XNNPACK_8DA4W: SMOLLM2_135M_8DA4W,
+ XNNPACK_BF16: SMOLLM2_135M_BF16,
+ },
+ /**
+ * Hugging Face SmolLM2 360M compact model. Balanced speed and intelligence
+ * for lightweight conversational assistants.
+ */
+ SMOLLM2_360M: {
+ ...SMOLLM2_360M_8DA4W,
+ XNNPACK_8DA4W: SMOLLM2_360M_8DA4W,
+ XNNPACK_BF16: SMOLLM2_360M_BF16,
+ },
+ /**
+ * Hugging Face SmolLM2 1.7B language model. Powerful general-purpose text
+ * generation, creative writing, and general knowledge Q&A.
+ */
+ SMOLLM2_1_7B: {
+ ...SMOLLM2_1_7B_8DA4W,
+ XNNPACK_8DA4W: SMOLLM2_1_7B_8DA4W,
+ XNNPACK_BF16: SMOLLM2_1_7B_BF16,
+ },
+ /**
+ * Hammer 2.1 0.5B function-calling model. Specialized for lightweight agentic
+ * tool calling, JSON extraction, and structured output parsing.
+ */
+ HAMMER2_1_0_5B: {
+ ...HAMMER2_1_0_5B_XNNPACK_8DA4W,
+ XNNPACK_8DA4W: HAMMER2_1_0_5B_XNNPACK_8DA4W,
+ XNNPACK_BF16: HAMMER2_1_0_5B_XNNPACK_BF16,
+ },
+ /**
+ * Hammer 2.1 1.5B function-calling model. Optimized for multi-tool agentic
+ * workflows, API function calling, and structured JSON schemas.
+ */
+ HAMMER2_1_1_5B: {
+ ...HAMMER2_1_1_5B_XNNPACK_8DA4W,
+ XNNPACK_8DA4W: HAMMER2_1_1_5B_XNNPACK_8DA4W,
+ XNNPACK_BF16: HAMMER2_1_1_5B_XNNPACK_BF16,
+ },
+ /**
+ * Hammer 2.1 3B function-calling model. High-capacity agentic reasoning,
+ * complex multi-step tool execution, and robust schema compliance.
+ */
+ HAMMER2_1_3B: {
+ ...HAMMER2_1_3B_XNNPACK_8DA4W,
+ XNNPACK_8DA4W: HAMMER2_1_3B_XNNPACK_8DA4W,
+ XNNPACK_BF16: HAMMER2_1_3B_XNNPACK_BF16,
+ },
+ /**
+ * Microsoft Phi-4 Mini 3.8B reasoning model. Exceptional for math problem
+ * solving, logical reasoning, code synthesis, and analytical tasks.
+ */
+ PHI4_MINI: {
+ ...PHI4_MINI_XNNPACK_8DA4W,
+ XNNPACK_8DA4W: PHI4_MINI_XNNPACK_8DA4W,
+ XNNPACK_BF16: PHI4_MINI_XNNPACK_BF16,
+ },
+ /**
+ * Alibaba Qwen 2.5 0.5B multilingual model. Extremely efficient for fast
+ * multi-language translation and basic conversational chat.
+ */
+ QWEN2_5_0_5B: {
+ ...QWEN2_5_0_5B_XNNPACK_8DA4W,
+ XNNPACK_8DA4W: QWEN2_5_0_5B_XNNPACK_8DA4W,
+ XNNPACK_BF16: QWEN2_5_0_5B_XNNPACK_BF16,
+ },
+ /**
+ * Alibaba Qwen 2.5 1.5B multilingual model. Great for balanced multilingual
+ * chat, text summarization, and cross-lingual understanding.
+ */
+ QWEN2_5_1_5B: {
+ ...QWEN2_5_1_5B_XNNPACK_8DA4W,
+ XNNPACK_8DA4W: QWEN2_5_1_5B_XNNPACK_8DA4W,
+ XNNPACK_BF16: QWEN2_5_1_5B_XNNPACK_BF16,
+ },
+ /**
+ * Alibaba Qwen 2.5 3B multilingual model. High capability across 29+
+ * languages for complex translation, long-form writing, and Q&A.
+ */
+ QWEN2_5_3B: {
+ ...QWEN2_5_3B_XNNPACK_8DA4W,
+ XNNPACK_8DA4W: QWEN2_5_3B_XNNPACK_8DA4W,
+ XNNPACK_BF16: QWEN2_5_3B_XNNPACK_BF16,
+ },
+ /**
+ * Alibaba Qwen 3 0.6B next-gen text model. Low-latency multilingual model
+ * for fast turn-taking and concise response generation.
+ */
+ QWEN3_0_6B: {
+ ...QWEN3_0_6B_XNNPACK_8DA4W,
+ XNNPACK_8DA4W: QWEN3_0_6B_XNNPACK_8DA4W,
+ XNNPACK_BF16: QWEN3_0_6B_XNNPACK_BF16,
+ },
+ /**
+ * Alibaba Qwen 3 1.7B next-gen text model. Versatile multilingual assistant
+ * for high-quality instruction following and knowledge retrieval.
+ */
+ QWEN3_1_7B: {
+ ...QWEN3_1_7B_XNNPACK_8DA4W,
+ XNNPACK_8DA4W: QWEN3_1_7B_XNNPACK_8DA4W,
+ XNNPACK_BF16: QWEN3_1_7B_XNNPACK_BF16,
+ },
+ /**
+ * Alibaba Qwen 3 4B high-capacity text model. Top-tier multilingual
+ * reasoning, technical content generation, and multi-turn dialogue.
+ */
+ QWEN3_4B: {
+ ...QWEN3_4B_XNNPACK_8DA4W,
+ XNNPACK_8DA4W: QWEN3_4B_XNNPACK_8DA4W,
+ XNNPACK_BF16: QWEN3_4B_XNNPACK_BF16,
+ },
+ /**
+ * Google Gemma 4 E2B generative text model. Built on Google's Gemini tech
+ * for high-fidelity instruction following and mobile assistance.
+ */
+ GEMMA4_E2B: {
+ ...GEMMA4_E2B_XNNPACK_8DA4W,
+ XNNPACK_8DA4W: GEMMA4_E2B_XNNPACK_8DA4W,
+ MLX_INT4: GEMMA4_E2B_MLX_INT4,
+ },
+ },
+
/**
* Text embedding models mapping sentences and documents into dense vector
* representations for semantic search and RAG.
diff --git a/yarn.lock b/yarn.lock
index 875b8db572..e45de3eec1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3130,6 +3130,13 @@ __metadata:
languageName: node
linkType: hard
+"@huggingface/jinja@npm:^0.5.9":
+ version: 0.5.9
+ resolution: "@huggingface/jinja@npm:0.5.9"
+ checksum: 10/8147f05df29b609ebb923f9e294f485897c5b5b92cddb1e41d8469b2992def242d9b536b8e9ca450a24a88fae0df6764986ab1248033f34edd4f5eb9dc2c4d78
+ languageName: node
+ linkType: hard
+
"@humanwhocodes/config-array@npm:^0.13.0":
version: 0.13.0
resolution: "@humanwhocodes/config-array@npm:0.13.0"
@@ -11412,11 +11419,14 @@ __metadata:
"@react-native/metro-config": "npm:^0.86.0"
"@react-navigation/drawer": "npm:^7.9.4"
"@react-navigation/native": "npm:^7.2.2"
+ "@shopify/react-native-skia": "npm:2.6.2"
"@types/react": "npm:~19.2.0"
babel-preset-expo: "npm:~56.0.14"
expo: "npm:~56.0.9"
expo-build-properties: "npm:~56.0.17"
expo-constants: "npm:~56.0.17"
+ expo-image-manipulator: "npm:~56.0.18"
+ expo-image-picker: "npm:~56.0.18"
expo-linking: "npm:~56.0.13"
expo-router: "npm:~56.2.9"
react: "npm:19.2.3"
@@ -12398,6 +12408,7 @@ __metadata:
resolution: "react-native-executorch@workspace:packages/react-native-executorch"
dependencies:
"@babel/core": "npm:^7.25.1"
+ "@huggingface/jinja": "npm:^0.5.9"
"@react-native/babel-preset": "npm:0.83.6"
"@react-native/metro-config": "npm:^0.86.0"
"@types/react": "npm:^19.1.12"