Skip to content

Commit

Permalink
Implement support for self-hosted and local LLMs (#20)
Browse files Browse the repository at this point in the history
  • Loading branch information
SommerEngineering authored Jul 3, 2024
1 parent 6cc1d37 commit 2926366
Show file tree
Hide file tree
Showing 21 changed files with 408 additions and 52 deletions.
1 change: 1 addition & 0 deletions app/MindWork AI Studio/Components/Blocks/Changelog.Logs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public readonly record struct Log(int Build, string Display, string Filename)

public static readonly Log[] LOGS =
[
new (159, "v0.6.3, build 159 (2024-07-03 18:26 UTC)", "v0.6.3.md"),
new (158, "v0.6.2, build 158 (2024-07-01 18:03 UTC)", "v0.6.2.md"),
new (157, "v0.6.1, build 157 (2024-06-30 19:00 UTC)", "v0.6.1.md"),
new (156, "v0.6.0, build 156 (2024-06-30 12:49 UTC)", "v0.6.0.md"),
Expand Down
2 changes: 1 addition & 1 deletion app/MindWork AI Studio/Components/Pages/Chat.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ private async Task SendMessage()
// Use the selected provider to get the AI response.
// By awaiting this line, we wait for the entire
// content to be streamed.
await aiText.CreateFromProviderAsync(this.selectedProvider.UsedProvider.CreateProvider(this.selectedProvider.InstanceName), this.JsRuntime, this.SettingsManager, this.selectedProvider.Model, this.chatThread);
await aiText.CreateFromProviderAsync(this.selectedProvider.UsedProvider.CreateProvider(this.selectedProvider.InstanceName, this.selectedProvider.Hostname), this.JsRuntime, this.SettingsManager, this.selectedProvider.Model, this.chatThread);

// Disable the stream state:
this.isStreaming = false;
Expand Down
17 changes: 13 additions & 4 deletions app/MindWork AI Studio/Components/Pages/Settings.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@page "/settings"
@using AIStudio.Provider

<MudText Typo="Typo.h3" Class="mb-12">Settings</MudText>

Expand All @@ -11,7 +12,7 @@
<col style="width: 12em;"/>
<col style="width: 12em;"/>
<col/>
<col style="width: 20em;"/>
<col style="width: 34em;"/>
</ColGroup>
<HeaderContent>
<MudTh>#</MudTh>
Expand All @@ -24,12 +25,20 @@
<MudTd>@context.Num</MudTd>
<MudTd>@context.InstanceName</MudTd>
<MudTd>@context.UsedProvider</MudTd>
<MudTd>@context.Model</MudTd>
<MudTd>
@if(context.UsedProvider is not Providers.SELF_HOSTED)
@context.Model
else
@("as selected by provider")
</MudTd>
<MudTd Style="text-align: left;">
<MudButton Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.Edit" Class="mr-2" OnClick="() => this.EditProvider(context)">
<MudButton Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.OpenInBrowser" Class="ma-2" Href="@this.GetProviderDashboardURL(context.UsedProvider)" Target="_blank" Disabled="@(context.UsedProvider is Providers.NONE or Providers.SELF_HOSTED)">
Open Dashboard
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.Edit" Class="ma-2" OnClick="() => this.EditProvider(context)">
Edit
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Error" StartIcon="@Icons.Material.Filled.Delete" Class="mr-2" OnClick="() => this.DeleteProvider(context)">
<MudButton Variant="Variant.Filled" Color="Color.Error" StartIcon="@Icons.Material.Filled.Delete" Class="ma-2" OnClick="() => this.DeleteProvider(context)">
Delete
</MudButton>
</MudTd>
Expand Down
13 changes: 12 additions & 1 deletion app/MindWork AI Studio/Components/Pages/Settings.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ private async Task EditProvider(AIStudio.Settings.Provider provider)
{ x => x.DataInstanceName, provider.InstanceName },
{ x => x.DataProvider, provider.UsedProvider },
{ x => x.DataModel, provider.Model },
{ x => x.DataHostname, provider.Hostname },
{ x => x.IsSelfHosted, provider.IsSelfHosted },
{ x => x.IsEditing, true },
};

Expand Down Expand Up @@ -81,14 +83,23 @@ private async Task DeleteProvider(AIStudio.Settings.Provider provider)
if (dialogResult.Canceled)
return;

var providerInstance = provider.UsedProvider.CreateProvider(provider.InstanceName);
var providerInstance = provider.UsedProvider.CreateProvider(provider.InstanceName, provider.Hostname);
var deleteSecretResponse = await this.SettingsManager.DeleteAPIKey(this.JsRuntime, providerInstance);
if(deleteSecretResponse.Success)
{
this.SettingsManager.ConfigurationData.Providers.Remove(provider);
await this.SettingsManager.StoreSettings();
}
}

