@capgo/capacitor-llm 8.0.19 → 8.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,40 +1,72 @@
1
1
  package ee.forgr.capgo_llm;
2
2
 
3
3
  import android.content.Context;
4
+ import android.os.Build;
5
+ import com.google.ai.edge.litertlm.Backend;
6
+ import com.google.ai.edge.litertlm.Content;
7
+ import com.google.ai.edge.litertlm.Conversation;
8
+ import com.google.ai.edge.litertlm.ConversationConfig;
9
+ import com.google.ai.edge.litertlm.Engine;
10
+ import com.google.ai.edge.litertlm.EngineConfig;
11
+ import com.google.ai.edge.litertlm.Message;
12
+ import com.google.ai.edge.litertlm.SamplerConfig;
13
+ import com.google.common.util.concurrent.ListenableFuture;
4
14
  import com.google.mediapipe.tasks.genai.llminference.LlmInference;
5
15
  import com.google.mediapipe.tasks.genai.llminference.LlmInference.LlmInferenceOptions;
6
- import java.util.HashMap;
7
- import java.util.List;
8
- import java.util.Locale;
16
+ import com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession;
17
+ import com.google.mlkit.genai.common.FeatureStatus;
18
+ import com.google.mlkit.genai.common.StreamingCallback;
19
+ import com.google.mlkit.genai.prompt.Candidate;
20
+ import com.google.mlkit.genai.prompt.GenerateContentRequest;
21
+ import com.google.mlkit.genai.prompt.GenerateContentResponse;
22
+ import com.google.mlkit.genai.prompt.Generation;
23
+ import com.google.mlkit.genai.prompt.TextPart;
24
+ import com.google.mlkit.genai.prompt.java.GenerativeModelFutures;
25
+ import java.io.File;
26
+ import java.io.FileOutputStream;
27
+ import java.io.InputStream;
28
+ import java.util.Collections;
9
29
  import java.util.Map;
10
30
  import java.util.UUID;
31
+ import java.util.concurrent.CancellationException;
32
+ import java.util.concurrent.ConcurrentHashMap;
33
+ import java.util.concurrent.ExecutionException;
11
34
  import java.util.concurrent.Executor;
12
35
  import java.util.concurrent.Executors;
13
36
  import java.util.concurrent.atomic.AtomicBoolean;
14
- import org.pytorch.executorch.extension.llm.LlmCallback;
15
- import org.pytorch.executorch.extension.llm.LlmModule;
16
37
 
