/// @file /// @brief File implementing the LLM. using System; using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; using UnityEditor; using UnityEngine; namespace LLMUnity { [DefaultExecutionOrder(-1)] /// @ingroup llm /// /// Class implementing the LLM server. /// public class LLM : MonoBehaviour { /// show/hide advanced options in the GameObject [Tooltip("show/hide advanced options in the GameObject")] [HideInInspector] public bool advancedOptions = false; /// enable remote server functionality [Tooltip("enable remote server functionality")] [LocalRemote] public bool remote = false; /// port to use for the remote LLM server [Tooltip("port to use for the remote LLM server")] [Remote] public int port = 13333; /// number of threads to use (-1 = all) [Tooltip("number of threads to use (-1 = all)")] [LLM] public int numThreads = -1; /// number of model layers to offload to the GPU (0 = GPU not used). /// If the user's GPU is not supported, the LLM will fall back to the CPU [Tooltip("number of model layers to offload to the GPU (0 = GPU not used). If the user's GPU is not supported, the LLM will fall back to the CPU")] [LLM] public int numGPULayers = 0; /// log the output of the LLM in the Unity Editor. [Tooltip("log the output of the LLM in the Unity Editor.")] [LLM] public bool debug = false; /// number of prompts that can happen in parallel (-1 = number of LLMCaller objects) [Tooltip("number of prompts that can happen in parallel (-1 = number of LLMCaller objects)")] [LLMAdvanced] public int parallelPrompts = -1; /// do not destroy the LLM GameObject when loading a new Scene. [Tooltip("do not destroy the LLM GameObject when loading a new Scene.")] [LLMAdvanced] public bool dontDestroyOnLoad = true; /// Size of the prompt context (0 = context size of the model). /// This is the number of tokens the model can take as input when generating responses. [Tooltip("Size of the prompt context (0 = context size of the model). This is the number of tokens the model can take as input when generating responses.")] [DynamicRange("minContextLength", "maxContextLength", false), Model] public int contextSize = 8192; /// Batch size for prompt processing. [Tooltip("Batch size for prompt processing.")] [ModelAdvanced] public int batchSize = 512; /// Boolean set to true if the server has started and is ready to receive requests, false otherwise. public bool started { get; protected set; } = false; /// Boolean set to true if the server has failed to start. public bool failed { get; protected set; } = false; /// Boolean set to true if the models were not downloaded successfully. public static bool modelSetupFailed { get; protected set; } = false; /// Boolean set to true if the server has started and is ready to receive requests, false otherwise. public static bool modelSetupComplete { get; protected set; } = false; /// LLM model to use (.gguf format) [Tooltip("LLM model to use (.gguf format)")] [ModelAdvanced] public string model = ""; /// Chat template for the model [Tooltip("Chat template for the model")] [ModelAdvanced] public string chatTemplate = ChatTemplate.DefaultTemplate; /// LORA models to use (.gguf format) [Tooltip("LORA models to use (.gguf format)")] [ModelAdvanced] public string lora = ""; /// the weights of the LORA models being used. [Tooltip("the weights of the LORA models being used.")] [ModelAdvanced] public string loraWeights = ""; /// enable use of flash attention [Tooltip("enable use of flash attention")] [ModelExtras] public bool flashAttention = false; /// API key to use for the server [Tooltip("API key to use for the server")] public string APIKey; // SSL certificate [SerializeField] private string SSLCert = ""; public string SSLCertPath = ""; // SSL key [SerializeField] private string SSLKey = ""; public string SSLKeyPath = ""; /// \cond HIDE public int minContextLength = 0; public int maxContextLength = 0; public string architecture => llmlib.architecture; IntPtr LLMObject = IntPtr.Zero; List clients = new List(); LLMLib llmlib; StreamWrapper logStreamWrapper = null; Thread llmThread = null; List streamWrappers = new List(); public LLMManager llmManager = new LLMManager(); private readonly object startLock = new object(); static readonly object staticLock = new object(); public LoraManager loraManager = new LoraManager(); string loraPre = ""; string loraWeightsPre = ""; public bool embeddingsOnly = false; public int embeddingLength = 0; /// \endcond public LLM() { LLMManager.Register(this); } void OnValidate() { if (lora != loraPre || loraWeights != loraWeightsPre) { loraManager.FromStrings(lora, loraWeights); (loraPre, loraWeightsPre) = (lora, loraWeights); } } /// /// The Unity Awake function that starts the LLM server. /// public async void Awake() { if (!enabled) return; #if !UNITY_EDITOR modelSetupFailed = !await LLMManager.Setup(); #endif modelSetupComplete = true; if (modelSetupFailed) { failed = true; return; } string arguments = GetLlamaccpArguments(); if (arguments == null) { failed = true; return; } await Task.Run(() => StartLLMServer(arguments)); if (!started) return; if (dontDestroyOnLoad) DontDestroyOnLoad(transform.root.gameObject); } /// /// Allows to wait until the LLM is ready /// public async Task WaitUntilReady() { while (!started) await Task.Yield(); } /// /// Allows to wait until the LLM models are downloaded and ready /// /// function to call with the download progress (float) public static async Task WaitUntilModelSetup(Callback downloadProgressCallback = null) { if (downloadProgressCallback != null) LLMManager.downloadProgressCallbacks.Add(downloadProgressCallback); while (!modelSetupComplete) await Task.Yield(); return !modelSetupFailed; } /// \cond HIDE public static string GetLLMManagerAsset(string path) { #if UNITY_EDITOR if (!EditorApplication.isPlaying) return GetLLMManagerAssetEditor(path); #endif return GetLLMManagerAssetRuntime(path); } public static string GetLLMManagerAssetEditor(string path) { // empty if (string.IsNullOrEmpty(path)) return path; // LLMManager - return location the file will be stored in StreamingAssets ModelEntry modelEntry = LLMManager.Get(path); if (modelEntry != null) return modelEntry.filename; // StreamingAssets - return relative location within StreamingAssets string assetPath = LLMUnitySetup.GetAssetPath(path); // Note: this will return the full path if a full path is passed string basePath = LLMUnitySetup.GetAssetPath(); if (File.Exists(assetPath)) { if (LLMUnitySetup.IsSubPath(assetPath, basePath)) return LLMUnitySetup.RelativePath(assetPath, basePath); } // full path if (!File.Exists(assetPath)) { LLMUnitySetup.LogError($"Model {path} was not found."); } else { string errorMessage = $"The model {path} was loaded locally. You can include it in the build in one of these ways:"; errorMessage += $"\n-Copy the model inside the StreamingAssets folder and use its StreamingAssets path"; errorMessage += $"\n-Load the model with the model manager inside the LLM GameObject and use its filename"; LLMUnitySetup.LogWarning(errorMessage); } return path; } public static string GetLLMManagerAssetRuntime(string path) { // empty if (string.IsNullOrEmpty(path)) return path; // LLMManager string managerPath = LLMManager.GetAssetPath(path); if (!string.IsNullOrEmpty(managerPath) && File.Exists(managerPath)) return managerPath; // StreamingAssets string assetPath = LLMUnitySetup.GetAssetPath(path); if (File.Exists(assetPath)) return assetPath; // download path assetPath = LLMUnitySetup.GetDownloadAssetPath(path); if (File.Exists(assetPath)) return assetPath; // give up return path; } /// \endcond /// /// Allows to set the model used by the LLM. /// The model provided is copied to the Assets/StreamingAssets folder that allows it to also work in the build. /// Models supported are in .gguf format. /// /// path to model to use (.gguf format) public void SetModel(string path) { model = GetLLMManagerAsset(path); if (!string.IsNullOrEmpty(model)) { ModelEntry modelEntry = LLMManager.Get(model); if (modelEntry == null) modelEntry = new ModelEntry(GetLLMManagerAssetRuntime(model)); SetTemplate(modelEntry.chatTemplate); maxContextLength = modelEntry.contextLength; if (contextSize > maxContextLength) contextSize = maxContextLength; SetEmbeddings(modelEntry.embeddingLength, modelEntry.embeddingOnly); if (contextSize == 0 && modelEntry.contextLength > 32768) { LLMUnitySetup.LogWarning($"The model {path} has very large context size ({modelEntry.contextLength}), consider setting it to a smaller value (<=32768) to avoid filling up the RAM"); } } #if UNITY_EDITOR if (!EditorApplication.isPlaying) EditorUtility.SetDirty(this); #endif } /// /// Allows to set a LORA model to use in the LLM. /// The model provided is copied to the Assets/StreamingAssets folder that allows it to also work in the build. /// Models supported are in .gguf format. /// /// path to LORA model to use (.gguf format) public void SetLora(string path, float weight = 1) { AssertNotStarted(); loraManager.Clear(); AddLora(path, weight); } /// /// Allows to add a LORA model to use in the LLM. /// The model provided is copied to the Assets/StreamingAssets folder that allows it to also work in the build. /// Models supported are in .gguf format. /// /// path to LORA model to use (.gguf format) public void AddLora(string path, float weight = 1) { AssertNotStarted(); loraManager.Add(path, weight); UpdateLoras(); } /// /// Allows to remove a LORA model from the LLM. /// Models supported are in .gguf format. /// /// path to LORA model to remove (.gguf format) public void RemoveLora(string path) { AssertNotStarted(); loraManager.Remove(path); UpdateLoras(); } /// /// Allows to remove all LORA models from the LLM. /// public void RemoveLoras() { AssertNotStarted(); loraManager.Clear(); UpdateLoras(); } /// /// Allows to change the weight (scale) of a LORA model in the LLM. /// /// path of LORA model to change (.gguf format) /// weight of LORA public void SetLoraWeight(string path, float weight) { loraManager.SetWeight(path, weight); UpdateLoras(); if (started) ApplyLoras(); } /// /// Allows to change the weights (scale) of the LORA models in the LLM. /// /// Dictionary (string, float) mapping the path of LORA models with weights to change public void SetLoraWeights(Dictionary loraToWeight) { foreach (KeyValuePair entry in loraToWeight) loraManager.SetWeight(entry.Key, entry.Value); UpdateLoras(); if (started) ApplyLoras(); } public void UpdateLoras() { (lora, loraWeights) = loraManager.ToStrings(); (loraPre, loraWeightsPre) = (lora, loraWeights); #if UNITY_EDITOR if (!EditorApplication.isPlaying) EditorUtility.SetDirty(this); #endif } /// /// Set the chat template for the LLM. /// /// the chat template to use. The available templates can be found in the ChatTemplate.templates.Keys array public void SetTemplate(string templateName, bool setDirty = true) { chatTemplate = templateName; if (started) llmlib?.LLM_SetTemplate(LLMObject, chatTemplate); #if UNITY_EDITOR if (setDirty && !EditorApplication.isPlaying) EditorUtility.SetDirty(this); #endif } /// /// Set LLM Embedding parameters /// /// number of embedding dimensions /// if true, the LLM will be used only for embeddings public void SetEmbeddings(int embeddingLength, bool embeddingsOnly) { this.embeddingsOnly = embeddingsOnly; this.embeddingLength = embeddingLength; #if UNITY_EDITOR if (!EditorApplication.isPlaying) EditorUtility.SetDirty(this); #endif } /// \cond HIDE string ReadFileContents(string path) { if (String.IsNullOrEmpty(path)) return ""; else if (!File.Exists(path)) { LLMUnitySetup.LogError($"File {path} not found!"); return ""; } return File.ReadAllText(path); } /// \endcond /// /// Use a SSL certificate for the LLM server. /// /// the SSL certificate path public void SetSSLCert(string path) { SSLCertPath = path; SSLCert = ReadFileContents(path); } /// /// Use a SSL key for the LLM server. /// /// the SSL key path public void SetSSLKey(string path) { SSLKeyPath = path; SSLKey = ReadFileContents(path); } /// /// Returns the chat template of the LLM. /// /// chat template of the LLM public string GetTemplate() { return chatTemplate; } protected virtual string GetLlamaccpArguments() { // Start the LLM server in a cross-platform way if ((SSLCert != "" && SSLKey == "") || (SSLCert == "" && SSLKey != "")) { LLMUnitySetup.LogError($"Both SSL certificate and key need to be provided!"); return null; } if (model == "") { LLMUnitySetup.LogError("No model file provided!"); return null; } string modelPath = GetLLMManagerAssetRuntime(model); if (!File.Exists(modelPath)) { LLMUnitySetup.LogError($"File {modelPath} not found!"); return null; } loraManager.FromStrings(lora, loraWeights); string loraArgument = ""; foreach (string lora in loraManager.GetLoras()) { string loraPath = GetLLMManagerAssetRuntime(lora); if (!File.Exists(loraPath)) { LLMUnitySetup.LogError($"File {loraPath} not found!"); return null; } loraArgument += $" --lora \"{loraPath}\""; } int numThreadsToUse = numThreads; if (Application.platform == RuntimePlatform.Android && numThreads <= 0) numThreadsToUse = LLMUnitySetup.AndroidGetNumBigCores(); int slots = GetNumClients(); string arguments = $"-m \"{modelPath}\" -c {contextSize} -b {batchSize} --log-disable -np {slots}"; if (embeddingsOnly) arguments += " --embedding"; if (numThreadsToUse > 0) arguments += $" -t {numThreadsToUse}"; arguments += loraArgument; if (numGPULayers > 0) arguments += $" -ngl {numGPULayers}"; if (LLMUnitySetup.FullLlamaLib && flashAttention) arguments += $" --flash-attn"; if (remote) { arguments += $" --port {port} --host 0.0.0.0"; if (!String.IsNullOrEmpty(APIKey)) arguments += $" --api-key {APIKey}"; } // the following is the equivalent for running from command line string serverCommand; if (Application.platform == RuntimePlatform.WindowsEditor || Application.platform == RuntimePlatform.WindowsPlayer) serverCommand = "undreamai_server.exe"; else serverCommand = "./undreamai_server"; serverCommand += " " + arguments; serverCommand += $" --template \"{chatTemplate}\""; if (remote && SSLCert != "" && SSLKey != "") serverCommand += $" --ssl-cert-file {SSLCertPath} --ssl-key-file {SSLKeyPath}"; LLMUnitySetup.Log($"Deploy server command: {serverCommand}"); return arguments; } private void SetupLogging() { logStreamWrapper = ConstructStreamWrapper(LLMUnitySetup.LogWarning, true); llmlib?.Logging(logStreamWrapper.GetStringWrapper()); } private void StopLogging() { if (logStreamWrapper == null) return; llmlib?.StopLogging(); DestroyStreamWrapper(logStreamWrapper); } private void StartLLMServer(string arguments) { started = false; failed = false; bool useGPU = numGPULayers > 0; foreach (string arch in LLMLib.PossibleArchitectures(useGPU)) { string error; try { InitLib(arch); InitService(arguments); LLMUnitySetup.Log($"Using architecture: {arch}"); break; } catch (LLMException e) { error = e.Message; Destroy(); } catch (DestroyException) { break; } catch (Exception e) { error = $"{e.GetType()}: {e.Message}"; } LLMUnitySetup.Log($"Tried architecture: {arch}, error: " + error); } if (llmlib == null) { LLMUnitySetup.LogError("LLM service couldn't be created"); failed = true; return; } CallWithLock(StartService); LLMUnitySetup.Log("LLM service created"); } private void InitLib(string arch) { llmlib = new LLMLib(arch); CheckLLMStatus(false); } void CallWithLock(EmptyCallback fn) { lock (startLock) { if (llmlib == null) throw new DestroyException(); fn(); } } private void InitService(string arguments) { lock (staticLock) { if (debug) CallWithLock(SetupLogging); CallWithLock(() => { LLMObject = llmlib.LLM_Construct(arguments); }); CallWithLock(() => llmlib.LLM_SetTemplate(LLMObject, chatTemplate)); if (remote) { if (SSLCert != "" && SSLKey != "") { LLMUnitySetup.Log("Using SSL"); CallWithLock(() => llmlib.LLM_SetSSL(LLMObject, SSLCert, SSLKey)); } CallWithLock(() => llmlib.LLM_StartServer(LLMObject)); } CallWithLock(() => CheckLLMStatus(false)); } } private void StartService() { llmThread = new Thread(() => llmlib.LLM_Start(LLMObject)); llmThread.Start(); while (!llmlib.LLM_Started(LLMObject)) {} ApplyLoras(); started = true; } /// /// Registers a local LLMCaller object. /// This allows to bind the LLMCaller "client" to a specific slot of the LLM. /// /// /// public int Register(LLMCaller llmCaller) { clients.Add(llmCaller); int index = clients.IndexOf(llmCaller); if (parallelPrompts != -1) return index % parallelPrompts; return index; } protected int GetNumClients() { return Math.Max(parallelPrompts == -1 ? clients.Count : parallelPrompts, 1); } /// \cond HIDE public delegate void LLMStatusCallback(IntPtr LLMObject, IntPtr stringWrapper); public delegate void LLMNoInputReplyCallback(IntPtr LLMObject, IntPtr stringWrapper); public delegate void LLMReplyCallback(IntPtr LLMObject, string json_data, IntPtr stringWrapper); /// \endcond StreamWrapper ConstructStreamWrapper(Callback streamCallback = null, bool clearOnUpdate = false) { StreamWrapper streamWrapper = new StreamWrapper(llmlib, streamCallback, clearOnUpdate); streamWrappers.Add(streamWrapper); return streamWrapper; } void DestroyStreamWrapper(StreamWrapper streamWrapper) { streamWrappers.Remove(streamWrapper); streamWrapper.Destroy(); } /// /// The Unity Update function. It is used to retrieve the LLM replies. public void Update() { foreach (StreamWrapper streamWrapper in streamWrappers) streamWrapper.Update(); } void AssertStarted() { string error = null; if (failed) error = "LLM service couldn't be created"; else if (!started) error = "LLM service not started"; if (error != null) { LLMUnitySetup.LogError(error); throw new Exception(error); } } void AssertNotStarted() { if (started) { string error = "This method can't be called when the LLM has started"; LLMUnitySetup.LogError(error); throw new Exception(error); } } void CheckLLMStatus(bool log = true) { if (llmlib == null) { return; } IntPtr stringWrapper = llmlib.StringWrapper_Construct(); int status = llmlib.LLM_Status(LLMObject, stringWrapper); string result = llmlib.GetStringWrapperResult(stringWrapper); llmlib.StringWrapper_Delete(stringWrapper); string message = $"LLM {status}: {result}"; if (status > 0) { if (log) LLMUnitySetup.LogError(message); throw new LLMException(message, status); } else if (status < 0) { if (log) LLMUnitySetup.LogWarning(message); } } async Task LLMNoInputReply(LLMNoInputReplyCallback callback) { AssertStarted(); IntPtr stringWrapper = llmlib.StringWrapper_Construct(); await Task.Run(() => callback(LLMObject, stringWrapper)); string result = llmlib?.GetStringWrapperResult(stringWrapper); llmlib?.StringWrapper_Delete(stringWrapper); CheckLLMStatus(); return result; } async Task LLMReply(LLMReplyCallback callback, string json) { AssertStarted(); IntPtr stringWrapper = llmlib.StringWrapper_Construct(); await Task.Run(() => callback(LLMObject, json, stringWrapper)); string result = llmlib?.GetStringWrapperResult(stringWrapper); llmlib?.StringWrapper_Delete(stringWrapper); CheckLLMStatus(); return result; } /// /// Tokenises the provided query. /// /// json request containing the query /// tokenisation result public async Task Tokenize(string json) { AssertStarted(); LLMReplyCallback callback = (IntPtr LLMObject, string jsonData, IntPtr strWrapper) => { llmlib.LLM_Tokenize(LLMObject, jsonData, strWrapper); }; return await LLMReply(callback, json); } /// /// Detokenises the provided query. /// /// json request containing the query /// detokenisation result public async Task Detokenize(string json) { AssertStarted(); LLMReplyCallback callback = (IntPtr LLMObject, string jsonData, IntPtr strWrapper) => { llmlib.LLM_Detokenize(LLMObject, jsonData, strWrapper); }; return await LLMReply(callback, json); } /// /// Computes the embeddings of the provided query. /// /// json request containing the query /// embeddings result public async Task Embeddings(string json) { AssertStarted(); LLMReplyCallback callback = (IntPtr LLMObject, string jsonData, IntPtr strWrapper) => { llmlib.LLM_Embeddings(LLMObject, jsonData, strWrapper); }; return await LLMReply(callback, json); } /// /// Sets the lora scale, only works after the LLM service has started /// /// switch result public void ApplyLoras() { LoraWeightRequestList loraWeightRequest = new LoraWeightRequestList(); loraWeightRequest.loraWeights = new List(); float[] weights = loraManager.GetWeights(); if (weights.Length == 0) return; for (int i = 0; i < weights.Length; i++) { loraWeightRequest.loraWeights.Add(new LoraWeightRequest() { id = i, scale = weights[i] }); } string json = JsonUtility.ToJson(loraWeightRequest); int startIndex = json.IndexOf("["); int endIndex = json.LastIndexOf("]") + 1; json = json.Substring(startIndex, endIndex - startIndex); IntPtr stringWrapper = llmlib.StringWrapper_Construct(); llmlib.LLM_LoraWeight(LLMObject, json, stringWrapper); llmlib.StringWrapper_Delete(stringWrapper); } /// /// Gets a list of the lora adapters /// /// list of lara adapters public async Task> ListLoras() { AssertStarted(); LLMNoInputReplyCallback callback = (IntPtr LLMObject, IntPtr strWrapper) => { llmlib.LLM_LoraList(LLMObject, strWrapper); }; string json = await LLMNoInputReply(callback); if (String.IsNullOrEmpty(json)) return null; LoraWeightResultList loraRequest = JsonUtility.FromJson("{\"loraWeights\": " + json + "}"); return loraRequest.loraWeights; } /// /// Allows to save / restore the state of a slot /// /// json request containing the query /// slot result public async Task Slot(string json) { AssertStarted(); LLMReplyCallback callback = (IntPtr LLMObject, string jsonData, IntPtr strWrapper) => { llmlib.LLM_Slot(LLMObject, jsonData, strWrapper); }; return await LLMReply(callback, json); } /// /// Allows to use the chat and completion functionality of the LLM. /// /// json request containing the query /// callback function to call with intermediate responses /// completion result public async Task Completion(string json, Callback streamCallback = null) { AssertStarted(); if (streamCallback == null) streamCallback = (string s) => {}; StreamWrapper streamWrapper = ConstructStreamWrapper(streamCallback); await Task.Run(() => llmlib.LLM_Completion(LLMObject, json, streamWrapper.GetStringWrapper())); if (!started) return null; streamWrapper.Update(); string result = streamWrapper.GetString(); DestroyStreamWrapper(streamWrapper); CheckLLMStatus(); return result; } /// /// Allows to cancel the requests in a specific slot of the LLM /// /// slot of the LLM public void CancelRequest(int id_slot) { AssertStarted(); llmlib?.LLM_Cancel(LLMObject, id_slot); CheckLLMStatus(); } /// /// Stops and destroys the LLM /// public void Destroy() { lock (staticLock) lock (startLock) { try { if (llmlib != null) { if (LLMObject != IntPtr.Zero) { llmlib.LLM_Stop(LLMObject); if (remote) llmlib.LLM_StopServer(LLMObject); StopLogging(); llmThread?.Join(); llmlib.LLM_Delete(LLMObject); LLMObject = IntPtr.Zero; } llmlib.Destroy(); llmlib = null; } started = false; failed = false; } catch (Exception e) { LLMUnitySetup.LogError(e.Message); } } } /// /// The Unity OnDestroy function called when the onbject is destroyed. /// The function StopProcess is called to stop the LLM server. /// public void OnDestroy() { Destroy(); LLMManager.Unregister(this); } } }