LLM for Unity  v2.3.0
Create characters in Unity with LLMs!
Loading...
Searching...
No Matches
LLMUnitySetup.cs
Go to the documentation of this file.
1
3using UnityEditor;
4using System.IO;
5using UnityEngine;
6using System.Threading.Tasks;
7using System;
8using System.IO.Compression;
9using System.Collections.Generic;
10using UnityEngine.Networking;
11
15namespace LLMUnity
16{
18 public sealed class FloatAttribute : PropertyAttribute
19 {
20 public float Min { get; private set; }
21 public float Max { get; private set; }
22
23 public FloatAttribute(float min, float max)
24 {
25 Min = min;
26 Max = max;
27 }
28 }
29 public sealed class IntAttribute : PropertyAttribute
30 {
31 public int Min { get; private set; }
32 public int Max { get; private set; }
33
34 public IntAttribute(int min, int max)
35 {
36 Min = min;
37 Max = max;
38 }
39 }
40
41 [AttributeUsage(AttributeTargets.Field, Inherited = true, AllowMultiple = false)]
42 public class DynamicRangeAttribute : PropertyAttribute
43 {
44 public readonly string minVariable;
45 public readonly string maxVariable;
46 public bool intOrFloat;
47
48 public DynamicRangeAttribute(string minVariable, string maxVariable, bool intOrFloat)
49 {
50 this.minVariable = minVariable;
51 this.maxVariable = maxVariable;
52 this.intOrFloat = intOrFloat;
53 }
54 }
55
56 public class LLMAttribute : PropertyAttribute {}
57 public class LocalRemoteAttribute : PropertyAttribute {}
58 public class RemoteAttribute : PropertyAttribute {}
59 public class LocalAttribute : PropertyAttribute {}
60 public class ModelAttribute : PropertyAttribute {}
61 public class ModelExtrasAttribute : PropertyAttribute {}
62 public class ChatAttribute : PropertyAttribute {}
63 public class LLMUnityAttribute : PropertyAttribute {}
64
65 public class AdvancedAttribute : PropertyAttribute {}
66 public class LLMAdvancedAttribute : AdvancedAttribute {}
67 public class ModelAdvancedAttribute : AdvancedAttribute {}
68 public class ChatAdvancedAttribute : AdvancedAttribute {}
69
70 public class NotImplementedException : Exception
71 {
72 public NotImplementedException() : base("The method needs to be implemented by subclasses.") {}
73 }
74
75 public delegate void EmptyCallback();
76 public delegate void Callback<T>(T message);
77 public delegate Task TaskCallback<T>(T message);
78 public delegate T2 ContentCallback<T, T2>(T message);
79 public delegate void ActionCallback(string source, string target);
80
81 [Serializable]
82 public struct StringPair
83 {
84 public string source;
85 public string target;
86 }
87
88 [Serializable]
89 public class ListStringPair
90 {
91 public List<StringPair> pairs;
92 }
94
99 public class LLMUnitySetup
100 {
101 // DON'T CHANGE! the version is autocompleted with a GitHub action
103 public static string Version = "v2.3.0";
105 public static string LlamaLibVersion = "v1.1.12";
107 public static string LlamaLibReleaseURL = $"https://github.com/undreamai/LlamaLib/releases/download/{LlamaLibVersion}";
109 public static string LlamaLibURL = $"{LlamaLibReleaseURL}/undreamai-{LlamaLibVersion}-llamacpp.zip";
111 public static string LlamaLibExtensionURL = $"{LlamaLibReleaseURL}/undreamai-{LlamaLibVersion}-llamacpp-full.zip";
113 public static string libraryPath = GetAssetPath(Path.GetFileName(LlamaLibURL).Replace(".zip", ""));
115 public static string LLMUnityStore = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "LLMUnity");
117 public static string modelDownloadPath = Path.Combine(LLMUnityStore, "models");
119 public static string LLMManagerPath = GetAssetPath("LLMManager.json");
120
122 [HideInInspector] public static readonly Dictionary<string, (string, string, string)[]> modelOptions = new Dictionary<string, (string, string, string)[]>()
123 {
124 {"Medium models", new(string, string, string)[]
125 {
126 ("Llama 3.1 8B", "https://huggingface.co/bartowski/Meta-Llama-3.1-8B-Instruct-GGUF/resolve/main/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf?download=true", "https://huggingface.co/meta-llama/Meta-Llama-3.1-8B/blob/main/LICENSE"),
127 ("Mistral 7B Instruct v0.2", "https://huggingface.co/TheBloke/Mistral-7B-Instruct-v0.2-GGUF/resolve/main/mistral-7b-instruct-v0.2.Q4_K_M.gguf?download=true", null),
128 ("Gemma 2 9B it", "https://huggingface.co/bartowski/gemma-2-9b-it-GGUF/resolve/main/gemma-2-9b-it-Q4_K_M.gguf?download=true", "https://ai.google.dev/gemma/terms"),
129 ("OpenHermes 2.5 7B", "https://huggingface.co/TheBloke/OpenHermes-2.5-Mistral-7B-GGUF/resolve/main/openhermes-2.5-mistral-7b.Q4_K_M.gguf?download=true", null),
130 }},
131 {"Small models", new(string, string, string)[]
132 {
133 ("Llama 3.2 3B", "https://huggingface.co/hugging-quants/Llama-3.2-3B-Instruct-Q4_K_M-GGUF/resolve/main/llama-3.2-3b-instruct-q4_k_m.gguf", null),
134 ("Phi 3.5 4B", "https://huggingface.co/bartowski/Phi-3.5-mini-instruct-GGUF/resolve/main/Phi-3.5-mini-instruct-Q4_K_M.gguf", null),
135 }},
136 {"Tiny models", new(string, string, string)[]
137 {
138 ("Llama 3.2 1B", "https://huggingface.co/hugging-quants/Llama-3.2-1B-Instruct-Q4_K_M-GGUF/resolve/main/llama-3.2-1b-instruct-q4_k_m.gguf", null),
139 ("Qwen 2 0.5B", "https://huggingface.co/Qwen/Qwen2-0.5B-Instruct-GGUF/resolve/main/qwen2-0_5b-instruct-q4_k_m.gguf?download=true", null),
140 }},
141 {"RAG models", new(string, string, string)[]
142 {
143 ("All MiniLM L12 v2", "https://huggingface.co/leliuga/all-MiniLM-L12-v2-GGUF/resolve/main/all-MiniLM-L12-v2.Q4_K_M.gguf", null),
144 ("BGE large en v1.5", "https://huggingface.co/CompendiumLabs/bge-large-en-v1.5-gguf/resolve/main/bge-large-en-v1.5-q4_k_m.gguf", null),
145 ("BGE base en v1.5", "https://huggingface.co/CompendiumLabs/bge-base-en-v1.5-gguf/resolve/main/bge-base-en-v1.5-q4_k_m.gguf", null),
146 ("BGE small en v1.5", "https://huggingface.co/CompendiumLabs/bge-small-en-v1.5-gguf/resolve/main/bge-small-en-v1.5-q4_k_m.gguf", null),
147 }},
148 };
149
151 [LLMUnity] public static DebugModeType DebugMode = DebugModeType.All;
152 static string DebugModeKey = "DebugMode";
153 public static bool FullLlamaLib = false;
154 static string FullLlamaLibKey = "FullLlamaLib";
155 static List<Callback<string>> errorCallbacks = new List<Callback<string>>();
156 static readonly object lockObject = new object();
157 static Dictionary<string, Task> androidExtractTasks = new Dictionary<string, Task>();
158
159 public enum DebugModeType
160 {
161 All,
162 Warning,
163 Error,
164 None
165 }
166
167 public static void Log(string message)
168 {
169 if ((int)DebugMode > (int)DebugModeType.All) return;
170 Debug.Log(message);
171 }
172
173 public static void LogWarning(string message)
174 {
175 if ((int)DebugMode > (int)DebugModeType.Warning) return;
176 Debug.LogWarning(message);
177 }
178
179 public static void LogError(string message)
180 {
181 if ((int)DebugMode > (int)DebugModeType.Error) return;
182 Debug.LogError(message);
183 foreach (Callback<string> errorCallback in errorCallbacks) errorCallback(message);
184 }
185
186 static void LoadPlayerPrefs()
187 {
188 DebugMode = (DebugModeType)PlayerPrefs.GetInt(DebugModeKey, (int)DebugModeType.All);
189 FullLlamaLib = PlayerPrefs.GetInt(FullLlamaLibKey, 0) == 1;
190 }
191
192 public static void SetDebugMode(DebugModeType newDebugMode)
193 {
194 if (DebugMode == newDebugMode) return;
195 DebugMode = newDebugMode;
196 PlayerPrefs.SetInt(DebugModeKey, (int)DebugMode);
197 PlayerPrefs.Save();
198 }
199
200#if UNITY_EDITOR
201 public static void SetFullLlamaLib(bool value)
202 {
203 if (FullLlamaLib == value) return;
204 FullLlamaLib = value;
205 PlayerPrefs.SetInt(FullLlamaLibKey, value ? 1 : 0);
206 PlayerPrefs.Save();
207 _ = DownloadLibrary();
208 }
209
210#endif
211
212 public static string GetAssetPath(string relPath = "")
213 {
214 // Path to store llm server binaries and models
215 string assetsDir = Application.platform == RuntimePlatform.Android ? Application.persistentDataPath : Application.streamingAssetsPath;
216 return Path.Combine(assetsDir, relPath).Replace('\\', '/');
217 }
218
219#if UNITY_EDITOR
220 [InitializeOnLoadMethod]
221 static async Task InitializeOnLoad()
222 {
223 LoadPlayerPrefs();
224 await DownloadLibrary();
225 }
226
227#else
228 [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
229 void InitializeOnLoad()
230 {
231 LoadPlayerPrefs();
232 }
233
234#endif
235
236 static Dictionary<string, ResumingWebClient> downloadClients = new Dictionary<string, ResumingWebClient>();
237
238 public static void CancelDownload(string savePath)
239 {
240 if (!downloadClients.ContainsKey(savePath)) return;
241 downloadClients[savePath].CancelDownloadAsync();
242 downloadClients.Remove(savePath);
243 }
244
245 public static async Task DownloadFile(
246 string fileUrl, string savePath, bool overwrite = false,
247 Callback<string> callback = null, Callback<float> progressCallback = null
248 )
249 {
250 if (File.Exists(savePath) && !overwrite)
251 {
252 Log($"File already exists at: {savePath}");
253 }
254 else
255 {
256 Log($"Downloading {fileUrl} to {savePath}...");
257 string tmpPath = Path.Combine(Application.temporaryCachePath, Path.GetFileName(savePath));
258
259 ResumingWebClient client = new ResumingWebClient();
260 downloadClients[savePath] = client;
261 await client.DownloadFileTaskAsyncResume(new Uri(fileUrl), tmpPath, !overwrite, progressCallback);
262 downloadClients.Remove(savePath);
263#if UNITY_EDITOR
264 AssetDatabase.StartAssetEditing();
265#endif
266 Directory.CreateDirectory(Path.GetDirectoryName(savePath));
267 File.Move(tmpPath, savePath);
268#if UNITY_EDITOR
269 AssetDatabase.StopAssetEditing();
270#endif
271 Log($"Download complete!");
272 }
273
274 progressCallback?.Invoke(1f);
275 callback?.Invoke(savePath);
276 }
277
278 public static async Task AndroidExtractFile(string assetName, bool overwrite = false, bool log = true, int chunkSize = 1024*1024)
279 {
280 Task extractionTask;
281 lock (lockObject)
282 {
283 if (!androidExtractTasks.TryGetValue(assetName, out extractionTask))
284 {
285 extractionTask = AndroidExtractFileOnce(assetName, overwrite, log, chunkSize);
286 androidExtractTasks[assetName] = extractionTask;
287 }
288 }
289 await extractionTask;
290 }
291
292 public static async Task AndroidExtractFileOnce(string assetName, bool overwrite = false, bool log = true, int chunkSize = 1024*1024)
293 {
294 string source = "jar:file://" + Application.dataPath + "!/assets/" + assetName;
295 string target = GetAssetPath(assetName);
296 if (!overwrite && File.Exists(target))
297 {
298 if (log) Log($"File {target} already exists");
299 return;
300 }
301
302 Log($"Extracting {source} to {target}");
303
304 // UnityWebRequest to read the file from StreamingAssets
305 UnityWebRequest www = UnityWebRequest.Get(source);
306 // Send the request and await its completion
307 var operation = www.SendWebRequest();
308
309 while (!operation.isDone) await Task.Delay(1);
310 if (www.result != UnityWebRequest.Result.Success)
311 {
312 LogError("Failed to load file from StreamingAssets: " + www.error);
313 }
314 else
315 {
316 byte[] buffer = new byte[chunkSize];
317 using (Stream responseStream = new MemoryStream(www.downloadHandler.data))
318 using (FileStream fileStream = new FileStream(target, FileMode.Create, FileAccess.Write))
319 {
320 int bytesRead;
321 while ((bytesRead = await responseStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
322 {
323 await fileStream.WriteAsync(buffer, 0, bytesRead);
324 }
325 }
326 }
327 }
328
329 public static async Task AndroidExtractAsset(string path, bool overwrite = false)
330 {
331 if (Application.platform != RuntimePlatform.Android) return;
332 await AndroidExtractFile(Path.GetFileName(path), overwrite);
333 }
334
335 public static string GetFullPath(string path)
336 {
337 return Path.GetFullPath(path).Replace('\\', '/');
338 }
339
340 public static bool IsSubPath(string childPath, string parentPath)
341 {
342 return GetFullPath(childPath).StartsWith(GetFullPath(parentPath), StringComparison.OrdinalIgnoreCase);
343 }
344
345 public static string RelativePath(string fullPath, string basePath)
346 {
347 // Get the full paths and replace backslashes with forward slashes (or vice versa)
348 string fullParentPath = GetFullPath(basePath).TrimEnd('/');
349 string fullChildPath = GetFullPath(fullPath);
350
351 string relativePath = fullChildPath;
352 if (fullChildPath.StartsWith(fullParentPath, StringComparison.OrdinalIgnoreCase))
353 {
354 relativePath = fullChildPath.Substring(fullParentPath.Length);
355 while (relativePath.StartsWith("/")) relativePath = relativePath.Substring(1);
356 }
357 return relativePath;
358 }
359
360#if UNITY_EDITOR
361
362 [HideInInspector] public static float libraryProgress = 1;
363
364 public static void CreateEmptyFile(string path)
365 {
366 File.Create(path).Dispose();
367 }
368
369 static void ExtractInsideDirectory(string zipPath, string extractPath, bool overwrite = true)
370 {
371 using (ZipArchive archive = ZipFile.OpenRead(zipPath))
372 {
373 foreach (ZipArchiveEntry entry in archive.Entries)
374 {
375 if (string.IsNullOrEmpty(entry.Name)) continue;
376 string destinationPath = Path.Combine(extractPath, entry.FullName);
377 Directory.CreateDirectory(Path.GetDirectoryName(destinationPath));
378 entry.ExtractToFile(destinationPath, overwrite);
379 }
380 }
381 }
382
383 static async Task DownloadAndExtractInsideDirectory(string url, string path, string setupDir)
384 {
385 string urlName = Path.GetFileName(url);
386 string setupFile = Path.Combine(setupDir, urlName + ".complete");
387 if (File.Exists(setupFile)) return;
388
389 string zipPath = Path.Combine(Application.temporaryCachePath, urlName);
390 await DownloadFile(url, zipPath, true, null, SetLibraryProgress);
391
392 AssetDatabase.StartAssetEditing();
393 ExtractInsideDirectory(zipPath, path);
394 CreateEmptyFile(setupFile);
395 AssetDatabase.StopAssetEditing();
396
397 File.Delete(zipPath);
398 }
399
400 static async Task DownloadLibrary()
401 {
402 if (libraryProgress < 1) return;
403 libraryProgress = 0;
404
405 try
406 {
407 string setupDir = Path.Combine(libraryPath, "setup");
408 Directory.CreateDirectory(setupDir);
409
410 // setup LlamaLib in StreamingAssets
411 await DownloadAndExtractInsideDirectory(LlamaLibURL, libraryPath, setupDir);
412
413 // setup LlamaLib in Plugins for Android
414 AssetDatabase.StartAssetEditing();
415 string androidDir = Path.Combine(libraryPath, "android");
416 if (Directory.Exists(androidDir))
417 {
418 string androidPluginsDir = Path.Combine(Application.dataPath, "Plugins", "Android");
419 Directory.CreateDirectory(androidPluginsDir);
420 string pluginDir = Path.Combine(androidPluginsDir, Path.GetFileName(libraryPath));
421 if (Directory.Exists(pluginDir)) Directory.Delete(pluginDir, true);
422 Directory.Move(androidDir, pluginDir);
423 if (File.Exists(androidDir + ".meta")) File.Delete(androidDir + ".meta");
424 }
425 AssetDatabase.StopAssetEditing();
426
427 // setup LlamaLib extras in StreamingAssets
428 if (FullLlamaLib) await DownloadAndExtractInsideDirectory(LlamaLibExtensionURL, libraryPath, setupDir);
429 }
430 catch (Exception e)
431 {
432 LogError(e.Message);
433 }
434
435 libraryProgress = 1;
436 }
437
438 private static void SetLibraryProgress(float progress)
439 {
440 libraryProgress = Math.Min(0.99f, progress);
441 }
442
443 public static string AddAsset(string assetPath)
444 {
445 if (!File.Exists(assetPath))
446 {
447 LogError($"{assetPath} does not exist!");
448 return null;
449 }
450 string assetDir = GetAssetPath();
451 if (IsSubPath(assetPath, assetDir)) return RelativePath(assetPath, assetDir);
452
453 string filename = Path.GetFileName(assetPath);
454 string fullPath = GetAssetPath(filename);
455 AssetDatabase.StartAssetEditing();
456 foreach (string path in new string[] {fullPath, fullPath + ".meta"})
457 {
458 if (File.Exists(path)) File.Delete(path);
459 }
460 File.Copy(assetPath, fullPath);
461 AssetDatabase.StopAssetEditing();
462 return filename;
463 }
464
465#endif
467
469 public static void AddErrorCallBack(Callback<string> callback)
470 {
471 errorCallbacks.Add(callback);
472 }
473
475 public static void RemoveErrorCallBack(Callback<string> callback)
476 {
477 errorCallbacks.Remove(callback);
478 }
479
481 public static void ClearErrorCallBacks()
482 {
483 errorCallbacks.Clear();
484 }
485
486 public static int GetMaxFreqKHz(int cpuId)
487 {
488 string[] paths = new string[]
489 {
490 $"/sys/devices/system/cpu/cpufreq/stats/cpu{cpuId}/time_in_state",
491 $"/sys/devices/system/cpu/cpu{cpuId}/cpufreq/stats/time_in_state",
492 $"/sys/devices/system/cpu/cpu{cpuId}/cpufreq/cpuinfo_max_freq"
493 };
494
495 foreach (var path in paths)
496 {
497 if (!File.Exists(path)) continue;
498
499 int maxFreqKHz = 0;
500 using (StreamReader sr = new StreamReader(path))
501 {
502 string line;
503 while ((line = sr.ReadLine()) != null)
504 {
505 string[] parts = line.Split(' ');
506 if (parts.Length > 0 && int.TryParse(parts[0], out int freqKHz))
507 {
508 if (freqKHz > maxFreqKHz)
509 {
510 maxFreqKHz = freqKHz;
511 }
512 }
513 }
514 }
515 if (maxFreqKHz != 0) return maxFreqKHz;
516 }
517 return -1;
518 }
519
520 public static bool IsSmtCpu(int cpuId)
521 {
522 string[] paths = new string[]
523 {
524 $"/sys/devices/system/cpu/cpu{cpuId}/topology/core_cpus_list",
525 $"/sys/devices/system/cpu/cpu{cpuId}/topology/thread_siblings_list"
526 };
527
528 foreach (var path in paths)
529 {
530 if (!File.Exists(path)) continue;
531 using (StreamReader sr = new StreamReader(path))
532 {
533 string line;
534 while ((line = sr.ReadLine()) != null)
535 {
536 if (line.Contains(",") || line.Contains("-"))
537 {
538 return true;
539 }
540 }
541 }
542 }
543 return false;
544 }
545
550 public static int AndroidGetNumBigCores()
551 {
552 int maxFreqKHzMin = int.MaxValue;
553 int maxFreqKHzMax = 0;
554 List<int> cpuMaxFreqKHz = new List<int>();
555 List<bool> cpuIsSmtCpu = new List<bool>();
556
557 try
558 {
559 string cpuPath = "/sys/devices/system/cpu/";
560 int coreIndex;
561 if (Directory.Exists(cpuPath))
562 {
563 foreach (string cpuDir in Directory.GetDirectories(cpuPath))
564 {
565 string dirName = Path.GetFileName(cpuDir);
566 if (!dirName.StartsWith("cpu")) continue;
567 if (!int.TryParse(dirName.Substring(3), out coreIndex)) continue;
568
569 int maxFreqKHz = GetMaxFreqKHz(coreIndex);
570 cpuMaxFreqKHz.Add(maxFreqKHz);
571 if (maxFreqKHz > maxFreqKHzMax) maxFreqKHzMax = maxFreqKHz;
572 if (maxFreqKHz < maxFreqKHzMin) maxFreqKHzMin = maxFreqKHz;
573 cpuIsSmtCpu.Add(IsSmtCpu(coreIndex));
574 }
575 }
576 }
577 catch (Exception e)
578 {
579 LogError(e.Message);
580 }
581
582 int numBigCores = 0;
583 int numCores = SystemInfo.processorCount;
584 int maxFreqKHzMedium = (maxFreqKHzMin + maxFreqKHzMax) / 2;
585 if (maxFreqKHzMedium == maxFreqKHzMax) numBigCores = numCores;
586 else
587 {
588 for (int i = 0; i < cpuMaxFreqKHz.Count; i++)
589 {
590 if (cpuIsSmtCpu[i] || cpuMaxFreqKHz[i] >= maxFreqKHzMedium) numBigCores++;
591 }
592 }
593
594 if (numBigCores == 0) numBigCores = SystemInfo.processorCount / 2;
595 else numBigCores = Math.Min(numBigCores, SystemInfo.processorCount);
596
597 return numBigCores;
598 }
599
605 {
606 List<int> capacities = new List<int>();
607 int minCapacity = int.MaxValue;
608 try
609 {
610 string cpuPath = "/sys/devices/system/cpu/";
611 int coreIndex;
612 if (Directory.Exists(cpuPath))
613 {
614 foreach (string cpuDir in Directory.GetDirectories(cpuPath))
615 {
616 string dirName = Path.GetFileName(cpuDir);
617 if (!dirName.StartsWith("cpu")) continue;
618 if (!int.TryParse(dirName.Substring(3), out coreIndex)) continue;
619
620 string capacityPath = Path.Combine(cpuDir, "cpu_capacity");
621 if (!File.Exists(capacityPath)) break;
622
623 int capacity = int.Parse(File.ReadAllText(capacityPath).Trim());
624 capacities.Add(capacity);
625 if (minCapacity > capacity) minCapacity = capacity;
626 }
627 }
628 }
629 catch (Exception e)
630 {
631 LogError(e.Message);
632 }
633
634 int numBigCores = 0;
635 foreach (int capacity in capacities)
636 {
637 if (capacity >= 2 * minCapacity) numBigCores++;
638 }
639
640 if (numBigCores == 0 || numBigCores > SystemInfo.processorCount) numBigCores = SystemInfo.processorCount;
641 return numBigCores;
642 }
643 }
644}
Class implementing helper functions for setup and process management.
static int AndroidGetNumBigCoresCapacity()
Calculates the number of big cores in Android similarly to Unity (https://docs.unity3d....
static void RemoveErrorCallBack(Callback< string > callback)
Remove callback function added for error logs.
static string LLMManagerPath
Path of file with build information for runtime.
static string LlamaLibReleaseURL
LlamaLib release url.
static string libraryPath
LlamaLib path.
static void ClearErrorCallBacks()
Remove all callback function added for error logs.
static string modelDownloadPath
Model download path.
static string LlamaLibVersion
LlamaLib version.
static int AndroidGetNumBigCores()
Calculates the number of big cores in Android similarly to ncnn (https://github.com/Tencent/ncnn)
static string Version
LLM for Unity version.
static readonly Dictionary< string,(string, string, string)[]> modelOptions
Default models for download.
static string LLMUnityStore
LLMnity store path.
static string LlamaLibExtensionURL
LlamaLib extension url.
static string LlamaLibURL
LlamaLib url.
static void AddErrorCallBack(Callback< string > callback)
Add callback function to call for error logs.