local-llm-rn 1.0.2 → 1.0.3

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.
@@ -10,6 +10,7 @@
10
10
  #include <android/log.h>
11
11
 
12
12
  #include <string>
13
+ #include <memory>
13
14
  #include <unordered_map>
14
15
  #include <mutex>
15
16
  #include <atomic>
@@ -51,6 +52,12 @@ static std::unordered_map<std::string, hilum_context *> g_contexts;
51
52
  static std::unordered_map<std::string, hilum_mtmd *> g_mtmd_contexts;
52
53
  static std::unordered_map<std::string, hilum_emb_ctx *> g_emb_contexts;
53
54
 
55
+ using ModelOwner = std::shared_ptr<hilum_model>;
56
+ static std::unordered_map<std::string, ModelOwner> g_model_owners;
57
+ static std::unordered_map<std::string, std::vector<ModelOwner>> g_context_model_owners;
58
+ static std::unordered_map<std::string, ModelOwner> g_mtmd_model_owners;
59
+ static std::unordered_map<std::string, ModelOwner> g_emb_model_owners;
60
+
54
61
  // ── Log state ────────────────────────────────────────────────────────────────
55
62
 
56
63
  static std::atomic<bool> g_log_events_enabled{false};
@@ -60,6 +67,29 @@ static std::mutex g_log_mutex;
60
67
 
61
68
  // ── JNI helpers ──────────────────────────────────────────────────────────────
62
69
 