private string GetProviderDashboardURL(Providers provider) => provider switch
{
Providers.OPEN_AI => "https://platform.openai.com/usage",
Providers.MISTRAL => "https://console.mistral.ai/usage/",
Providers.ANTHROPIC => "https://console.anthropic.com/settings/plans",

_ => string.Empty,
};

#endregion
}
14 changes: 12 additions & 2 deletions app/MindWork AI Studio/Provider/Providers.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using AIStudio.Provider.Anthropic;
using AIStudio.Provider.Mistral;
using AIStudio.Provider.OpenAI;
using AIStudio.Provider.SelfHosted;

namespace AIStudio.Provider;

Expand All @@ -10,9 +11,12 @@ namespace AIStudio.Provider;
public enum Providers
{
NONE,

OPEN_AI,
ANTHROPIC,
MISTRAL,

SELF_HOSTED,
}

/// <summary>
Expand All @@ -27,11 +31,14 @@ public static class ExtensionsProvider
/// <returns>The human-readable name of the provider.</returns>
public static string ToName(this Providers provider) => provider switch
{
Providers.NONE => "No provider selected",

Providers.OPEN_AI => "OpenAI",
Providers.ANTHROPIC => "Anthropic",
Providers.MISTRAL => "Mistral",

Providers.NONE => "No provider selected",
Providers.SELF_HOSTED => "Self-hosted",

_ => "Unknown",
};

Expand All @@ -40,13 +47,16 @@ public static class ExtensionsProvider
/// </summary>
/// <param name="provider">The provider value.</param>
/// <param name="instanceName">The used instance name.</param>
/// <param name="hostname">The hostname of the provider.</param>
/// <returns>The provider instance.</returns>
public static IProvider CreateProvider(this Providers provider, string instanceName) => provider switch
public static IProvider CreateProvider(this Providers provider, string instanceName, string hostname = "http://localhost:1234") => provider switch
{
Providers.OPEN_AI => new ProviderOpenAI { InstanceName = instanceName },
Providers.ANTHROPIC => new ProviderAnthropic { InstanceName = instanceName },
Providers.MISTRAL => new ProviderMistral { InstanceName = instanceName },

Providers.SELF_HOSTED => new ProviderSelfHosted(hostname) { InstanceName = instanceName },

_ => new NoProvider(),
};
}
16 changes: 16 additions & 0 deletions app/MindWork AI Studio/Provider/SelfHosted/ChatRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace AIStudio.Provider.SelfHosted;

/// <summary>
/// The chat request model.
/// </summary>
/// <param name="Model">Which model to use for chat completion.</param>
/// <param name="Messages">The chat messages.</param>
/// <param name="Stream">Whether to stream the chat completion.</param>
/// <param name="MaxTokens">The maximum number of tokens to generate.</param>
public readonly record struct ChatRequest(
string Model,
IList<Message> Messages,
bool Stream,

int MaxTokens
);
8 changes: 8 additions & 0 deletions app/MindWork AI Studio/Provider/SelfHosted/Message.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace AIStudio.Provider.SelfHosted;

/// <summary>
/// Chat message model.
/// </summary>
/// <param name="Content">The text content of the message.</param>
/// <param name="Role">The role of the message.</param>
public readonly record struct Message(string Content, string Role);
5 changes: 5 additions & 0 deletions app/MindWork AI Studio/Provider/SelfHosted/ModelsResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace AIStudio.Provider.SelfHosted;

public readonly record struct ModelsResponse(string Object, Model[] Data);

public readonly record struct Model(string Id, string Object, string OwnedBy);
162 changes: 162 additions & 0 deletions app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;

using AIStudio.Chat;
using AIStudio.Provider.OpenAI;
using AIStudio.Settings;

namespace AIStudio.Provider.SelfHosted;

public sealed class ProviderSelfHosted(string hostname) : BaseProvider($"{hostname}/v1/"), IProvider
{
private static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};

#region Implementation of IProvider

public string Id => "Self-hosted";

public string InstanceName { get; set; } = "Self-hosted";

public async IAsyncEnumerable<string> StreamChatCompletion(IJSRuntime jsRuntime, SettingsManager settings, Provider.Model chatModel, ChatThread chatThread, [EnumeratorCancellation] CancellationToken token = default)
{
// Prepare the system prompt:
var systemPrompt = new Message
{
Role = "system",
Content = chatThread.SystemPrompt,
};

// Prepare the OpenAI HTTP chat request:
var providerChatRequest = JsonSerializer.Serialize(new ChatRequest
{
Model = (await this.GetTextModels(jsRuntime, settings, token: token)).First().Id,

// Build the messages:
// - First of all the system prompt
// - Then none-empty user and AI messages
Messages = [systemPrompt, ..chatThread.Blocks.Where(n => n.ContentType is ContentType.TEXT && !string.IsNullOrWhiteSpace((n.Content as ContentText)?.Text)).Select(n => new Message
{
Role = n.Role switch
{
ChatRole.USER => "user",
ChatRole.AI => "assistant",
ChatRole.SYSTEM => "system",
_ => "user",
},
Content = n.Content switch
{
ContentText text => text.Text,
_ => string.Empty,
}
}).ToList()],

// Right now, we only support streaming completions:
Stream = true,
MaxTokens = -1,
}, JSON_SERIALIZER_OPTIONS);

// Build the HTTP post request:
var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions");

// Set the content:
request.Content = new StringContent(providerChatRequest, Encoding.UTF8, "application/json");

// Send the request with the ResponseHeadersRead option.
// This allows us to read the stream as soon as the headers are received.
// This is important because we want to stream the responses.
var response = await this.httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);

