← All source files

V18 / 8d896b8a86e5

LMStudioSetup.cs

Lilith/Core/LMStudio/LMStudioSetup.cs

Unchanged from the preceding version
CS
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Text.Json;

namespace Lilith.Core.LMStudio;

public static class LMStudioSetup
{
    public static string BaseUrl => (Environment.GetEnvironmentVariable("LILITH_LMSTUDIO_URL") ?? "http://127.0.0.1:1234").TrimEnd('/');
    public static void Authorize(HttpRequestMessage request)
    {
        string? token = Environment.GetEnvironmentVariable("LMSTUDIO_API_TOKEN");
        if (!string.IsNullOrWhiteSpace(token)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
    }

    public static string? GuiPath => new[]
    {
        Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "LM Studio", "LM Studio.exe"),
        Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "LM Studio", "LM Studio.exe")
    }.FirstOrDefault(File.Exists);

    private static string? CliPath
    {
        get
        {
            string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
            string pointer = Path.Combine(home, ".lmstudio-home-pointer");
            string studioHome = File.Exists(pointer) ? File.ReadAllText(pointer).Trim() : Path.Combine(home, ".lmstudio");
            return new[] { Path.Combine(studioHome, "bin", "lms.exe"),
                Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "lm-studio", "bin", "lms.exe") }
                .FirstOrDefault(File.Exists);
        }
    }

    public static bool IsInstalled() => GuiPath is not null || CliPath is not null;

    public static async Task InstallGuiAsync(Logger logger)
    {
        logger.WriteLine("LM Studio", "Downloading the official Windows GUI installer…", LogColors.Cyan);
        string installer = Path.Combine(Path.GetTempPath(), "LM-Studio-" + Guid.NewGuid().ToString("N") + ".exe");
        string verifier = installer + ".verify.ps1";
        try
        {
            using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(15) };
            using var downloadDeadline = new CancellationTokenSource(TimeSpan.FromMinutes(15));
            string arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture == System.Runtime.InteropServices.Architecture.Arm64 ? "arm64" : "x64";
            using var response = await http.GetAsync($"https://lmstudio.ai/download/latest/win32/{arch}", HttpCompletionOption.ResponseHeadersRead, downloadDeadline.Token);
            response.EnsureSuccessStatusCode();
            await using (var file = File.Create(installer))
            await using (var source = await response.Content.ReadAsStreamAsync(downloadDeadline.Token))
            {
                long total = response.Content.Headers.ContentLength ?? 0, received = 0;
                var buffer = new byte[81920];
                int count;
                while ((count = await source.ReadAsync(buffer, downloadDeadline.Token)) > 0)
                {
                    await file.WriteAsync(buffer.AsMemory(0, count), downloadDeadline.Token);
                    received += count;
                    if (total > 0) logger.WriteProgress("LM Studio", received, total);
                }
                if (received == 0 || (total > 0 && received != total)) throw new InvalidDataException("LM Studio installer download is incomplete.");
            }
            await File.WriteAllTextAsync(verifier, "param([string]$Installer)\n$ErrorActionPreference = 'Stop'\nif ((Get-AuthenticodeSignature -LiteralPath $Installer).Status -ne 'Valid') { throw 'LM Studio installer signature is invalid.' }\n");
            await RunProcessAsync("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", verifier, "-Installer", installer], logger, TimeSpan.FromSeconds(30));
            await RunProcessAsync(installer, ["/S"], logger, TimeSpan.FromMinutes(15));
            if (GuiPath is null) throw new InvalidOperationException("LM Studio GUI was not found after installation. Install it from lmstudio.ai/download and retry.");
        }
        finally
        {
            if (File.Exists(installer)) File.Delete(installer);
            if (File.Exists(verifier)) File.Delete(verifier);
        }
    }

    public static async Task EnsureServerAsync(Logger logger, bool startIfMissing = true)
    {
        if (await ProbeAsync()) return;
        if (!startIfMissing) throw new InvalidOperationException("LM Studio server is not ready. Enable Local Server in its Developer tab.");
        if (GuiPath is { } gui)
        {
            var start = new ProcessStartInfo(gui) { UseShellExecute = false, WindowStyle = ProcessWindowStyle.Hidden };
            start.Environment.Remove("ELECTRON_RUN_AS_NODE");
            Process.Start(start)?.Dispose();
        }
        // The GUI initializes its bundled CLI on first launch.
        for (int i = 0; i < 20 && CliPath is null; i++) await Task.Delay(500);
        if (CliPath is not { } cli) throw new InvalidOperationException("Open LM Studio once and enable Developer → Local Server, then retry. The lms CLI is not available yet.");
        if (!Uri.TryCreate(BaseUrl, UriKind.Absolute, out var uri) || !uri.IsLoopback)
            throw new InvalidOperationException("Automatic LM Studio startup only supports a local endpoint.");
        try
        {
            await RunProcessAsync(cli, ["server", "start", "--port", uri.Port.ToString(), "--bind", "127.0.0.1"], logger, TimeSpan.FromSeconds(60));
        }
        catch (Exception ex) when (ex is InvalidOperationException or TimeoutException)
        {
            throw new InvalidOperationException("LM Studio could not start its local service. Open the GUI, complete first-launch setup, and enable Developer → Local Server, then retry.", ex);
        }
        for (int i = 0; i < 30; i++) { if (await ProbeAsync()) return; await Task.Delay(500); }
        throw new TimeoutException("LM Studio API did not become ready. Check its Developer tab and API token settings.");
    }

    private static async Task<bool> ProbeAsync()
    {
        try { await ListModelsAsync(TimeSpan.FromSeconds(2)); return true; }
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { return false; }
    }

    public static async Task<IReadOnlyList<LMStudioModel>> ListModelsAsync(TimeSpan? timeout = null)
    {
        using var http = new HttpClient { Timeout = timeout ?? TimeSpan.FromSeconds(15) };
        using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + "/api/v1/models");
        Authorize(request);
        using var json = await http.SendJsonDocumentAsync(request);
        return json.RootElement.GetProperty("models").EnumerateArray()
            .Where(m => m.GetProperty("type").GetString() == "llm")
            .Select(m =>
            {
                var instances = m.GetProperty("loaded_instances");
                JsonElement? loaded = instances.GetArrayLength() > 0 ? instances[0] : null;
                return new LMStudioModel(m.GetProperty("key").GetString()!, m.GetProperty("display_name").GetString()!,
                    loaded?.GetProperty("id").GetString(), loaded?.GetProperty("config").GetProperty("context_length").GetInt32(), instances.GetArrayLength());
            }).ToList();
    }

    public static async Task DownloadModelAsync(string model, Logger logger)
    {
        using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
        using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(40));
        async Task<JsonDocument> GetStatusAsync(string? job = null)
        {
            using var request = job is null ? HttpExtensions.JsonRequest(BaseUrl + "/api/v1/models/download", new { model })
                : new HttpRequestMessage(HttpMethod.Get, BaseUrl + "/api/v1/models/download/status/" + Uri.EscapeDataString(job));
            Authorize(request);
            return await http.SendJsonDocumentAsync(request, deadline.Token);
        }
        using var first = await GetStatusAsync();
        string? job = first.RootElement.TryGetProperty("job_id", out var id) ? id.GetString() : null;
        string? status = first.RootElement.GetProperty("status").GetString();
        if (status is "completed" or "already_downloaded") return;
        if (status != "downloading" || job is null) throw new InvalidOperationException($"LM Studio download returned {status}.");
        while (true)
        {
            await Task.Delay(750, deadline.Token);
            using var current = await GetStatusAsync(job);
            status = current.RootElement.GetProperty("status").GetString();
            if (status == "completed") { logger.WriteLine("LM Studio", "Model download completed.", LogColors.Green); return; }
            if (status is "failed" or "paused") throw new InvalidOperationException($"LM Studio download is {status}. Resume it in LM Studio and retry setup.");
            if (current.RootElement.TryGetProperty("downloaded_bytes", out var bytes) && current.RootElement.TryGetProperty("total_size_bytes", out var total))
                logger.WriteProgress("LM Studio", bytes.GetInt64(), total.GetInt64());
        }
    }

    private static async Task RunProcessAsync(string executable, string[] arguments, Logger logger, TimeSpan timeout)
    {
        var start = new ProcessStartInfo(executable) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true };
        start.Environment.Remove("ELECTRON_RUN_AS_NODE");
        foreach (string argument in arguments) start.ArgumentList.Add(argument);
        using var process = Process.Start(start) ?? throw new InvalidOperationException("Could not start LM Studio setup.");
        async Task DrainAsync(StreamReader reader) { while (await reader.ReadLineAsync() is { } line) logger.WriteLine("LM Studio", line, LogColors.Gray); }
        Task output = DrainAsync(process.StandardOutput), error = DrainAsync(process.StandardError);
        try { await process.WaitForExitOrKillAsync(timeout, "LM Studio setup process timed out."); }
        finally { await Task.WhenAll(output, error); }
        if (process.ExitCode != 0) throw new InvalidOperationException($"LM Studio setup exited with code {process.ExitCode}.");
    }
}