diff --git a/README.md b/README.md index f8278f7c..980764e1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Open Chat Playground (OCP) is a web UI that is able to connect virtually any LLM - [x] [GitHub Models](https://docs.github.com/github-models/about-github-models) - [ ] [Google Vertex AI](https://cloud.google.com/vertex-ai/docs) - [ ] [Docker Model Runner](https://docs.docker.com/ai/model-runner) -- [ ] [Foundry Local](https://learn.microsoft.com/azure/ai-foundry/foundry-local/what-is-foundry-local) +- [x] [Foundry Local](https://learn.microsoft.com/azure/ai-foundry/foundry-local/what-is-foundry-local) - [x] [Hugging Face](https://huggingface.co/docs) - [ ] [Ollama](https://github.com/ollama/ollama/tree/main/docs) - [ ] [Anthropic](https://docs.anthropic.com) diff --git a/docs/README.md b/docs/README.md index 7c7091f2..f15610da 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,7 @@ - [Azure AI Foundry](azure-ai-foundry.md) - [GitHub Models](github-models.md) +- [Foundry Local](foundry-local.md) - [Hugging Face](hugging-face.md) - [LG](lg.md) - [OpenAI](openai.md) diff --git a/docs/foundry-local.md b/docs/foundry-local.md new file mode 100644 index 00000000..53a933f6 --- /dev/null +++ b/docs/foundry-local.md @@ -0,0 +1,77 @@ +# OpenChat Playground with Foundry Local + +This page describes how to run OpenChat Playground (OCP) with Foundry Local models integration. + +## Get the repository root + +1. Get the repository root. + + ```bash + # bash/zsh + REPOSITORY_ROOT=$(git rev-parse --show-toplevel) + ``` + + ```powershell + # PowerShell + $REPOSITORY_ROOT = git rev-parse --show-toplevel + ``` + +## Run on local machine + +1. Make sure the Foundry Local server is up and running. + + ```bash + foundry service start + ``` + +1. Download the Foundry Local model. The default model OCP uses is `phi-4-mini`. + + ```bash + foundry model download phi-4-mini + ``` + + Alternatively, if you want to run with a different model, say `qwen2.5-7b`, other than the default one, download it first by running the following command. + + ```bash + foundry model download qwen2.5-7b + ``` + + Make sure to follow the model MUST be selected from the CLI output of `foundry model list`. + +1. Make sure you are at the repository root. + + ```bash + cd $REPOSITORY_ROOT + ``` + +1. Run the app. + + ```bash + # bash/zsh + dotnet run --project $REPOSITORY_ROOT/src/OpenChat.PlaygroundApp -- \ + --connector-type FoundryLocal + ``` + + ```powershell + # PowerShell + dotnet run --project $REPOSITORY_ROOT\src\OpenChat.PlaygroundApp -- ` + --connector-type FoundryLocal + ``` + + Alternatively, if you want to run with a different model, say `qwen2.5-7b`, make sure you've already downloaded the model by running the `foundry model download qwen2.5-7b` command. + + ```bash + # bash/zsh + dotnet run --project $REPOSITORY_ROOT/src/OpenChat.PlaygroundApp -- \ + --connector-type FoundryLocal \ + --alias qwen2.5-7b + ``` + + ```powershell + # PowerShell + dotnet run --project $REPOSITORY_ROOT\src\OpenChat.PlaygroundApp -- ` + --connector-type FoundryLocal ` + --alias qwen2.5-7b + ``` + +1. Open your web browser, navigate to `http://localhost:5280`, and enter prompts. \ No newline at end of file diff --git a/src/OpenChat.PlaygroundApp/Abstractions/LanguageModelConnector.cs b/src/OpenChat.PlaygroundApp/Abstractions/LanguageModelConnector.cs index 006b41cb..0bcd469d 100644 --- a/src/OpenChat.PlaygroundApp/Abstractions/LanguageModelConnector.cs +++ b/src/OpenChat.PlaygroundApp/Abstractions/LanguageModelConnector.cs @@ -38,6 +38,7 @@ public static async Task CreateChatClientAsync(AppSettings settings { ConnectorType.AzureAIFoundry => new AzureAIFoundryConnector(settings), ConnectorType.GitHubModels => new GitHubModelsConnector(settings), + ConnectorType.FoundryLocal => new FoundryLocalConnector(settings), ConnectorType.HuggingFace => new HuggingFaceConnector(settings), ConnectorType.LG => new LGConnector(settings), ConnectorType.OpenAI => new OpenAIConnector(settings), diff --git a/src/OpenChat.PlaygroundApp/Connectors/FoundryLocalConnector.cs b/src/OpenChat.PlaygroundApp/Connectors/FoundryLocalConnector.cs new file mode 100644 index 00000000..986e56d1 --- /dev/null +++ b/src/OpenChat.PlaygroundApp/Connectors/FoundryLocalConnector.cs @@ -0,0 +1,60 @@ +using System.ClientModel; + +using Microsoft.AI.Foundry.Local; +using Microsoft.Extensions.AI; + +using OpenAI; + +using OpenChat.PlaygroundApp.Abstractions; +using OpenChat.PlaygroundApp.Configurations; + +namespace OpenChat.PlaygroundApp.Connectors; + +/// +/// This represents the connector entity for Foundry Local. +/// +/// instance. +public class FoundryLocalConnector(AppSettings settings) : LanguageModelConnector(settings.FoundryLocal) +{ + private readonly AppSettings _appSettings = settings ?? throw new ArgumentNullException(nameof(settings)); + + /// + public override bool EnsureLanguageModelSettingsValid() + { + if (this.Settings is not FoundryLocalSettings settings) + { + throw new InvalidOperationException("Missing configuration: FoundryLocal."); + } + + if (string.IsNullOrWhiteSpace(settings.Alias!.Trim())) + { + throw new InvalidOperationException("Missing configuration: FoundryLocal:Alias."); + } + + return true; + } + + /// + public override async Task GetChatClientAsync() + { + var settings = this.Settings as FoundryLocalSettings; + var alias = settings!.Alias!; + + var manager = await FoundryLocalManager.StartModelAsync(aliasOrModelId: alias).ConfigureAwait(false); + var model = await manager.GetModelInfoAsync(aliasOrModelId: alias).ConfigureAwait(false); + + var credential = new ApiKeyCredential(manager.ApiKey); + var options = new OpenAIClientOptions() + { + Endpoint = manager.Endpoint, + }; + + var client = new OpenAIClient(credential, options); + var chatClient = client.GetChatClient(model?.ModelId) + .AsIChatClient(); + + Console.WriteLine($"The {this._appSettings.ConnectorType} connector created with model: {settings.Alias}"); + + return chatClient; + } +} \ No newline at end of file diff --git a/test/OpenChat.PlaygroundApp.Tests/Abstractions/LanguageModelConnectorTests.cs b/test/OpenChat.PlaygroundApp.Tests/Abstractions/LanguageModelConnectorTests.cs index dc52a413..2ce915dd 100644 --- a/test/OpenChat.PlaygroundApp.Tests/Abstractions/LanguageModelConnectorTests.cs +++ b/test/OpenChat.PlaygroundApp.Tests/Abstractions/LanguageModelConnectorTests.cs @@ -77,7 +77,6 @@ public void Given_Null_Settings_When_CreateChatClient_Invoked_Then_It_Should_Thr [InlineData(ConnectorType.AmazonBedrock)] [InlineData(ConnectorType.GoogleVertexAI)] [InlineData(ConnectorType.DockerModelRunner)] - [InlineData(ConnectorType.FoundryLocal)] [InlineData(ConnectorType.Ollama)] [InlineData(ConnectorType.Anthropic)] [InlineData(ConnectorType.Naver)] @@ -93,4 +92,31 @@ public void Given_Unsupported_ConnectorType_When_CreateChatClient_Invoked_Then_I func.ShouldThrow() .Message.ShouldContain($"Connector type '{connectorType}'"); } -} \ No newline at end of file + + [Trait("Category", "UnitTest")] + [Theory] + // [InlineData(typeof(AmazonBedrockConnector))] + // [InlineData(typeof(AzureAIFoundryConnector))] + [InlineData(typeof(GitHubModelsConnector))] + // [InlineData(typeof(GoogleVertexAIConnector))] + // [InlineData(typeof(DockerModelRunnerConnector))] + [InlineData(typeof(FoundryLocalConnector))] + [InlineData(typeof(HuggingFaceConnector))] + // [InlineData(typeof(OllamaConnector))] + // [InlineData(typeof(AnthropicConnector))] + // [InlineData(typeof(LGConnector))] + // [InlineData(typeof(NaverConnector))] + [InlineData(typeof(OpenAIConnector))] + // [InlineData(typeof(UpstageConnector))] + public void Given_Concrete_Connectors_When_Checking_Inheritance_Then_Should_Inherit_From_LanguageModelConnector(Type derivedType) + { + // Arrange + var baseType = typeof(LanguageModelConnector); + + // Act + var result = baseType.IsAssignableFrom(derivedType); + + // Assert + result.ShouldBeTrue(); + } +} diff --git a/test/OpenChat.PlaygroundApp.Tests/Connectors/FoundryLocalConnectorTests.cs b/test/OpenChat.PlaygroundApp.Tests/Connectors/FoundryLocalConnectorTests.cs new file mode 100644 index 00000000..3e78f38f --- /dev/null +++ b/test/OpenChat.PlaygroundApp.Tests/Connectors/FoundryLocalConnectorTests.cs @@ -0,0 +1,217 @@ +using Microsoft.Extensions.AI; + +using OpenChat.PlaygroundApp.Abstractions; +using OpenChat.PlaygroundApp.Configurations; +using OpenChat.PlaygroundApp.Connectors; + +namespace OpenChat.PlaygroundApp.Tests.Connectors; + +public class FoundryLocalConnectorTests +{ + private const string Alias = "phi-4-mini"; + + private static AppSettings BuildAppSettings(string? alias = Alias) + { + return new AppSettings + { + ConnectorType = ConnectorType.FoundryLocal, + FoundryLocal = new FoundryLocalSettings + { + Alias = alias + } + }; + } + + [Trait("Category", "UnitTest")] + [Theory] + [InlineData(typeof(LanguageModelConnector), typeof(FoundryLocalConnector), true)] + [InlineData(typeof(FoundryLocalConnector), typeof(LanguageModelConnector), false)] + public void Given_BaseType_Then_It_Should_Be_AssignableFrom_DerivedType(Type baseType, Type derivedType, bool expected) + { + // Act + var result = baseType.IsAssignableFrom(derivedType); + + // Assert + result.ShouldBe(expected); + } + + [Trait("Category", "UnitTest")] + [Fact] + public void Given_Null_Settings_When_Instantiated_Then_It_Should_Throw() + { + // Act + Action action = () => new FoundryLocalConnector(null!); + + // Assert + action.ShouldThrow() + .Message.ShouldContain("settings"); + } + + [Trait("Category", "UnitTest")] + [Fact] + public void Given_Settings_Is_Null_When_EnsureLanguageModelSettingsValid_Invoked_Then_It_Should_Throw() + { + // Arrange + var settings = new AppSettings + { + ConnectorType = ConnectorType.FoundryLocal, + FoundryLocal = null + }; + var connector = new FoundryLocalConnector(settings); + + // Act + Action action = () => connector.EnsureLanguageModelSettingsValid(); + + // Assert + action.ShouldThrow() + .Message.ShouldContain("FoundryLocal"); + } + + [Trait("Category", "UnitTest")] + [Fact] + public void Given_Settings_When_Instantiated_Then_It_Should_Return() + { + // Arrange + var settings = BuildAppSettings(); + + // Act + var result = new FoundryLocalConnector(settings); + + // Assert + result.ShouldNotBeNull(); + } + + [Trait("Category", "UnitTest")] + [Fact] + public void Given_Null_Settings_When_EnsureLanguageModelSettingsValid_Invoked_Then_It_Should_Throw() + { + // Arrange + var settings = new AppSettings + { + ConnectorType = ConnectorType.FoundryLocal, + FoundryLocal = null + }; + var connector = new FoundryLocalConnector(settings); + + // Act + Action action = () => connector.EnsureLanguageModelSettingsValid(); + + // Assert + action.ShouldThrow() + .Message.ShouldContain("FoundryLocal"); + } + + [Trait("Category", "UnitTest")] + [Theory] + [InlineData(null, typeof(NullReferenceException), "Object reference not set to an instance of an object")] + [InlineData("", typeof(InvalidOperationException), "FoundryLocal:Alias")] + [InlineData(" ", typeof(InvalidOperationException), "FoundryLocal:Alias")] + [InlineData("\t\n\r", typeof(InvalidOperationException), "FoundryLocal:Alias")] + public void Given_Invalid_Alias_When_EnsureLanguageModelSettingsValid_Invoked_Then_It_Should_Throw(string? alias, Type expectedType, string expectedMessage) + { + // Arrange + var settings = BuildAppSettings(alias: alias); + var connector = new FoundryLocalConnector(settings); + + // Act + Action action = () => connector.EnsureLanguageModelSettingsValid(); + + // Assert + action.ShouldThrow(expectedType) + .Message.ShouldContain(expectedMessage); + } + + [Trait("Category", "UnitTest")] + [Fact] + public void Given_Valid_Settings_When_EnsureLanguageModelSettingsValid_Invoked_Then_It_Should_Return_True() + { + // Arrange + var settings = BuildAppSettings(); + var connector = new FoundryLocalConnector(settings); + + // Act + var result = connector.EnsureLanguageModelSettingsValid(); + + // Assert + result.ShouldBeTrue(); + } + + [Trait("Category", "IntegrationTest")] + [Trait("Category", "LLMRequired")] + [Theory] + [InlineData(null, typeof(InvalidOperationException), "Model not found in catalog.")] + [InlineData("", typeof(InvalidOperationException), "Model not found in catalog.")] + [InlineData(" ", typeof(InvalidOperationException), "Model not found in catalog.")] + [InlineData("not-a-model", typeof(InvalidOperationException), "Model not-a-model not found in catalog.")] + public void Given_Invalid_Alias_When_GetChatClient_Invoked_Then_It_Should_Throw(string? alias, Type expected, string message) + { + // Arrange + var settings = BuildAppSettings(alias: alias); + var connector = new FoundryLocalConnector(settings); + + // Act + Func func = async () => await connector.GetChatClientAsync(); + + // Assert + func.ShouldThrow(expected) + .Message.ShouldContain(message); + } + + [Trait("Category", "IntegrationTest")] + [Trait("Category", "LLMRequired")] + [Fact] + public async Task Given_Valid_Settings_When_GetChatClient_Invoked_Then_It_Should_Return_ChatClient() + { + // Arrange + var settings = BuildAppSettings(); + var connector = new FoundryLocalConnector(settings); + + // Act + var client = await connector.GetChatClientAsync(); + + // Assert + client.ShouldNotBeNull(); + client.ShouldBeAssignableTo(); + } + + [Trait("Category", "UnitTest")] + [Theory] + [InlineData(null, typeof(NullReferenceException), "Object reference not set to an instance of an object")] + [InlineData("", typeof(InvalidOperationException), "Missing configuration: FoundryLocal")] + [InlineData(" ", typeof(InvalidOperationException), "Missing configuration: FoundryLocal")] + public void Given_Invalid_Settings_When_CreateChatClientAsync_Invoked_Then_It_Should_Throw(string? alias, Type expected, string expectedMessage) + { + // Arrange + var settings = new AppSettings + { + ConnectorType = ConnectorType.FoundryLocal, + FoundryLocal = new FoundryLocalSettings + { + Alias = alias + } + }; + + // Act + Func func = async () => await LanguageModelConnector.CreateChatClientAsync(settings); + + // Assert + func.ShouldThrow(expected) + .Message.ShouldContain(expectedMessage); + } + + [Trait("Category", "IntegrationTest")] + [Trait("Category", "LLMRequired")] + [Fact] + public async Task Given_Valid_Settings_When_CreateChatClientAsync_Invoked_Then_It_Should_Return_IChatClient() + { + // Arrange + var settings = BuildAppSettings(); + + // Act + var result = await LanguageModelConnector.CreateChatClientAsync(settings); + + // Assert + result.ShouldNotBeNull(); + result.ShouldBeAssignableTo(); + } +}