expo-local-llm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -0
  3. package/android/build.gradle +36 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/java/expo/modules/localllm/ConversationHistory.kt +39 -0
  6. package/android/src/main/java/expo/modules/localllm/Enums.kt +13 -0
  7. package/android/src/main/java/expo/modules/localllm/Exceptions.kt +12 -0
  8. package/android/src/main/java/expo/modules/localllm/ExpoLocalLlmModule.kt +57 -0
  9. package/android/src/main/java/expo/modules/localllm/LLMSession.kt +119 -0
  10. package/android/src/main/java/expo/modules/localllm/Records.kt +23 -0
  11. package/build/ExpoLocalLlm.types.d.ts +35 -0
  12. package/build/ExpoLocalLlm.types.d.ts.map +1 -0
  13. package/build/ExpoLocalLlm.types.js +2 -0
  14. package/build/ExpoLocalLlm.types.js.map +1 -0
  15. package/build/ExpoLocalLlmModule.d.ts +10 -0
  16. package/build/ExpoLocalLlmModule.d.ts.map +1 -0
  17. package/build/ExpoLocalLlmModule.js +10 -0
  18. package/build/ExpoLocalLlmModule.js.map +1 -0
  19. package/build/LLMSession.d.ts +9 -0
  20. package/build/LLMSession.d.ts.map +1 -0
  21. package/build/LLMSession.js +16 -0
  22. package/build/LLMSession.js.map +1 -0
  23. package/build/index.d.ts +5 -0
  24. package/build/index.d.ts.map +1 -0
  25. package/build/index.js +5 -0
  26. package/build/index.js.map +1 -0
  27. package/build/useLocalLLM.d.ts +18 -0
  28. package/build/useLocalLLM.d.ts.map +1 -0
  29. package/build/useLocalLLM.js +121 -0
  30. package/build/useLocalLLM.js.map +1 -0
  31. package/expo-module.config.json +9 -0
  32. package/ios/Enums.swift +11 -0
  33. package/ios/Exceptions.swift +19 -0
  34. package/ios/ExpoLocalLlm.podspec +26 -0
  35. package/ios/ExpoLocalLlmModule.swift +56 -0
  36. package/ios/FoundationModelBridge.swift +59 -0
  37. package/ios/LLMSession.swift +68 -0
  38. package/ios/Records.swift +12 -0
  39. package/package.json +61 -0
  40. package/src/ExpoLocalLlm.types.ts +47 -0
  41. package/src/ExpoLocalLlmModule.ts +19 -0
  42. package/src/LLMSession.ts +19 -0
  43. package/src/__tests__/ExpoLocalLlm.test.ts +82 -0
  44. package/src/__tests__/useLocalLLM.test.ts +30 -0
  45. package/src/index.ts +4 -0
  46. package/src/useLocalLLM.ts +176 -0
