diff --git a/infra/main.bicep b/infra/main.bicep index 47be29cb..fdbf321a 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -19,6 +19,9 @@ param githubModelsModel string = '' @secure() param githubModelsToken string = '' // Google Vertex AI +param googleVertexAIModel string = '' +@secure() +param googleVertexAIApiKey string = '' // Docker Model Runner // Foundry Local // Hugging Face @@ -68,6 +71,8 @@ module resources 'resources.bicep' = { connectorType: connectorType githubModelsModel: githubModelsModel githubModelsToken: githubModelsToken + googleVertexAIModel: googleVertexAIModel + googleVertexAIApiKey: googleVertexAIApiKey huggingFaceModel: huggingFaceModel openAIModel: openAIModel openAIApiKey: openAIApiKey diff --git a/infra/main.parameters.json b/infra/main.parameters.json index 8fa82a24..0f2129d9 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -17,6 +17,12 @@ "githubModelsToken": { "value": "${GH_MODELS_TOKEN}" }, + "googleVertexAIModel": { + "value": "${GOOGLE_VERTEX_AI_MODEL}" + }, + "googleVertexAIApiKey": { + "value": "${GOOGLE_VERTEX_AI_API_KEY}" + }, "huggingFaceModel": { "value": "${HUGGING_FACE_MODEL}" }, diff --git a/infra/resources.bicep b/infra/resources.bicep index ec5c67bd..7963a01d 100644 --- a/infra/resources.bicep +++ b/infra/resources.bicep @@ -13,6 +13,9 @@ param githubModelsModel string = '' @secure() param githubModelsToken string = '' // Google Vertex AI +param googleVertexAIModel string = '' +@secure() +param googleVertexAIApiKey string = '' // Docker Model Runner // Foundry Local // Hugging Face @@ -120,6 +123,17 @@ var envGitHubModels = (connectorType == '' || connectorType == 'GitHubModels') ? } ] : []) : [] // Google Vertex AI +var envGoogleVertexAI = (connectorType == '' || connectorType == 'GoogleVertexAI') ? concat(googleVertexAIModel != '' ? [ + { + name: 'GoogleVertexAI__Model' + value: googleVertexAIModel + } +] : [], googleVertexAIApiKey != '' ? [ + { + name: 'GoogleVertexAI__ApiKey' + secretRef: 'google-vertex-ai-api-key' + } +] : []) : [] // Docker Model Runner // Foundry Local // Hugging Face diff --git a/src/OpenChat.PlaygroundApp/Abstractions/LanguageModelConnector.cs b/src/OpenChat.PlaygroundApp/Abstractions/LanguageModelConnector.cs index d3d9ab63..1f07eb72 100644 --- a/src/OpenChat.PlaygroundApp/Abstractions/LanguageModelConnector.cs +++ b/src/OpenChat.PlaygroundApp/Abstractions/LanguageModelConnector.cs @@ -37,6 +37,7 @@ public static async Task CreateChatClientAsync(AppSettings settings LanguageModelConnector connector = settings.ConnectorType switch { ConnectorType.GitHubModels => new GitHubModelsConnector(settings), + ConnectorType.GoogleVertexAI => new GoogleVertexAIConnector(settings), ConnectorType.HuggingFace => new HuggingFaceConnector(settings), ConnectorType.OpenAI => new OpenAIConnector(settings), _ => throw new NotSupportedException($"Connector type '{settings.ConnectorType}' is not supported.") diff --git a/src/OpenChat.PlaygroundApp/Connectors/GoogleVertexAIConnector.cs b/src/OpenChat.PlaygroundApp/Connectors/GoogleVertexAIConnector.cs new file mode 100644 index 00000000..4cda22ec --- /dev/null +++ b/src/OpenChat.PlaygroundApp/Connectors/GoogleVertexAIConnector.cs @@ -0,0 +1,51 @@ +using System.ClientModel; + +using Microsoft.Extensions.AI; + +using Mscc.GenerativeAI.Microsoft; + +using OpenChat.PlaygroundApp.Abstractions; +using OpenChat.PlaygroundApp.Configurations; + +namespace OpenChat.PlaygroundApp.Connectors; + +/// +/// This represents the connector entity for Google Vertex AI. +/// +public class GoogleVertexAIConnector(AppSettings settings) : LanguageModelConnector(settings.GoogleVertexAI) +{ + /// + public override bool EnsureLanguageModelSettingsValid() + { + var settings = this.Settings as GoogleVertexAISettings; + if (settings is null) + { + throw new InvalidOperationException("Missing configuration: GoogleVertexAI."); + } + + if (string.IsNullOrWhiteSpace(settings.ApiKey?.Trim()) == true) + { + throw new InvalidOperationException("Missing configuration: GoogleVertexAI:ApiKey."); + } + + if (string.IsNullOrWhiteSpace(settings.Model?.Trim()) == true) + { + throw new InvalidOperationException("Missing configuration: GoogleVertexAI:Model."); + } + + return true; + } + + /// + public override async Task GetChatClientAsync() + { + var settings = this.Settings as GoogleVertexAISettings; + + var apiKey = settings?.ApiKey ?? throw new InvalidOperationException("Missing configuration: GoogleVertexAI:ApiKey."); + var model = settings?.Model ?? throw new InvalidOperationException("Missing configuration: GoogleVertexAI:Model."); + + var chatClient = new GeminiChatClient(apiKey, model); + + return await Task.FromResult(chatClient).ConfigureAwait(false); + } +} diff --git a/test/OpenChat.PlaygroundApp.Tests/Abstractions/LanguageModelConnectorTests.cs b/test/OpenChat.PlaygroundApp.Tests/Abstractions/LanguageModelConnectorTests.cs index a23e9ee5..52249ec0 100644 --- a/test/OpenChat.PlaygroundApp.Tests/Abstractions/LanguageModelConnectorTests.cs +++ b/test/OpenChat.PlaygroundApp.Tests/Abstractions/LanguageModelConnectorTests.cs @@ -58,7 +58,7 @@ public async Task Given_Unsupported_ConnectorType_When_CreateChatClient_Invoked_ // [InlineData(typeof(AmazonBedrockConnector))] // [InlineData(typeof(AzureAIFoundryConnector))] [InlineData(typeof(GitHubModelsConnector))] - // [InlineData(typeof(GoogleVertexAIConnector))] + [InlineData(typeof(GoogleVertexAIConnector))] // [InlineData(typeof(DockerModelRunnerConnector))] // [InlineData(typeof(FoundryLocalConnector))] // [InlineData(typeof(HuggingFaceConnector))] diff --git a/test/OpenChat.PlaygroundApp.Tests/Connectors/GoogleVertexAIConnectorTests.cs b/test/OpenChat.PlaygroundApp.Tests/Connectors/GoogleVertexAIConnectorTests.cs new file mode 100644 index 00000000..df22be4d --- /dev/null +++ b/test/OpenChat.PlaygroundApp.Tests/Connectors/GoogleVertexAIConnectorTests.cs @@ -0,0 +1,193 @@ +using Microsoft.Extensions.AI; + +using OpenChat.PlaygroundApp.Abstractions; +using OpenChat.PlaygroundApp.Configurations; +using OpenChat.PlaygroundApp.Connectors; + +namespace OpenChat.PlaygroundApp.Tests.Connectors; + +public class GoogleVertexAIConnectorTests +{ + private const string ApiKey = "AIzaSyA1234567890abcdefgHIJKLMNOpqrstuv"; + private const string Model = "test-model"; + private static AppSettings BuildAppSettings(string? apiKey = ApiKey, string? model = Model) + { + return new AppSettings + { + ConnectorType = ConnectorType.GoogleVertexAI, + GoogleVertexAI = new GoogleVertexAISettings + { + ApiKey = apiKey, + Model = model + } + }; + } + + [Trait("Category", "UnitTest")] + [Fact] + public void Given_Settings_Is_Null_When_EnsureLanguageModelSettingsValid_Invoked_Then_It_Should_Throw() + { + // Arrange + var appSettings = new AppSettings { ConnectorType = ConnectorType.GoogleVertexAI, GoogleVertexAI = null }; + var connector = new GoogleVertexAIConnector(appSettings); + + // Act + Action action = () => connector.EnsureLanguageModelSettingsValid(); + + // Assert + action.ShouldThrow() + .Message.ShouldContain("GoogleVertexAI"); + } + + [Trait("Category", "UnitTest")] + [Theory] + [InlineData(null, "GoogleVertexAI:ApiKey")] + [InlineData("", "GoogleVertexAI:ApiKey")] + [InlineData(" ", "GoogleVertexAI:ApiKey")] + public void Given_Invalid_ApiKey_When_EnsureLanguageModelSettingsValid_Invoked_Then_It_Should_Throw(string? apiKey, string expectedMessage) + { + // Arrange + var appSettings = BuildAppSettings(apiKey: apiKey); + var connector = new GoogleVertexAIConnector(appSettings); + + // Act + Action action = () => connector.EnsureLanguageModelSettingsValid(); + + // Assert + action.ShouldThrow() + .Message.ShouldContain(expectedMessage); + } + + [Trait("Category", "UnitTest")] + [Theory] + [InlineData(null, "GoogleVertexAI:Model")] + [InlineData("", "GoogleVertexAI:Model")] + [InlineData(" ", "GoogleVertexAI:Model")] + public void Given_Invalid_Model_When_EnsureLanguageModelSettingsValid_Invoked_Then_It_Should_Throw(string? model, string expectedMessage) + { + // Arrange + var appSettings = BuildAppSettings(apiKey: "valid-key", model: model); + var connector = new GoogleVertexAIConnector(appSettings); + + // Act + Action action = () => connector.EnsureLanguageModelSettingsValid(); + + // Assert + action.ShouldThrow() + .Message.ShouldContain(expectedMessage); + } + + [Trait("Category", "UnitTest")] + [Fact] + public void Given_Valid_Settings_When_EnsureLanguageModelSettingsValid_Invoked_Then_It_Should_Return_True() + { + // Arrange + var appSettings = BuildAppSettings(); + var connector = new GoogleVertexAIConnector(appSettings); + + // Act + var result = connector.EnsureLanguageModelSettingsValid(); + + // Assert + result.ShouldBeTrue(); + } + + [Trait("Category", "UnitTest")] + [Fact] + public async Task Given_Valid_Settings_When_GetChatClient_Invoked_Then_It_Should_Return_ChatClient() + { + var settings = BuildAppSettings(); + var connector = new GoogleVertexAIConnector(settings); + + var client = await connector.GetChatClientAsync(); + + client.ShouldNotBeNull(); + } + + [Trait("Category", "UnitTest")] + [Fact] + public void Given_Settings_Is_Null_When_GetChatClientAsync_Invoked_Then_It_Should_Throw() + { + // Arrange + var appSettings = new AppSettings { ConnectorType = ConnectorType.GoogleVertexAI, GoogleVertexAI = null }; + var connector = new GoogleVertexAIConnector(appSettings); + + // Act + Func func = async () => await connector.GetChatClientAsync(); + + // Assert + func.ShouldThrow(); + } + + [Trait("Category", "UnitTest")] + [Theory] + [InlineData(null, typeof(InvalidOperationException), "GoogleVertexAI:ApiKey")] + [InlineData("", typeof(ArgumentException), "key")] + public async Task Given_Missing_ApiKey_When_GetChatClient_Invoked_Then_It_Should_Throw(string? apiKey, Type expected, string message) + { + // Arrange + var settings = BuildAppSettings(apiKey: apiKey); + var connector = new GoogleVertexAIConnector(settings); + + // Act + Func func = async () => await connector.GetChatClientAsync(); + + // Assert + func.ShouldThrow(expected) + .Message.ShouldContain(message); + } + + [Trait("Category", "UnitTest")] + [Theory] + [InlineData(null, typeof(InvalidOperationException), "model")] + public async Task Given_Missing_Model_When_GetChatClient_Invoked_Then_It_Should_Throw(string? model, Type expected, string message) + { + // Arrange + var settings = BuildAppSettings(model: model); + var connector = new GoogleVertexAIConnector(settings); + + // Act + Func func = async () => await connector.GetChatClientAsync(); + + // Assert + func.ShouldThrow(expected) + .Message.ShouldContain(message); + } + + [Trait("Category", "UnitTest")] + [Fact] + public async Task Given_Valid_Settings_When_CreateChatClientAsync_Invoked_Then_It_Should_Return_ChatClient() + { + // Arrange + var settings = BuildAppSettings(); + + // Act + var result = await LanguageModelConnector.CreateChatClientAsync(settings); + + // Assert + result.ShouldNotBeNull(); + result.ShouldBeAssignableTo(); + } + + [Trait("Category", "UnitTest")] + [Fact] + public void Given_Invalid_Settings_When_CreateChatClientAsync_Invoked_Then_It_Should_Throw() + { + // Arrange + var settings = new AppSettings + { + ConnectorType = ConnectorType.GoogleVertexAI, + GoogleVertexAI = new GoogleVertexAISettings + { + ApiKey = null, + Model = "test-model" + } + }; + + // Act + Func func = async () => await LanguageModelConnector.CreateChatClientAsync(settings); + + // Assert + func.ShouldThrow(); + } +}