diff --git a/.github/workflows/azure-dev.yml b/.github/workflows/azure-dev.yml index ecfc628f..66bcfeeb 100644 --- a/.github/workflows/azure-dev.yml +++ b/.github/workflows/azure-dev.yml @@ -31,6 +31,7 @@ jobs: AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} GH_MODELS_TOKEN: ${{ secrets.GH_MODELS_TOKEN }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} steps: @@ -133,6 +134,7 @@ jobs: shell: bash env: GitHubModels__Token: ${{ env.GH_MODELS_TOKEN }} + Anthropic__ApiKey: ${{ env.ANTHROPIC_API_KEY }} OpenAI__ApiKey: ${{ env.OPENAI_API_KEY }} run: | azd provision --no-prompt diff --git a/README.md b/README.md index f8278f7c..f086a9be 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Open Chat Playground (OCP) is a web UI that is able to connect virtually any LLM - [ ] [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) +- [x] [Anthropic](https://docs.anthropic.com) - [ ] [Naver](https://api.ncloud-docs.com/docs/ai-naver-clovastudio-summary) - [x] [LG](https://github.com/LG-AI-EXAONE) - [x] [OpenAI](https://openai.com/api) @@ -63,6 +63,7 @@ Open Chat Playground (OCP) is a web UI that is able to connect virtually any LLM - [Use Azure AI Foundry](./docs/azure-ai-foundry.md#run-on-local-machine) - [Use GitHub Models](./docs/github-models.md#run-on-local-machine) - [Use Hugging Face](./docs/hugging-face.md#run-on-local-machine) +- [Use Anthropic](./docs/anthropic.md#run-on-local-machine) - [Use LG](./docs/lg.md#run-on-local-machine) - [Use OpenAI](./docs/openai.md#run-on-local-machine) - [Use Upstage](./docs/upstage.md#run-on-local-machine) @@ -72,6 +73,7 @@ Open Chat Playground (OCP) is a web UI that is able to connect virtually any LLM - [Use Azure AI Foundry](./docs/azure-ai-foundry.md#run-in-local-container) - [Use GitHub Models](./docs/github-models.md#run-in-local-container) - [Use Hugging Face](./docs/hugging-face.md#run-in-local-container) +- [Use Anthropic](./docs/anthropic.md#run-in-local-container) - [Use LG](./docs/lg.md#run-in-local-container) - [Use OpenAI](./docs/openai.md#run-in-local-container) - [Use Upstage](./docs/upstage.md#run-in-local-container) @@ -81,6 +83,7 @@ Open Chat Playground (OCP) is a web UI that is able to connect virtually any LLM - [Use Azure AI Foundry](./docs/azure-ai-foundry.md#run-on-azure) - [Use GitHub Models](./docs/github-models.md#run-on-azure) - [Use Hugging Face](./docs/hugging-face.md#run-on-azure) +- [Use Anthropic](./docs/anthropic.md#run-on-azure) - [Use LG](./docs/lg.md#run-on-azure) - [Use OpenAI](./docs/openai.md#run-on-azure) - [Use Upstage](./docs/upstage.md#run-on-azure) diff --git a/docs/README.md b/docs/README.md index 7c7091f2..2d98ff71 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,6 +3,7 @@ - [Azure AI Foundry](azure-ai-foundry.md) - [GitHub Models](github-models.md) - [Hugging Face](hugging-face.md) +- [Anthropic](anthropic.md) - [LG](lg.md) - [OpenAI](openai.md) - [Upstage](upstage.md) diff --git a/docs/anthropic.md b/docs/anthropic.md new file mode 100644 index 00000000..6c1fc548 --- /dev/null +++ b/docs/anthropic.md @@ -0,0 +1,225 @@ +# OpenChat Playground with Anthropic + +This page describes how to run OpenChat Playground (OCP) with [Anthropic models](https://docs.claude.com/en/docs/about-claude/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 you are at the repository root. + + ```bash + cd $REPOSITORY_ROOT + ``` + +1. Add Anthropic API Key for Claude connection. Make sure you should replace `{{ANTHROPIC_API_KEY}}` with your Anthropic API key. + + ```bash + # bash/zsh + dotnet user-secrets --project $REPOSITORY_ROOT/src/OpenChat.PlaygroundApp \ + set Anthropic:ApiKey "{{ANTHROPIC_API_KEY}}" + ``` + + ```bash + # PowerShell + dotnet user-secrets --project $REPOSITORY_ROOT/src/OpenChat.PlaygroundApp ` + set Anthropic:ApiKey "{{ANTHROPIC_API_KEY}}" + ``` + + > For more details about Anthropic API keys, refer to the doc, [Anthropic API Documentation](https://docs.anthropic.com/claude/reference/getting-started-with-the-api). + +1. Run the app. The default model OCP uses is [Claude Sonnet 4](https://www-cdn.anthropic.com/6be99a52cb68eb70eb9572b4cafad13df32ed995.pdf). + + ```bash + # bash/zsh + dotnet run --project $REPOSITORY_ROOT/src/OpenChat.PlaygroundApp -- \ + --connector-type Anthropic + ``` + + ```powershell + # PowerShell + dotnet run --project $REPOSITORY_ROOT/src/OpenChat.PlaygroundApp -- ` + --connector-type Anthropic + ``` + + Alternatively, if you want to run with a different model, say [Claude Opus 4.1](http://www.anthropic.com/claude-opus-4-1-system-card), other than the default one, you can specify it as an argument: + + ```bash + # bash/zsh + dotnet run --project $REPOSITORY_ROOT/src/OpenChat.PlaygroundApp -- \ + --connector-type Anthropic \ + --model claude-opus-4-1 + ``` + + ```powershell + # PowerShell + dotnet run --project $REPOSITORY_ROOT/src/OpenChat.PlaygroundApp -- ` + --connector-type Anthropic ` + --model claude-opus-4-1 + ``` + +1. Open your web browser, navigate to `http://localhost:5280`, and enter prompts. + +## Run in local container + +1. Make sure you are at the repository root. + + ```bash + cd $REPOSITORY_ROOT + ``` + +1. Build a container. + + ```bash + docker build -f Dockerfile -t openchat-playground:latest . + ``` + +1. Get Anthropic API Key. + + ```bash + # bash/zsh + API_KEY=$(dotnet user-secrets --project ./src/OpenChat.PlaygroundApp list --json | \ + sed -n '/^\/\//d; p' | jq -r '."Anthropic:ApiKey"') + ``` + + ```bash + # PowerShell + $API_KEY = (dotnet user-secrets --project ./src/OpenChat.PlaygroundApp list --json | ` + Select-String -NotMatch '^//(BEGIN|END)' | ConvertFrom-Json).'Anthropic:ApiKey' + ``` + +1. Run the app. The default model OCP uses is [Claude Sonnet 4](https://www-cdn.anthropic.com/6be99a52cb68eb70eb9572b4cafad13df32ed995.pdf). + + ```bash + # bash/zsh - from locally built container + docker run -i --rm -p 8080:8080 openchat-playground:latest --connector-type Anthropic \ + --api-key $API_KEY + ``` + + ```powershell + # PowerShell - from locally built container + docker run -i --rm -p 8080:8080 openchat-playground:latest --connector-type Anthropic ` + --api-key $API_KEY + ``` + + ```bash + # bash/zsh - from GitHub Container Registry + docker run -i --rm -p 8080:8080 ghcr.io/aliencube/open-chat-playground/openchat-playground:latest \ + --connector-type Anthropic \ + --api-key $API_KEY + ``` + + ```powershell + # PowerShell - from GitHub Container Registry + docker run -i --rm -p 8080:8080 ghcr.io/aliencube/open-chat-playground/openchat-playground:latest ` + --connector-type Anthropic ` + --api-key $API_KEY + ``` + + Alternatively, if you want to run with a different model, say [Claude Opus 4.1](http://www.anthropic.com/claude-opus-4-1-system-card), other than the default one, you can specify it as an argument: + + ```bash + # bash/zsh - from locally built container with custom model + docker run -i --rm -p 8080:8080 openchat-playground:latest --connector-type Anthropic \ + --api-key $API_KEY \ + --model claude-opus-4-1 + ``` + + ```powershell + # PowerShell - from locally built container with custom model + docker run -i --rm -p 8080:8080 openchat-playground:latest --connector-type Anthropic ` + --api-key $API_KEY ` + --model claude-opus-4-1 + ``` + +1. Open your web browser, navigate to `http://localhost:8080`, and enter prompts. + +## Run on Azure + +1. Make sure you are at the repository root. + + ```bash + cd $REPOSITORY_ROOT + ``` + +1. Login to Azure. + + ```bash + azd auth login + ``` + +1. Check login status. + + ```bash + azd auth login --check-status + ``` + +1. Initialize `azd` template. + + ```bash + azd init + ``` + + > **NOTE**: You will be asked to provide environment name for provisioning. + +1. Get Anthropic API Key. + + ```bash + # bash/zsh + API_KEY=$(dotnet user-secrets --project ./src/OpenChat.PlaygroundApp list --json | \ + sed -n '/^\/\//d; p' | jq -r '."Anthropic:ApiKey"') + ``` + + ```bash + # PowerShell + $API_KEY = (dotnet user-secrets --project ./src/OpenChat.PlaygroundApp list --json | ` + Select-String -NotMatch '^//(BEGIN|END)' | ConvertFrom-Json).'Anthropic:ApiKey' + ``` + +1. Set Anthropic API Key to azd environment variables. + + ```bash + azd env set ANTHROPIC_API_KEY $API_KEY + ``` + + The default model OCP uses is [Claude Sonnet 4](https://www-cdn.anthropic.com/6be99a52cb68eb70eb9572b4cafad13df32ed995.pdf). If you want to run with a different model, say [Claude Opus 4.1](http://www.anthropic.com/claude-opus-4-1-system-card), other than the default one, add it to azd environment variables. + + ```bash + azd env set ANTHROPIC_MODEL claude-opus-4-1 + ``` + +1. Set the connector type to `Anthropic`. + + ```bash + azd env set CONNECTOR_TYPE Anthropic + ``` + +1. Run the following commands in order to provision and deploy the app. + + ```bash + azd up + ``` + + > **NOTE**: You will be asked to provide Azure subscription and location for deployment. + + Once deployed, you will be able to see the deployed OCP app URL. + +1. Open your web browser, navigate to the OCP app URL, and enter prompts. + +1. Clean up all the resources. + + ```bash + azd down --force --purge + ``` diff --git a/infra/main.bicep b/infra/main.bicep index 16809ba8..cb3774f7 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -29,6 +29,9 @@ param huggingFaceModel string = '' // Ollama param ollamaModel string = '' // Anthropic +param anthropicModel string = '' +@secure() +param anthropicApiKey string = '' // LG param lgModel string = '' // Naver @@ -89,6 +92,8 @@ module resources 'resources.bicep' = { githubModelsToken: githubModelsToken huggingFaceModel: huggingFaceModel ollamaModel: ollamaModel + anthropicModel: anthropicModel + anthropicApiKey: anthropicApiKey lgModel: lgModel openAIModel: openAIModel openAIApiKey: openAIApiKey diff --git a/infra/main.parameters.json b/infra/main.parameters.json index b17eb69b..6ea910f0 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -29,6 +29,12 @@ "huggingFaceModel": { "value": "${HUGGING_FACE_MODEL=hf.co/Qwen/Qwen3-0.6B-GGUF}" }, + "anthropicModel" : { + "value": "${ANTHROPIC_MODEL=claude-sonnet-4-0}" + }, + "anthropicApiKey" : { + "value": "${ANTHROPIC_API_KEY}" + }, "lgModel": { "value": "${LG_MODEL=hf.co/LGAI-EXAONE/EXAONE-4.0-1.2B-GGUF}" }, diff --git a/infra/resources.bicep b/infra/resources.bicep index a9896604..993a801f 100644 --- a/infra/resources.bicep +++ b/infra/resources.bicep @@ -24,6 +24,9 @@ param huggingFaceModel string = '' // Ollama param ollamaModel string = '' // Anthropic +param anthropicModel string = '' +@secure() +param anthropicApiKey string = '' // LG param lgModel string = '' // Naver @@ -231,6 +234,17 @@ var envOllama = connectorType == 'Ollama' ? concat(ollamaModel != '' ? [ } ] : []) : [] // Anthropic +var envAnthropic = connectorType == 'Anthropic' ? concat(anthropicModel != '' ? [ + { + name: 'Anthropic__Model' + value: anthropicModel + } +] : [], anthropicApiKey != '' ? [ + { + name: 'Anthropic__ApiKey' + secretRef: 'anthropic-api-key' + } +] : []) : [] // LG var envLG = connectorType == 'LG' ? concat(lgModel != '' ? [ { @@ -288,6 +302,11 @@ module openchatPlaygroundApp 'br/public:avm/res/app/container-app:0.18.1' = { name: 'github-models-token' value: githubModelsToken } + ] : [], anthropicApiKey != '' ? [ + { + name: 'anthropic-api-key' + value: anthropicApiKey + } ] : [], openAIApiKey != '' ? [ { name: 'openai-api-key' @@ -326,6 +345,7 @@ module openchatPlaygroundApp 'br/public:avm/res/app/container-app:0.18.1' = { envGitHubModels, envHuggingFace, envOllama, + envAnthropic, envLG, envOpenAI, envUpstage, diff --git a/src/OpenChat.PlaygroundApp/Abstractions/ArgumentOptions.cs b/src/OpenChat.PlaygroundApp/Abstractions/ArgumentOptions.cs index 73d58f07..24c09ad0 100644 --- a/src/OpenChat.PlaygroundApp/Abstractions/ArgumentOptions.cs +++ b/src/OpenChat.PlaygroundApp/Abstractions/ArgumentOptions.cs @@ -42,6 +42,7 @@ private static readonly (ConnectorType ConnectorType, string Argument, bool IsSw // Anthropic (ConnectorType.Anthropic, ArgumentOptionConstants.Anthropic.ApiKey, false), (ConnectorType.Anthropic, ArgumentOptionConstants.Anthropic.Model, false), + (ConnectorType.Anthropic, ArgumentOptionConstants.Anthropic.MaxTokens, false), // LG (ConnectorType.LG, ArgumentOptionConstants.LG.BaseUrl, false), (ConnectorType.LG, ArgumentOptionConstants.LG.Model, false), @@ -237,6 +238,7 @@ public static AppSettings Parse(IConfiguration config, string[] args) settings.Anthropic ??= new AnthropicSettings(); settings.Anthropic.ApiKey = anthropic.ApiKey ?? settings.Anthropic.ApiKey; settings.Anthropic.Model = anthropic.Model ?? settings.Anthropic.Model; + settings.Anthropic.MaxTokens = anthropic.MaxTokens ?? settings.Anthropic.MaxTokens; settings.Model = anthropic.Model ?? settings.Anthropic.Model; break; @@ -460,7 +462,8 @@ private static void DisplayHelpForAnthropic() Console.ForegroundColor = foregroundColor; Console.WriteLine($" {ArgumentOptionConstants.Anthropic.ApiKey} The Anthropic API key."); - Console.WriteLine($" {ArgumentOptionConstants.Anthropic.Model} The Anthropic model name. Default to 'claude-sonnet-4-0'"); + Console.WriteLine($" {ArgumentOptionConstants.Anthropic.Model} The Anthropic model name. Default to 'claude-sonnet-4-0'"); + Console.WriteLine($" {ArgumentOptionConstants.Anthropic.MaxTokens} The maximum tokens (>= 1). Default to 1000"); Console.WriteLine(); } diff --git a/src/OpenChat.PlaygroundApp/Configurations/AnthropicSettings.cs b/src/OpenChat.PlaygroundApp/Configurations/AnthropicSettings.cs index 5e8ba75f..fa780396 100644 --- a/src/OpenChat.PlaygroundApp/Configurations/AnthropicSettings.cs +++ b/src/OpenChat.PlaygroundApp/Configurations/AnthropicSettings.cs @@ -12,17 +12,22 @@ public partial class AppSettings } /// -/// This represents the app settings entity for Anthropic Claude. +/// This represents the app settings entity for Anthropic. /// public class AnthropicSettings : LanguageModelSettings { /// - /// Gets or sets the API key for Anthropic Claude. + /// Gets or sets the API key for Anthropic. /// public string? ApiKey { get; set; } /// - /// Gets or sets the model name of Anthropic Claude. + /// Gets or sets the model name of Anthropic. /// public string? Model { get; set; } + + /// + /// Gets or sets the maximum number of output tokens for Anthropic. + /// + public int? MaxTokens { get; set; } } \ No newline at end of file diff --git a/src/OpenChat.PlaygroundApp/Connectors/AnthropicConnector.cs b/src/OpenChat.PlaygroundApp/Connectors/AnthropicConnector.cs new file mode 100644 index 00000000..7166ae20 --- /dev/null +++ b/src/OpenChat.PlaygroundApp/Connectors/AnthropicConnector.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.AI; + +using Anthropic.SDK; + +using OpenChat.PlaygroundApp.Abstractions; +using OpenChat.PlaygroundApp.Configurations; + +namespace OpenChat.PlaygroundApp.Connectors; + +/// +/// This represents the connector entity for Anthropic. +/// +public class AnthropicConnector(AppSettings settings) : LanguageModelConnector(settings.Anthropic) +{ + private readonly AppSettings _appSettings = settings ?? throw new ArgumentNullException(nameof(settings)); + + /// + public override bool EnsureLanguageModelSettingsValid() + { + if (this.Settings is not AnthropicSettings settings) + { + throw new InvalidOperationException("Missing configuration: Anthropic."); + } + + if (string.IsNullOrWhiteSpace(settings.ApiKey!.Trim()) == true) + { + throw new InvalidOperationException("Missing configuration: Anthropic:ApiKey."); + } + + if (string.IsNullOrWhiteSpace(settings.Model!.Trim()) == true) + { + throw new InvalidOperationException("Missing configuration: Anthropic:Model."); + } + + return true; + } + + /// + public override async Task GetChatClientAsync() + { + var settings = this.Settings as AnthropicSettings; + var apiKey = settings?.ApiKey; + + if (string.IsNullOrWhiteSpace(apiKey) == true) + { + throw new InvalidOperationException("Missing configuration: Anthropic:ApiKey."); + } + + var client = new AnthropicClient() { Auth = new APIAuthentication(apiKey) }; + + var chatClient = client.Messages + .AsBuilder() + .UseFunctionInvocation() + .Use((messages, options, next, cancellationToken) => + { + options!.ModelId = settings!.Model; + options.MaxOutputTokens ??= 1000; + return next(messages, options, cancellationToken); + }) + .Build(); + + Console.WriteLine($"The {this._appSettings.ConnectorType} connector created with model: {settings!.Model}"); + + return await Task.FromResult(chatClient).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/OpenChat.PlaygroundApp/Constants/ArgumentOptionConstants.cs b/src/OpenChat.PlaygroundApp/Constants/ArgumentOptionConstants.cs index c3f88624..a9170e03 100644 --- a/src/OpenChat.PlaygroundApp/Constants/ArgumentOptionConstants.cs +++ b/src/OpenChat.PlaygroundApp/Constants/ArgumentOptionConstants.cs @@ -182,6 +182,11 @@ public static class Anthropic /// Defines the constant for '--model'. /// public const string Model = "--model"; + + /// + /// Defines the constant for '--max-tokens'. + /// + public const string MaxTokens = "--max-tokens"; } /// diff --git a/src/OpenChat.PlaygroundApp/Options/AnthropicArgumentOptions.cs b/src/OpenChat.PlaygroundApp/Options/AnthropicArgumentOptions.cs index d3a17e59..553b0407 100644 --- a/src/OpenChat.PlaygroundApp/Options/AnthropicArgumentOptions.cs +++ b/src/OpenChat.PlaygroundApp/Options/AnthropicArgumentOptions.cs @@ -5,20 +5,26 @@ namespace OpenChat.PlaygroundApp.Options; /// -/// This represents the argument options entity for Anthropic Claude. +/// This represents the argument options entity for Anthropic. /// public class AnthropicArgumentOptions : ArgumentOptions { /// - /// Gets or sets the API key for Anthropic Claude. + /// Gets or sets the API key for Anthropic. /// public string? ApiKey { get; set; } /// - /// Gets or sets the model name of Anthropic Claude. + /// Gets or sets the model name of Anthropic. /// public string? Model { get; set; } + /// + /// Gets or sets the maximum number of tokens for Anthropic. + /// Mirrors the 'max_tokens' option as 'MaxTokens'. + /// + public int? MaxTokens { get; set; } + /// protected override void ParseOptions(IConfiguration config, string[] args) { @@ -26,9 +32,10 @@ protected override void ParseOptions(IConfiguration config, string[] args) config.Bind(settings); var anthropic = settings.Anthropic; - + this.ApiKey ??= anthropic?.ApiKey; this.Model ??= anthropic?.Model; + this.MaxTokens ??= anthropic?.MaxTokens; for (var i = 0; i < args.Length; i++) { @@ -48,6 +55,16 @@ protected override void ParseOptions(IConfiguration config, string[] args) } break; + case ArgumentOptionConstants.Anthropic.MaxTokens: + if (i + 1 < args.Length) + { + if (int.TryParse(args[++i], out var maxTokens)) + { + this.MaxTokens = maxTokens; + } + } + break; + default: break; } diff --git a/src/OpenChat.PlaygroundApp/appsettings.json b/src/OpenChat.PlaygroundApp/appsettings.json index 1ee914c5..c971cf06 100644 --- a/src/OpenChat.PlaygroundApp/appsettings.json +++ b/src/OpenChat.PlaygroundApp/appsettings.json @@ -56,7 +56,8 @@ "Anthropic": { "ApiKey": "{{ANTHROPIC_API_KEY}}", - "Model": "claude-sonnet-4-0" + "Model": "claude-sonnet-4-0", + "MaxTokens": "1000" }, "LG": { diff --git a/test/OpenChat.PlaygroundApp.Tests/Components/Pages/Chat/ChatHeaderUITests.cs b/test/OpenChat.PlaygroundApp.Tests/Components/Pages/Chat/ChatHeaderUITests.cs index 18d749d9..621cf2f8 100644 --- a/test/OpenChat.PlaygroundApp.Tests/Components/Pages/Chat/ChatHeaderUITests.cs +++ b/test/OpenChat.PlaygroundApp.Tests/Components/Pages/Chat/ChatHeaderUITests.cs @@ -1,4 +1,4 @@ -using Microsoft.Playwright; +using Microsoft.Playwright; using Microsoft.Playwright.Xunit; using OpenChat.PlaygroundApp.Connectors; diff --git a/test/OpenChat.PlaygroundApp.Tests/Connectors/AnthropicConnectorTests.cs b/test/OpenChat.PlaygroundApp.Tests/Connectors/AnthropicConnectorTests.cs new file mode 100644 index 00000000..a8848c5f --- /dev/null +++ b/test/OpenChat.PlaygroundApp.Tests/Connectors/AnthropicConnectorTests.cs @@ -0,0 +1,220 @@ +using Microsoft.Extensions.AI; + +using OpenChat.PlaygroundApp.Abstractions; +using OpenChat.PlaygroundApp.Configurations; +using OpenChat.PlaygroundApp.Connectors; + +namespace OpenChat.PlaygroundApp.Tests.Connectors; + +public class AnthropicConnectorTests +{ + private const string ApiKey = "test-api-key"; + private const string Model = "test-model"; + + private static AppSettings BuildAppSettings(string? apiKey = ApiKey, string? model = Model) + { + return new AppSettings + { + ConnectorType = ConnectorType.Anthropic, + Anthropic = new AnthropicSettings + { + ApiKey = apiKey, + Model = model + } + }; + } + + [Trait("Category", "UnitTest")] + [Fact(Skip = "Anthropic connector is not enabled yet.")] + public void Given_Null_Settings_When_Instantiated_Then_It_Should_Throw() + { + // Act + Action action = () => new AnthropicConnector(null!); + + // Assert + action.ShouldThrow() + .Message.ShouldContain("settings"); + } + + [Trait("Category", "UnitTest")] + [Fact(Skip = "Anthropic connector is not enabled yet.")] + public void Given_Settings_When_Instantiated_Then_It_Should_Return() + { + // Arrange + var settings = BuildAppSettings(); + + // Act + var result = new AnthropicConnector(settings); + + // Assert + result.ShouldNotBeNull(); + } + + [Trait("Category", "UnitTest")] + [Theory(Skip = "Anthropic connector is not enabled yet.")] + [InlineData(typeof(LanguageModelConnector), typeof(AnthropicConnector), true)] + [InlineData(typeof(AnthropicConnector), 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(Skip = "Anthropic connector is not enabled yet.")] + public void Given_Settings_Is_Null_When_EnsureLanguageModelSettingsValid_Invoked_Then_It_Should_Throw() + { + // Arrange + var settings = new AppSettings { ConnectorType = ConnectorType.Anthropic, Anthropic = null }; + var connector = new AnthropicConnector(settings); + + // Act + Action action = () => connector.EnsureLanguageModelSettingsValid(); + + // Assert + action.ShouldThrow() + .Message.ShouldContain("Missing configuration: Anthropic."); + } + + [Trait("Category", "UnitTest")] + [Theory(Skip = "Anthropic connector is not enabled yet.")] + [InlineData(null, typeof(NullReferenceException), "Object reference not set to an instance of an object")] + [InlineData("", typeof(InvalidOperationException), "Anthropic:ApiKey")] + [InlineData(" ", typeof(InvalidOperationException), "Anthropic:ApiKey")] + [InlineData("\t\n\r", typeof(InvalidOperationException), "Anthropic:ApiKey")] + public void Given_Invalid_ApiKey_When_EnsureLanguageModelSettingsValid_Invoked_Then_It_Should_Throw(string? apiKey, Type expectedException, string expectedMessage) + { + // Arrange + var settings = BuildAppSettings(apiKey: apiKey); + var connector = new AnthropicConnector(settings); + + // Act + Action action = () => connector.EnsureLanguageModelSettingsValid(); + + // Assert + action.ShouldThrow(expectedException) + .Message.ShouldContain(expectedMessage); + } + + [Trait("Category", "UnitTest")] + [Theory(Skip = "Anthropic connector is not enabled yet.")] + [InlineData(null, typeof(NullReferenceException), "Object reference not set to an instance of an object")] + [InlineData("", typeof(InvalidOperationException), "Anthropic:Model")] + [InlineData(" ", typeof(InvalidOperationException), "Anthropic:Model")] + [InlineData("\t\n\r", typeof(InvalidOperationException), "Anthropic:Model")] + public void Given_Invalid_Model_When_EnsureLanguageModelSettingsValid_Invoked_Then_It_Should_Throw(string? model, Type expectedException, string expectedMessage) + { + // Arrange + var settings = BuildAppSettings(apiKey: "valid-key", model: model); + var connector = new AnthropicConnector(settings); + + // Act + Action action = () => connector.EnsureLanguageModelSettingsValid(); + + // Assert + action.ShouldThrow(expectedException) + .Message.ShouldContain(expectedMessage); + } + + [Trait("Category", "UnitTest")] + [Fact(Skip = "Anthropic connector is not enabled yet.")] + public void Given_Valid_Settings_When_EnsureLanguageModelSettingsValid_Invoked_Then_It_Should_Return_True() + { + // Arrange + var settings = BuildAppSettings(); + var connector = new AnthropicConnector(settings); + + // Act + var result = connector.EnsureLanguageModelSettingsValid(); + + // Assert + result.ShouldBeTrue(); + } + + [Trait("Category", "UnitTest")] + [Fact(Skip = "Anthropic connector is not enabled yet.")] + public async Task Given_Valid_Settings_When_GetChatClientAsync_Invoked_Then_It_Should_Return_ChatClient() + { + // Arrange + var settings = BuildAppSettings(); + var connector = new AnthropicConnector(settings); + + // Act + var client = await connector.GetChatClientAsync(); + + // Assert + client.ShouldNotBeNull(); + } + + [Trait("Category", "UnitTest")] + [Fact(Skip = "Anthropic connector is not enabled yet.")] + public void Given_Settings_Is_Null_When_GetChatClientAsync_Invoked_Then_It_Should_Throw() + { + // Arrange + var settings = new AppSettings { ConnectorType = ConnectorType.Anthropic, Anthropic = null }; + var connector = new AnthropicConnector(settings); + + // Act + Func func = async () => await connector.GetChatClientAsync(); + + // Assert + func.ShouldThrow() + .Message.ShouldContain("Missing configuration: Anthropic:ApiKey."); + } + + [Trait("Category", "UnitTest")] + [Theory(Skip = "Anthropic connector is not enabled yet.")] + [InlineData(null, typeof(InvalidOperationException), "Anthropic:ApiKey")] + [InlineData("", typeof(InvalidOperationException), "Anthropic:ApiKey")] + public void Given_Missing_ApiKey_When_GetChatClientAsync_Invoked_Then_It_Should_Throw(string? apiKey, Type expectedException, string expectedMessage) + { + // Arrange + var settings = BuildAppSettings(apiKey: apiKey); + var connector = new AnthropicConnector(settings); + + // Act + Func func = async () => await connector.GetChatClientAsync(); + + // Assert + func.ShouldThrow(expectedException) + .Message.ShouldContain(expectedMessage); + } + + [Trait("Category", "UnitTest")] + [Fact(Skip = "Anthropic connector is not enabled in the factory yet.")] + 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")] + [Theory(Skip = "Anthropic connector is not enabled in the factory yet.")] + [InlineData(null, "claude-sonnet-4-0", typeof(NullReferenceException))] + [InlineData("", "claude-sonnet-4-0", typeof(InvalidOperationException))] + [InlineData(" ", "claude-sonnet-4-0", typeof(InvalidOperationException))] + [InlineData("test-api-key", null, typeof(NullReferenceException))] + [InlineData("test-api-key", "", typeof(InvalidOperationException))] + [InlineData("test-api-key", " ", typeof(InvalidOperationException))] + public void Given_Invalid_Settings_When_CreateChatClientAsync_Invoked_Then_It_Should_Throw(string? apiKey, string? model, Type expectedType) + { + // Arrange + var settings = BuildAppSettings(apiKey: apiKey, model: model); + + // Act + Func func = async () => await LanguageModelConnector.CreateChatClientAsync(settings); + + // Assert + func.ShouldThrow(expectedType); + } +} \ No newline at end of file diff --git a/test/OpenChat.PlaygroundApp.Tests/Options/AnthropicArgumentOptionsTests.cs b/test/OpenChat.PlaygroundApp.Tests/Options/AnthropicArgumentOptionsTests.cs index a0d8183f..918b6a6b 100644 --- a/test/OpenChat.PlaygroundApp.Tests/Options/AnthropicArgumentOptionsTests.cs +++ b/test/OpenChat.PlaygroundApp.Tests/Options/AnthropicArgumentOptionsTests.cs @@ -11,14 +11,18 @@ public class AnthropicArgumentOptionsTests { private const string ApiKey = "test-api-key"; private const string Model = "test-model"; + private const string MaxTokens = "1000"; private const string ApiKeyConfigKey = "Anthropic:ApiKey"; private const string ModelConfigKey = "Anthropic:Model"; + private const string MaxTokensConfigKey = "Anthropic:MaxTokens"; private static IConfiguration BuildConfigWithAnthropic( string? configApiKey = ApiKey, string? configModel = Model, + string? configMaxTokens = MaxTokens, string? envApiKey = null, - string? envModel = null) + string? envModel = null, + string? envMaxTokens = null) { // Base configuration values (lowest priority) var configDict = new Dictionary @@ -34,7 +38,13 @@ private static IConfiguration BuildConfigWithAnthropic( { configDict[ModelConfigKey] = configModel; } - if (string.IsNullOrWhiteSpace(envApiKey) == true && string.IsNullOrWhiteSpace(envModel) == true) + if (string.IsNullOrWhiteSpace(configMaxTokens) == false) + { + configDict[MaxTokensConfigKey] = configMaxTokens; + } + if (string.IsNullOrWhiteSpace(envApiKey) == true && + string.IsNullOrWhiteSpace(envModel) == true && + string.IsNullOrWhiteSpace(envMaxTokens) == true) { return new ConfigurationBuilder() .AddInMemoryCollection(configDict!) @@ -51,12 +61,18 @@ private static IConfiguration BuildConfigWithAnthropic( { envDict[ModelConfigKey] = envModel; } + if (string.IsNullOrWhiteSpace(envMaxTokens) == false) + { + envDict[MaxTokensConfigKey] = envMaxTokens; + } return new ConfigurationBuilder() .AddInMemoryCollection(configDict!) // Base configuration (lowest priority) .AddInMemoryCollection(envDict!) // Environment variables (medium priority) .Build(); } + + private static int? IntValueOf(string? value) => string.IsNullOrWhiteSpace(value) ? null : int.Parse(value); [Trait("Category", "UnitTest")] [Theory] @@ -87,6 +103,7 @@ public void Given_Nothing_When_Parse_Invoked_Then_It_Should_Set_Config() settings.Anthropic.ShouldNotBeNull(); settings.Anthropic.ApiKey.ShouldBe(ApiKey); settings.Anthropic.Model.ShouldBe(Model); + settings.Anthropic.MaxTokens.ShouldBe(IntValueOf(MaxTokens)); } [Trait("Category", "UnitTest")] @@ -108,6 +125,7 @@ public void Given_CLI_ApiKey_When_Parse_Invoked_Then_It_Should_Use_CLI_ApiKey(st settings.Anthropic.ShouldNotBeNull(); settings.Anthropic.ApiKey.ShouldBe(cliApiKey); settings.Anthropic.Model.ShouldBe(Model); + settings.Anthropic.MaxTokens.ShouldBe(IntValueOf(MaxTokens)); } [Trait("Category", "UnitTest")] @@ -129,19 +147,43 @@ public void Given_CLI_Model_When_Parse_Invoked_Then_It_Should_Use_CLI_Model(stri settings.Anthropic.ShouldNotBeNull(); settings.Anthropic.ApiKey.ShouldBe(ApiKey); settings.Anthropic.Model.ShouldBe(cliModel); + settings.Anthropic.MaxTokens.ShouldBe(IntValueOf(MaxTokens)); + } + + [Trait("Category", "UnitTest")] + [Theory] + [InlineData("2000")] + public void Given_CLI_MaxTokens_When_Parse_Invoked_Then_It_Should_Use_CLI_MaxTokens(string cliMaxTokens) + { + // Arrange + var config = BuildConfigWithAnthropic(); + var args = new[] + { + ArgumentOptionConstants.Anthropic.MaxTokens, cliMaxTokens + }; + + // Act + var settings = ArgumentOptions.Parse(config, args); + + // Assert + settings.Anthropic.ShouldNotBeNull(); + settings.Anthropic.ApiKey.ShouldBe(ApiKey); + settings.Anthropic.Model.ShouldBe(Model); + settings.Anthropic.MaxTokens.ShouldBe(IntValueOf(cliMaxTokens)); } [Trait("Category", "UnitTest")] [Theory] - [InlineData("cli-api-key", "cli-model")] - public void Given_All_CLI_Arguments_When_Parse_Invoked_Then_It_Should_Use_CLI(string cliApiKey, string cliModel) + [InlineData("cli-api-key", "cli-model", "2000")] + public void Given_All_CLI_Arguments_When_Parse_Invoked_Then_It_Should_Use_CLI(string cliApiKey, string cliModel, string cliMaxTokens) { // Arrange var config = BuildConfigWithAnthropic(); var args = new[] { ArgumentOptionConstants.Anthropic.ApiKey, cliApiKey, - ArgumentOptionConstants.Anthropic.Model, cliModel + ArgumentOptionConstants.Anthropic.Model, cliModel, + ArgumentOptionConstants.Anthropic.MaxTokens, cliMaxTokens }; // Act @@ -151,12 +193,14 @@ public void Given_All_CLI_Arguments_When_Parse_Invoked_Then_It_Should_Use_CLI(st settings.Anthropic.ShouldNotBeNull(); settings.Anthropic.ApiKey.ShouldBe(cliApiKey); settings.Anthropic.Model.ShouldBe(cliModel); + settings.Anthropic.MaxTokens.ShouldBe(IntValueOf(cliMaxTokens)); } [Trait("Category", "UnitTest")] [Theory] [InlineData(ArgumentOptionConstants.Anthropic.ApiKey)] [InlineData(ArgumentOptionConstants.Anthropic.Model)] + [InlineData(ArgumentOptionConstants.Anthropic.MaxTokens)] public void Given_CLI_ArgumentWithoutValue_When_Parse_Invoked_Then_It_Should_Use_Config(string argument) { // Arrange @@ -170,6 +214,7 @@ public void Given_CLI_ArgumentWithoutValue_When_Parse_Invoked_Then_It_Should_Use settings.Anthropic.ShouldNotBeNull(); settings.Anthropic.ApiKey.ShouldBe(ApiKey); settings.Anthropic.Model.ShouldBe(Model); + settings.Anthropic.MaxTokens.ShouldBe(IntValueOf(MaxTokens)); } [Trait("Category", "UnitTest")] @@ -187,6 +232,7 @@ public void Given_Unrelated_CLI_Arguments_When_Parse_Invoked_Then_It_Should_Use_ settings.Anthropic.ShouldNotBeNull(); settings.Anthropic.ApiKey.ShouldBe(ApiKey); settings.Anthropic.Model.ShouldBe(Model); + settings.Anthropic.MaxTokens.ShouldBe(IntValueOf(MaxTokens)); } [Trait("Category", "UnitTest")] @@ -208,6 +254,29 @@ public void Given_Anthropic_With_ModelName_StartingWith_Dashes_When_Parse_Invoke settings.Anthropic.ShouldNotBeNull(); settings.Anthropic.ApiKey.ShouldBe(ApiKey); settings.Anthropic.Model.ShouldBe(cliModel); + settings.Anthropic.MaxTokens.ShouldBe(IntValueOf(MaxTokens)); + } + + [Trait("Category", "UnitTest")] + [Theory] + [InlineData("-1")] + public void Given_Anthropic_With_MaxTokens_StartingWith_Dashes_When_Parse_Invoked_Then_It_Should_Treat_As_Value(string cliMaxTokens) + { + // Arrange + var config = BuildConfigWithAnthropic(); + var args = new[] + { + ArgumentOptionConstants.Anthropic.MaxTokens, cliMaxTokens + }; + + // Act + var settings = ArgumentOptions.Parse(config, args); + + // Assert + settings.Anthropic.ShouldNotBeNull(); + settings.Anthropic.ApiKey.ShouldBe(ApiKey); + settings.Anthropic.Model.ShouldBe(Model); + settings.Anthropic.MaxTokens.ShouldBe(IntValueOf(cliMaxTokens)); } [Trait("Category", "UnitTest")] @@ -229,15 +298,16 @@ public void Given_Anthropic_With_ApiKey_StartingWith_Dashes_When_Parse_Invoked_T settings.Anthropic.ShouldNotBeNull(); settings.Anthropic.ApiKey.ShouldBe(cliApiKey); settings.Anthropic.Model.ShouldBe(Model); + settings.Anthropic.MaxTokens.ShouldBe(IntValueOf(MaxTokens)); } [Trait("Category", "UnitTest")] [Theory] - [InlineData("config-api-key", "config-model")] - public void Given_ConfigValues_And_No_CLI_When_Parse_Invoked_Then_It_Should_Use_Config(string configApiKey, string configModel) + [InlineData("config-api-key", "config-model", "1000")] + public void Given_ConfigValues_And_No_CLI_When_Parse_Invoked_Then_It_Should_Use_Config(string configApiKey, string configModel, string configMaxTokens) { // Arrange - var config = BuildConfigWithAnthropic(configApiKey, configModel); + var config = BuildConfigWithAnthropic(configApiKey, configModel, configMaxTokens); var args = Array.Empty(); // Act @@ -247,21 +317,23 @@ public void Given_ConfigValues_And_No_CLI_When_Parse_Invoked_Then_It_Should_Use_ settings.Anthropic.ShouldNotBeNull(); settings.Anthropic.ApiKey.ShouldBe(configApiKey); settings.Anthropic.Model.ShouldBe(configModel); + settings.Anthropic.MaxTokens.ShouldBe(IntValueOf(configMaxTokens)); } [Trait("Category", "UnitTest")] [Theory] - [InlineData("config-api-key", "config-model", "cli-api-key", "cli-model")] + [InlineData("config-api-key", "config-model", "1000", "cli-api-key", "cli-model", "2000")] public void Given_ConfigValues_And_CLI_When_Parse_Invoked_Then_It_Should_Use_CLI( - string configApiKey, string configModel, - string cliApiKey, string cliModel) + string configApiKey, string configModel, string configMaxTokens, + string cliApiKey, string cliModel, string cliMaxTokens) { // Arrange - var config = BuildConfigWithAnthropic(configApiKey, configModel); + var config = BuildConfigWithAnthropic(configApiKey, configModel, configMaxTokens); var args = new[] { ArgumentOptionConstants.Anthropic.ApiKey, cliApiKey, - ArgumentOptionConstants.Anthropic.Model, cliModel + ArgumentOptionConstants.Anthropic.Model, cliModel, + ArgumentOptionConstants.Anthropic.MaxTokens, cliMaxTokens }; // Act @@ -271,17 +343,18 @@ public void Given_ConfigValues_And_CLI_When_Parse_Invoked_Then_It_Should_Use_CLI settings.Anthropic.ShouldNotBeNull(); settings.Anthropic.ApiKey.ShouldBe(cliApiKey); settings.Anthropic.Model.ShouldBe(cliModel); + settings.Anthropic.MaxTokens.ShouldBe(IntValueOf(cliMaxTokens)); } [Trait("Category", "UnitTest")] [Theory] - [InlineData("env-api-key", "env-model")] - public void Given_EnvironmentVariables_And_No_Config_When_Parse_Invoked_Then_It_Should_Use_EnvironmentVariables(string envApiKey, string envModel) + [InlineData("env-api-key", "env-model", "1500")] + public void Given_EnvironmentVariables_And_No_Config_When_Parse_Invoked_Then_It_Should_Use_EnvironmentVariables(string envApiKey, string envModel, string envMaxTokens) { // Arrange var config = BuildConfigWithAnthropic( - configApiKey: null, configModel: null, - envApiKey: envApiKey, envModel: envModel); + configApiKey: null, configModel: null, configMaxTokens: null, + envApiKey: envApiKey, envModel: envModel, envMaxTokens: envMaxTokens); var args = Array.Empty(); // Act @@ -291,17 +364,20 @@ public void Given_EnvironmentVariables_And_No_Config_When_Parse_Invoked_Then_It_ settings.Anthropic.ShouldNotBeNull(); settings.Anthropic.ApiKey.ShouldBe(envApiKey); settings.Anthropic.Model.ShouldBe(envModel); + settings.Anthropic.MaxTokens.ShouldBe(IntValueOf(envMaxTokens)); } [Trait("Category", "UnitTest")] [Theory] - [InlineData("config-api-key", "config-model", "env-api-key", "env-model")] + [InlineData("config-api-key", "config-model", "1000", "env-api-key", "env-model", "1500")] public void Given_ConfigValues_And_EnvironmentVariables_When_Parse_Invoked_Then_It_Should_Use_EnvironmentVariables( - string configApiKey, string configModel, - string envApiKey, string envModel) + string configApiKey, string configModel, string configMaxTokens, + string envApiKey, string envModel, string envMaxTokens) { // Arrange - var config = BuildConfigWithAnthropic(configApiKey, configModel, envApiKey, envModel); + var config = BuildConfigWithAnthropic( + configApiKey, configModel, configMaxTokens, + envApiKey, envModel, envMaxTokens); var args = Array.Empty(); // Act @@ -311,22 +387,26 @@ public void Given_ConfigValues_And_EnvironmentVariables_When_Parse_Invoked_Then_ settings.Anthropic.ShouldNotBeNull(); settings.Anthropic.ApiKey.ShouldBe(envApiKey); settings.Anthropic.Model.ShouldBe(envModel); + settings.Anthropic.MaxTokens.ShouldBe(IntValueOf(envMaxTokens)); } [Trait("Category", "UnitTest")] [Theory] - [InlineData("config-api-key", "config-model", "env-api-key", "env-model", "cli-api-key", "cli-model")] + [InlineData("config-api-key", "config-model", "1000", "env-api-key", "env-model", "1500", "cli-api-key", "cli-model", "2000")] public void Given_ConfigValues_And_EnvironmentVariables_And_CLI_When_Parse_Invoked_Then_It_Should_Use_CLI( - string configApiKey, string configModel, - string envApiKey, string envModel, - string cliApiKey, string cliModel) + string configApiKey, string configModel, string configMaxTokens, + string envApiKey, string envModel, string envMaxTokens, + string cliApiKey, string cliModel, string cliMaxTokens) { // Arrange - var config = BuildConfigWithAnthropic(configApiKey, configModel, envApiKey, envModel); + var config = BuildConfigWithAnthropic( + configApiKey, configModel, configMaxTokens, + envApiKey, envModel, envMaxTokens); var args = new[] { ArgumentOptionConstants.Anthropic.ApiKey, cliApiKey, - ArgumentOptionConstants.Anthropic.Model, cliModel + ArgumentOptionConstants.Anthropic.Model, cliModel, + ArgumentOptionConstants.Anthropic.MaxTokens, cliMaxTokens }; // Act @@ -336,17 +416,20 @@ public void Given_ConfigValues_And_EnvironmentVariables_And_CLI_When_Parse_Invok settings.Anthropic.ShouldNotBeNull(); settings.Anthropic.ApiKey.ShouldBe(cliApiKey); settings.Anthropic.Model.ShouldBe(cliModel); + settings.Anthropic.MaxTokens.ShouldBe(IntValueOf(cliMaxTokens)); } [Trait("Category", "UnitTest")] [Theory] - [InlineData("config-api-key", "config-model", null, "env-model")] + [InlineData("config-api-key", "config-model", "1000", null, "env-model", null)] public void Given_Partial_EnvironmentVariables_When_Parse_Invoked_Then_It_Should_Mix_Config_And_Environment( - string configApiKey, string configModel, - string? envApiKey, string envModel) + string configApiKey, string configModel, string configMaxTokens, + string? envApiKey, string envModel, string? envMaxTokens) { // Arrange - var config = BuildConfigWithAnthropic(configApiKey, configModel, envApiKey, envModel); + var config = BuildConfigWithAnthropic( + configApiKey, configModel, configMaxTokens, + envApiKey, envModel, envMaxTokens); var args = Array.Empty(); // Act @@ -356,22 +439,26 @@ public void Given_Partial_EnvironmentVariables_When_Parse_Invoked_Then_It_Should settings.Anthropic.ShouldNotBeNull(); settings.Anthropic.ApiKey.ShouldBe(configApiKey); settings.Anthropic.Model.ShouldBe(envModel); + settings.Anthropic.MaxTokens.ShouldBe(IntValueOf(configMaxTokens)); } [Trait("Category", "UnitTest")] [Theory] - [InlineData("config-api-key", "config-model", "env-api-key", null, null, "cli-model")] + [InlineData("config-api-key", "config-model", "1000", "env-api-key", null, null, null, "cli-model", null)] public void Given_Mixed_Priority_Sources_When_Parse_Invoked_Then_It_Should_Respect_Priority_Order( - string configApiKey, string configModel, - string envApiKey, string? envModel, - string? cliApiKey, string cliModel) + string configApiKey, string configModel, string configMaxTokens, + string envApiKey, string? envModel, string? envMaxTokens, + string? cliApiKey, string cliModel, string? cliMaxTokens) { // Arrange - var config = BuildConfigWithAnthropic(configApiKey, configModel, envApiKey, envModel); + var config = BuildConfigWithAnthropic( + configApiKey, configModel, configMaxTokens, + envApiKey, envModel, envMaxTokens); var args = new[] { ArgumentOptionConstants.Anthropic.ApiKey, cliApiKey, - ArgumentOptionConstants.Anthropic.Model, cliModel + ArgumentOptionConstants.Anthropic.Model, cliModel, + ArgumentOptionConstants.Anthropic.MaxTokens, cliMaxTokens }; // Act @@ -381,19 +468,21 @@ public void Given_Mixed_Priority_Sources_When_Parse_Invoked_Then_It_Should_Respe settings.Anthropic.ShouldNotBeNull(); settings.Anthropic.ApiKey.ShouldBe(envApiKey); settings.Anthropic.Model.ShouldBe(cliModel); + settings.Anthropic.MaxTokens.ShouldBe(IntValueOf(configMaxTokens)); } [Trait("Category", "UnitTest")] [Theory] - [InlineData("cli-api-key", "cli-model")] - public void Given_Anthropic_With_KnownArguments_When_Parse_Invoked_Then_Help_Should_Be_False(string cliApiKey, string cliModel) + [InlineData("cli-api-key", "cli-model", "2000")] + public void Given_Anthropic_With_KnownArguments_When_Parse_Invoked_Then_Help_Should_Be_False(string cliApiKey, string cliModel, string cliMaxTokens) { // Arrange - var config = BuildConfigWithAnthropic(ApiKey, Model); + var config = BuildConfigWithAnthropic(ApiKey, Model, MaxTokens); var args = new[] { ArgumentOptionConstants.Anthropic.ApiKey, cliApiKey, - ArgumentOptionConstants.Anthropic.Model, cliModel + ArgumentOptionConstants.Anthropic.Model, cliModel, + ArgumentOptionConstants.Anthropic.MaxTokens, cliMaxTokens }; // Act @@ -407,6 +496,7 @@ public void Given_Anthropic_With_KnownArguments_When_Parse_Invoked_Then_Help_Sho [Theory] [InlineData(ArgumentOptionConstants.Anthropic.ApiKey)] [InlineData(ArgumentOptionConstants.Anthropic.Model)] + [InlineData(ArgumentOptionConstants.Anthropic.MaxTokens)] public void Given_Anthropic_With_KnownArgument_WithoutValue_When_Parse_Invoked_Then_Help_Should_Be_False(string argument) { // Arrange @@ -423,7 +513,7 @@ public void Given_Anthropic_With_KnownArgument_WithoutValue_When_Parse_Invoked_T [Trait("Category", "UnitTest")] [Theory] [InlineData("cli-api-key", "--unknown-flag")] - public void Given_Anthropic_With_Known_And_Unknown_Argument_When_Parse_Invoked_Then_Help_Should_Be_True( + public void Given_Anthropic_With_Known_ApiKey_And_Unknown_Argument_When_Parse_Invoked_Then_Help_Should_Be_True( string cliApiKey, string argument) { // Arrange @@ -443,15 +533,58 @@ public void Given_Anthropic_With_Known_And_Unknown_Argument_When_Parse_Invoked_T [Trait("Category", "UnitTest")] [Theory] - [InlineData("cli-api-key", "cli-model")] - public void Given_CLI_Only_When_Parse_Invoked_Then_Help_Should_Be_False(string cliApiKey, string cliModel) + [InlineData("cli-model", "--unknown-flag")] + public void Given_Anthropic_With_Known_Model_And_Unknown_Argument_When_Parse_Invoked_Then_Help_Should_Be_True( + string cliModel, string argument) + { + // Arrange + var config = BuildConfigWithAnthropic(); + var args = new[] + { + ArgumentOptionConstants.Anthropic.Model, cliModel, + argument + }; + + // Act + var settings = ArgumentOptions.Parse(config, args); + + // Assert + settings.Help.ShouldBeTrue(); + } + + [Trait("Category", "UnitTest")] + [Theory] + [InlineData("2000", "--unknown-flag")] + public void Given_Anthropic_With_Known_MaxTokens_And_Unknown_Argument_When_Parse_Invoked_Then_Help_Should_Be_True( + string cliMaxTokens, string argument) + { + // Arrange + var config = BuildConfigWithAnthropic(); + var args = new[] + { + ArgumentOptionConstants.Anthropic.MaxTokens, cliMaxTokens, + argument + }; + + // Act + var settings = ArgumentOptions.Parse(config, args); + + // Assert + settings.Help.ShouldBeTrue(); + } + + [Trait("Category", "UnitTest")] + [Theory] + [InlineData("cli-api-key", "cli-model", "2000")] + public void Given_CLI_Only_When_Parse_Invoked_Then_Help_Should_Be_False(string cliApiKey, string cliModel, string cliMaxTokens) { // Arrange var config = BuildConfigWithAnthropic(); var args = new[] { ArgumentOptionConstants.Anthropic.ApiKey, cliApiKey, - ArgumentOptionConstants.Anthropic.Model, cliModel + ArgumentOptionConstants.Anthropic.Model, cliModel, + ArgumentOptionConstants.Anthropic.MaxTokens, cliMaxTokens }; // Act @@ -463,13 +596,13 @@ public void Given_CLI_Only_When_Parse_Invoked_Then_Help_Should_Be_False(string c [Trait("Category", "UnitTest")] [Theory] - [InlineData("env-api-key", "env-model")] - public void Given_EnvironmentVariables_Only_When_Parse_Invoked_Then_Help_Should_Be_False(string envApiKey, string envModel) + [InlineData("env-api-key", "env-model", "1500")] + public void Given_EnvironmentVariables_Only_When_Parse_Invoked_Then_Help_Should_Be_False(string envApiKey, string envModel, string envMaxTokens) { // Arrange var config = BuildConfigWithAnthropic( - configApiKey: null, configModel: null, - envApiKey: envApiKey, envModel: envModel); + configApiKey: null, configModel: null, configMaxTokens: null, + envApiKey: envApiKey, envModel: envModel, envMaxTokens: envMaxTokens); var args = Array.Empty(); // Act @@ -481,8 +614,8 @@ public void Given_EnvironmentVariables_Only_When_Parse_Invoked_Then_Help_Should_ [Trait("Category", "UnitTest")] [Theory] - [InlineData(null, null, ConnectorType.Unknown, false)] - public void Given_AnthropicArgumentOptions_When_Creating_Instance_Then_Should_Have_Correct_Properties(string? expectedApiKey, string? expectedModel, ConnectorType expectedConnectorType, bool expectedHelp) + [InlineData(null, null, null, ConnectorType.Unknown, false)] + public void Given_AnthropicArgumentOptions_When_Creating_Instance_Then_Should_Have_Correct_Properties(string? expectedApiKey, string? expectedModel, string? expectedMaxTokens, ConnectorType expectedConnectorType, bool expectedHelp) { // Act var options = new AnthropicArgumentOptions(); @@ -491,6 +624,7 @@ public void Given_AnthropicArgumentOptions_When_Creating_Instance_Then_Should_Ha options.ShouldNotBeNull(); options.ApiKey.ShouldBe(expectedApiKey); options.Model.ShouldBe(expectedModel); + options.MaxTokens.ShouldBe(IntValueOf(expectedMaxTokens)); options.ConnectorType.ShouldBe(expectedConnectorType); options.Help.ShouldBe(expectedHelp); }