← All source files

V4 / 2d54b29f17a8

OllamaClient.cs

Lilith/Core/Ollama/OllamaClient.cs

Unchanged from the preceding version
CS
using System.Diagnostics;

namespace Lilith.Core.Ollama;

public class OllamaClient(Logger logger, ChatHistoryStore? historyStore = null, HttpClient? httpClient = null) : ProviderClient(logger, historyStore, httpClient)
{
    private const string StatusBlue = "#AAAAFF";
    private const string StatusGray = "#AAAAAA";
    private bool _contextKnown;

    public string BaseUrl { get; } = "http://localhost:11434";
    public string ChatApiUrl => $"{BaseUrl}/api/chat";
    public string ShowApiUrl => $"{BaseUrl}/api/show";
    protected override async Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages)
        => await GenerateReplyAsync(messages, CancellationToken.None);

    protected override async Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages, CancellationToken cancellationToken)
    {
        var request = new ChatRequest(Model, messages, Stream: false, Options: _contextKnown ? new ChatOptions(NumCtx) : null,
            Tools: ToolDefinitions);
        using HttpRequestMessage httpReq = HttpExtensions.JsonRequest(ChatApiUrl, request);
        return await Http.SendJsonAsync<ChatChunk>(httpReq, cancellationToken);
    }

    public override ProviderClient CreateScenarioBranch(string prompt, Logger branchLogger, ChatHistoryStore store)
    {
        var child = new OllamaClient(branchLogger, store, Http)
        {
            Model = Model, SystemPrompt = prompt, NumCtx = NumCtx, _contextKnown = _contextKnown,
            AgentName = "Scenario"
        };
        child.InitializeConversation();
        return child;
    }
    /// <summary>Connects to a prepared model; never installs Ollama or downloads a model.</summary>
    public static async Task<OllamaClient> CreateClient(
        string model,
        string systemPrompt,
        Logger logger,
        bool startOllamaIfNotRunning,
        string agentName = "Lilith", ChatHistoryStore? historyStore = null)
    {
        var client = new OllamaClient(logger, historyStore)
        {
            Model = model,
            SystemPrompt = systemPrompt,
            AgentName = agentName
        };
        client.InitializeConversation();

        client.PrintStatus($"Initializing client for model '{model}'...", StatusBlue);

        try
        {
            await logger.TimeStageAsync("Server readiness", () => EnsureServerAsync(logger, startOllamaIfNotRunning));
            bool available = await logger.TimeStageAsync("Model readiness", () => IsModelAvailableAsync(model));
            if (!available) throw new InvalidOperationException($"Model '{model}' is not installed. Run console setup to choose and download a model.");
            client.NumCtx = await logger.TimeStageAsync("Model specifications", client.GetModelContextSizeAsync);
            client.PrintStatus(client._contextKnown ? $"Context window: {client.NumCtx:N0} tokens." :
                $"Context size is unknown; usage uses a {client.NumCtx:N0}-token estimate and Ollama selects its own request default.",
                client._contextKnown ? LogColors.Green : LogColors.Orange);
            return client;
        }
        catch
        {
            client.Http.Dispose();
            throw;
        }
    }

    internal async Task<int> GetModelContextSizeAsync()
    {
        _contextKnown = false;
        try
        {
            var request = new ShowModelRequest(Model);
            using HttpRequestMessage httpReq = HttpExtensions.JsonRequest(ShowApiUrl, request);
            using HttpResponseMessage httpResp = await Http.SendAsync(httpReq);
            if (!httpResp.IsSuccessStatusCode)
            {
                PrintStatus("Could not retrieve model specs; retaining Ollama defaults (context estimate: 4,096 tokens).", LogColors.Orange);
                return 4096;
            }

            string json = await httpResp.Content.ReadAsStringAsync();
            ShowModelResponse? response = json.Deserialize<ShowModelResponse>();
            if (response?.ModelInfo is { } info)
            {
                foreach (var kv in info)
                {
                    if (kv.Key.EndsWith(".context_length", StringComparison.Ordinal) && kv.Value.TryGetInt32(out int ctx) && ctx > 0)
                    {
                        _contextKnown = true;
                        return ctx;
                    }
                }
            }
        }
        catch (Exception ex)
        {
            PrintStatus($"Model spec query failed ({ex.Message}); retaining Ollama defaults (context estimate: 4,096 tokens).", LogColors.Orange);
        }

        PrintStatus("Valid model context metadata is unavailable; retaining Ollama defaults (context estimate: 4,096 tokens).", LogColors.Orange);
        return 4096;
    }

    public static async Task EnsureServerAsync(Logger logger, bool startIfMissing = true)
    {
        using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
        if (await http.IsReachableAsync("http://localhost:11434/")) { logger.WriteInfo("Ollama", "Ollama server already running; skipping server start."); return; }
        if (!startIfMissing) throw new InvalidOperationException("Ollama server is not running.");
        var psi = new ProcessStartInfo(ResolveExecutable()) { UseShellExecute = false, CreateNoWindow = true };
        psi.ArgumentList.Add("serve");
        using var server = Process.Start(psi) ?? throw new InvalidOperationException("Could not start Ollama server.");
        var timer = Stopwatch.StartNew();
        while (timer.Elapsed < TimeSpan.FromSeconds(60))
        {
            if (await http.IsReachableAsync("http://localhost:11434/")) return;
            // The installer/app may already own the port while still discovering GPUs.
            // An exited second serve process does not prove that the API cannot become ready.
            await Task.Delay(500);
        }
        if (!server.HasExited) server.Kill(entireProcessTree: true);
        throw new TimeoutException(server.HasExited
            ? $"Ollama API did not become ready within 60 seconds (startup process exit {server.ExitCode})."
            : "Ollama server did not become ready within 60 seconds.");
    }

    private static string ResolveExecutable()
    {
        string installed = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Ollama", "ollama.exe");
        return File.Exists(installed) ? installed : "ollama";
    }

    public static bool IsOllamaInstalled()
    {
        try
        {
            var psi = new ProcessStartInfo(ResolveExecutable(), "--version")
            {
                CreateNoWindow = true,
                UseShellExecute = false,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
            };
            using var proc = Process.Start(psi);
            if (proc == null)
            {
                return false;
            }

            Task stdout = proc.StandardOutput.ReadToEndAsync();
            Task stderr = proc.StandardError.ReadToEndAsync();

            if (!proc.WaitForExit(5000))
            {
                try
                {
                    proc.Kill();
                }
                catch
                {
                    // ignore
                }

                return false;
            }

            Task.WaitAll(stdout, stderr);
            return proc.ExitCode == 0;
        }
        catch
        {
            return false;
        }
    }

    private static async Task<bool> PullModelAsync(OllamaClient client)
    {
        try
        {
            client.PrintStatus($"Pulling model '{client.Model}'…", StatusBlue);

            var psi = new ProcessStartInfo
            {
                FileName = ResolveExecutable(),
                CreateNoWindow = true,
                UseShellExecute = false,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
            };
            psi.ArgumentList.Add("pull");
            psi.ArgumentList.Add(client.Model);

            using var proc = Process.Start(psi);
            if (proc is null)
            {
                client.PrintStatus("Could not start ollama pull process.", LogColors.Red);
                return false;
            }

            var tracker = new OllamaCliProgressTracker(client.Model);
            Task stdoutTask = OllamaCliStreamReader.PumpAsync(proc.StandardOutput, tracker);
            Task stderrTask = OllamaCliStreamReader.PumpAsync(proc.StandardError, tracker);

            DateTime started = DateTime.UtcNow;
            DateTime deadline = started.AddMinutes(30);
            while (!proc.HasExited && DateTime.UtcNow < deadline)
            {
                TimeSpan elapsed = DateTime.UtcNow - started;
                TimeSpan remaining = deadline - DateTime.UtcNow;
                client.PrintProgress(tracker.GetDisplayMessage(elapsed, remaining), StatusGray);
                await Task.Delay(250);
            }

            if (!proc.HasExited)
            {
                proc.Kill(entireProcessTree: true);
                await proc.WaitForExitAsync();
                await Task.WhenAll(stdoutTask, stderrTask);
                throw new TimeoutException("Model download exceeded 30 minutes.");
            }
            await proc.WaitForExitAsync();
            await Task.WhenAll(stdoutTask, stderrTask);

            if (proc.ExitCode != 0 && !await IsModelAvailableAsync(client.Model))
            {
                client.PrintStatus($"ollama pull failed (exit {proc.ExitCode}).", LogColors.Red);
                return false;
            }

            if (await IsModelAvailableAsync(client.Model))
            {
                client.PrintStatus(
                    $"Model '{client.Model}' ready ({(DateTime.UtcNow - started):mm\\:ss}).",
                    LogColors.Green);
                return true;
            }

            client.PrintStatus($"Timed out waiting for model '{client.Model}' to become available.", LogColors.Orange);
            if (!proc.HasExited)
            {
                proc.Kill(entireProcessTree: true);
            }

            return false;
        }
        catch (Exception ex)
        {
            client.PrintStatus($"Model pull error: {ex.Message}", LogColors.Red);
            return false;
        }
    }

    public static async Task<bool> IsModelAvailableAsync(string model)
    {
        using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
        using var request = HttpExtensions.JsonRequest("http://localhost:11434/api/show", new ShowModelRequest(model));
        using var response = await http.SendAsync(request);
        if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return false;
        response.EnsureSuccessStatusCode();
        return true;
    }

    public static async Task InstallAsync(Logger logger)
    {
        using var client = new OllamaClient(logger);
        if (!await InstallOllamaAsync(client)) throw new InvalidOperationException("Ollama installation failed. Relaunch setup to retry.");
    }

    public static async Task DownloadModelAsync(string model, Logger logger)
    {
        using var client = new OllamaClient(logger) { Model = model };
        if (!await PullModelAsync(client)) throw new InvalidOperationException($"Model '{model}' download failed. Relaunch setup to retry.");
    }

    private static async Task<bool> InstallOllamaAsync(OllamaClient client)
    {
        try
        {
            var psi = new ProcessStartInfo
            {
                FileName = "powershell",
                Arguments = "-NoProfile -ExecutionPolicy Bypass -Command " +
                            "\"irm https://ollama.com/install.ps1 | iex\"",
                CreateNoWindow = true,
                UseShellExecute = false,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
            };

            using var proc = Process.Start(psi);
            if (proc == null)
            {
                return false;
            }

            proc.OutputDataReceived += (_, e) =>
            {
                if (!string.IsNullOrEmpty(e.Data))
                {
                    client.PrintInstallerOutput(e.Data, StatusGray);
                }
            };
            proc.ErrorDataReceived += (_, e) =>
            {
                if (!string.IsNullOrEmpty(e.Data))
                {
                    client.PrintInstallerOutput(e.Data, LogColors.Orange);
                }
            };
            proc.BeginOutputReadLine();
            proc.BeginErrorReadLine();

            await proc.WaitForExitOrKillAsync(TimeSpan.FromMinutes(10), "Ollama installation timed out.");
            return proc.ExitCode == 0 && IsOllamaInstalled();
        }
        catch (Exception ex)
        {
            client.PrintStatus($"Installer error: {ex.Message}", LogColors.Red);
            return false;
        }
    }

    private void PrintStatus(string message, string color) =>
        Logger.WriteLine("Ollama", message, color);

    internal void PrintInstallerOutput(string message, string color)
    {
        if (RegexPatterns.InstallerProgress().IsMatch(message))
            PrintProgress(message.Trim(), color);
        else
            PrintStatus(message, color);
    }

    private void PrintProgress(string message, string color) =>
        Logger.WriteProgress("Ollama", message, color);
}