70
+ static JNIEnv *get_jni_env(JavaVM *jvm, bool &detach) {
71
+ detach = false;
72
+ if (!jvm) {
73
+ LOGE("Cannot access JNI environment: JavaVM is null");
74
+ return nullptr;
75
+ }
76
+
77
+ JNIEnv *env = nullptr;
78
+ const jint status = jvm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6);
79
+ if (status == JNI_OK) return env;
80
+ if (status != JNI_EDETACHED) {
81
+ LOGE("Cannot access JNI environment: GetEnv returned %d", status);
82
+ return nullptr;
83
+ }
84
+ if (jvm->AttachCurrentThread(&env, nullptr) != JNI_OK) {
85
+ LOGE("Cannot access JNI environment: AttachCurrentThread failed");
86
+ return nullptr;
87
+ }
88
+
89
+ detach = true;
90
+ return env;
91
+ }
92
+
63
93
  static std::string jstring_to_std(JNIEnv *env, jstring jstr) {
64
94
  if (!jstr) return "";
65
95
  const char *chars = env->GetStringUTFChars(jstr, nullptr);
@@ -339,6 +369,7 @@ JNI_FN(nativeLoadModel)(JNIEnv *env, jobject thiz, jstring path, jobject options
339
369
  {
340
370
  std::lock_guard<std::mutex> lock(g_mutex);
341
371
  g_models[modelId] = model;
372
+ g_model_owners[modelId] = ModelOwner(model, hilum_model_free);
342
373
  }
343
374
  LOGI("Model loaded: %s", modelId.c_str());
344
375
  return std_to_jstring(env, modelId);
@@ -356,11 +387,15 @@ JNI_FN(nativeGetModelSize)(JNIEnv *env, jobject thiz, jstring modelId) {
356
387
  JNIEXPORT void JNICALL
357
388
  JNI_FN(nativeFreeModel)(JNIEnv *env, jobject thiz, jstring modelId) {
358
389
  std::string id = jstring_to_std(env, modelId);
359
- std::lock_guard<std::mutex> lock(g_mutex);
360
- auto it = g_models.find(id);
361
- if (it != g_models.end()) {
362
- hilum_model_free(it->second);
363
- g_models.erase(it);
390
+ ModelOwner owner;
391
+ {
392
+ std::lock_guard<std::mutex> lock(g_mutex);
393
+ g_models.erase(id);
394
+ auto it = g_model_owners.find(id);
395
+ if (it != g_model_owners.end()) {
396
+ owner = std::move(it->second);
397
+ g_model_owners.erase(it);
398
+ }
364
399
  }
365
400
  }
366
401
 
@@ -373,6 +408,7 @@ JNI_FN(nativeCreateContext)(JNIEnv *env, jobject thiz, jstring modelId, jobject
373
408
  auto it = g_models.find(mid);
374
409
  if (it == g_models.end()) return std_to_jstring(env, "");
375
410
 
411
+ std::vector<ModelOwner> owners{g_model_owners[mid]};
376
412
  hilum_context_params params = hilum_context_default_params();
377
413
  if (options) {
378
414
  params.n_ctx = (uint32_t)get_int_from_map(env, options, "n_ctx", params.n_ctx);
@@ -386,7 +422,10 @@ JNI_FN(nativeCreateContext)(JNIEnv *env, jobject thiz, jstring modelId, jobject
386
422
  std::string draftId = get_string_from_map(env, options, "draft_model_id");
387
423
  if (!draftId.empty()) {
388
424
  auto dit = g_models.find(draftId);
389
- if (dit != g_models.end()) params.draft_model = dit->second;
425
+ if (dit != g_models.end()) {
426
+ params.draft_model = dit->second;
427
+ owners.push_back(g_model_owners[draftId]);
428
+ }
390
429
  }
391
430
  }
392
431
 
@@ -396,6 +435,7 @@ JNI_FN(nativeCreateContext)(JNIEnv *env, jobject thiz, jstring modelId, jobject
396
435
 
397
436
  std::string ctxId = generate_uuid();
398
437
  g_contexts[ctxId] = ctx;
438
+ g_context_model_owners[ctxId] = std::move(owners);
399
439
  return std_to_jstring(env, ctxId);
400
440
  }
401
441
 
@@ -411,12 +451,22 @@ JNI_FN(nativeGetContextSize)(JNIEnv *env, jobject thiz, jstring contextId) {
411
451
  JNIEXPORT void JNICALL
412
452
  JNI_FN(nativeFreeContext)(JNIEnv *env, jobject thiz, jstring contextId) {
413
453
  std::string id = jstring_to_std(env, contextId);
414
- std::lock_guard<std::mutex> lock(g_mutex);
415
- auto it = g_contexts.find(id);
416
- if (it != g_contexts.end()) {
417
- hilum_context_free(it->second);
418
- g_contexts.erase(it);
454
+ hilum_context *context = nullptr;
455
+ std::vector<ModelOwner> owners;
456
+ {
457
+ std::lock_guard<std::mutex> lock(g_mutex);
458
+ auto it = g_contexts.find(id);
459
+ if (it != g_contexts.end()) {
460
+ context = it->second;
461
+ g_contexts.erase(it);
462
+ }
463
+ auto owner = g_context_model_owners.find(id);
464
+ if (owner != g_context_model_owners.end()) {
465
+ owners = std::move(owner->second);
466
+ g_context_model_owners.erase(owner);
467
+ }
419
468
  }
469
+ if (context) hilum_context_free(context);
420
470
  }
421
471
 
422
472
  // ── Warmup ───────────────────────────────────────────────────────────────────
@@ -638,12 +688,9 @@ JNI_FN(nativeStartStream)(JNIEnv *env, jobject thiz, jstring modelId, jstring co
638
688
  [](const char *token, int32_t token_len, void *ud) -> bool {
639
689
  auto *s = static_cast<StreamState *>(ud);
640
690
 
641
- JNIEnv *env;
642
691
  bool detach = false;
643
- if (s->jvm->GetEnv((void **)&env, JNI_VERSION_1_6) == JNI_EDETACHED) {
644
- s->jvm->AttachCurrentThread(&env, nullptr);
645
- detach = true;
646
- }
692
+ JNIEnv *env = get_jni_env(s->jvm, detach);
693
+ if (!env) return false;
647
694
 
648
695
  jclass cls = env->GetObjectClass(s->moduleRef);
649
696
  jmethodID emitMethod = env->GetMethodID(cls, "emitToken",
@@ -823,6 +870,7 @@ JNI_FN(nativeLoadProjector)(JNIEnv *env, jobject thiz, jstring modelId,
823
870
 
824
871
  std::string mtmdId = generate_uuid();
825
872
  g_mtmd_contexts[mtmdId] = mtmd;
873
+ g_mtmd_model_owners[mtmdId] = g_model_owners[mid];
826
874
  return std_to_jstring(env, mtmdId);
827
875
  }
828
876
 
@@ -838,12 +886,22 @@ JNI_FN(nativeSupportVision)(JNIEnv *env, jobject thiz, jstring mtmdId) {
838
886
  JNIEXPORT void JNICALL
839
887
  JNI_FN(nativeFreeMtmdContext)(JNIEnv *env, jobject thiz, jstring mtmdId) {
840
888
  std::string id = jstring_to_std(env, mtmdId);
841
- std::lock_guard<std::mutex> lock(g_mutex);
842
- auto it = g_mtmd_contexts.find(id);
843
- if (it != g_mtmd_contexts.end()) {
844
- hilum_mtmd_free(it->second);
845
- g_mtmd_contexts.erase(it);
889
+ hilum_mtmd *mtmd = nullptr;
890
+ ModelOwner owner;
891
+ {
892
+ std::lock_guard<std::mutex> lock(g_mutex);
893
+ auto it = g_mtmd_contexts.find(id);
894
+ if (it != g_mtmd_contexts.end()) {
895
+ mtmd = it->second;
896
+ g_mtmd_contexts.erase(it);
897
+ }
898
+ auto modelOwner = g_mtmd_model_owners.find(id);
899
+ if (modelOwner != g_mtmd_model_owners.end()) {
900
+ owner = std::move(modelOwner->second);
901
+ g_mtmd_model_owners.erase(modelOwner);
902
+ }
846
903
  }
904
+ if (mtmd) hilum_mtmd_free(mtmd);
847
905
  }
848
906
 
849
907
  JNIEXPORT jstring JNICALL
@@ -960,12 +1018,9 @@ JNI_FN(nativeStartStreamVision)(JNIEnv *env, jobject thiz, jstring modelId,
960
1018
  [](const char *token, int32_t token_len, void *ud) -> bool {
961
1019
  auto *s = static_cast<StreamState *>(ud);
962
1020
 
963
- JNIEnv *env;
964
1021
  bool detach = false;
965
- if (s->jvm->GetEnv((void **)&env, JNI_VERSION_1_6) == JNI_EDETACHED) {
966
- s->jvm->AttachCurrentThread(&env, nullptr);
967
- detach = true;
968
- }
1022
+ JNIEnv *env = get_jni_env(s->jvm, detach);
1023
+ if (!env) return false;
969
1024
 
970
1025
  jclass cls = env->GetObjectClass(s->moduleRef);
971
1026
  jmethodID emitMethod = env->GetMethodID(cls, "emitToken",
@@ -1043,9 +1098,31 @@ JNI_FN(nativeCreateEmbeddingContext)(JNIEnv *env, jobject thiz, jstring modelId,
1043
1098
 
1044
1099
  std::string ctxId = generate_uuid();
1045
1100
  g_emb_contexts[ctxId] = ectx;
1101
+ g_emb_model_owners[ctxId] = g_model_owners[mid];
1046
1102
  return std_to_jstring(env, ctxId);
1047
1103
  }
1048
1104
 
1105
+ JNIEXPORT void JNICALL
1106
+ JNI_FN(nativeFreeEmbeddingContext)(JNIEnv *env, jobject thiz, jstring contextId) {
1107
+ std::string id = jstring_to_std(env, contextId);
1108
+ hilum_emb_ctx *context = nullptr;
1109
+ ModelOwner owner;
1110
+ {
1111
+ std::lock_guard<std::mutex> lock(g_mutex);
1112
+ auto it = g_emb_contexts.find(id);
1113
+ if (it != g_emb_contexts.end()) {
1114
+ context = it->second;
1115
+ g_emb_contexts.erase(it);
1116
+ }
1117
+ auto modelOwner = g_emb_model_owners.find(id);
1118
+ if (modelOwner != g_emb_model_owners.end()) {
1119
+ owner = std::move(modelOwner->second);
1120
+ g_emb_model_owners.erase(modelOwner);
1121
+ }
1122
+ }
1123
+ if (context) hilum_emb_context_free(context);
1124
+ }
1125
+
1049
1126
  JNIEXPORT jobject JNICALL
1050
1127
  JNI_FN(nativeEmbed)(JNIEnv *env, jobject thiz, jstring contextId, jstring modelId,
1051
1128
  jobject tokens) {
@@ -1174,10 +1251,10 @@ JNI_FN(nativeStartBatch)(JNIEnv *env, jobject thiz, jstring modelId, jstring con
1174
1251
  if (mi == g_models.end() || ci == g_contexts.end()) {
1175
1252
  jclass cls = env->GetObjectClass(thiz);
1176
1253
  jmethodID emitMethod = env->GetMethodID(cls, "emitBatchToken",
1177
- "(Ljava/lang/String;ILjava/lang/String;ZLjava/lang/String;)V");
1254
+ "(Ljava/lang/String;ILjava/lang/String;ZLjava/lang/String;Ljava/lang/String;)V");
1178
1255
  jstring jcid = std_to_jstring(env, cid);
1179
1256
  jstring jerr = env->NewStringUTF("Not found");
1180
- env->CallVoidMethod(thiz, emitMethod, jcid, -1, nullptr, JNI_TRUE, jerr);
1257
+ env->CallVoidMethod(thiz, emitMethod, jcid, -1, nullptr, JNI_TRUE, nullptr, jerr);
1181
1258
  env->DeleteLocalRef(jcid);
1182
1259
  env->DeleteLocalRef(jerr);
1183
1260
  env->DeleteLocalRef(cls);
@@ -1208,32 +1285,29 @@ JNI_FN(nativeStartBatch)(JNIEnv *env, jobject thiz, jstring modelId, jstring con
1208
1285
  };
1209
1286
  BatchState *state = new BatchState{moduleRef, cid, g_jvm};
1210
1287
 
1211
- hilum_generate_batch(model, ctx, prompt_ptrs.data(), n_seqs, gc.params,
1288
+ hilum_error batchErr = hilum_generate_batch(model, ctx, prompt_ptrs.data(), n_seqs, gc.params,
1212
1289
  [](hilum_batch_event event, void *ud) -> bool {
1213
1290
  auto *s = static_cast<BatchState *>(ud);
1214
1291
 
1215
- JNIEnv *env;
1216
1292
  bool detach = false;
1217
- if (s->jvm->GetEnv((void **)&env, JNI_VERSION_1_6) == JNI_EDETACHED) {
1218
- s->jvm->AttachCurrentThread(&env, nullptr);
1219
- detach = true;
1220
- }
1293
+ JNIEnv *env = get_jni_env(s->jvm, detach);
1294
+ if (!env) return false;
1221
1295
 
1222
1296
  jclass cls = env->GetObjectClass(s->moduleRef);
1223
1297
  jmethodID emitMethod = env->GetMethodID(cls, "emitBatchToken",
1224
- "(Ljava/lang/String;ILjava/lang/String;ZLjava/lang/String;)V");
1298
+ "(Ljava/lang/String;ILjava/lang/String;ZLjava/lang/String;Ljava/lang/String;)V");
1225
1299
  jstring jcid = env->NewStringUTF(s->ctxId.c_str());
1226
1300
 
1227
1301
  if (event.done) {
1228
1302
  jstring reason = event.finish_reason
1229
1303
  ? env->NewStringUTF(event.finish_reason) : env->NewStringUTF("stop");
1230
1304
  env->CallVoidMethod(s->moduleRef, emitMethod, jcid, event.seq_index,
1231
- nullptr, JNI_TRUE, reason);
1305
+ nullptr, JNI_TRUE, reason, nullptr);
1232
1306
  env->DeleteLocalRef(reason);
1233
1307
  } else {
1234
1308
  jstring jtok = env->NewStringUTF(std::string(event.token, event.token_len).c_str());
1235
1309
  env->CallVoidMethod(s->moduleRef, emitMethod, jcid, event.seq_index,
1236
- jtok, JNI_FALSE, nullptr);
1310
+ jtok, JNI_FALSE, nullptr, nullptr);
1237
1311
  env->DeleteLocalRef(jtok);
1238
1312
  }
1239
1313
  env->DeleteLocalRef(jcid);
@@ -1242,6 +1316,17 @@ JNI_FN(nativeStartBatch)(JNIEnv *env, jobject thiz, jstring modelId, jstring con
1242
1316
  if (detach) s->jvm->DetachCurrentThread();
1243
1317
  return true;
1244
1318
  }, state);
1319
+ if (batchErr != HILUM_OK) {
1320
+ jclass cls = env->GetObjectClass(thiz);
1321
+ jmethodID emitMethod = env->GetMethodID(cls, "emitBatchToken",
1322
+ "(Ljava/lang/String;ILjava/lang/String;ZLjava/lang/String;Ljava/lang/String;)V");
1323
+ jstring jcid = std_to_jstring(env, cid);
1324
+ jstring jerr = env->NewStringUTF(hilum_error_str(batchErr));
1325
+ env->CallVoidMethod(thiz, emitMethod, jcid, -1, nullptr, JNI_TRUE, nullptr, jerr);
1326
+ env->DeleteLocalRef(jcid);
1327
+ env->DeleteLocalRef(jerr);
1328
+ env->DeleteLocalRef(cls);
1329
+ }
1245
1330
  env->DeleteGlobalRef(moduleRef);
1246
1331
  delete state;
1247
1332
  }
@@ -1297,12 +1382,9 @@ JNI_FN(nativeEnableLogEvents)(JNIEnv *env, jobject thiz, jboolean enabled) {
1297
1382
  hilum_log_set([](hilum_log_level level, const char *text, void *) {
1298
1383
  if (!g_log_events_enabled.load(std::memory_order_relaxed)) return;
1299
1384
 
1300
- JNIEnv *env;
1301
1385
  bool detach = false;
1302
- if (g_jvm->GetEnv((void **)&env, JNI_VERSION_1_6) == JNI_EDETACHED) {
1303
- g_jvm->AttachCurrentThread(&env, nullptr);
1304
- detach = true;
1305
- }
1386
+ JNIEnv *env = get_jni_env(g_jvm, detach);
1387
+ if (!env) return;
1306
1388
 
1307
1389
  std::lock_guard<std::mutex> lock(g_log_mutex);
1308
1390
  if (g_module_ref) {
@@ -1327,16 +1409,4 @@ JNI_FN(nativeEnableLogEvents)(JNIEnv *env, jobject thiz, jboolean enabled) {
1327
1409
  }
1328
1410
  }
1329
1411
 
1330
- // ── Downloads (handled in Kotlin, JNI just provides storage path) ────────────
1331
-
1332
- JNIEXPORT jstring JNICALL
1333
- JNI_FN(nativeGetModelStoragePath)(JNIEnv *env, jobject thiz, jstring filesDir) {
1334
- std::string dir = jstring_to_std(env, filesDir);
1335
- std::string path = dir + "/local-llm/models";
1336
- // Create directory if needed
1337
- std::string cmd = "mkdir -p " + path;
1338
- system(cmd.c_str());
1339
- return std_to_jstring(env, path);
1340
- }
1341
-
1342
1412
  } // extern "C"
@@ -21,12 +21,16 @@ class LocalLLMModule(reactContext: ReactApplicationContext) :
21
21
 
22
22
  /** Allowed root for file I/O — prevents reading/writing arbitrary paths. */
23
23
  private val allowedStorageRoot: String by lazy {
24
- File(reactApplicationContext.filesDir, "local-llm").absolutePath
24
+ File(reactApplicationContext.filesDir, "local-llm").canonicalPath
25
25
  }
26
26
 
27
27
  private fun isPathAllowed(path: String): Boolean {
28
- val resolved = File(path).canonicalPath
29
- return resolved.startsWith(allowedStorageRoot)
28
+ return try {
29
+ val resolved = File(path).canonicalPath
30
+ resolved == allowedStorageRoot || resolved.startsWith(allowedStorageRoot + File.separator)
31
+ } catch (_: Exception) {
32
+ false
33
+ }
30
34
  }
31
35
 
32
36
  init {
@@ -50,13 +54,14 @@ class LocalLLMModule(reactContext: ReactApplicationContext) :
50
54
 
51
55
  @Suppress("unused")
52
56
  fun emitBatchToken(contextId: String, seqIndex: Int, token: String?,
53
- done: Boolean, finishReason: String?) {
57
+ done: Boolean, finishReason: String?, error: String?) {
54
58
  val params = Arguments.createMap().apply {
55
59
  putString("contextId", contextId)
56
60
  putInt("seqIndex", seqIndex)
57
61
  putBoolean("done", done)
58
62
  if (token != null) putString("token", token)
59
63
  if (finishReason != null) putString("finishReason", finishReason)
64
+ if (error != null) putString("error", error)
60
65
  }
61
66
  sendEvent("onBatchToken", params)
62
67
  }
@@ -155,7 +160,9 @@ class LocalLLMModule(reactContext: ReactApplicationContext) :
155
160
  override fun getModelSize(modelId: String): Double = nativeGetModelSize(modelId)
156
161
 
157
162
  @ReactMethod
158
- override fun freeModel(modelId: String) { nativeFreeModel(modelId) }
163
+ override fun freeModel(modelId: String) {
164
+ executor.execute { nativeFreeModel(modelId) }
165
+ }
159
166
 
160
167
  // ── Context lifecycle ───────────────────────────────────────────────────
161
168
 
@@ -167,7 +174,9 @@ class LocalLLMModule(reactContext: ReactApplicationContext) :
167
174
  override fun getContextSize(contextId: String): Double = nativeGetContextSize(contextId).toDouble()
168
175
 
169
176
  @ReactMethod
170
- override fun freeContext(contextId: String) { nativeFreeContext(contextId) }
177
+ override fun freeContext(contextId: String) {
178
+ executor.execute { nativeFreeContext(contextId) }
179
+ }
171
180
 
172
181
  // ── Warmup ──────────────────────────────────────────────────────────────
173
182
 
@@ -298,7 +307,9 @@ class LocalLLMModule(reactContext: ReactApplicationContext) :
298
307
  override fun supportVision(mtmdId: String): Boolean = nativeSupportVision(mtmdId)
299
308
 
300
309
  @ReactMethod
301
- override fun freeMtmdContext(mtmdId: String) { nativeFreeMtmdContext(mtmdId) }
310
+ override fun freeMtmdContext(mtmdId: String) {
311
+ executor.execute { nativeFreeMtmdContext(mtmdId) }
312
+ }
302
313
 
303
314
  @ReactMethod
304
315
  override fun generateVision(modelId: String, contextId: String, mtmdId: String,
@@ -340,6 +351,11 @@ class LocalLLMModule(reactContext: ReactApplicationContext) :
340
351
  override fun createEmbeddingContext(modelId: String, options: ReadableMap): String =
341
352
  nativeCreateEmbeddingContext(modelId, options.toHashMap())
342
353
 
354
+ @ReactMethod
355
+ override fun freeEmbeddingContext(contextId: String) {
356
+ executor.execute { nativeFreeEmbeddingContext(contextId) }
357
+ }
358
+
343
359
  @ReactMethod(isBlockingSynchronousMethod = true)
344
360
  override fun embed(contextId: String, modelId: String, tokens: ReadableArray): WritableArray {
345
361
  val tokenList = ArrayList<Int>()
@@ -402,6 +418,10 @@ class LocalLLMModule(reactContext: ReactApplicationContext) :
402
418
  private val activeDownloads = java.util.concurrent.ConcurrentHashMap<String, Thread>()
403
419
 
404
420
  private fun executeDownload(url: String, destPath: String, resumeFrom: Long) {
421
+ if (!isPathAllowed(destPath)) {
422
+ emitDownloadError(url, "Destination is outside local-llm storage", false)
423
+ return
424
+ }
405
425
  val thread = Thread {
406
426
  try {
407
427
  val destFile = File(destPath)
@@ -472,6 +492,10 @@ class LocalLLMModule(reactContext: ReactApplicationContext) :
472
492
 
473
493
  @ReactMethod
474
494
  override fun resumeDownload(url: String, destPath: String) {
495
+ if (!isPathAllowed(destPath)) {
496
+ emitDownloadError(url, "Destination is outside local-llm storage", false)
497
+ return
498
+ }
475
499
  val destFile = File(destPath)
476
500
  val existingBytes = if (destFile.exists()) destFile.length() else 0L
477
501
  executeDownload(url, destPath, existingBytes)
@@ -512,10 +536,11 @@ class LocalLLMModule(reactContext: ReactApplicationContext) :
512
536
  }
513
537
 
514
538
  @ReactMethod(isBlockingSynchronousMethod = true)
515
- override fun fileExists(path: String): Boolean = File(path).exists()
539
+ override fun fileExists(path: String): Boolean = isPathAllowed(path) && File(path).exists()
516
540
 
517
541
  @ReactMethod(isBlockingSynchronousMethod = true)
518
- override fun getFileSize(path: String): Double = File(path).length().toDouble()
542
+ override fun getFileSize(path: String): Double =
543
+ if (isPathAllowed(path)) File(path).length().toDouble() else 0.0
519
544
 
520
545
  @ReactMethod(isBlockingSynchronousMethod = true)
521
546
  override fun readTextFile(path: String): String? {
@@ -546,6 +571,10 @@ class LocalLLMModule(reactContext: ReactApplicationContext) :
546
571
  override fun sha256File(path: String, promise: Promise) {
547
572
  executor.execute {
548
573
  try {
574
+ if (!isPathAllowed(path)) {
575
+ promise.reject("E_INVALID_PATH", "Path is outside local-llm storage: $path")
576
+ return@execute
577
+ }
549
578
  val file = File(path)
550
579
  if (!file.exists()) {
551
580
  promise.reject("E_FILE_NOT_FOUND", "File not found: $path")
@@ -612,6 +641,7 @@ class LocalLLMModule(reactContext: ReactApplicationContext) :
612
641
  private external fun nativeGetEmbeddingDimension(modelId: String): Int
613
642
  private external fun nativeCreateEmbeddingContext(modelId: String,
614
643
  options: HashMap<String, Any?>): String
644
+ private external fun nativeFreeEmbeddingContext(contextId: String)
615
645
  private external fun nativeEmbed(contextId: String, modelId: String,
616
646
  tokens: ArrayList<Int>): List<Any>
617
647
  private external fun nativeEmbedBatch(contextId: String, modelId: String,
@@ -623,5 +653,4 @@ class LocalLLMModule(reactContext: ReactApplicationContext) :
623
653
  options: HashMap<String, Any?>)
624
654
  private external fun nativeSetLogLevel(level: Int)
625
655
  private external fun nativeEnableLogEvents(enabled: Boolean)
626
- private external fun nativeGetModelStoragePath(filesDir: String): String
627
656
  }
@@ -313,14 +313,25 @@ static void evict_sampler_cache(llama_context * ctx) {
313
313
 
314
314
  /* ── Logging ───────────────────────────────────────────────────────────────── */
315
315
 
316
+ static std::mutex g_log_mutex;
317
+ static std::mutex g_log_set_mutex;
316
318
  static hilum_log_callback g_log_cb = nullptr;
317
319
  static void * g_log_ud = nullptr;
318
320
  static std::atomic<int> g_log_min_level{0};
319
321
 
320
322
  static void native_log_callback(enum ggml_log_level level, const char * text, void * /*ud*/) {
321
- if (!g_log_cb) return;
322
323
  if (static_cast<int>(level) < g_log_min_level.load(std::memory_order_relaxed)) return;
323
- g_log_cb(static_cast<hilum_log_level>(level), text, g_log_ud);
324
+
325
+ hilum_log_callback callback;
326
+ void * user_data;
327
+ {
328
+ std::lock_guard<std::mutex> lock(g_log_mutex);
329
+ callback = g_log_cb;
330
+ user_data = g_log_ud;
331
+ }
332
+ if (callback) {
333
+ callback(static_cast<hilum_log_level>(level), text, user_data);
334
+ }
324
335
  }
325
336
 
326
337
  /* ── Helpers ───────────────────────────────────────────────────────────────── */
@@ -391,8 +402,15 @@ HILUM_API const char * hilum_backend_info(void) {
391
402
  return llama_print_system_info();
392
403
  }
393
404
 
405
+ #define HILUM_STRINGIFY_INNER(value) #value
406
+ #define HILUM_STRINGIFY(value) HILUM_STRINGIFY_INNER(value)
407
+
394
408
  HILUM_API const char * hilum_backend_version(void) {
395
- return "hilum-llm v1.0.0 (hilum-local-llm-engine)";
409
+ return "hilum-llm v"
410
+ HILUM_STRINGIFY(HILUM_API_VERSION_MAJOR) "."
411
+ HILUM_STRINGIFY(HILUM_API_VERSION_MINOR) "."
412
+ HILUM_STRINGIFY(HILUM_API_VERSION_PATCH)
413
+ " (hilum-local-llm-engine)";
396
414
  }
397
415
 
398
416
  HILUM_API uint32_t hilum_api_version(void) {
@@ -1725,6 +1743,7 @@ HILUM_API hilum_error hilum_generate_batch(
1725
1743
 
1726
1744
  // 2. Create one sampler per sequence
1727
1745
  std::vector<llama_sampler *> samplers(n_prompts);
1746
+ std::vector<llama_token> first_tokens(n_prompts, LLAMA_TOKEN_NULL);
1728
1747
  const SamplerParams batch_sampler_params = to_sampler_params(params);
1729
1748
  for (int32_t i = 0; i < n_prompts; i++) {
1730
1749
  samplers[i] = create_sampler(
@@ -1754,6 +1773,7 @@ HILUM_API hilum_error hilum_generate_batch(
1754
1773
  for (size_t offset = 0; offset < entries.size(); offset += n_batch_size) {
1755
1774
  size_t chunk_end = std::min(entries.size(), offset + static_cast<size_t>(n_batch_size));
1756
1775
  int32_t chunk_size = static_cast<int32_t>(chunk_end - offset);
1776
+ std::vector<std::pair<int32_t, int32_t>> logit_entries;
1757
1777
 
1758
1778
  llama_batch batch = llama_batch_init(chunk_size, 0, n_prompts);
1759
1779
  for (int32_t i = 0; i < chunk_size; i++) {
@@ -1763,24 +1783,28 @@ HILUM_API hilum_error hilum_generate_batch(
1763
1783
  batch.n_seq_id[i] = 1;
1764
1784
  batch.seq_id[i][0] = e.seq_id;
1765
1785
 
1786
+ batch.logits[i] = e.logits ? 1 : 0;
1766
1787
  if (e.logits) {
1767
- bool is_last = true;
1768
- for (size_t j = offset + i + 1; j < entries.size(); j++) {
1769
- if (entries[j].seq_id == e.seq_id) { is_last = false; break; }
1770
- }
1771
- batch.logits[i] = is_last ? 1 : 0;
1772
- } else {
1773
- batch.logits[i] = 0;
1788
+ logit_entries.emplace_back(static_cast<int32_t>(e.seq_id), i);
1774
1789
  }
1775
1790
  }
1776
1791
  batch.n_tokens = chunk_size;
1777
1792
 
1778
1793
  int32_t ret = llama_decode(ctx->llm, batch);
1779
- llama_batch_free(batch);
1780
1794
  if (ret != 0) {
1795
+ llama_batch_free(batch);
1781
1796
  for (auto * s : samplers) llama_sampler_free(s);
1782
1797
  return HILUM_ERR_DECODE_FAILED;
1783
1798
  }
1799
+
1800
+ // llama_decode only retains logits from the most recent call. Sample
1801
+ // every sequence whose terminal prompt token was decoded in this
1802
+ // chunk before a later chunk can replace those output rows.
1803
+ for (const auto & [seq, batch_index] : logit_entries) {
1804
+ first_tokens[seq] = llama_sampler_sample(
1805
+ samplers[seq], ctx->llm, batch_index);
1806
+ }
1807
+ llama_batch_free(batch);
1784
1808
  }
1785
1809
  }
1786
1810
 
@@ -1791,7 +1815,11 @@ HILUM_API hilum_error hilum_generate_batch(
1791
1815
 
1792
1816
  // First token for each sequence
1793
1817
  for (int32_t s = 0; s < n_prompts; s++) {
1794
- llama_token token = llama_sampler_sample(samplers[s], ctx->llm, -1);
1818
+ llama_token token = first_tokens[s];
1819
+ if (token == LLAMA_TOKEN_NULL) {
1820
+ for (auto * sampler : samplers) llama_sampler_free(sampler);
1821
+ return HILUM_ERR_DECODE_FAILED;
1822
+ }
1795
1823
  if (token == eos) {
1796
1824
  seq_done[s] = true;
1797
1825
  n_active--;
@@ -1813,6 +1841,11 @@ HILUM_API hilum_error hilum_generate_batch(
1813
1841
  n_active--;
1814
1842
  hilum_batch_event done_ev{s, nullptr, 0, true, "cancelled"};
1815
1843
  callback(done_ev, user_data);
1844
+ } else if (seq_generated[s] >= params.max_tokens) {
1845
+ seq_done[s] = true;
1846
+ n_active--;
1847
+ hilum_batch_event done_ev{s, nullptr, 0, true, "length"};
1848
+ callback(done_ev, user_data);
1816
1849
  }
1817
1850
  }
1818
1851
 
@@ -1839,7 +1872,10 @@ HILUM_API hilum_error hilum_generate_batch(
1839
1872
 
1840
1873
  int32_t ret = llama_decode(ctx->llm, batch);
1841
1874
  llama_batch_free(batch);
1842
- if (ret != 0) break;
1875
+ if (ret != 0) {
1876
+ for (auto * sampler : samplers) llama_sampler_free(sampler);
1877
+ return HILUM_ERR_DECODE_FAILED;
1878
+ }
1843
1879
 
1844
1880
  for (int32_t i = 0; i < batch_count; i++) {
1845
1881
  int32_t s = active_seqs[i];
@@ -1939,14 +1975,13 @@ HILUM_API hilum_error hilum_quantize(
1939
1975
  /* ── Logging ───────────────────────────────────────────────────────────────── */
1940
1976
 
1941
1977
  HILUM_API void hilum_log_set(hilum_log_callback callback, void * user_data) {
1942
- if (g_log_cb) {
1943
- llama_log_set(nullptr, nullptr);
1944
- }
1945
- g_log_cb = callback;
1946
- g_log_ud = user_data;
1947
- if (callback) {
1948
- llama_log_set(native_log_callback, nullptr);
1978
+ std::lock_guard<std::mutex> set_lock(g_log_set_mutex);
1979
+ {
1980
+ std::lock_guard<std::mutex> lock(g_log_mutex);
1981
+ g_log_cb = callback;
1982
+ g_log_ud = user_data;
1949
1983
  }
1984
+ llama_log_set(callback ? native_log_callback : nullptr, nullptr);
1950
1985
  }
1951
1986
 
1952
1987
  HILUM_API void hilum_log_set_level(hilum_log_level min_level) {
@@ -30,7 +30,7 @@
30
30
  */
31
31
  #define HILUM_API_VERSION_MAJOR 1
32
32
  #define HILUM_API_VERSION_MINOR 0
33
- #define HILUM_API_VERSION_PATCH 0
33
+ #define HILUM_API_VERSION_PATCH 1
34
34
  #define HILUM_API_VERSION \
35
35
  ((uint32_t)(((HILUM_API_VERSION_MAJOR) << 16) | ((HILUM_API_VERSION_MINOR) << 8) | (HILUM_API_VERSION_PATCH)))
36
36
 
@@ -38,14 +38,16 @@
38
38
  #include <stdbool.h>
39
39
  #include <stddef.h>
40
40
 
41
- #ifdef _WIN32
42
- # ifdef HILUM_BUILD
41
+ #if defined(_WIN32) && defined(HILUM_SHARED)
42
+ # if defined(HILUM_BUILD)
43
43
  # define HILUM_API __declspec(dllexport)
44
44
  # else
45
45
  # define HILUM_API __declspec(dllimport)
46
46
  # endif
47
- #else
47
+ #elif !defined(_WIN32)
48
48
  # define HILUM_API __attribute__((visibility("default")))
49
+ #else
50
+ # define HILUM_API
49
51
  #endif
50
52
 
51
53
  #ifdef __cplusplus
package/ios/LocalLLM.mm CHANGED
@@ -16,6 +16,7 @@
16
16
 
17
17
  #include <string>
18
18
  #include <unordered_map>
19
+ #include <memory>
19
20
  #include <mutex>
20
21
  #include <atomic>
21
22
 
@@ -25,10 +26,6 @@
25
26
  #import <os/proc.h>
26
27
  #import <CommonCrypto/CommonDigest.h>
27
28
 
28
- #ifdef RCT_NEW_ARCH_ENABLED
29
- #import <memory>
30
- #endif
31
-
32
29
  // ── UUID generation ──────────────────────────────────────────────────────────
33
30
 
34
31
  static NSString *generateUUID() {
@@ -50,9 +47,11 @@ static NSString *allowedStorageRoot() {
50
47
  /// Returns YES if the path is under the allowed storage root (Application Support/local-llm/).
51
48
  /// This prevents callers from reading/writing arbitrary filesystem locations.
52
49
  static BOOL isPathAllowed(NSString *path) {
53
- NSString *resolved = [path stringByStandardizingPath];
54
- NSString *root = [allowedStorageRoot() stringByStandardizingPath];
55
- return [resolved hasPrefix:root];
50
+ NSString *resolved = [[path stringByStandardizingPath] stringByResolvingSymlinksInPath];
51
+ NSString *root = [[allowedStorageRoot() stringByStandardizingPath]
52
+ stringByResolvingSymlinksInPath];
53
+ NSString *rootPrefix = [root stringByAppendingString:@"/"];
54
+ return [resolved isEqualToString:root] || [resolved hasPrefix:rootPrefix];
56
55
  }
57
56
 
58
57
  // ── Handle maps ──────────────────────────────────────────────────────────────
@@ -64,6 +63,12 @@ static std::unordered_map<std::string, hilum_context *> g_contexts;
64
63
  static std::unordered_map<std::string, hilum_mtmd *> g_mtmd_contexts;
65
64
  static std::unordered_map<std::string, hilum_emb_ctx *> g_emb_contexts;
66
65
 
66
+ using ModelOwner = std::shared_ptr<hilum_model>;
67
+ static std::unordered_map<std::string, ModelOwner> g_model_owners;
68
+ static std::unordered_map<std::string, std::vector<ModelOwner>> g_context_model_owners;
69
+ static std::unordered_map<std::string, ModelOwner> g_mtmd_model_owners;
70
+ static std::unordered_map<std::string, ModelOwner> g_emb_model_owners;
71
+
67
72
  // ── Log state ────────────────────────────────────────────────────────────────
68
73
 
69
74
  static std::atomic<bool> g_log_events_enabled{false};
@@ -303,7 +308,9 @@ RCT_EXPORT_METHOD(loadModel:(NSString *)path
303
308
  NSString *modelId = generateUUID();
304
309
  {
305
310
  std::lock_guard<std::mutex> lock(g_mutex);
306
- g_models[[modelId UTF8String]] = model;
311
+ const std::string id = [modelId UTF8String];
312
+ g_models[id] = model;
313
+ g_model_owners[id] = ModelOwner(model, hilum_model_free);
307
314
  }
308
315
  resolve(modelId);
309
316
  });
@@ -317,12 +324,19 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getModelSize:(NSString *)modelId) {
317
324
  }
318
325
 
319
326
  RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(freeModel:(NSString *)modelId) {
320
- std::lock_guard<std::mutex> lock(g_mutex);
321
- auto it = g_models.find([modelId UTF8String]);
322
- if (it != g_models.end()) {
323
- hilum_model_free(it->second);
324
- g_models.erase(it);
325
- }
327
+ dispatch_async(inference_queue(), ^{
328
+ ModelOwner owner;
329
+ {
330
+ std::lock_guard<std::mutex> lock(g_mutex);
331
+ const std::string id = [modelId UTF8String];
332
+ g_models.erase(id);
333
+ auto it = g_model_owners.find(id);
334
+ if (it != g_model_owners.end()) {
335
+ owner = std::move(it->second);
336
+ g_model_owners.erase(it);
337
+ }
338
+ }
339
+ });
326
340
  return nil;
327
341
  }
328
342
 
@@ -354,7 +368,15 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(createContext:(NSString *)modelId
354
368
  if (err != HILUM_OK) return @"";
355
369
 
356
370
  NSString *ctxId = generateUUID();
357
- g_contexts[[ctxId UTF8String]] = ctx;
371
+ const std::string contextKey = [ctxId UTF8String];
372
+ g_contexts[contextKey] = ctx;
373
+ auto & owners = g_context_model_owners[contextKey];
374
+ owners.push_back(g_model_owners[[modelId UTF8String]]);
375
+ if (options[@"draft_model_id"]) {
376
+ const std::string draftKey = [options[@"draft_model_id"] UTF8String];
377
+ auto owner = g_model_owners.find(draftKey);
378
+ if (owner != g_model_owners.end()) owners.push_back(owner->second);
379
+ }
358
380
  return ctxId;
359
381
  }
360
382
 
@@ -366,12 +388,27 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getContextSize:(NSString *)contextId) {
366
388
  }
367
389
 
368
390
  RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(freeContext:(NSString *)contextId) {
369
- std::lock_guard<std::mutex> lock(g_mutex);
370
- auto it = g_contexts.find([contextId UTF8String]);
371
- if (it != g_contexts.end()) {
372
- hilum_context_free(it->second);
373
- g_contexts.erase(it);
374
- }
391
+ dispatch_async(inference_queue(), ^{
392
+ hilum_context *context = nullptr;
393
+ std::vector<ModelOwner> owners;
394
+ {
395
+ std::lock_guard<std::mutex> lock(g_mutex);
396
+ const std::string id = [contextId UTF8String];
397
+ auto it = g_contexts.find(id);
398
+ if (it != g_contexts.end()) {
399
+ context = it->second;
400
+ g_contexts.erase(it);
401
+ }
402
+ auto owner = g_context_model_owners.find(id);
403
+ if (owner != g_context_model_owners.end()) {
404
+ owners = std::move(owner->second);
405
+ g_context_model_owners.erase(owner);
406
+ }
407
+ }
408
+ if (context) {
409
+ hilum_context_free(context);
410
+ }
411
+ });
375
412
  return nil;
376
413
  }
377
414
 
@@ -663,7 +700,9 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(loadProjector:(NSString *)modelId
663
700
  if (err != HILUM_OK) return @"";
664
701
 
665
702
  NSString *mtmdId = generateUUID();
666
- g_mtmd_contexts[[mtmdId UTF8String]] = mtmd;
703
+ const std::string mtmdKey = [mtmdId UTF8String];
704
+ g_mtmd_contexts[mtmdKey] = mtmd;
705
+ g_mtmd_model_owners[mtmdKey] = g_model_owners[[modelId UTF8String]];
667
706
  return mtmdId;
668
707
  }
669
708
 
@@ -675,12 +714,27 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(supportVision:(NSString *)mtmdId) {
675
714
  }
676
715
 
677
716
  RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(freeMtmdContext:(NSString *)mtmdId) {
678
- std::lock_guard<std::mutex> lock(g_mutex);
679
- auto it = g_mtmd_contexts.find([mtmdId UTF8String]);
680
- if (it != g_mtmd_contexts.end()) {
681
- hilum_mtmd_free(it->second);
682
- g_mtmd_contexts.erase(it);
683
- }
717
+ dispatch_async(inference_queue(), ^{
718
+ hilum_mtmd *mtmd = nullptr;
719
+ ModelOwner owner;
720
+ {
721
+ std::lock_guard<std::mutex> lock(g_mutex);
722
+ const std::string id = [mtmdId UTF8String];
723
+ auto it = g_mtmd_contexts.find(id);
724
+ if (it != g_mtmd_contexts.end()) {
725
+ mtmd = it->second;
726
+ g_mtmd_contexts.erase(it);
727
+ }
728
+ auto modelOwner = g_mtmd_model_owners.find(id);
729
+ if (modelOwner != g_mtmd_model_owners.end()) {
730
+ owner = std::move(modelOwner->second);
731
+ g_mtmd_model_owners.erase(modelOwner);
732
+ }
733
+ }
734
+ if (mtmd) {
735
+ hilum_mtmd_free(mtmd);
736
+ }
737
+ });
684
738
  return nil;
685
739
  }
686
740
 
@@ -848,10 +902,37 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(createEmbeddingContext:(NSString *)modelI
848
902
  if (err != HILUM_OK) return @"";
849
903
 
850
904
  NSString *ctxId = generateUUID();
851
- g_emb_contexts[[ctxId UTF8String]] = ectx;
905
+ const std::string contextKey = [ctxId UTF8String];
906
+ g_emb_contexts[contextKey] = ectx;
907
+ g_emb_model_owners[contextKey] = g_model_owners[[modelId UTF8String]];
852
908
  return ctxId;
853
909
  }
854
910
 
911
+ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(freeEmbeddingContext:(NSString *)contextId) {
912
+ dispatch_async(inference_queue(), ^{
913
+ hilum_emb_ctx *context = nullptr;
914
+ ModelOwner owner;
915
+ {
916
+ std::lock_guard<std::mutex> lock(g_mutex);
917
+ const std::string id = [contextId UTF8String];
918
+ auto it = g_emb_contexts.find(id);
919
+ if (it != g_emb_contexts.end()) {
920
+ context = it->second;
921
+ g_emb_contexts.erase(it);
922
+ }
923
+ auto modelOwner = g_emb_model_owners.find(id);
924
+ if (modelOwner != g_emb_model_owners.end()) {
925
+ owner = std::move(modelOwner->second);
926
+ g_emb_model_owners.erase(modelOwner);
927
+ }
928
+ }
929
+ if (context) {
930
+ hilum_emb_context_free(context);
931
+ }
932
+ });
933
+ return nil;
934
+ }
935
+
855
936
  RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(embed:(NSString *)contextId
856
937
  modelId:(NSString *)modelId
857
938
  tokens:(NSArray<NSNumber *> *)tokens) {
@@ -957,7 +1038,7 @@ RCT_EXPORT_METHOD(startBatch:(NSString *)modelId
957
1038
  };
958
1039
  BatchState state = { self, contextId };
959
1040
 
960
- hilum_generate_batch(model, ctx, prompt_ptrs.data(), n_seqs, gc.params,
1041
+ hilum_error batchError = hilum_generate_batch(model, ctx, prompt_ptrs.data(), n_seqs, gc.params,
961
1042
  [](hilum_batch_event event, void *ud) -> bool {
962
1043
  auto *s = static_cast<BatchState *>(ud);
963
1044
  if (event.done) {
@@ -977,6 +1058,12 @@ RCT_EXPORT_METHOD(startBatch:(NSString *)modelId
977
1058
  }
978
1059
  return true;
979
1060
  }, &state);
1061
+ if (batchError != HILUM_OK) {
1062
+ [self sendEventWithName:@"onBatchToken" body:@{
1063
+ @"contextId": contextId, @"done": @YES, @"error": @(hilum_error_str(batchError)),
1064
+ @"seqIndex": @(-1)
1065
+ }];
1066
+ }
980
1067
  });
981
1068
  }
982
1069
 
@@ -1033,6 +1120,12 @@ RCT_EXPORT_METHOD(enableLogEvents:(BOOL)enabled) {
1033
1120
  // ── Downloads ────────────────────────────────────────────────────────────────
1034
1121
 
1035
1122
  RCT_EXPORT_METHOD(downloadModel:(NSString *)url destPath:(NSString *)destPath) {
1123
+ if (!isPathAllowed(destPath)) {
1124
+ [self sendEventWithName:@"onDownloadError" body:@{
1125
+ @"url": url ?: @"", @"error": @"Destination is outside local-llm storage", @"resumable": @NO
1126
+ }];
1127
+ return;
1128
+ }
1036
1129
  NSURL *nsUrl = [NSURL URLWithString:url];
1037
1130
  if (!nsUrl) return;
1038
1131
  _downloadDelegate.destPaths[url] = destPath;
@@ -1041,6 +1134,12 @@ RCT_EXPORT_METHOD(downloadModel:(NSString *)url destPath:(NSString *)destPath) {
1041
1134
  }
1042
1135
 
1043
1136
  RCT_EXPORT_METHOD(resumeDownload:(NSString *)url destPath:(NSString *)destPath) {
1137
+ if (!isPathAllowed(destPath)) {
1138
+ [self sendEventWithName:@"onDownloadError" body:@{
1139
+ @"url": url ?: @"", @"error": @"Destination is outside local-llm storage", @"resumable": @NO
1140
+ }];
1141
+ return;
1142
+ }
1044
1143
  NSData *data = _downloadDelegate.resumeData[url];
1045
1144
  _downloadDelegate.destPaths[url] = destPath;
1046
1145
  if (data) {
@@ -1118,10 +1217,12 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getModelStoragePath) {
1118
1217
  }
1119
1218
 
1120
1219
  RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(fileExists:(NSString *)path) {
1220
+ if (!isPathAllowed(path)) return @(NO);
1121
1221
  return @([[NSFileManager defaultManager] fileExistsAtPath:path]);
1122
1222
  }
1123
1223
 
1124
1224
  RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getFileSize:(NSString *)path) {
1225
+ if (!isPathAllowed(path)) return @0;
1125
1226
  NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil];
1126
1227
  NSNumber *size = attrs[NSFileSize];
1127
1228
  return size ?: @0;
@@ -1159,6 +1260,10 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(removePath:(NSString *)path) {
1159
1260
  RCT_EXPORT_METHOD(sha256File:(NSString *)path
1160
1261
  resolve:(RCTPromiseResolveBlock)resolve
1161
1262
  reject:(RCTPromiseRejectBlock)reject) {
1263
+ if (!isPathAllowed(path)) {
1264
+ reject(@"E_INVALID_PATH", @"Path is outside local-llm storage", nil);
1265
+ return;
1266
+ }
1162
1267
  dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
1163
1268
  NSInputStream *stream = [NSInputStream inputStreamWithFileAtPath:path];
1164
1269
  if (!stream) {
@@ -1,11 +1,15 @@
1
+ require "json"
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
+
1
5
  Pod::Spec.new do |s|
2
6
  s.name = "local-llm-rn"
3
- s.version = "0.1.0"
7
+ s.version = package["version"]
4
8
  s.summary = "Local LLM inference for React Native"
5
9
  s.homepage = "https://github.com/hilum-labs/local-llm-rn"
6
10
  s.license = { :type => "MIT", :file => "LICENSE" }
7
11
  s.author = "Hilum Labs"
8
- s.source = { :git => "https://github.com/hilum-labs/local-llm-rn.git", :tag => s.version.to_s }
12
+ s.source = { :git => "https://github.com/hilum-labs/local-llm-rn.git", :tag => "v#{s.version}" }
9
13
  s.platforms = { :ios => "16.0" }
10
14
 
11
15
  # Only the RN bridge (ios/) needs ARC; ggml Metal .m files use manual retain/release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-llm-rn",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "packageManager": "pnpm@10.29.2",
5
5
  "description": "On-device LLM inference for React Native with Metal (iOS) and Vulkan (Android) GPU acceleration",
6
6
  "main": "src/index.ts",
@@ -20,6 +20,8 @@
20
20
  ],
21
21
  "scripts": {
22
22
  "smoke:native-bridge": "vitest run tests/native-contract.test.ts tests/native-bridge.test.ts tests/local-llm.test.ts tests/vision.test.ts && bash scripts/verify-vendoring.sh",
23
+ "verify:core-version": "node scripts/verify-core-version.mjs",
24
+ "verify:release": "node scripts/validate-release.mjs",
23
25
  "verify:vendoring": "bash scripts/verify-vendoring.sh",
24
26
  "prepare": "bash scripts/prepare.sh",
25
27
  "prepack": "bash scripts/prepack.sh",
@@ -32,14 +34,32 @@
32
34
  "react-native": ">=0.76"
33
35
  },
34
36
  "dependencies": {
35
- "local-llm-js-core": "0.2.1"
37
+ "local-llm-js-core": "0.3.0"
36
38
  },
37
39
  "devDependencies": {
38
40
  "@types/react": "^18.0.0",
39
41
  "react": "^18.0.0",
40
42
  "react-native": "^0.76.0",
41
43
  "typescript": "^5.5.0",
42
- "vitest": "^4.0.18"
44
+ "vite": "7.3.5",
45
+ "vitest": "^4.1.10"
46
+ },
47
+ "pnpm": {
48
+ "overrides": {
49
+ "@babel/plugin-transform-modules-systemjs@>=7.12.0 <7.29.4": "7.29.7",
50
+ "@babel/core@<7.29.6": "7.29.7",
51
+ "brace-expansion@<2": "1.1.16",
52
+ "esbuild@<0.28.1": "0.28.1",
53
+ "js-yaml@<4": "3.15.0",
54
+ "node-forge@<=1.3.3": "1.4.0",
55
+ "picomatch@<3": "2.3.2",
56
+ "picomatch@>=4 <4.0.5": "4.0.5",
57
+ "shell-quote@<=1.8.3": "1.10.0",
58
+ "postcss@<8.5.10": "8.5.19",
59
+ "vite@>=7 <7.3.5": "7.3.5",
60
+ "ws@>=6 <7": "6.2.5",
61
+ "ws@>=7 <8": "7.5.12"
62
+ }
43
63
  },
44
64
  "codegenConfig": {
45
65
  "name": "LocalLLMSpec",
@@ -57,6 +57,7 @@ export interface Spec extends TurboModule {
57
57
 
58
58
  getEmbeddingDimension(modelId: string): number;
59
59
  createEmbeddingContext(modelId: string, options: Object): string;
60
+ freeEmbeddingContext(contextId: string): void;
60
61
  embed(contextId: string, modelId: string, tokens: number[]): number[];
61
62
  embedBatch(contextId: string, modelId: string, tokenArrays: number[][]): number[][];
62
63
 
package/src/cache.ts CHANGED
@@ -16,6 +16,25 @@ function joinPath(...parts: string[]): string {
16
16
  .join('/');
17
17
  }
18
18
 
19
+ function normalizePath(path: string): string {
20
+ const absolute = path.startsWith('/');
21
+ const segments: string[] = [];
22
+
23
+ for (const segment of path.replace(/\\/g, '/').split('/')) {
24
+ if (!segment || segment === '.') continue;
25
+ if (segment === '..') {
26
+ if (segments.length === 0) {
27
+ throw new LocalLLMError(LocalLLMErrorCode.INVALID_PATH, `Path "${path}" escapes its root`);
28
+ }
29
+ segments.pop();
30
+ continue;
31
+ }
32
+ segments.push(segment);
33
+ }
34
+
35
+ return `${absolute ? '/' : ''}${segments.join('/')}` || (absolute ? '/' : '.');
36
+ }
37
+
19
38
  export class ModelCache implements ModelRegistry {
20
39
  readonly cacheDir: string;
21
40
  private indexPath: string;
@@ -27,8 +46,9 @@ export class ModelCache implements ModelRegistry {
27
46
 
28
47
  /** Ensure a path is within the cache directory to prevent path traversal. */
29
48
  private assertSafePath(path: string): void {
30
- const normalized = path.replace(/\/+/g, '/').replace(/\/\.\.\//g, '/');
31
- if (!normalized.startsWith(this.cacheDir)) {
49
+ const normalized = normalizePath(path);
50
+ const cacheRoot = normalizePath(this.cacheDir).replace(/\/$/, '');
51
+ if (normalized !== cacheRoot && !normalized.startsWith(`${cacheRoot}/`)) {
32
52
  throw new LocalLLMError(
33
53
  LocalLLMErrorCode.INVALID_PATH,
34
54
  `Path "${path}" is outside the cache directory "${this.cacheDir}"`,
@@ -35,7 +35,19 @@ export class ModelManager {
35
35
  async downloadModel(url: string, options?: DownloadOptions): Promise<string> {
36
36
  const resolvedUrl = resolveModelUrl(url);
37
37
  const cached = await this.cache.getCachedModel(resolvedUrl);
38
- if (cached) return cached.path;
38
+ if (cached) {
39
+ const actualSize = NativeLocalLLM.getFileSize(cached.path);
40
+ const expectedSize = options?.expectedSize ?? cached.size;
41
+ const sizeMatches = actualSize > 0 && actualSize === expectedSize;
42
+ const hashMatches = !options?.expectedSha256
43
+ || await NativeLocalLLM.sha256File(cached.path) === options.expectedSha256.toLowerCase();
44
+
45
+ if (sizeMatches && hashMatches) return cached.path;
46
+
47
+ // Never return a cache entry whose bytes no longer match its recorded or
48
+ // caller-provided integrity metadata. Remove it and perform a clean download.
49
+ await this.cache.removeModel(resolvedUrl);
50
+ }
39
51
 
40
52
  const targetPath = this.cache.targetPath(resolvedUrl);
41
53
  await this.downloader.download(resolvedUrl, targetPath, options?.onProgress);
@@ -48,7 +60,7 @@ export class ModelManager {
48
60
  `Downloaded file is empty: ${resolvedUrl}`,
49
61
  );
50
62
  }
51
- if (options?.expectedSize && size !== options.expectedSize) {
63
+ if (options?.expectedSize !== undefined && size !== options.expectedSize) {
52
64
  NativeLocalLLM.removePath(targetPath);
53
65
  throw new LocalLLMError(
54
66
  LocalLLMErrorCode.DOWNLOAD_INTEGRITY_MISMATCH,
@@ -1,6 +1,12 @@
1
1
  import NativeLocalLLM from './NativeLocalLLM';
2
2
  import { NativeEventEmitter, NativeModules } from 'react-native';
3
- import type { NativeAddon, NativeModel, NativeContext, NativeMtmdContext } from 'local-llm-js-core/native';
3
+ import type {
4
+ NativeAddon,
5
+ NativeModel,
6
+ NativeContext,
7
+ NativeEmbeddingContext,
8
+ NativeMtmdContext,
9
+ } from 'local-llm-js-core/native';
4
10
  import { getDeviceCapabilities } from './device';
5
11
  import { LocalLLMError, LocalLLMErrorCode } from './errors';
6
12
 
@@ -134,13 +140,20 @@ export function createReactNativeAddon(): NativeAddon {
134
140
 
135
141
  inferStream: (model, ctx, prompt, options, callback) => {
136
142
  const ctxId = unwrapId(ctx);
143
+ let settled = false;
137
144
  const sub = emitter.addListener('onToken', (event) => {
138
- if (event.contextId !== ctxId) return;
139
- if (event.error) { callback(new LocalLLMError(LocalLLMErrorCode.STREAM_FAILED, event.error), null); sub.remove(); return; }
140
- if (event.done) { callback(null, null); sub.remove(); return; }
145
+ if (event.contextId !== ctxId || settled) return;
146
+ if (event.error) { settled = true; sub.remove(); callback(new LocalLLMError(LocalLLMErrorCode.STREAM_FAILED, event.error), null); return; }
147
+ if (event.done) { settled = true; sub.remove(); callback(null, null); return; }
141
148
  callback(null, event.token);
142
149
  });
143
- NativeLocalLLM.startStream(unwrapId(model), ctxId, prompt, options ?? {});
150
+ try {
151
+ NativeLocalLLM.startStream(unwrapId(model), ctxId, prompt, options ?? {});
152
+ } catch (error) {
153
+ settled = true;
154
+ sub.remove();
155
+ callback(error instanceof Error ? error : new Error(String(error)), null);
156
+ }
144
157
  },
145
158
 
146
159
  createMtmdContext: (model, projectorPath, options) => {
@@ -160,15 +173,22 @@ export function createReactNativeAddon(): NativeAddon {
160
173
  inferStreamVision: (model, ctx, mtmdCtx, prompt, imageBuffers, options, callback) => {
161
174
  const ctxId = unwrapId(ctx);
162
175
  const base64s = imageBuffers.map((buf) => toBase64(buf));
176
+ let settled = false;
163
177
  const sub = emitter.addListener('onToken', (event) => {
164
- if (event.contextId !== ctxId) return;
165
- if (event.error) { callback(new LocalLLMError(LocalLLMErrorCode.VISION_FAILED, event.error), null); sub.remove(); return; }
166
- if (event.done) { callback(null, null); sub.remove(); return; }
178
+ if (event.contextId !== ctxId || settled) return;
179
+ if (event.error) { settled = true; sub.remove(); callback(new LocalLLMError(LocalLLMErrorCode.VISION_FAILED, event.error), null); return; }
180
+ if (event.done) { settled = true; sub.remove(); callback(null, null); return; }
167
181
  callback(null, event.token);
168
182
  });
169
- NativeLocalLLM.startStreamVision(
170
- unwrapId(model), ctxId, unwrapId(mtmdCtx), prompt, base64s, options ?? {},
171
- );
183
+ try {
184
+ NativeLocalLLM.startStreamVision(
185
+ unwrapId(model), ctxId, unwrapId(mtmdCtx), prompt, base64s, options ?? {},
186
+ );
187
+ } catch (error) {
188
+ settled = true;
189
+ sub.remove();
190
+ callback(error instanceof Error ? error : new Error(String(error)), null);
191
+ }
172
192
  },
173
193
 
174
194
  jsonSchemaToGrammar: (schemaJson) => NativeLocalLLM.jsonSchemaToGrammar(schemaJson),
@@ -176,8 +196,9 @@ export function createReactNativeAddon(): NativeAddon {
176
196
  getEmbeddingDimension: (model) => NativeLocalLLM.getEmbeddingDimension(unwrapId(model)),
177
197
  createEmbeddingContext: (model, options) => {
178
198
  const id = NativeLocalLLM.createEmbeddingContext(unwrapId(model), options ?? {});
179
- return wrapId(id, 'NativeContext') as NativeContext;
199
+ return wrapId(id, 'NativeEmbeddingContext') as NativeEmbeddingContext;
180
200
  },
201
+ freeEmbeddingContext: (ctx) => NativeLocalLLM.freeEmbeddingContext(unwrapId(ctx)),
181
202
  embed: (ctx, model, tokens) => {
182
203
  const plain = tokens instanceof Int32Array ? Array.from(tokens) : tokens;
183
204
  return new Float32Array(NativeLocalLLM.embed(unwrapId(ctx), unwrapId(model), plain));
@@ -190,19 +211,34 @@ export function createReactNativeAddon(): NativeAddon {
190
211
 
191
212
  inferBatch: (model, ctx, prompts, options, callback) => {
192
213
  const ctxId = unwrapId(ctx);
193
- let completedCount = 0;
214
+ const completedSequences = new Set<number>();
215
+ let settled = false;
194
216
  const sub = emitter.addListener('onBatchToken', (event) => {
195
- if (event.contextId !== ctxId) return;
196
- if (event.error) { callback(new LocalLLMError(LocalLLMErrorCode.INFERENCE_FAILED, event.error), null, event.seqIndex, null); return; }
217
+ if (event.contextId !== ctxId || settled) return;
218
+ if (event.error) {
219
+ settled = true;
220
+ sub.remove();
221
+ callback(new LocalLLMError(LocalLLMErrorCode.INFERENCE_FAILED, event.error), null, event.seqIndex, null);
222
+ return;
223
+ }
197
224
  if (event.done) {
225
+ completedSequences.add(event.seqIndex);
226
+ if (completedSequences.size >= prompts.length) {
227
+ settled = true;
228
+ sub.remove();
229
+ }
198
230
  callback(null, null, event.seqIndex, event.finishReason ?? null);
199
- completedCount++;
200
- if (completedCount >= prompts.length) sub.remove();
201
231
  return;
202
232
  }
203
233
  callback(null, event.token, event.seqIndex, null);
204
234
  });
205
- NativeLocalLLM.startBatch(unwrapId(model), ctxId, prompts, options as any);
235
+ try {
236
+ NativeLocalLLM.startBatch(unwrapId(model), ctxId, prompts, options as any);
237
+ } catch (error) {
238
+ settled = true;
239
+ sub.remove();
240
+ callback(error instanceof Error ? error : new Error(String(error)), null, -1, null);
241
+ }
206
242
  },
207
243
 
208
244
  quantize: (inputPath, outputPath, options, callback) => {
@@ -210,7 +246,12 @@ export function createReactNativeAddon(): NativeAddon {
210
246
  sub.remove();
211
247
  callback(event.error ? new LocalLLMError(LocalLLMErrorCode.QUANTIZE_FAILED, event.error) : null);
212
248
  });
213
- NativeLocalLLM.quantize(inputPath, outputPath, options);
249
+ try {
250
+ NativeLocalLLM.quantize(inputPath, outputPath, options);
251
+ } catch (error) {
252
+ sub.remove();
253
+ callback(error instanceof Error ? error : new Error(String(error)));
254
+ }
214
255
  },
215
256
 
216
257
  setLogCallback: (() => {
@@ -33,11 +33,16 @@ export function createNativeDownloader(): DownloadAdapter {
33
33
  }),
34
34
  ];
35
35
 
36
- if (resumableUrls.has(url)) {
37
- resumableUrls.delete(url);
38
- NativeLocalLLM.resumeDownload(url, destPath);
39
- } else {
40
- NativeLocalLLM.downloadModel(url, destPath);
36
+ try {
37
+ if (resumableUrls.has(url)) {
38
+ resumableUrls.delete(url);
39
+ NativeLocalLLM.resumeDownload(url, destPath);
40
+ } else {
41
+ NativeLocalLLM.downloadModel(url, destPath);
42
+ }
43
+ } catch (error) {
44
+ subs.forEach((s) => s.remove());
45
+ reject(error instanceof Error ? error : new Error(String(error)));
41
46
  }
42
47
  });
43
48
  },