17
38
  public class LLM {
18
39
 
19
- private enum Engine {
40
+ private enum BackendType {
41
+ LITERT,
20
42
  MEDIAPIPE,
21
- EXECUTORCH
43
+ GEMINI_NANO
22
44
  }
23
45
 
46
+ private static final double DEFAULT_TOP_P = 0.95d;
24
47
  private static LLM instance;
25
- private LlmInference mediaPipeInference;
26
- private LlmModule executorchModule;
27
- private Map<String, ChatSession> chatSessions;
48
+
49
+ private final Context context;
50
+ private final Executor executor;
51
+ private final Map<String, ChatSession> chatSessions;
52
+
53
+ private Engine engine;
54
+ private GenerativeModelFutures geminiNanoModel;
55
+ private LlmInference llmInference;
28
56
  private boolean isReady = false;
29
- private Context context;
30
- private Executor executor;
31
57
  private String modelPath = null;
32
- private String tokenizerPath = null;
33
- private Engine currentEngine = Engine.MEDIAPIPE;
58
+ private String modelType = null;
59
+ private BackendType activeBackend = BackendType.LITERT;
60
+ private long currentLoadId = 0L;
61
+
62
+ private Integer maxTokens = 2048;
63
+ private Integer topk = 40;
64
+ private Float temperature = 0.1f;
65
+ private Integer randomSeed = 101;
34
66
 
35
67
  private LLM(Context context) {
36
68
  this.context = context;
37
- this.chatSessions = new HashMap<>();
69
+ this.chatSessions = new ConcurrentHashMap<>();
38
70
  this.executor = Executors.newSingleThreadExecutor();
39
71
  }
40
72
 
@@ -45,109 +77,54 @@ public class LLM {
45
77
  return instance;
46
78
  }
47
79
 
48
- // Model parameters
49
- private Integer maxTokens = 2048;
50
- private Integer sequenceLength = 2048;
51
- private Integer topk = 40;
52
- private Float temperature = 0.1f;
53
- private String modelType = null;
54
-
55
80
  public void setModel(
56
81
  String path,
57
- String engine,
58
82
  String modelType,
59
- String tokenizerPath,
60
- List<String> specialTokens,
61
83
  Integer maxTokens,
62
- Integer sequenceLength,
63
84
  Integer topk,
64
85
  Float temperature,
86
+ Integer randomSeed,
87
+ String backend,
65
88
  ModelLoadCallback callback
66
89
  ) {
67
- this.modelPath = path;
68
- this.tokenizerPath = tokenizerPath;
69
- this.modelType = modelType;
70
- this.maxTokens = maxTokens;
71
- this.sequenceLength = sequenceLength != null ? sequenceLength : maxTokens;
72
- this.topk = topk;
73
- this.temperature = temperature;
74
- try {
75
- this.currentEngine = resolveEngine(engine, path, tokenizerPath);
76
- } catch (IllegalArgumentException e) {
77
- isReady = false;
78
- if (callback != null) {
79
- callback.onError(e.getMessage());
80
- }
81
- return;
82
- }
83
- isReady = false;
90
+ ModelLoadRequest request = nextLoadRequest(path, modelType, maxTokens, topk, temperature, randomSeed, backend);
91
+ resetModelState();
84
92
 
85
93
  android.util.Log.d(
86
94
  "LLM",
87
- "setModel called with engine: " +
88
- currentEngine +
89
- ", path: " +
90
- path +
91
- ", tokenizerPath: " +
92
- tokenizerPath +
95
+ "setModel called with path: " +
96
+ request.modelPath +
97
+ ", modelType: " +
98
+ request.modelType +
93
99
  ", maxTokens: " +
94
- maxTokens +
95
- ", sequenceLength: " +
96
- this.sequenceLength +
100
+ request.maxTokens +
97
101
  ", topk: " +
98
- topk +
102
+ request.topk +
99
103
  ", temperature: " +
100
- temperature +
101
- ", specialTokens: " +
102
- (specialTokens == null ? 0 : specialTokens.size())
104
+ request.temperature
103
105
  );
104
- initializeModel(callback);
106
+ initializeModel(request, callback);
105
107
  }
106
108
 
107
- private Engine resolveEngine(String engine, String path, String tokenizerPath) {
108
- String normalizedEngine = engine == null ? "auto" : engine.toLowerCase(Locale.US);
109
- if ("executorch".equals(normalizedEngine)) {
110
- return Engine.EXECUTORCH;
111
- }
112
- if ("mediapipe".equals(normalizedEngine)) {
113
- return Engine.MEDIAPIPE;
114
- }
115
- if ("apple".equals(normalizedEngine)) {
116
- throw new IllegalArgumentException("Apple Intelligence is only available on iOS");
117
- }
118
- if (tokenizerPath != null && !tokenizerPath.isEmpty()) {
119
- return Engine.EXECUTORCH;
120
- }
121
- if (path != null && path.toLowerCase(Locale.US).endsWith(".pte")) {
122
- return Engine.EXECUTORCH;
123
- }
124
- return Engine.MEDIAPIPE;
109
+ private synchronized ModelLoadRequest nextLoadRequest(
110
+ String path,
111
+ String modelType,
112
+ Integer maxTokens,
113
+ Integer topk,
114
+ Float temperature,
115
+ Integer randomSeed,
116
+ String backend
117
+ ) {
118
+ currentLoadId += 1;
119
+ return new ModelLoadRequest(path, modelType, maxTokens, topk, temperature, randomSeed, backend, currentLoadId);
125
120
  }
126
121
 
127
- private void releaseCurrentModel() {
128
- if (mediaPipeInference != null) {
129
- try {
130
- mediaPipeInference.close();
131
- } catch (Exception e) {
132
- android.util.Log.w("LLM", "Failed to close MediaPipe inference: " + e.getMessage());
133
- } finally {
134
- mediaPipeInference = null;
135
- }
136
- }
137
-
138
- if (executorchModule != null) {
139
- try {
140
- executorchModule.stop();
141
- } catch (Exception e) {
142
- android.util.Log.w("LLM", "Failed to stop ExecuTorch module: " + e.getMessage());
143
- } finally {
144
- executorchModule = null;
145
- }
146
- }
122
+ private synchronized boolean isCurrentLoad(long loadId) {
123
+ return loadId == currentLoadId;
147
124
  }
148
125
 
149
- private void initializeModel(ModelLoadCallback callback) {
150
- if (modelPath == null) {
126
+ private void initializeModel(ModelLoadRequest request, ModelLoadCallback callback) {
127
+ if (request.modelPath == null) {
151
128
  if (callback != null) {
152
129
  callback.onError("Model path not set");
153
130
  }
@@ -156,277 +133,539 @@ public class LLM {
156
133
 
157
134
  executor.execute(() -> {
158
135
  try {
159
- releaseCurrentModel();
160
- if (currentEngine == Engine.EXECUTORCH) {
161
- initializeExecutorchModel();
136
+ if (!isCurrentLoad(request.loadId)) {
137
+ notifySupersededLoad(callback);
138
+ return;
139
+ }
140
+
141
+ BackendType backend = resolveBackend(request.modelPath, request.modelType);
142
+ String actualPath = backend == BackendType.GEMINI_NANO ? request.modelPath : resolveModelPath(request.modelPath, backend);
143
+
144
+ android.util.Log.d("LLM", "Resolved model path: " + actualPath + " using backend: " + backend);
145
+
146
+ if (backend == BackendType.LITERT) {
147
+ Engine loadedEngine = initializeLiteRtModel(actualPath, request);
148
+ if (!isCurrentLoad(request.loadId)) {
149
+ closeEngine(loadedEngine);
150
+ notifySupersededLoad(callback);
151
+ return;
152
+ }
153
+ applyLoadedModel(request, backend, loadedEngine, null, null);
162
154
  } else {
163
- initializeMediaPipeModel();
155
+ if (backend == BackendType.GEMINI_NANO) {
156
+ initializeGeminiNanoModel(request, callback);
157
+ return;
158
+ }
159
+
160
+ LlmInference loadedInference = initializeMediaPipeModel(actualPath, request);
161
+ if (!isCurrentLoad(request.loadId)) {
162
+ closeLlmInference(loadedInference);
163
+ notifySupersededLoad(callback);
164
+ return;
165
+ }
166
+ applyLoadedModel(request, backend, null, loadedInference, null);
164
167
  }
165
- isReady = true;
166
168
 
167
169
  if (callback != null) {
168
170
  callback.onSuccess();
169
171
  }
170
- } catch (Exception e) {
171
- isReady = false;
172
- e.printStackTrace();
172
+ } catch (Exception exception) {
173
+ if (!isCurrentLoad(request.loadId)) {
174
+ notifySupersededLoad(callback);
175
+ return;
176
+ }
177
+
178
+ android.util.Log.e("LLM", "Failed to initialize model", exception);
179
+ resetModelState();
180
+
173
181
  if (callback != null) {
174
- callback.onError(e.getMessage());
182
+ callback.onError(exception.getMessage());
175
183
  }
176
184
  }
177
185
  });
178
186
  }
179
187
 
180
- private void initializeMediaPipeModel() throws Exception {
181
- String actualPath = resolveModelPath(modelPath, true);
182
- android.util.Log.d("LLM", "Final MediaPipe model path: " + actualPath);
188
+ private void notifySupersededLoad(ModelLoadCallback callback) {
189
+ if (callback != null) {
190
+ callback.onError("Model load was superseded by a newer request");
191
+ }
192
+ }
183
193
 
184
- LlmInferenceOptions.Builder optionsBuilder = LlmInferenceOptions.builder()
185
- .setModelPath(actualPath)
186
- .setMaxTokens(maxTokens)
187
- .setMaxTopK(topk);
194
+ private synchronized void applyLoadedModel(
195
+ ModelLoadRequest request,
196
+ BackendType backend,
197
+ Engine loadedEngine,
198
+ LlmInference loadedInference,
199
+ GenerativeModelFutures loadedGeminiNanoModel
200
+ ) {
201
+ modelPath = request.modelPath;
202
+ modelType = request.modelType;
203
+ maxTokens = request.maxTokens;
204
+ topk = request.topk;
205
+ temperature = request.temperature;
206
+ randomSeed = request.randomSeed;
207
+ activeBackend = backend;
208
+ engine = loadedEngine;
209
+ llmInference = loadedInference;
210
+ geminiNanoModel = loadedGeminiNanoModel;
211
+ isReady = true;
212
+ }
188
213
 
189
- LlmInferenceOptions options = optionsBuilder.build();
190
- mediaPipeInference = LlmInference.createFromOptions(context, options);
214
+ private Engine initializeLiteRtModel(String actualPath, ModelLoadRequest request) {
215
+ Backend primaryBackend = resolveLiteRtBackend(request.backend);
216
+ EngineConfig config = new EngineConfig(
217
+ actualPath,
218
+ primaryBackend,
219
+ primaryBackend,
220
+ primaryBackend,
221
+ request.maxTokens,
222
+ context.getCacheDir().getAbsolutePath()
223
+ );
224
+ Engine loadedEngine = new Engine(config);
225
+ loadedEngine.initialize();
226
+ return loadedEngine;
191
227
  }
192
228
 
193
- private void initializeExecutorchModel() throws Exception {
194
- if (tokenizerPath == null || tokenizerPath.isEmpty()) {
195
- throw new RuntimeException("tokenizerPath is required for ExecuTorch models");
229
+ private Backend resolveLiteRtBackend(String backend) {
230
+ if (backend != null && "gpu".equalsIgnoreCase(backend.trim())) {
231
+ return new Backend.GPU();
196
232
  }
197
233
 
198
- String actualModelPath = resolveModelPath(modelPath, false);
199
- String actualTokenizerPath = resolveModelPath(tokenizerPath, false);
200
- int executorModelType = resolveExecutorchModelType(modelType);
234
+ return new Backend.CPU();
235
+ }
201
236
 
202
- android.util.Log.d("LLM", "Final ExecuTorch model path: " + actualModelPath);
203
- android.util.Log.d("LLM", "Final ExecuTorch tokenizer path: " + actualTokenizerPath);
237
+ private LlmInference initializeMediaPipeModel(String actualPath, ModelLoadRequest request) {
238
+ LlmInferenceOptions options = LlmInferenceOptions.builder()
239
+ .setModelPath(actualPath)
240
+ .setMaxTokens(request.maxTokens)
241
+ .setMaxTopK(request.topk)
242
+ .build();
243
+ return LlmInference.createFromOptions(context, options);
244
+ }
245
+
246
+ private void initializeGeminiNanoModel(ModelLoadRequest request, ModelLoadCallback callback) {
247
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
248
+ if (callback != null) {
249
+ callback.onError("Gemini Nano requires Android API 26 or higher");
250
+ }
251
+ return;
252
+ }
204
253
 
205
- executorchModule = new LlmModule(executorModelType, actualModelPath, actualTokenizerPath, temperature);
206
- executorchModule.load();
254
+ GenerativeModelFutures loadedModel = GenerativeModelFutures.from(Generation.INSTANCE.getClient());
255
+ ListenableFuture<Integer> statusFuture = loadedModel.checkStatus();
256
+ statusFuture.addListener(
257
+ () -> {
258
+ try {
259
+ if (!isCurrentLoad(request.loadId)) {
260
+ notifySupersededLoad(callback);
261
+ return;
262
+ }
263
+
264
+ Integer status = statusFuture.get();
265
+ if (status == FeatureStatus.AVAILABLE) {
266
+ applyLoadedModel(request, BackendType.GEMINI_NANO, null, null, loadedModel);
267
+ if (callback != null) {
268
+ callback.onSuccess();
269
+ }
270
+ return;
271
+ }
272
+
273
+ resetModelState();
274
+ if (callback != null) {
275
+ callback.onError("Gemini Nano is " + geminiNanoStatusName(status) + " on this device");
276
+ }
277
+ } catch (CancellationException | ExecutionException exception) {
278
+ resetModelState();
279
+ if (callback != null) {
280
+ callback.onError(exception.getMessage());
281
+ }
282
+ } catch (InterruptedException exception) {
283
+ Thread.currentThread().interrupt();
284
+ resetModelState();
285
+ if (callback != null) {
286
+ callback.onError(exception.getMessage());
287
+ }
288
+ }
289
+ },
290
+ executor
291
+ );
207
292
  }
208
293
 
209
- private int resolveExecutorchModelType(String modelType) {
210
- if (modelType == null) {
211
- return LlmModule.MODEL_TYPE_TEXT;
294
+ private String geminiNanoStatusName(Integer status) {
295
+ if (status == null) {
296
+ return "unknown";
212
297
  }
213
- String normalized = modelType.toLowerCase(Locale.US);
214
- if ("text-vision".equals(normalized) || "vision".equals(normalized)) {
215
- return LlmModule.MODEL_TYPE_TEXT_VISION;
298
+
299
+ switch (status) {
300
+ case FeatureStatus.UNAVAILABLE:
301
+ return "unavailable";
302
+ case FeatureStatus.DOWNLOADABLE:
303
+ return "downloadable but not downloaded";
304
+ case FeatureStatus.DOWNLOADING:
305
+ return "downloading";
306
+ case FeatureStatus.AVAILABLE:
307
+ return "available";
308
+ default:
309
+ return "unknown";
216
310
  }
217
- if ("multimodal".equals(normalized)) {
218
- return LlmModule.MODEL_TYPE_MULTIMODAL;
311
+ }
312
+
313
+ private BackendType resolveBackend(String path, String modelType) {
314
+ if (isGeminiNanoModel(path, modelType)) {
315
+ return BackendType.GEMINI_NANO;
219
316
  }
220
- return LlmModule.MODEL_TYPE_TEXT;
317
+
318
+ return "litertlm".equals(normalizeModelType(path, modelType)) ? BackendType.LITERT : BackendType.MEDIAPIPE;
221
319
  }
222
320
 
223
- private String resolveModelPath(String path, boolean copyMediaPipeCompanion) throws Exception {
224
- android.util.Log.d("LLM", "Original model path: " + path);
321
+ private boolean isGeminiNanoModel(String path, String modelType) {
322
+ String requestedModel = modelType != null && !modelType.isBlank() ? modelType : path;
323
+ return "gemininano".equals(normalizeSystemModelName(requestedModel));
324
+ }
225
325
 
226
- if (!path.startsWith("/android_asset/")) {
227
- return path;
326
+ private String normalizeSystemModelName(String value) {
327
+ return stripUrlSuffix(value).toLowerCase().replace("-", "").replace("_", "").replace(" ", "");
328
+ }
329
+
330
+ private String normalizeModelType(String path, String modelType) {
331
+ if (modelType != null && !modelType.isBlank()) {
332
+ return stripUrlSuffix(modelType).toLowerCase();
228
333
  }
229
334
 
230
- String assetPath = path.substring("/android_asset/".length());
231
- android.util.Log.d("LLM", "Asset path: " + assetPath);
335
+ String normalizedPath = stripUrlSuffix(path);
336
+ int extensionIndex = normalizedPath.lastIndexOf('.');
337
+ if (extensionIndex == -1 || extensionIndex == normalizedPath.length() - 1) {
338
+ return "";
339
+ }
340
+
341
+ return normalizedPath.substring(extensionIndex + 1).toLowerCase();
342
+ }
343
+
344
+ private String stripUrlSuffix(String value) {
345
+ int cutoff = value.length();
346
+ int queryIndex = value.indexOf('?');
347
+ int fragmentIndex = value.indexOf('#');
232
348
 
233
- try (java.io.InputStream is = context.getAssets().open(assetPath)) {
234
- android.util.Log.d("LLM", "Asset exists: " + assetPath);
235
- } catch (Exception e) {
236
- android.util.Log.e("LLM", "Asset not found: " + assetPath, e);
237
- throw new RuntimeException("Asset not found: " + assetPath);
349
+ if (queryIndex >= 0 && queryIndex < cutoff) {
350
+ cutoff = queryIndex;
351
+ }
352
+ if (fragmentIndex >= 0 && fragmentIndex < cutoff) {
353
+ cutoff = fragmentIndex;
238
354
  }
239
355
 
240
- java.io.File cacheDir = context.getCacheDir();
241
- java.io.File modelFile = new java.io.File(cacheDir, assetPath);
242
- java.io.File parent = modelFile.getParentFile();
243
- if (parent != null) {
244
- parent.mkdirs();
356
+ return value.substring(0, cutoff);
357
+ }
358
+
359
+ private String resolveModelPath(String path, BackendType backendType) throws Exception {
360
+ if (!path.startsWith("/android_asset/")) {
361
+ return path;
245
362
  }
246
363
 
364
+ String assetPath = path.substring("/android_asset/".length());
365
+ File cacheDir = context.getCacheDir();
366
+ File modelFile = new File(cacheDir, assetPath);
367
+
247
368
  copyAssetToFile(assetPath, modelFile);
248
- android.util.Log.d("LLM", "Copied asset to: " + modelFile.getAbsolutePath());
249
369
 
250
- if (copyMediaPipeCompanion && assetPath.endsWith(".task")) {
251
- copyOptionalCompanion(assetPath, cacheDir);
370
+ if (backendType == BackendType.MEDIAPIPE && assetPath.endsWith(".task")) {
371
+ String companionPath = assetPath.replace(".task", ".litertlm");
372
+ try {
373
+ copyAssetToFile(companionPath, new File(cacheDir, companionPath));
374
+ } catch (Exception exception) {
375
+ android.util.Log.d("LLM", "No MediaPipe companion file found for " + assetPath);
376
+ }
252
377
  }
253
378
 
254
379
  return modelFile.getAbsolutePath();
255
380
  }
256
381
 
257
- private void copyOptionalCompanion(String assetPath, java.io.File cacheDir) {
258
- String litertlmPath = assetPath.replace(".task", ".litertlm");
259
- try (java.io.InputStream litertlmIs = context.getAssets().open(litertlmPath)) {
260
- java.io.File litertlmFile = new java.io.File(cacheDir, litertlmPath);
261
- java.io.File parent = litertlmFile.getParentFile();
262
- if (parent != null) {
263
- parent.mkdirs();
264
- }
265
- copyAssetToFile(litertlmPath, litertlmFile);
266
- android.util.Log.d("LLM", "Also copied companion file: " + litertlmFile.getAbsolutePath());
267
- } catch (Exception e) {
268
- android.util.Log.d("LLM", "No companion .litertlm file found");
382
+ private void copyAssetToFile(String assetPath, File destination) throws Exception {
383
+ File parent = destination.getParentFile();
384
+ if (parent != null && !parent.exists()) {
385
+ parent.mkdirs();
269
386
  }
270
- }
271
387
 
272
- private void copyAssetToFile(String assetPath, java.io.File destFile) throws Exception {
273
388
  try (
274
- java.io.InputStream is = context.getAssets().open(assetPath);
275
- java.io.FileOutputStream fos = new java.io.FileOutputStream(destFile)
389
+ InputStream inputStream = context.getAssets().open(assetPath);
390
+ FileOutputStream outputStream = new FileOutputStream(destination)
276
391
  ) {
277
- byte[] buffer = new byte[4096];
392
+ byte[] buffer = new byte[8192];
278
393
  int length;
279
- while ((length = is.read(buffer)) > 0) {
280
- fos.write(buffer, 0, length);
394
+ while ((length = inputStream.read(buffer)) > 0) {
395
+ outputStream.write(buffer, 0, length);
281
396
  }
282
397
  }
283
398
  }
284
399
 
285
- public String createChat() {
400
+ public synchronized String createChat() {
401
+ if (!isReady) {
402
+ throw new IllegalStateException("Model not ready");
403
+ }
404
+
286
405
  String chatId = UUID.randomUUID().toString();
287
- ChatSession session = new ChatSession();
406
+ ChatSession session;
407
+
408
+ if (activeBackend == BackendType.LITERT) {
409
+ if (engine == null) {
410
+ throw new IllegalStateException("LiteRT-LM engine not initialized");
411
+ }
412
+ session = ChatSession.forLiteRt(createLiteRtConversation());
413
+ } else if (activeBackend == BackendType.GEMINI_NANO) {
414
+ if (geminiNanoModel == null) {
415
+ throw new IllegalStateException("Gemini Nano model not initialized");
416
+ }
417
+ session = ChatSession.forGeminiNano();
418
+ } else {
419
+ if (llmInference == null) {
420
+ throw new IllegalStateException("MediaPipe model not initialized");
421
+ }
422
+ session = ChatSession.forMediaPipe();
423
+ }
424
+
288
425
  chatSessions.put(chatId, session);
289
426
  return chatId;
290
427
  }
291
428
 
429
+ private Conversation createLiteRtConversation() {
430
+ ConversationConfig defaults = new ConversationConfig();
431
+ SamplerConfig samplerConfig = new SamplerConfig(topk, DEFAULT_TOP_P, temperature.doubleValue(), randomSeed);
432
+ ConversationConfig config = defaults.copy(
433
+ defaults.getSystemInstruction(),
434
+ defaults.getInitialMessages(),
435
+ defaults.getTools(),
436
+ samplerConfig,
437
+ defaults.getAutomaticToolCalling(),
438
+ defaults.getChannels()
439
+ );
440
+ return engine.createConversation(config);
441
+ }
442
+
292
443
  public void sendMessage(String chatId, String message, MessageCallback callback) {
293
- ChatSession session = chatSessions.get(chatId);
294
- if (session == null) {
295
- callback.onError("Chat session not found");
444
+ ChatSession session;
445
+ LlmInference inference;
446
+ GenerativeModelFutures nanoModel;
447
+ Integer sessionTopk;
448
+ Float sessionTemperature;
449
+ Integer sessionMaxTokens;
450
+ Integer sessionRandomSeed;
451
+ synchronized (this) {
452
+ session = chatSessions.get(chatId);
453
+ if (session == null) {
454
+ throw new IllegalStateException("Chat session not found");
455
+ }
456
+
457
+ if (!isReady) {
458
+ throw new IllegalStateException("Model not ready");
459
+ }
460
+
461
+ inference = llmInference;
462
+ nanoModel = geminiNanoModel;
463
+ sessionTopk = topk;
464
+ sessionTemperature = temperature;
465
+ sessionMaxTokens = maxTokens;
466
+ sessionRandomSeed = randomSeed;
467
+ }
468
+
469
+ if (session.backendType == BackendType.LITERT) {
470
+ sendMessageWithLiteRt(chatId, message, session, callback);
296
471
  return;
297
472
  }
298
473
 
299
- if (!isReady) {
300
- callback.onError("Model not ready");
474
+ if (session.backendType == BackendType.GEMINI_NANO) {
475
+ if (nanoModel == null) {
476
+ throw new IllegalStateException("Gemini Nano model not ready");
477
+ }
478
+
479
+ sendMessageWithGeminiNano(
480
+ chatId,
481
+ message,
482
+ session,
483
+ nanoModel,
484
+ sessionMaxTokens,
485
+ sessionTopk,
486
+ sessionTemperature,
487
+ sessionRandomSeed,
488
+ callback
489
+ );
301
490
  return;
302
491
  }
303
492
 
304
- if (currentEngine == Engine.EXECUTORCH) {
305
- sendExecutorchMessage(chatId, message, session, callback);
306
- } else {
307
- sendMediaPipeMessage(chatId, message, session, callback);
493
+ if (inference == null) {
494
+ throw new IllegalStateException("MediaPipe model not ready");
308
495
  }
309
- }
310
496
 
311
- private void sendExecutorchMessage(String chatId, String message, ChatSession session, MessageCallback callback) {
312
- final LlmModule module = executorchModule;
313
- final int generationSequenceLength = sequenceLength;
314
- final float generationTemperature = temperature;
497
+ sendMessageWithMediaPipe(chatId, message, session, inference, sessionTopk, sessionTemperature, callback);
498
+ }
315
499
 
316
- if (module == null) {
317
- callback.onError("ExecuTorch model not ready");
318
- return;
500
+ private void sendMessageWithLiteRt(String chatId, String message, ChatSession session, MessageCallback callback) {
501
+ if (session.conversation == null) {
502
+ throw new IllegalStateException("LiteRT-LM conversation not found");
319
503
  }
320
504
 
321
- executor.execute(() -> {
322
- try {
323
- session.addMessage("user", message);
324
- String fullPrompt = session.buildPrompt(message);
325
- AtomicBoolean completed = new AtomicBoolean(false);
326
- StringBuilder fullResponse = new StringBuilder();
327
-
328
- module.resetContext();
329
- int errorCode = module.generate(
330
- fullPrompt,
331
- generationSequenceLength,
332
- new LlmCallback() {
333
- @Override
334
- public void onResult(String result) {
335
- if (result == null || result.isEmpty()) {
336
- return;
337
- }
338
- callback.onTextReceived(chatId, result, true);
339
- fullResponse.append(result);
340
- }
505
+ session.conversation.sendMessageAsync(
506
+ message,
507
+ new com.google.ai.edge.litertlm.MessageCallback() {
508
+ @Override
509
+ public void onMessage(Message responseMessage) {
510
+ String chunk = extractText(responseMessage);
511
+ if (!chunk.isEmpty()) {
512
+ callback.onTextReceived(chatId, chunk, true);
513
+ }
514
+ }
341
515
 
342
- @Override
343
- public void onStats(String stats) {
344
- if (completed.compareAndSet(false, true)) {
345
- session.addMessage("assistant", fullResponse.toString());
346
- callback.onComplete(chatId);
347
- }
348
- }
349
- },
350
- false,
351
- generationTemperature,
352
- 0,
353
- 0
354
- );
355
-
356
- if (errorCode != 0) {
357
- completed.set(true);
358
- callback.onError("ExecuTorch error " + errorCode);
359
- } else if (completed.compareAndSet(false, true)) {
360
- session.addMessage("assistant", fullResponse.toString());
516
+ @Override
517
+ public void onDone() {
361
518
  callback.onComplete(chatId);
362
519
  }
363
- } catch (Exception e) {
364
- callback.onError(e.getMessage());
520
+
521
+ @Override
522
+ public void onError(Throwable throwable) {
523
+ callback.onError(throwable.getMessage());
524
+ }
525
+ },
526
+ Collections.emptyMap()
527
+ );
528
+ }
529
+
530
+ private String extractText(Message responseMessage) {
531
+ StringBuilder builder = new StringBuilder();
532
+
533
+ for (Content content : responseMessage.getContents().getContents()) {
534
+ if (content instanceof Content.Text) {
535
+ builder.append(((Content.Text) content).getText());
365
536
  }
366
- });
537
+ }
538
+
539
+ return builder.toString();
367
540
  }
368
541
 
369
- private void sendMediaPipeMessage(String chatId, String message, ChatSession session, MessageCallback callback) {
370
- final LlmInference inference = mediaPipeInference;
371
- final int sessionTopk = topk;
372
- final float sessionTemperature = temperature;
542
+ private void sendMessageWithGeminiNano(
543
+ String chatId,
544
+ String message,
545
+ ChatSession session,
546
+ GenerativeModelFutures nanoModel,
547
+ Integer sessionMaxTokens,
548
+ Integer sessionTopk,
549
+ Float sessionTemperature,
550
+ Integer sessionRandomSeed,
551
+ MessageCallback callback
552
+ ) {
553
+ synchronized (session) {
554
+ session.addMessage("user", message);
555
+ }
373
556
 
374
- if (inference == null) {
375
- callback.onError("MediaPipe model not ready");
376
- return;
557
+ GenerateContentRequest.Builder requestBuilder;
558
+ synchronized (session) {
559
+ requestBuilder = new GenerateContentRequest.Builder(new TextPart(session.buildPrompt()));
560
+ }
561
+
562
+ requestBuilder.setMaxOutputTokens(Math.min(sessionMaxTokens, 256));
563
+ requestBuilder.setTopK(sessionTopk);
564
+ requestBuilder.setTemperature(sessionTemperature);
565
+ requestBuilder.setSeed(sessionRandomSeed);
566
+
567
+ StringBuffer fullResponse = new StringBuffer();
568
+ AtomicBoolean streamed = new AtomicBoolean(false);
569
+ ListenableFuture<GenerateContentResponse> responseFuture = nanoModel.generateContent(
570
+ requestBuilder.build(),
571
+ new StreamingCallback() {
572
+ @Override
573
+ public void onNewText(String additionalText) {
574
+ if (additionalText == null || additionalText.isEmpty()) {
575
+ return;
576
+ }
577
+
578
+ streamed.set(true);
579
+ fullResponse.append(additionalText);
580
+ callback.onTextReceived(chatId, additionalText, true);
581
+ }
582
+ }
583
+ );
584
+
585
+ responseFuture.addListener(
586
+ () -> {
587
+ try {
588
+ GenerateContentResponse response = responseFuture.get();
589
+ if (!streamed.get()) {
590
+ String text = extractText(response);
591
+ if (!text.isEmpty()) {
592
+ fullResponse.append(text);
593
+ callback.onTextReceived(chatId, text, true);
594
+ }
595
+ }
596
+
597
+ synchronized (session) {
598
+ session.addMessage("assistant", fullResponse.toString());
599
+ }
600
+ callback.onComplete(chatId);
601
+ } catch (CancellationException | ExecutionException exception) {
602
+ callback.onError(exception.getMessage());
603
+ } catch (InterruptedException exception) {
604
+ Thread.currentThread().interrupt();
605
+ callback.onError(exception.getMessage());
606
+ }
607
+ },
608
+ executor
609
+ );
610
+ }
611
+
612
+ private String extractText(GenerateContentResponse response) {
613
+ if (response.getCandidates().isEmpty()) {
614
+ return "";
377
615
  }
378
616
 
617
+ Candidate candidate = response.getCandidates().get(0);
618
+ return candidate.getText();
619
+ }
620
+
621
+ private void sendMessageWithMediaPipe(
622
+ String chatId,
623
+ String message,
624
+ ChatSession session,
625
+ LlmInference inference,
626
+ Integer sessionTopk,
627
+ Float sessionTemperature,
628
+ MessageCallback callback
629
+ ) {
379
630
  executor.execute(() -> {
631
+ LlmInferenceSession inferenceSession = null;
380
632
  try {
381
- // Add user message to history
382
633
  session.addMessage("user", message);
383
634
 
384
- // Build the full prompt with chat history
385
- String fullPrompt = session.buildPrompt(message);
635
+ String fullPrompt = session.buildPrompt();
386
636
  android.util.Log.d("LLM", "Full prompt: " + fullPrompt);
387
637
 
388
- // Create a session with proper options
389
- com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession.LlmInferenceSessionOptions sessionOptions =
390
- com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession.LlmInferenceSessionOptions.builder()
391
- .setTopK(sessionTopk)
392
- .setTemperature(sessionTemperature)
393
- .build();
638
+ LlmInferenceSession.LlmInferenceSessionOptions sessionOptions = LlmInferenceSession.LlmInferenceSessionOptions.builder()
639
+ .setTopK(sessionTopk)
640
+ .setTemperature(sessionTemperature)
641
+ .build();
394
642
 
395
- com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession inferenceSession =
396
- com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession.createFromOptions(inference, sessionOptions);
643
+ inferenceSession = LlmInferenceSession.createFromOptions(inference, sessionOptions);
644
+ LlmInferenceSession activeSession = inferenceSession;
397
645
 
398
- // Add the query
399
646
  inferenceSession.addQueryChunk(fullPrompt);
400
647
 
401
- // Use streaming API
402
648
  final StringBuilder fullResponse = new StringBuilder();
403
649
 
404
650
  com.google.mediapipe.tasks.genai.llminference.ProgressListener<String> resultListener =
405
651
  new com.google.mediapipe.tasks.genai.llminference.ProgressListener<String>() {
406
652
  private StringBuilder buffer = new StringBuilder();
407
653
  private boolean hasStarted = false;
408
- private final AtomicBoolean completed = new AtomicBoolean(false);
409
654
 
410
655
  @Override
411
656
  public void run(String partialResult, boolean done) {
412
657
  android.util.Log.d("LLM", "Partial result: " + partialResult + ", done: " + done);
413
658
 
414
- // Accumulate in buffer
415
659
  buffer.append(partialResult);
416
660
 
417
- // Process buffer when we have enough content or when done
418
661
  String content = buffer.toString();
419
-
420
- // Check if we can process some content
421
662
  StringBuilder toSend = new StringBuilder();
422
663
  int i = 0;
423
664
  while (i < content.length()) {
424
- // Check for escape sequence
425
665
  if (i < content.length() - 1 && content.charAt(i) == '\\' && content.charAt(i + 1) == 'n') {
426
666
  toSend.append('\n');
427
667
  i += 2;
428
668
  } else if (i == content.length() - 1 && content.charAt(i) == '\\' && !done) {
429
- // We have a backslash at the end and more chunks coming - wait for next chunk
430
669
  break;
431
670
  } else {
432
671
  toSend.append(content.charAt(i));
@@ -434,14 +673,11 @@ public class LLM {
434
673
  }
435
674
  }
436
675
 
437
- // Update buffer to contain only unprocessed content
438
676
  buffer = new StringBuilder(content.substring(i));
439
677
 
440
- // Send processed content if any
441
678
  String chunk = toSend.toString();
442
679
 
443
- // Remove leading newline from the very first content
444
- if (!hasStarted && chunk.length() > 0) {
680
+ if (!hasStarted && !chunk.isEmpty()) {
445
681
  chunk = chunk.replaceFirst("^\\n", "");
446
682
  hasStarted = true;
447
683
  }
@@ -451,37 +687,91 @@ public class LLM {
451
687
  fullResponse.append(chunk);
452
688
  }
453
689
 
454
- if (done && completed.compareAndSet(false, true)) {
455
- if (buffer.length() > 0) {
456
- String remaining = buffer.toString();
690
+ if (done && buffer.length() > 0) {
691
+ String remaining = buffer.toString();
692
+ if (!remaining.isEmpty()) {
457
693
  callback.onTextReceived(chatId, remaining, true);
458
694
  fullResponse.append(remaining);
459
- buffer = new StringBuilder();
460
695
  }
696
+ }
461
697
 
698
+ if (done) {
462
699
  session.addMessage("assistant", fullResponse.toString());
463
700
  callback.onComplete(chatId);
464
-
465
- try {
466
- inferenceSession.close();
467
- } catch (Exception e) {
468
- android.util.Log.e("LLM", "Failed to close session: " + e.getMessage());
469
- }
701
+ closeMediaPipeSession(activeSession);
470
702
  }
471
703
  }
472
704
  };
473
705
 
474
706
  inferenceSession.generateResponseAsync(resultListener);
475
- } catch (Exception e) {
476
- callback.onError(e.getMessage());
707
+ } catch (Exception exception) {
708
+ closeMediaPipeSession(inferenceSession);
709
+ callback.onError(exception.getMessage());
477
710
  }
478
711
  });
479
712
  }
480
713
 
481
- public String getReadiness() {
714
+ public synchronized String getReadiness() {
482
715
  return isReady ? "ready" : "not_ready";
483
716
  }
484
717
 
718
+ private synchronized void resetModelState() {
719
+ clearChatSessions();
720
+
721
+ closeEngine(engine);
722
+ closeLlmInference(llmInference);
723
+ engine = null;
724
+ llmInference = null;
725
+ geminiNanoModel = null;
726
+ modelPath = null;
727
+ modelType = null;
728
+ isReady = false;
729
+ }
730
+
731
+ private void closeEngine(Engine loadedEngine) {
732
+ if (loadedEngine != null) {
733
+ try {
734
+ loadedEngine.close();
735
+ } catch (Exception exception) {
736
+ android.util.Log.w("LLM", "Failed to close LiteRT-LM engine", exception);
737
+ }
738
+ }
739
+ }
740
+
741
+ private void closeMediaPipeSession(LlmInferenceSession inferenceSession) {
742
+ if (inferenceSession != null) {
743
+ try {
744
+ inferenceSession.close();
745
+ } catch (Exception exception) {
746
+ android.util.Log.e("LLM", "Failed to close MediaPipe session", exception);
747
+ }
748
+ }
749
+ }
750
+
751
+ private void closeLlmInference(LlmInference inference) {
752
+ if (inference != null) {
753
+ try {
754
+ inference.close();
755
+ } catch (Exception exception) {
756
+ android.util.Log.w("LLM", "Failed to close MediaPipe model", exception);
757
+ }
758
+ }
759
+ }
760
+
761
+ private void clearChatSessions() {
762
+ for (ChatSession session : chatSessions.values()) {
763
+ if (session.conversation != null) {
764
+ try {
765
+ session.conversation.close();
766
+ } catch (Exception exception) {
767
+ android.util.Log.w("LLM", "Failed to close LiteRT-LM conversation", exception);
768
+ }
769
+ }
770
+ }
771
+
772
+ chatSessions.clear();
773
+ }
774
+
485
775
  public interface MessageCallback {
486
776
  void onTextReceived(String chatId, String text, boolean isChunk);
487
777
  void onComplete(String chatId);
@@ -493,17 +783,31 @@ public class LLM {
493
783
  void onError(String error);
494
784
  }
495
785
 
496
- // Inner class to manage chat sessions
497
786
  private static class ChatSession {
498
787
 
499
- private StringBuilder history;
788
+ private final BackendType backendType;
789
+ private final Conversation conversation;
790
+ private final StringBuilder history;
500
791
 
501
- ChatSession() {
792
+ private ChatSession(BackendType backendType, Conversation conversation) {
793
+ this.backendType = backendType;
794
+ this.conversation = conversation;
502
795
  this.history = new StringBuilder();
503
796
  }
504
797
 
798
+ static ChatSession forLiteRt(Conversation conversation) {
799
+ return new ChatSession(BackendType.LITERT, conversation);
800
+ }
801
+
802
+ static ChatSession forMediaPipe() {
803
+ return new ChatSession(BackendType.MEDIAPIPE, null);
804
+ }
805
+
806
+ static ChatSession forGeminiNano() {
807
+ return new ChatSession(BackendType.GEMINI_NANO, null);
808
+ }
809
+
505
810
  void addMessage(String role, String content) {
506
- // Store messages in a cleaner format for history
507
811
  if (history.length() > 0) {
508
812
  history.append("\n");
509
813
  }
@@ -514,8 +818,40 @@ public class LLM {
514
818
  }
515
819
  }
516
820
 
517
- String buildPrompt(String newMessage) {
821
+ String buildPrompt() {
518
822
  return history.toString();
519
823
  }
520
824
  }
825
+
826
+ private static class ModelLoadRequest {
827
+
828
+ private final String modelPath;
829
+ private final String modelType;
830
+ private final Integer maxTokens;
831
+ private final Integer topk;
832
+ private final Float temperature;
833
+ private final Integer randomSeed;
834
+ private final String backend;
835
+ private final long loadId;
836
+
837
+ private ModelLoadRequest(
838
+ String modelPath,
839
+ String modelType,
840
+ Integer maxTokens,
841
+ Integer topk,
842
+ Float temperature,
843
+ Integer randomSeed,
844
+ String backend,
845
+ long loadId
846
+ ) {
847
+ this.modelPath = modelPath;
848
+ this.modelType = modelType;
849
+ this.maxTokens = maxTokens;
850
+ this.topk = topk;
851
+ this.temperature = temperature;
852
+ this.randomSeed = randomSeed;
853
+ this.backend = backend;
854
+ this.loadId = loadId;
855
+ }
856
+ }
521
857
  }