// Open the response stream:
var providerStream = await response.Content.ReadAsStreamAsync(token);

// Add a stream reader to read the stream, line by line:
var streamReader = new StreamReader(providerStream);

// Read the stream, line by line:
while(!streamReader.EndOfStream)
{
// Check if the token is cancelled:
if(token.IsCancellationRequested)
yield break;

// Read the next line:
var line = await streamReader.ReadLineAsync(token);

// Skip empty lines:
if(string.IsNullOrWhiteSpace(line))
continue;

// Skip lines that do not start with "data: ". Regard
// to the specification, we only want to read the data lines:
if(!line.StartsWith("data: ", StringComparison.InvariantCulture))
continue;

// Check if the line is the end of the stream:
if (line.StartsWith("data: [DONE]", StringComparison.InvariantCulture))
yield break;

ResponseStreamLine providerResponse;
try
{
// We know that the line starts with "data: ". Hence, we can
// skip the first 6 characters to get the JSON data after that.
var jsonData = line[6..];

// Deserialize the JSON data:
providerResponse = JsonSerializer.Deserialize<ResponseStreamLine>(jsonData, JSON_SERIALIZER_OPTIONS);
}
catch
{
// Skip invalid JSON data:
continue;
}

// Skip empty responses:
if(providerResponse == default || providerResponse.Choices.Count == 0)
continue;

// Yield the response:
yield return providerResponse.Choices[0].Delta.Content;
}
}

#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
/// <inheritdoc />
public async IAsyncEnumerable<ImageURL> StreamImageCompletion(IJSRuntime jsRuntime, SettingsManager settings, Provider.Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default)
{
yield break;
}
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously


public async Task<IEnumerable<Provider.Model>> GetTextModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default)
{
var request = new HttpRequestMessage(HttpMethod.Get, "models");
var response = await this.httpClient.SendAsync(request, token);
if(!response.IsSuccessStatusCode)
return [];

var modelResponse = await response.Content.ReadFromJsonAsync<ModelsResponse>(token);
if (modelResponse.Data.Length > 1)
Console.WriteLine("Warning: multiple models found; using the first one.");

var firstModel = modelResponse.Data.First();
return [ new Provider.Model(firstModel.Id) ];
}

#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
/// <inheritdoc />
public Task<IEnumerable<Provider.Model>> GetImageModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default)
{
return Task.FromResult(Enumerable.Empty<Provider.Model>());
}
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously

#endregion
}
2 changes: 1 addition & 1 deletion app/MindWork AI Studio/Settings/Data.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ public sealed class Data
/// The version of the settings file. Allows us to upgrade the settings
/// when a new version is available.
/// </summary>
public Version Version { get; init; } = Version.V1;
public Version Version { get; init; } = Version.V2;

/// <summary>
/// List of configured providers.
Expand Down
7 changes: 6 additions & 1 deletion app/MindWork AI Studio/Settings/Provider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ namespace AIStudio.Settings;
/// <param name="Id">The provider's ID.</param>
/// <param name="InstanceName">The provider's instance name. Useful for multiple instances of the same provider, e.g., to distinguish between different OpenAI API keys.</param>
/// <param name="UsedProvider">The provider used.</param>
/// <param name="IsSelfHosted">Whether the provider is self-hosted.</param>
/// <param name="Hostname">The hostname of the provider. Useful for self-hosted providers.</param>
/// <param name="Model">The LLM model to use for chat.</param>
public readonly record struct Provider(uint Num, string Id, string InstanceName, Providers UsedProvider, Model Model)
public readonly record struct Provider(uint Num, string Id, string InstanceName, Providers UsedProvider, Model Model, bool IsSelfHosted = false, string Hostname = "http://localhost:1234")
{
#region Overrides of ValueType

Expand All @@ -21,6 +23,9 @@ public readonly record struct Provider(uint Num, string Id, string InstanceName,
/// <returns>A string that represents the current provider in a human-readable format.</returns>
public override string ToString()
{
if(this.IsSelfHosted)
return $"{this.InstanceName} ({this.UsedProvider.ToName()}, {this.Hostname}, {this.Model})";

return $"{this.InstanceName} ({this.UsedProvider.ToName()}, {this.Model})";
}

Expand Down
Loading

0 comments on commit 2926366

Please sign in to comment.