V11 / ca6f28eff240
OllamaClient.cs
Lilith/Core/Ollama/OllamaClient.cs
Unchanged from the preceding versionCS
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)
{
// A cold localhost/DNS connection can exceed two seconds even when the
// API is ready. Do not misreport a running provider as absent.
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
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);
}