@@ -0,0 +1,121 @@
1
+ import { useEffect, useState, useCallback, useMemo } from "react";
2
+ import ExpoLocalLlmModule from "./ExpoLocalLlmModule";
3
+ import { createLLMSession } from "./LLMSession";
4
+ export function useLocalLLM(options = {}) {
5
+ const [availability, setAvailability] = useState("unknown");
6
+ const [isGenerating, setIsGenerating] = useState(false);
7
+ const [streamedText, setStreamedText] = useState("");
8
+ const [downloadProgress, setDownloadProgress] = useState(null);
9
+ const [error, setError] = useState(null);
10
+ // Stabilize options to avoid unnecessary session recreation
11
+ const stableConfig = useMemo(() => options, [
12
+ options.instructions,
13
+ options.options?.temperature,
14
+ options.options?.maxTokens,
15
+ options.options?.topK,
16
+ ]);
17
+ const session = useMemo(() => {
18
+ if (!ExpoLocalLlmModule)
19
+ return null;
20
+ try {
21
+ return createLLMSession(stableConfig);
22
+ }
23
+ catch (e) {
24
+ return null;
25
+ }
26
+ }, [stableConfig]);
27
+ useEffect(() => {
28
+ if (!ExpoLocalLlmModule)
29
+ return;
30
+ setAvailability(ExpoLocalLlmModule.getAvailability());
31
+ const subs = [];
32
+ subs.push(ExpoLocalLlmModule.addListener("downloadProgress", (event) => {
33
+ setDownloadProgress(event.progress);
34
+ }));
35
+ subs.push(ExpoLocalLlmModule.addListener("availabilityChange", (event) => {
36
+ setAvailability(event.availability);
37
+ }));
38
+ if (session) {
39
+ subs.push(session.addListener("token", (event) => {
40
+ setStreamedText(event.accumulated);
41
+ }));
42
+ subs.push(session.addListener("streamComplete", (_event) => {
43
+ setIsGenerating(false);
44
+ }));
45
+ subs.push(session.addListener("streamError", (event) => {
46
+ setError(event.error);
47
+ setIsGenerating(false);
48
+ }));
49
+ }
50
+ return () => {
51
+ subs.forEach((s) => s.remove());
52
+ session?.release();
53
+ };
54
+ }, [session]);
55
+ const respond = useCallback(async (prompt) => {
56
+ if (!session)
57
+ throw new Error("Session not available");
58
+ setError(null);
59
+ setIsGenerating(true);
60
+ try {
61
+ const result = await session.respond(prompt);
62
+ return result;
63
+ }
64
+ catch (e) {
65
+ setError(e.message);
66
+ throw e;
67
+ }
68
+ finally {
69
+ setIsGenerating(false);
70
+ }
71
+ }, [session]);
72
+ const streamResponse = useCallback(async (prompt) => {
73
+ if (!session)
74
+ throw new Error("Session not available");
75
+ setError(null);
76
+ setStreamedText("");
77
+ setIsGenerating(true);
78
+ try {
79
+ await session.streamResponse(prompt);
80
+ }
81
+ catch (e) {
82
+ setError(e.message);
83
+ setIsGenerating(false);
84
+ throw e;
85
+ }
86
+ }, [session]);
87
+ const cancelStream = useCallback(async () => {
88
+ if (!session)
89
+ return;
90
+ await session.cancelStream();
91
+ setIsGenerating(false);
92
+ }, [session]);
93
+ const downloadModel = useCallback(async () => {
94
+ if (!ExpoLocalLlmModule)
95
+ return;
96
+ await ExpoLocalLlmModule.downloadModel();
97
+ }, []);
98
+ if (!ExpoLocalLlmModule) {
99
+ return {
100
+ availability: "notEligible",
101
+ session: null,
102
+ isGenerating: false,
103
+ streamedText: "",
104
+ downloadProgress: null,
105
+ error: null,
106
+ };
107
+ }
108
+ return {
109
+ availability,
110
+ session,
111
+ isGenerating,
112
+ streamedText,
113
+ downloadProgress,
114
+ error,
115
+ respond: session && availability === "available" ? respond : undefined,
116
+ streamResponse: session && availability === "available" ? streamResponse : undefined,
117
+ cancelStream: session && availability === "available" ? cancelStream : undefined,
118
+ downloadModel,
119
+ };
120
+ }
121
+ //# sourceMappingURL=useLocalLLM.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useLocalLLM.js","sourceRoot":"","sources":["../src/useLocalLLM.ts"],"names":[],"mappings":"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","sourcesContent":["import { useEffect, useState, useCallback, useMemo } from \"react\";\n\nimport type {\n ModelAvailability,\n SessionConfig,\n TokenEvent,\n StreamCompleteEvent,\n StreamErrorEvent,\n DownloadProgress,\n} from \"./ExpoLocalLlm.types\";\nimport ExpoLocalLlmModule from \"./ExpoLocalLlmModule\";\nimport { createLLMSession, LLMSession } from \"./LLMSession\";\n\ntype UseLocalLLMOptions = SessionConfig;\n\ntype UseLocalLLMResult = {\n availability: ModelAvailability;\n session: LLMSession | null;\n isGenerating: boolean;\n streamedText: string;\n downloadProgress: number | null;\n error: string | null;\n respond?: (prompt: string) => Promise<string>;\n streamResponse?: (prompt: string) => Promise<void>;\n cancelStream?: () => Promise<void>;\n downloadModel?: () => Promise<void>;\n};\n\nexport function useLocalLLM(\n options: UseLocalLLMOptions = {}\n): UseLocalLLMResult {\n const [availability, setAvailability] =\n useState<ModelAvailability>(\"unknown\");\n const [isGenerating, setIsGenerating] = useState(false);\n const [streamedText, setStreamedText] = useState(\"\");\n const [downloadProgress, setDownloadProgress] = useState<number | null>(null);\n const [error, setError] = useState<string | null>(null);\n\n // Stabilize options to avoid unnecessary session recreation\n const stableConfig = useMemo(\n () => options,\n [\n options.instructions,\n options.options?.temperature,\n options.options?.maxTokens,\n options.options?.topK,\n ]\n );\n\n const session = useMemo(() => {\n if (!ExpoLocalLlmModule) return null;\n try {\n return createLLMSession(stableConfig);\n } catch (e: any) {\n return null;\n }\n }, [stableConfig]);\n\n useEffect(() => {\n if (!ExpoLocalLlmModule) return;\n\n setAvailability(ExpoLocalLlmModule.getAvailability() as ModelAvailability);\n\n const subs: { remove(): void }[] = [];\n\n subs.push(\n ExpoLocalLlmModule.addListener(\n \"downloadProgress\",\n (event: DownloadProgress) => {\n setDownloadProgress(event.progress);\n }\n )\n );\n subs.push(\n ExpoLocalLlmModule.addListener(\"availabilityChange\", (event) => {\n setAvailability(event.availability);\n })\n );\n\n if (session) {\n subs.push(\n session.addListener(\"token\", (event: TokenEvent) => {\n setStreamedText(event.accumulated);\n })\n );\n subs.push(\n session.addListener(\"streamComplete\", (_event: StreamCompleteEvent) => {\n setIsGenerating(false);\n })\n );\n subs.push(\n session.addListener(\"streamError\", (event: StreamErrorEvent) => {\n setError(event.error);\n setIsGenerating(false);\n })\n );\n }\n\n return () => {\n subs.forEach((s) => s.remove());\n session?.release();\n };\n }, [session]);\n\n const respond = useCallback(\n async (prompt: string): Promise<string> => {\n if (!session) throw new Error(\"Session not available\");\n setError(null);\n setIsGenerating(true);\n try {\n const result = await session.respond(prompt);\n return result;\n } catch (e: any) {\n setError(e.message);\n throw e;\n } finally {\n setIsGenerating(false);\n }\n },\n [session]\n );\n\n const streamResponse = useCallback(\n async (prompt: string): Promise<void> => {\n if (!session) throw new Error(\"Session not available\");\n setError(null);\n setStreamedText(\"\");\n setIsGenerating(true);\n try {\n await session.streamResponse(prompt);\n } catch (e: any) {\n setError(e.message);\n setIsGenerating(false);\n throw e;\n }\n },\n [session]\n );\n\n const cancelStream = useCallback(async (): Promise<void> => {\n if (!session) return;\n await session.cancelStream();\n setIsGenerating(false);\n }, [session]);\n\n const downloadModel = useCallback(async (): Promise<void> => {\n if (!ExpoLocalLlmModule) return;\n await ExpoLocalLlmModule.downloadModel();\n }, []);\n\n if (!ExpoLocalLlmModule) {\n return {\n availability: \"notEligible\",\n session: null,\n isGenerating: false,\n streamedText: \"\",\n downloadProgress: null,\n error: null,\n };\n }\n\n return {\n availability,\n session,\n isGenerating,\n streamedText,\n downloadProgress,\n error,\n respond: session && availability === \"available\" ? respond : undefined,\n streamResponse:\n session && availability === \"available\" ? streamResponse : undefined,\n cancelStream:\n session && availability === \"available\" ? cancelStream : undefined,\n downloadModel,\n };\n}\n"]}
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["apple", "android"],
3
+ "apple": {
4
+ "modules": ["ExpoLocalLlmModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["expo.modules.localllm.ExpoLocalLlmModule"]
8
+ }
9
+ }
@@ -0,0 +1,11 @@
1
+ import ExpoModulesCore
2
+
3
+ enum ModelAvailability: String, Enumerable {
4
+ case available
5
+ case notEnabled
6
+ case notReady
7
+ case notEligible
8
+ case downloadRequired
9
+ case downloading
10
+ case unknown
11
+ }
@@ -0,0 +1,19 @@
1
+ import ExpoModulesCore
2
+
3
+ class NotSupportedException: Exception {
4
+ override var reason: String {
5
+ "Foundation Models requires iOS 26 or later"
6
+ }
7
+ }
8
+
9
+ class SessionInvalidException: Exception {
10
+ override var reason: String {
11
+ "LLM session is invalid or has been destroyed"
12
+ }
13
+ }
14
+
15
+ class StreamException: GenericException<String> {
16
+ override var reason: String {
17
+ "Stream error: \(param ?? "Unknown")"
18
+ }
19
+ }
@@ -0,0 +1,26 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'ExpoLocalLlm'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = package['license']
11
+ s.author = package['author']
12
+ s.homepage = package['homepage']
13
+ s.platforms = { :ios => '16.0' }
14
+ s.swift_version = '5.9'
15
+ s.source = { git: '' }
16
+ s.static_framework = true
17
+
18
+ s.dependency 'ExpoModulesCore'
19
+ s.weak_frameworks = 'FoundationModels'
20
+
21
+ s.pod_target_xcconfig = {
22
+ 'DEFINES_MODULE' => 'YES',
23
+ }
24
+
25
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
26
+ end
@@ -0,0 +1,56 @@
1
+ import ExpoModulesCore
2
+
3
+ public class ExpoLocalLlmModule: Module {
4
+ public func definition() -> ModuleDefinition {
5
+ Name("ExpoLocalLlm")
6
+
7
+ Events("downloadProgress", "availabilityChange")
8
+
9
+ Function("getAvailability") { () -> String in
10
+ if #available(iOS 26, *) {
11
+ return LLMSession.checkAvailability().rawValue
12
+ }
13
+ return ModelAvailability.notEligible.rawValue
14
+ }
15
+
16
+ AsyncFunction("downloadModel") {
17
+ // No-op on iOS — model is built into the OS
18
+ }
19
+
20
+ Class(LLMSession.self) {
21
+ Constructor { (config: SessionConfig) -> LLMSession in
22
+ let session = LLMSession()
23
+ try session.setup(config: config)
24
+ return session
25
+ }
26
+
27
+ AsyncFunction("respond") { (session: LLMSession, prompt: String) -> String in
28
+ return try await session.respond(to: prompt)
29
+ }
30
+
31
+ AsyncFunction("streamResponse") { (session: LLMSession, prompt: String) in
32
+ try session.startStream(prompt: prompt)
33
+ }
34
+
35
+ AsyncFunction("cancelStream") { (session: LLMSession) in
36
+ session.cancelStream()
37
+ }
38
+
39
+ Events("token", "streamComplete", "streamError")
40
+ }
41
+
42
+ OnAppEntersForeground {
43
+ // Re-check availability when user returns (may have toggled Apple Intelligence in Settings)
44
+ if #available(iOS 26, *) {
45
+ let current = LLMSession.checkAvailability().rawValue
46
+ self.sendEvent("availabilityChange", [
47
+ "availability": current
48
+ ])
49
+ }
50
+ }
51
+
52
+ OnAppEntersBackground {
53
+ // Cancel any active streams when app backgrounds
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,59 @@
1
+ import FoundationModels
2
+
3
+ @available(iOS 26, *)
4
+ enum FoundationModelBridge {
5
+ static func checkAvailability() -> ModelAvailability {
6
+ let availability = SystemLanguageModel.default.availability
7
+ switch availability {
8
+ case .available:
9
+ return .available
10
+ case .notAvailable(let reason):
11
+ switch reason {
12
+ case .deviceNotEligible:
13
+ return .notEligible
14
+ case .modelNotReady:
15
+ return .notReady
16
+ case .appleIntelligenceNotEnabled:
17
+ return .notEnabled
18
+ @unknown default:
19
+ return .unknown
20
+ }
21
+ @unknown default:
22
+ return .unknown
23
+ }
24
+ }
25
+
26
+ static func createSession(instructions: String?) -> LanguageModelSession {
27
+ if let instructions {
28
+ return LanguageModelSession(instructions: instructions)
29
+ }
30
+ return LanguageModelSession()
31
+ }
32
+
33
+ static func respond(session: Any, prompt: String) async throws -> String {
34
+ guard let session = session as? LanguageModelSession else {
35
+ throw SessionInvalidException()
36
+ }
37
+ let response = try await session.respond(to: prompt)
38
+ return response.content
39
+ }
40
+
41
+ static func stream(session: Any, prompt: String) -> AsyncThrowingStream<String, Error> {
42
+ guard let session = session as? LanguageModelSession else {
43
+ return AsyncThrowingStream { $0.finish(throwing: SessionInvalidException()) }
44
+ }
45
+ return AsyncThrowingStream { continuation in
46
+ Task {
47
+ do {
48
+ let stream = session.streamResponse(to: prompt)
49
+ for try await partial in stream {
50
+ continuation.yield(partial.content)
51
+ }
52
+ continuation.finish()
53
+ } catch {
54
+ continuation.finish(throwing: error)
55
+ }
56
+ }
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,68 @@
1
+ import ExpoModulesCore
2
+ import Foundation
3
+
4
+ class LLMSession: SharedObject {
5
+ private var nativeSession: Any?
6
+ private var streamTask: Task<Void, Never>?
7
+
8
+ static func checkAvailability() -> ModelAvailability {
9
+ guard #available(iOS 26, *) else {
10
+ return .notEligible
11
+ }
12
+ return FoundationModelBridge.checkAvailability()
13
+ }
14
+
15
+ func setup(config: SessionConfig) throws {
16
+ guard #available(iOS 26, *) else {
17
+ throw NotSupportedException()
18
+ }
19
+ nativeSession = FoundationModelBridge.createSession(instructions: config.instructions)
20
+ }
21
+
22
+ func respond(to prompt: String) async throws -> String {
23
+ guard #available(iOS 26, *), let session = nativeSession else {
24
+ throw NotSupportedException()
25
+ }
26
+ return try await FoundationModelBridge.respond(session: session, prompt: prompt)
27
+ }
28
+
29
+ func startStream(prompt: String) throws {
30
+ guard #available(iOS 26, *), let session = nativeSession else {
31
+ throw NotSupportedException()
32
+ }
33
+
34
+ streamTask?.cancel()
35
+
36
+ let weakSelf = self
37
+ streamTask = Task {
38
+ var accumulated = ""
39
+ do {
40
+ let stream = FoundationModelBridge.stream(session: session, prompt: prompt)
41
+ for try await token in stream {
42
+ accumulated += token
43
+ weakSelf.emit(event: "token", arguments: [
44
+ "token": token,
45
+ "accumulated": accumulated
46
+ ])
47
+ }
48
+ weakSelf.emit(event: "streamComplete", arguments: [
49
+ "text": accumulated
50
+ ])
51
+ } catch {
52
+ if Task.isCancelled { return }
53
+ weakSelf.emit(event: "streamError", arguments: [
54
+ "error": error.localizedDescription
55
+ ])
56
+ }
57
+ }
58
+ }
59
+
60
+ func cancelStream() {
61
+ streamTask?.cancel()
62
+ streamTask = nil
63
+ }
64
+
65
+ deinit {
66
+ streamTask?.cancel()
67
+ }
68
+ }
@@ -0,0 +1,12 @@
1
+ import ExpoModulesCore
2
+
3
+ struct SessionConfig: Record {
4
+ @Field var instructions: String?
5
+ @Field var options: GenerationOptions?
6
+ }
7
+
8
+ struct GenerationOptions: Record {
9
+ @Field var temperature: Double?
10
+ @Field var maxTokens: Int?
11
+ @Field var topK: Int?
12
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "expo-local-llm",
3
+ "version": "0.1.0",
4
+ "description": "Expo module for on-device LLM inference via Apple Foundation Models (iOS) and Gemini Nano (Android)",
5
+ "main": "build/index.js",
6
+ "types": "build/index.d.ts",
7
+ "files": [
8
+ "build",
9
+ "src",
10
+ "ios",
11
+ "android",
12
+ "expo-module.config.json"
13
+ ],
14
+ "scripts": {
15
+ "build": "expo-module build",
16
+ "clean": "expo-module clean",
17
+ "lint": "expo-module lint",
18
+ "test": "expo-module test",
19
+ "prepare": "expo-module prepare",
20
+ "prepublishOnly": "expo-module prepublishOnly",
21
+ "expo-module": "expo-module",
22
+ "open:ios": "xed example/ios",
23
+ "open:android": "open -a \"Android Studio\" example/android"
24
+ },
25
+ "keywords": [
26
+ "react-native",
27
+ "expo",
28
+ "expo-local-llm",
29
+ "llm",
30
+ "on-device",
31
+ "apple-intelligence",
32
+ "gemini-nano",
33
+ "foundation-models"
34
+ ],
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/GijungKim/expo-local-llm"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/GijungKim/expo-local-llm/issues"
41
+ },
42
+ "author": "GijungKim <tonykim970@gmail.com>",
43
+ "license": "MIT",
44
+ "homepage": "https://github.com/GijungKim/expo-local-llm#readme",
45
+ "dependencies": {},
46
+ "devDependencies": {
47
+ "@types/react": "~19.1.1",
48
+ "expo-module-scripts": "^55.0.2",
49
+ "expo": "^55.0.8",
50
+ "react-native": "0.82.1"
51
+ },
52
+ "jest": {
53
+ "preset": "jest-expo/node"
54
+ },
55
+ "peerDependencies": {
56
+ "expo": ">=52.0.0",
57
+ "expo-modules-core": ">=2.0.0",
58
+ "react": ">=18.0.0",
59
+ "react-native": ">=0.76.0"
60
+ }
61
+ }
@@ -0,0 +1,47 @@
1
+ export type ModelAvailability =
2
+ | "available"
3
+ | "notEnabled"
4
+ | "notReady"
5
+ | "notEligible"
6
+ | "downloadRequired"
7
+ | "downloading"
8
+ | "unknown";
9
+
10
+ export type GenerationOptions = {
11
+ temperature?: number;
12
+ maxTokens?: number;
13
+ topK?: number;
14
+ };
15
+
16
+ export type SessionConfig = {
17
+ instructions?: string;
18
+ options?: GenerationOptions;
19
+ };
20
+
21
+ export type TokenEvent = {
22
+ token: string;
23
+ accumulated: string;
24
+ };
25
+
26
+ export type StreamCompleteEvent = {
27
+ text: string;
28
+ };
29
+
30
+ export type StreamErrorEvent = {
31
+ error: string;
32
+ };
33
+
34
+ export type DownloadProgress = {
35
+ progress: number;
36
+ };
37
+
38
+ export type LLMSessionEvents = {
39
+ token: (event: TokenEvent) => void;
40
+ streamComplete: (event: StreamCompleteEvent) => void;
41
+ streamError: (event: StreamErrorEvent) => void;
42
+ };
43
+
44
+ export type ExpoLocalLlmModuleEvents = {
45
+ downloadProgress: (event: DownloadProgress) => void;
46
+ availabilityChange: (event: { availability: ModelAvailability }) => void;
47
+ };
@@ -0,0 +1,19 @@
1
+ import { NativeModule, requireNativeModule } from "expo-modules-core";
2
+
3
+ import type { ExpoLocalLlmModuleEvents } from "./ExpoLocalLlm.types";
4
+
5
+ declare class ExpoLocalLlmModuleType extends NativeModule<ExpoLocalLlmModuleEvents> {
6
+ getAvailability(): string;
7
+ downloadModel(): Promise<void>;
8
+ LLMSession: any;
9
+ }
10
+
11
+ let nativeModule: ExpoLocalLlmModuleType | null;
12
+
13
+ try {
14
+ nativeModule = requireNativeModule<ExpoLocalLlmModuleType>("ExpoLocalLlm");
15
+ } catch {
16
+ nativeModule = null;
17
+ }
18
+
19
+ export default nativeModule;
@@ -0,0 +1,19 @@
1
+ import { SharedObject, UnavailabilityError } from "expo-modules-core";
2
+
3
+ import type { SessionConfig, LLMSessionEvents } from "./ExpoLocalLlm.types";
4
+ import ExpoLocalLlmModule from "./ExpoLocalLlmModule";
5
+
6
+ export class LLMSession extends SharedObject<LLMSessionEvents> {
7
+ // Methods are implemented natively via the Class() DSL.
8
+ // TypeScript declarations provide type safety only.
9
+ respond!: (prompt: string) => Promise<string>;
10
+ streamResponse!: (prompt: string) => Promise<void>;
11
+ cancelStream!: () => Promise<void>;
12
+ }
13
+
14
+ export function createLLMSession(config: SessionConfig = {}): LLMSession {
15
+ if (!ExpoLocalLlmModule) {
16
+ throw new UnavailabilityError("ExpoLocalLlm", "createLLMSession");
17
+ }
18
+ return new ExpoLocalLlmModule.LLMSession(config) as LLMSession;
19
+ }
@@ -0,0 +1,82 @@
1
+ jest.mock("expo-modules-core", () => ({
2
+ requireNativeModule: jest.fn(() => {
3
+ throw new Error("Native module not available");
4
+ }),
5
+ NativeModule: class {},
6
+ SharedObject: class {},
7
+ UnavailabilityError: class UnavailabilityError extends Error {
8
+ constructor(moduleName: string, functionName: string) {
9
+ super(`${moduleName}.${functionName} is not available`);
10
+ }
11
+ },
12
+ }));
13
+
14
+ import { LLMSession, createLLMSession, ExpoLocalLlmModule } from "../index";
15
+
16
+ describe("ExpoLocalLlm", () => {
17
+ describe("exports", () => {
18
+ it("exports LLMSession class", () => {
19
+ expect(LLMSession).toBeDefined();
20
+ expect(typeof LLMSession).toBe("function");
21
+ });
22
+
23
+ it("exports createLLMSession function", () => {
24
+ expect(createLLMSession).toBeDefined();
25
+ expect(typeof createLLMSession).toBe("function");
26
+ });
27
+
28
+ it("exports ExpoLocalLlmModule as null when native module is unavailable", () => {
29
+ expect(ExpoLocalLlmModule).toBeNull();
30
+ });
31
+ });
32
+
33
+ describe("createLLMSession", () => {
34
+ it("throws when native module is not loaded", () => {
35
+ expect(() => createLLMSession()).toThrow();
36
+ });
37
+
38
+ it("throws with module name in message", () => {
39
+ expect(() => createLLMSession({ instructions: "test" })).toThrow(
40
+ /ExpoLocalLlm/
41
+ );
42
+ });
43
+ });
44
+
45
+ describe("ModelAvailability values", () => {
46
+ it("all values are valid strings", () => {
47
+ const values = [
48
+ "available",
49
+ "notEnabled",
50
+ "notReady",
51
+ "notEligible",
52
+ "downloadRequired",
53
+ "downloading",
54
+ "unknown",
55
+ ];
56
+ expect(values).toHaveLength(7);
57
+ values.forEach((v) => expect(typeof v).toBe("string"));
58
+ });
59
+ });
60
+
61
+ describe("SessionConfig", () => {
62
+ it("accepts config with all fields", () => {
63
+ const config = {
64
+ instructions: "You are a health assistant",
65
+ options: {
66
+ temperature: 0.7,
67
+ maxTokens: 256,
68
+ topK: 40,
69
+ },
70
+ };
71
+ expect(config.instructions).toBe("You are a health assistant");
72
+ expect(config.options.temperature).toBe(0.7);
73
+ expect(config.options.maxTokens).toBe(256);
74
+ expect(config.options.topK).toBe(40);
75
+ });
76
+
77
+ it("accepts empty config", () => {
78
+ const config = {};
79
+ expect(config).toEqual({});
80
+ });
81
+ });
82
+ });
@@ -0,0 +1,30 @@
1
+ jest.mock("expo-modules-core", () => ({
2
+ requireNativeModule: jest.fn(() => {
3
+ throw new Error("Native module not available");
4
+ }),
5
+ NativeModule: class {},
6
+ SharedObject: class {},
7
+ UnavailabilityError: class UnavailabilityError extends Error {
8
+ constructor(moduleName: string, functionName: string) {
9
+ super(`${moduleName}.${functionName} is not available`);
10
+ }
11
+ },
12
+ }));
13
+
14
+ jest.mock("react", () => ({
15
+ ...jest.requireActual("react"),
16
+ }));
17
+
18
+ import ExpoLocalLlmModule from "../ExpoLocalLlmModule";
19
+
20
+ describe("useLocalLLM prerequisites", () => {
21
+ it("native module is null in test environment", () => {
22
+ expect(ExpoLocalLlmModule).toBeNull();
23
+ });
24
+
25
+ it("useLocalLLM can be imported", () => {
26
+ const mod = require("../useLocalLLM");
27
+ expect(mod.useLocalLLM).toBeDefined();
28
+ expect(typeof mod.useLocalLLM).toBe("function");
29
+ });
30
+ });
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { useLocalLLM } from "./useLocalLLM";
2
+ export { LLMSession, createLLMSession } from "./LLMSession";
3
+ export { default as ExpoLocalLlmModule } from "./ExpoLocalLlmModule";
4
+ export * from "./ExpoLocalLlm.types";