@capgo/capacitor-llm 8.0.19 → 8.1.0

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