← All source files

V3 / 536edbbdbb6e

LMStudioClient.cs

Lilith/Core/LMStudio/LMStudioClient.cs

Changed in this version
CS
using System.Net.Http.Headers;
using System.Text.Json;
using Lilith.Core.Tools;

namespace Lilith.Core.LMStudio;

public sealed class LMStudioClient(Logger logger, ChatHistoryStore? historyStore = null, HttpClient? httpClient = null)
    : ProviderClient(logger, historyStore, httpClient)
{
    public string BaseUrl { get; init; } = LMStudioSetup.BaseUrl;

    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)
    {
        if (messages.Any(m => m.Images?.Count > 0))
            throw new NotSupportedException("LM Studio image input is not implemented in this interface.");
        using var request = HttpExtensions.JsonRequest(BaseUrl + "/v1/chat/completions", new
        {
            model = Model,
            stream = false,
            messages = messages.Select(ToWireMessage),
            tools = ToolDefinitions
        });
        LMStudioSetup.Authorize(request);
        using var json = await Http.SendJsonDocumentAsync(request, cancellationToken);
        var choice = json.RootElement.GetProperty("choices")[0];
        string? finish = choice.GetProperty("finish_reason").GetString();
        if (finish is not ("stop" or "tool_calls"))
            throw new InvalidDataException("LM Studio did not return a complete text reply.");
        var message = choice.GetProperty("message");
        if (message.GetProperty("role").GetString() != "assistant")
            throw new InvalidDataException("LM Studio returned an invalid assistant message.");
        List<ToolCall>? calls = null;
        if (message.TryGetProperty("tool_calls", out var toolCalls) && toolCalls.ValueKind != JsonValueKind.Null)
        {
            calls = [];
            foreach (var call in toolCalls.EnumerateArray())
            {
                if (call.GetProperty("type").GetString() != "function" ||
                    string.IsNullOrWhiteSpace(call.GetProperty("id").GetString()))
                    throw new InvalidDataException("Invalid LM Studio function call.");
                var function = call.GetProperty("function");
                using var arguments = JsonDocument.Parse(function.GetProperty("arguments").GetString()!);
                calls.Add(new ToolCall(new ToolFunction(function.GetProperty("name").GetString()!, arguments.RootElement.Clone()),
                    call.GetProperty("id").GetString()));
            }
            if (calls.Select(c => c.Id).Distinct().Count() != calls.Count)
                throw new InvalidDataException("Duplicate LM Studio tool call IDs.");
        }
        if ((finish == "tool_calls") != (calls is { Count: > 0 }))
            throw new InvalidDataException("LM Studio tool calls do not match the finish reason.");
        string? content = message.TryGetProperty("content", out var text) && text.ValueKind == JsonValueKind.String ? text.GetString() : null;
        if (calls is not { Count: > 0 } && content is null)
            throw new InvalidDataException("LM Studio returned no assistant content.");
        int? prompt = null, completion = null;
        if (json.RootElement.TryGetProperty("usage", out var usage))
        {
            if (usage.TryGetProperty("prompt_tokens", out var p)) prompt = p.GetInt32();
            if (usage.TryGetProperty("completion_tokens", out var c)) completion = c.GetInt32();
        }
        return new ChatChunk(new ChatMessage("assistant", content ?? string.Empty, ToolCalls: calls), true, prompt, completion);
    }

    private static object ToWireMessage(ChatMessage message)
    {
        var wire = new Dictionary<string, object?> { ["role"] = message.Role, ["content"] = message.Content };
        if (message.ToolCallId is { } id) wire["tool_call_id"] = id;
        if (message.ToolCalls is { Count: > 0 } calls)
            wire["tool_calls"] = calls.Select(c => new
            {
                id = c.Id, type = "function",
                function = new { name = c.Function.Name, arguments = c.Function.Arguments.GetRawText() }
            }).ToArray();
        return wire;
    }

    public override ProviderClient CreateScenarioBranch(string prompt, Logger branchLogger, ChatHistoryStore store)
    {
        var child = new LMStudioClient(branchLogger, store, Http)
        {
            Model = Model, BaseUrl = BaseUrl, SystemPrompt = prompt, NumCtx = NumCtx, AgentName = "Scenario"
        };
        child.InitializeConversation();
        return child;
    }

    public static async Task<LMStudioClient> CreateClient(string model, string systemPrompt, Logger logger,
        bool startServerIfNotRunning, string agentName = "Lilith", ChatHistoryStore? historyStore = null)
    {
        await LMStudioSetup.EnsureServerAsync(logger, startServerIfNotRunning);
        var client = new LMStudioClient(logger, historyStore) { Model = model, SystemPrompt = systemPrompt, AgentName = agentName };
        try
        {
            var models = await LMStudioSetup.ListModelsAsync();
            var selected = models.FirstOrDefault(m => m.Id == model)
                ?? throw new InvalidOperationException($"LM Studio model '{model}' is not downloaded. Run setup first.");
            if (selected.InstanceId is not null && selected.ContextLength is > 0)
            {
                client.Model = selected.InstanceId;
                client.NumCtx = selected.ContextLength.Value;
            }
            else
            {
                using var request = HttpExtensions.JsonRequest(client.BaseUrl + "/api/v1/models/load", new { model, echo_load_config = true });
                LMStudioSetup.Authorize(request);
                using var json = await client.Http.SendJsonDocumentAsync(request);
                client.Model = json.RootElement.GetProperty("instance_id").GetString()!;
                client.NumCtx = json.RootElement.GetProperty("load_config").GetProperty("context_length").GetInt32();
            }
            if (client.NumCtx <= 0) throw new InvalidDataException("LM Studio reported an invalid context window.");
            client.InitializeConversation();
            return client;
        }
        catch { client.Dispose(); throw; }
    }
}