From 3dd68cf9d0992363160275978cc5436ccdfddff8 Mon Sep 17 00:00:00 2001 From: Krzysztof Kasprowicz <60486987+Krzysztof318@users.noreply.github.com> Date: Thu, 4 Apr 2024 18:34:47 +0200 Subject: [PATCH 1/7] .Net: Anthropic initial (#5739) ### Motivation and Context ### Description cc: @RogerBarreto ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone :smile: --- dotnet/SK-dotnet.sln | 18 ++ .../.editorconfig | 8 + .../ClaudePromptExecutionSettingsTests.cs | 152 +++++++++++++++ .../Connectors.Anthropic.UnitTests.csproj | 56 ++++++ .../Core/ClaudeRequestTests.cs | 174 ++++++++++++++++++ .../Connectors.Anthropic/AssemblyInfo.cs | 6 + .../ClaudePromptExecutionSettings.cs | 160 ++++++++++++++++ .../Connectors.Anthropic.csproj | 32 ++++ .../Core/Claude/AuthorRoleConverter.cs | 48 +++++ .../Claude/Models/ClaudeMessageContent.cs | 61 ++++++ .../Core/Claude/Models/ClaudeRequest.cs | 145 +++++++++++++++ .../Connectors.Anthropic/Core/ClientBase.cs | 112 +++++++++++ .../Models/Claude/ClaudeFinishReason.cs | 87 +++++++++ .../Models/Claude/ClaudeMetadata.cs | 80 ++++++++ 14 files changed, 1139 insertions(+) create mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/.editorconfig create mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/ClaudePromptExecutionSettingsTests.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Connectors.Anthropic.UnitTests.csproj create mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/ClaudeRequestTests.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/AssemblyInfo.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/ClaudePromptExecutionSettings.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Connectors.Anthropic.csproj create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/AuthorRoleConverter.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/Models/ClaudeMessageContent.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/Models/ClaudeRequest.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/ClientBase.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Models/Claude/ClaudeFinishReason.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Models/Claude/ClaudeMetadata.cs diff --git a/dotnet/SK-dotnet.sln b/dotnet/SK-dotnet.sln index 6c5f23643dd5..99c5991e67fa 100644 --- a/dotnet/SK-dotnet.sln +++ b/dotnet/SK-dotnet.sln @@ -242,6 +242,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HuggingFaceImageTextExample EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connectors.Onnx.UnitTests", "src\Connectors\Connectors.Onnx.UnitTests\Connectors.Onnx.UnitTests.csproj", "{D06465FA-0308-494C-920B-D502DA5690CB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Connectors.Anthropic", "src\Connectors\Connectors.Anthropic\Connectors.Anthropic.csproj", "{A77031AC-5A71-4061-9451-923D3A5541E4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Connectors.Anthropic.UnitTests", "src\Connectors\Connectors.Anthropic.UnitTests\Connectors.Anthropic.UnitTests.csproj", "{3186E348-3558-42E6-B1DE-D24B816F46C5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -571,6 +575,18 @@ Global {D06465FA-0308-494C-920B-D502DA5690CB}.Publish|Any CPU.Build.0 = Debug|Any CPU {D06465FA-0308-494C-920B-D502DA5690CB}.Release|Any CPU.ActiveCfg = Release|Any CPU {D06465FA-0308-494C-920B-D502DA5690CB}.Release|Any CPU.Build.0 = Release|Any CPU + {A77031AC-5A71-4061-9451-923D3A5541E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A77031AC-5A71-4061-9451-923D3A5541E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A77031AC-5A71-4061-9451-923D3A5541E4}.Publish|Any CPU.ActiveCfg = Debug|Any CPU + {A77031AC-5A71-4061-9451-923D3A5541E4}.Publish|Any CPU.Build.0 = Debug|Any CPU + {A77031AC-5A71-4061-9451-923D3A5541E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A77031AC-5A71-4061-9451-923D3A5541E4}.Release|Any CPU.Build.0 = Release|Any CPU + {3186E348-3558-42E6-B1DE-D24B816F46C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3186E348-3558-42E6-B1DE-D24B816F46C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3186E348-3558-42E6-B1DE-D24B816F46C5}.Publish|Any CPU.ActiveCfg = Debug|Any CPU + {3186E348-3558-42E6-B1DE-D24B816F46C5}.Publish|Any CPU.Build.0 = Debug|Any CPU + {3186E348-3558-42E6-B1DE-D24B816F46C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3186E348-3558-42E6-B1DE-D24B816F46C5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -652,6 +668,8 @@ Global {13429BD6-4C4E-45EC-81AD-30BAC380AA60} = {FA3720F1-C99A-49B2-9577-A940257098BF} {8EE10EB0-A947-49CC-BCC1-18D93415B9E4} = {FA3720F1-C99A-49B2-9577-A940257098BF} {D06465FA-0308-494C-920B-D502DA5690CB} = {1B4CBDE0-10C2-4E7D-9CD0-FE7586C96ED1} + {A77031AC-5A71-4061-9451-923D3A5541E4} = {1B4CBDE0-10C2-4E7D-9CD0-FE7586C96ED1} + {3186E348-3558-42E6-B1DE-D24B816F46C5} = {1B4CBDE0-10C2-4E7D-9CD0-FE7586C96ED1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FBDC56A3-86AD-4323-AA0F-201E59123B83} diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/.editorconfig b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/.editorconfig new file mode 100644 index 000000000000..900bb5a52a52 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/.editorconfig @@ -0,0 +1,8 @@ +# Suppressing errors for Test projects under dotnet folder +[*.cs] +dotnet_diagnostic.CA2007.severity = none # Do not directly await a Task +dotnet_diagnostic.VSTHRD111.severity = none # Use .ConfigureAwait(bool) is hidden by default, set to none to prevent IDE from changing on autosave +dotnet_diagnostic.CS1591.severity = none # Missing XML comment for publicly visible type or member +dotnet_diagnostic.IDE1006.severity = warning # Naming rule violations + +resharper_convert_constructor_to_member_initializers_highlighting = false # Disable highlighting for "Convert constructor to member initializers" quick-fix \ No newline at end of file diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/ClaudePromptExecutionSettingsTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/ClaudePromptExecutionSettingsTests.cs new file mode 100644 index 000000000000..55d7b95aaede --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/ClaudePromptExecutionSettingsTests.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.Anthropic; +using Xunit; + +namespace SemanticKernel.Connectors.Anthropic.UnitTests; + +public sealed class ClaudePromptExecutionSettingsTests +{ + [Fact] + public void ItCreatesExecutionSettingsWithCorrectDefaults() + { + // Arrange + // Act + ClaudePromptExecutionSettings executionSettings = ClaudePromptExecutionSettings.FromExecutionSettings(null); + + // Assert + Assert.NotNull(executionSettings); + Assert.Null(executionSettings.Temperature); + Assert.Null(executionSettings.TopP); + Assert.Null(executionSettings.TopK); + Assert.Null(executionSettings.StopSequences); + Assert.Equal(ClaudePromptExecutionSettings.DefaultTextMaxTokens, executionSettings.MaxTokens); + } + + [Fact] + public void ItUsesExistingExecutionSettings() + { + // Arrange + ClaudePromptExecutionSettings actualSettings = new() + { + Temperature = 0.7, + TopP = 0.7f, + TopK = 20, + StopSequences = new[] { "foo", "bar" }, + MaxTokens = 128, + }; + + // Act + ClaudePromptExecutionSettings executionSettings = ClaudePromptExecutionSettings.FromExecutionSettings(actualSettings); + + // Assert + Assert.NotNull(executionSettings); + Assert.Equal(actualSettings, executionSettings); + } + + [Fact] + public void ItCreatesExecutionSettingsFromExtensionDataSnakeCase() + { + // Arrange + PromptExecutionSettings actualSettings = new() + { + ExtensionData = new Dictionary + { + { "max_tokens", 1000 }, + { "temperature", 0 } + } + }; + + // Act + ClaudePromptExecutionSettings executionSettings = ClaudePromptExecutionSettings.FromExecutionSettings(actualSettings); + + // Assert + Assert.NotNull(executionSettings); + Assert.Equal(1000, executionSettings.MaxTokens); + Assert.Equal(0, executionSettings.Temperature); + } + + [Fact] + public void ItCreatesExecutionSettingsFromJsonSnakeCase() + { + // Arrange + string json = """ + { + "temperature": 0.7, + "top_p": 0.7, + "top_k": 25, + "stop_sequences": [ "foo", "bar" ], + "max_tokens": 128 + } + """; + var actualSettings = JsonSerializer.Deserialize(json); + + // Act + ClaudePromptExecutionSettings executionSettings = ClaudePromptExecutionSettings.FromExecutionSettings(actualSettings); + + // Assert + Assert.NotNull(executionSettings); + Assert.Equal(0.7, executionSettings.Temperature); + Assert.Equal(0.7f, executionSettings.TopP); + Assert.Equal(25, executionSettings.TopK); + Assert.Equal(new[] { "foo", "bar" }, executionSettings.StopSequences); + Assert.Equal(128, executionSettings.MaxTokens); + } + + [Fact] + public void PromptExecutionSettingsCloneWorksAsExpected() + { + // Arrange + string json = """ + { + "model_id": "claude-pro", + "temperature": 0.7, + "top_p": 0.7, + "top_k": 25, + "stop_sequences": [ "foo", "bar" ], + "max_tokens": 128 + } + """; + var executionSettings = JsonSerializer.Deserialize(json); + + // Act + var clone = executionSettings!.Clone() as ClaudePromptExecutionSettings; + + // Assert + Assert.NotNull(clone); + Assert.Equal(executionSettings.ModelId, clone.ModelId); + Assert.Equal(executionSettings.Temperature, clone.Temperature); + Assert.Equivalent(executionSettings.ExtensionData, clone.ExtensionData); + Assert.Equivalent(executionSettings.StopSequences, clone.StopSequences); + } + + [Fact] + public void PromptExecutionSettingsFreezeWorksAsExpected() + { + // Arrange + string json = """ + { + "model_id": "claude-pro", + "temperature": 0.7, + "top_p": 0.7, + "top_k": 25, + "stop_sequences": [ "foo", "bar" ], + "max_tokens": 128 + } + """; + var executionSettings = JsonSerializer.Deserialize(json); + + // Act + executionSettings!.Freeze(); + + // Assert + Assert.True(executionSettings.IsFrozen); + Assert.Throws(() => executionSettings.ModelId = "claude"); + Assert.Throws(() => executionSettings.Temperature = 0.5); + Assert.Throws(() => executionSettings.StopSequences!.Add("baz")); + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Connectors.Anthropic.UnitTests.csproj b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Connectors.Anthropic.UnitTests.csproj new file mode 100644 index 000000000000..57eed53d6000 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Connectors.Anthropic.UnitTests.csproj @@ -0,0 +1,56 @@ + + + + SemanticKernel.Connectors.Anthropic.UnitTests + SemanticKernel.Connectors.Anthropic.UnitTests + net6.0 + 12 + LatestMajor + true + enable + disable + false + CA2007,CA1806,CA1869,CA1861,IDE0300,VSTHRD111,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0050,SKEXP0070 + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + Always + + + + \ No newline at end of file diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/ClaudeRequestTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/ClaudeRequestTests.cs new file mode 100644 index 000000000000..aa139c5cfbc1 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/ClaudeRequestTests.cs @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Anthropic; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core; +using Xunit; + +namespace SemanticKernel.Connectors.Anthropic.UnitTests.Core; + +public sealed class ClaudeRequestTests +{ + [Fact] + public void FromChatHistoryItReturnsClaudeRequestWithConfiguration() + { + // Arrange + ChatHistory chatHistory = []; + chatHistory.AddUserMessage("user-message"); + chatHistory.AddAssistantMessage("assist-message"); + chatHistory.AddUserMessage("user-message2"); + var executionSettings = new ClaudePromptExecutionSettings + { + Temperature = 1.5, + MaxTokens = 10, + TopP = 0.9f, + ModelId = "claude" + }; + + // Act + var request = ClaudeRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.Equal(executionSettings.Temperature, request.Temperature); + Assert.Equal(executionSettings.MaxTokens, request.MaxTokens); + Assert.Equal(executionSettings.TopP, request.TopP); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FromChatHistoryItReturnsClaudeRequestWithValidStreamingMode(bool streamMode) + { + // Arrange + ChatHistory chatHistory = []; + chatHistory.AddUserMessage("user-message"); + chatHistory.AddAssistantMessage("assist-message"); + chatHistory.AddUserMessage("user-message2"); + var executionSettings = new ClaudePromptExecutionSettings + { + Temperature = 1.5, + MaxTokens = 10, + TopP = 0.9f, + ModelId = "claude" + }; + + // Act + var request = ClaudeRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings, streamMode); + + // Assert + Assert.Equal(streamMode, request.Stream); + } + + [Fact] + public void FromChatHistoryItReturnsClaudeRequestWithChatHistory() + { + // Arrange + ChatHistory chatHistory = []; + chatHistory.AddUserMessage("user-message"); + chatHistory.AddAssistantMessage("assist-message"); + chatHistory.AddUserMessage("user-message2"); + var executionSettings = new ClaudePromptExecutionSettings() + { + ModelId = "claude", + MaxTokens = 128, + }; + + // Act + var request = ClaudeRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.Collection(request.Messages, + c => Assert.Equal(chatHistory[0].Content, c.Contents[0].Text), + c => Assert.Equal(chatHistory[1].Content, c.Contents[0].Text), + c => Assert.Equal(chatHistory[2].Content, c.Contents[0].Text)); + Assert.Collection(request.Messages, + c => Assert.Equal(chatHistory[0].Role, c.Role), + c => Assert.Equal(chatHistory[1].Role, c.Role), + c => Assert.Equal(chatHistory[2].Role, c.Role)); + } + + [Fact] + public void FromChatHistoryTextAsTextContentItReturnsClaudeRequestWithChatHistory() + { + // Arrange + ChatHistory chatHistory = []; + chatHistory.AddUserMessage("user-message"); + chatHistory.AddAssistantMessage("assist-message"); + chatHistory.AddUserMessage(contentItems: [new TextContent("user-message2")]); + var executionSettings = new ClaudePromptExecutionSettings() + { + ModelId = "claude", + MaxTokens = 128, + }; + + // Act + var request = ClaudeRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.Collection(request.Messages, + c => Assert.Equal(chatHistory[0].Content, c.Contents[0].Text), + c => Assert.Equal(chatHistory[1].Content, c.Contents[0].Text), + c => Assert.Equal(chatHistory[2].Items.Cast().Single().Text, c.Contents[0].Text)); + } + + [Fact] + public void FromChatHistoryImageAsImageContentItReturnsClaudeRequestWithChatHistory() + { + // Arrange + ReadOnlyMemory imageAsBytes = new byte[] { 0x00, 0x01, 0x02, 0x03 }; + ChatHistory chatHistory = []; + chatHistory.AddUserMessage("user-message"); + chatHistory.AddAssistantMessage("assist-message"); + chatHistory.AddUserMessage(contentItems: + [new ImageContent(imageAsBytes) { MimeType = "image/png" }]); + var executionSettings = new ClaudePromptExecutionSettings() + { + ModelId = "claude", + MaxTokens = 128, + }; + + // Act + var request = ClaudeRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.Collection(request.Messages, + c => Assert.Equal(chatHistory[0].Content, c.Contents[0].Text), + c => Assert.Equal(chatHistory[1].Content, c.Contents[0].Text), + c => + { + Assert.Equal(chatHistory[2].Items.Cast().Single().MimeType, c.Contents[0].Image!.MediaType); + Assert.True(imageAsBytes.ToArray().SequenceEqual(Convert.FromBase64String(c.Contents[0].Image!.Data))); + }); + } + + [Fact] + public void FromChatHistoryUnsupportedContentItThrowsNotSupportedException() + { + // Arrange + ChatHistory chatHistory = []; + chatHistory.AddUserMessage("user-message"); + chatHistory.AddAssistantMessage("assist-message"); + chatHistory.AddUserMessage(contentItems: [new DummyContent("unsupported-content")]); + var executionSettings = new ClaudePromptExecutionSettings() + { + ModelId = "claude", + MaxTokens = 128, + }; + + // Act + void Act() => ClaudeRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.Throws(Act); + } + + private sealed class DummyContent : KernelContent + { + public DummyContent(object? innerContent, string? modelId = null, IReadOnlyDictionary? metadata = null) + : base(innerContent, modelId, metadata) { } + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/AssemblyInfo.cs b/dotnet/src/Connectors/Connectors.Anthropic/AssemblyInfo.cs new file mode 100644 index 000000000000..fe66371dbc58 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; + +// This assembly is currently experimental. +[assembly: Experimental("SKEXP0070")] diff --git a/dotnet/src/Connectors/Connectors.Anthropic/ClaudePromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.Anthropic/ClaudePromptExecutionSettings.cs new file mode 100644 index 000000000000..0cb0a2f670b4 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/ClaudePromptExecutionSettings.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.SemanticKernel.Text; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic; + +/// +/// Represents the settings for executing a prompt with the Claude models. +/// +[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] +public sealed class ClaudePromptExecutionSettings : PromptExecutionSettings +{ + private double? _temperature; + private float? _topP; + private int? _topK; + private int? _maxTokens; + private IList? _stopSequences; + + /// + /// Default max tokens for a text generation. + /// + public static int DefaultTextMaxTokens { get; } = 1024; + + /// + /// Temperature controls the randomness of the completion. + /// The higher the temperature, the more random the completion. + /// Range is 0.0 to 1.0. + /// + [JsonPropertyName("temperature")] + public double? Temperature + { + get => this._temperature; + set + { + this.ThrowIfFrozen(); + this._temperature = value; + } + } + + /// + /// TopP controls the diversity of the completion. + /// The higher the TopP, the more diverse the completion. + /// + [JsonPropertyName("top_p")] + public float? TopP + { + get => this._topP; + set + { + this.ThrowIfFrozen(); + this._topP = value; + } + } + + /// + /// Gets or sets the value of the TopK property. + /// The TopK property represents the maximum value of a collection or dataset. + /// + [JsonPropertyName("top_k")] + public int? TopK + { + get => this._topK; + set + { + this.ThrowIfFrozen(); + this._topK = value; + } + } + + /// + /// The maximum number of tokens to generate in the completion. + /// + [JsonPropertyName("max_tokens")] + public int? MaxTokens + { + get => this._maxTokens; + set + { + this.ThrowIfFrozen(); + this._maxTokens = value; + } + } + + /// + /// Sequences where the completion will stop generating further tokens. + /// Maximum number of stop sequences is 5. + /// + [JsonPropertyName("stop_sequences")] + public IList? StopSequences + { + get => this._stopSequences; + set + { + this.ThrowIfFrozen(); + this._stopSequences = value; + } + } + + /// + public override void Freeze() + { + if (this.IsFrozen) + { + return; + } + + base.Freeze(); + + if (this._stopSequences is not null) + { + this._stopSequences = new ReadOnlyCollection(this._stopSequences); + } + } + + /// + public override PromptExecutionSettings Clone() + { + return new ClaudePromptExecutionSettings() + { + ModelId = this.ModelId, + ExtensionData = this.ExtensionData is not null ? new Dictionary(this.ExtensionData) : null, + Temperature = this.Temperature, + TopP = this.TopP, + TopK = this.TopK, + MaxTokens = this.MaxTokens, + StopSequences = this.StopSequences is not null ? new List(this.StopSequences) : null, + }; + } + + /// + /// Converts a object to a object. + /// + /// The object to convert. + /// + /// The converted object. If is null, + /// a new instance of is returned. If + /// is already a object, it is cast and returned. Otherwise, the method + /// tries to deserialize to a object. + /// If deserialization is successful, the converted object is returned. If deserialization fails or the converted object + /// is null, an is thrown. + /// + public static ClaudePromptExecutionSettings FromExecutionSettings(PromptExecutionSettings? executionSettings) + { + switch (executionSettings) + { + case null: + return new ClaudePromptExecutionSettings { MaxTokens = DefaultTextMaxTokens }; + case ClaudePromptExecutionSettings settings: + return settings; + } + + var json = JsonSerializer.Serialize(executionSettings); + return JsonSerializer.Deserialize(json, JsonOptionsCache.ReadPermissive)!; + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Connectors.Anthropic.csproj b/dotnet/src/Connectors/Connectors.Anthropic/Connectors.Anthropic.csproj new file mode 100644 index 000000000000..d851bca320ff --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Connectors.Anthropic.csproj @@ -0,0 +1,32 @@ + + + + + Microsoft.SemanticKernel.Connectors.Anthropic + $(AssemblyName) + netstandard2.0 + alpha + SKEXP0001,SKEXP0070 + + + + + + + + + Semantic Kernel - Anthropic Connectors + Semantic Kernel connectors for Anthropic generation platforms. Contains generation services. + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/AuthorRoleConverter.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/AuthorRoleConverter.cs new file mode 100644 index 000000000000..eb4369533bdd --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/AuthorRoleConverter.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; + +internal sealed class AuthorRoleConverter : JsonConverter +{ + public override AuthorRole Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string? role = reader.GetString(); + if (role == null) + { + throw new InvalidOperationException("Unexpected null value for author role"); + } + + if (role.Equals("user", StringComparison.OrdinalIgnoreCase)) + { + return AuthorRole.User; + } + + if (role.Equals("assistant", StringComparison.OrdinalIgnoreCase)) + { + return AuthorRole.Assistant; + } + + throw new JsonException($"Unexpected author role: {role}"); + } + + public override void Write(Utf8JsonWriter writer, AuthorRole value, JsonSerializerOptions options) + { + if (value == AuthorRole.Assistant) + { + writer.WriteStringValue("assistant"); + } + else if (value == AuthorRole.User) + { + writer.WriteStringValue("user"); + } + else + { + throw new JsonException($"Claude API doesn't support author role: {value}"); + } + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/Models/ClaudeMessageContent.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/Models/ClaudeMessageContent.cs new file mode 100644 index 000000000000..42afd9a686fb --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/Models/ClaudeMessageContent.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; + +/// +/// Represents the request/response content of Claude. +/// +internal sealed class ClaudeMessageContent +{ + /// + /// Type of content. Possible values are "text" and "image". + /// + [JsonRequired] + [JsonPropertyName("type")] + public string Type { get; set; } = null!; + + /// + /// Only used when type is "text". The text content. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// + /// Only used when type is "image". The image content. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("source")] + public SourceEntity? Image { get; set; } + + internal sealed class SourceEntity + { + [JsonConstructor] + internal SourceEntity(string type, string mediaType, string data) + { + this.Type = type; + this.MediaType = mediaType; + this.Data = data; + } + + /// + /// Currently supported only base64. + /// + [JsonPropertyName("type")] + public string Type { get; set; } + + /// + /// The media type of the image. + /// + [JsonPropertyName("media_type")] + public string MediaType { get; set; } + + /// + /// The base64 encoded image data. + /// + [JsonPropertyName("data")] + public string Data { get; set; } + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/Models/ClaudeRequest.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/Models/ClaudeRequest.cs new file mode 100644 index 000000000000..d0c8a5a04726 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/Models/ClaudeRequest.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; + +internal sealed class ClaudeRequest +{ + /// + /// Input messages.
+ /// Our models are trained to operate on alternating user and assistant conversational turns. + /// When creating a new Message, you specify the prior conversational turns with the messages parameter, + /// and the model then generates the next Message in the conversation. + /// Each input message must be an object with a role and content. You can specify a single user-role message, + /// or you can include multiple user and assistant messages. The first message must always use the user role. + /// If the final message uses the assistant role, the response content will continue immediately + /// from the content in that message. This can be used to constrain part of the model's response. + ///
+ [JsonPropertyName("messages")] + public IList Messages { get; set; } = null!; + + [JsonPropertyName("model")] + public string ModelId { get; set; } = null!; + + [JsonPropertyName("max_tokens")] + public int MaxTokens { get; set; } + + /// + /// A system prompt is a way of providing context and instructions to Claude, such as specifying a particular goal or persona. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("system")] + public string? SystemPrompt { get; set; } + + /// + /// Custom text sequences that will cause the model to stop generating. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("stop_sequences")] + public IList? StopSequences { get; set; } + + /// + /// Enables SSE streaming. + /// + [JsonPropertyName("stream")] + public bool Stream { get; set; } + + /// + /// Amount of randomness injected into the response.
+ /// Defaults to 1.0. Ranges from 0.0 to 1.0. Use temperature closer to 0.0 for analytical / multiple choice, and closer to 1.0 for creative and generative tasks.
+ /// Note that even with temperature of 0.0, the results will not be fully deterministic. + ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("temperature")] + public double? Temperature { get; set; } + + /// + /// In nucleus sampling, we compute the cumulative distribution over all the options for each subsequent token + /// in decreasing probability order and cut it off once it reaches a particular probability specified by top_p. + /// You should either alter temperature or top_p, but not both. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("top_p")] + public float? TopP { get; set; } + + /// + /// Only sample from the top K options for each subsequent token. + /// Used to remove "long tail" low probability responses. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("top_k")] + public int? TopK { get; set; } + + /// + /// Creates a object from the given and . + /// + /// The chat history to be assigned to the . + /// The execution settings to be applied to the . + /// Enables SSE streaming. (optional) + /// A new instance of . + internal static ClaudeRequest FromChatHistoryAndExecutionSettings( + ChatHistory chatHistory, + ClaudePromptExecutionSettings executionSettings, + bool streamingMode = false) + { + ClaudeRequest request = CreateRequest(chatHistory, executionSettings, streamingMode); + AddMessages(chatHistory, request); + return request; + } + + private static void AddMessages(ChatHistory chatHistory, ClaudeRequest request) + { + request.Messages = chatHistory.Select(message => new Message + { + Role = message.Role, + Contents = message.Items.Select(GetContentFromKernelContent).ToList() + }).ToList(); + } + + private static ClaudeRequest CreateRequest(ChatHistory chatHistory, ClaudePromptExecutionSettings executionSettings, bool streamingMode) + { + ClaudeRequest request = new() + { + ModelId = executionSettings.ModelId ?? throw new InvalidOperationException("Model ID must be provided."), + MaxTokens = executionSettings.MaxTokens ?? throw new InvalidOperationException("Max tokens must be provided."), + SystemPrompt = chatHistory.SingleOrDefault(c => c.Role == AuthorRole.System)?.Content, + StopSequences = executionSettings.StopSequences, + Stream = streamingMode, + Temperature = executionSettings.Temperature, + TopP = executionSettings.TopP, + TopK = executionSettings.TopK + }; + return request; + } + + private static ClaudeMessageContent GetContentFromKernelContent(KernelContent content) => content switch + { + TextContent textContent => new ClaudeMessageContent { Type = "text", Text = textContent.Text }, + ImageContent imageContent => new ClaudeMessageContent + { + Type = "image", Image = new ClaudeMessageContent.SourceEntity( + type: "base64", + mediaType: imageContent.MimeType ?? throw new InvalidOperationException("Image content must have a MIME type."), + data: imageContent.Data.HasValue + ? Convert.ToBase64String(imageContent.Data.Value.ToArray()) + : throw new InvalidOperationException("Image content must have a data.") + ) + }, + _ => throw new NotSupportedException($"Content type '{content.GetType().Name}' is not supported.") + }; + + internal sealed class Message + { + [JsonConverter(typeof(AuthorRoleConverter))] + [JsonPropertyName("role")] + public AuthorRole Role { get; set; } + + [JsonPropertyName("content")] + public IList Contents { get; set; } = null!; + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/ClientBase.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/ClientBase.cs new file mode 100644 index 000000000000..d63741fae3fe --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/ClientBase.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticKernel.Http; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; + +internal abstract class ClientBase +{ + private readonly Func>? _bearerTokenProvider; + + private readonly ILogger _logger; + + protected HttpClient HttpClient { get; } + + protected ClientBase( + HttpClient httpClient, + ILogger? logger, + Func> bearerTokenProvider) + : this(httpClient, logger) + { + Verify.NotNull(bearerTokenProvider); + this._bearerTokenProvider = bearerTokenProvider; + } + + protected ClientBase( + HttpClient httpClient, + ILogger? logger) + { + Verify.NotNull(httpClient); + + this.HttpClient = httpClient; + this._logger = logger ?? NullLogger.Instance; + } + + protected static void ValidateMaxTokens(int? maxTokens) + { + // If maxTokens is null, it means that the user wants to use the default model value + if (maxTokens is < 1) + { + throw new ArgumentException($"MaxTokens {maxTokens} is not valid, the value must be greater than zero"); + } + } + + protected async Task SendRequestAndGetStringBodyAsync( + HttpRequestMessage httpRequestMessage, + CancellationToken cancellationToken) + { + using var response = await this.HttpClient.SendWithSuccessCheckAsync(httpRequestMessage, cancellationToken) + .ConfigureAwait(false); + var body = await response.Content.ReadAsStringWithExceptionMappingAsync() + .ConfigureAwait(false); + return body; + } + + protected async Task SendRequestAndGetResponseImmediatelyAfterHeadersReadAsync( + HttpRequestMessage httpRequestMessage, + CancellationToken cancellationToken) + { + var response = await this.HttpClient.SendWithSuccessCheckAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + return response; + } + + protected static T DeserializeResponse(string body) + { + try + { + return JsonSerializer.Deserialize(body) ?? throw new JsonException("Response is null"); + } + catch (JsonException exc) + { + throw new KernelException("Unexpected response from model", exc) + { + Data = { { "ResponseData", body } }, + }; + } + } + + protected async Task CreateHttpRequestAsync(object requestData, Uri endpoint) + { + var httpRequestMessage = HttpRequest.CreatePostRequest(endpoint, requestData); + httpRequestMessage.Headers.Add("User-Agent", HttpHeaderConstant.Values.UserAgent); + httpRequestMessage.Headers.Add(HttpHeaderConstant.Names.SemanticKernelVersion, + HttpHeaderConstant.Values.GetAssemblyVersion(typeof(ClientBase))); + + if (this._bearerTokenProvider != null && await this._bearerTokenProvider().ConfigureAwait(false) is { } bearerKey) + { + httpRequestMessage.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", bearerKey); + } + + return httpRequestMessage; + } + + protected void Log(LogLevel logLevel, string? message, params object[] args) + { + if (this._logger.IsEnabled(logLevel)) + { +#pragma warning disable CA2254 // Template should be a constant string. + this._logger.Log(logLevel, message, args); +#pragma warning restore CA2254 + } + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/Claude/ClaudeFinishReason.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/Claude/ClaudeFinishReason.cs new file mode 100644 index 000000000000..69b915f7d651 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/Claude/ClaudeFinishReason.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic; + +/// +/// Represents a Claude Finish Reason. +/// +[JsonConverter(typeof(ClaudeFinishReasonConverter))] +public readonly struct ClaudeFinishReason : IEquatable +{ + /// + /// Natural stop point of the model or provided stop sequence. + /// + public static ClaudeFinishReason Stop { get; } = new("end_turn"); + + /// + /// The maximum number of tokens as specified in the request was reached. + /// + public static ClaudeFinishReason MaxTokens { get; } = new("max_tokens"); + + /// + /// One of your provided custom stop sequences was generated. + /// + public static ClaudeFinishReason StopSequence { get; } = new("stop_sequence"); + + /// + /// Gets the label of the property. + /// Label is used for serialization. + /// + public string Label { get; } + + /// + /// Represents a Claude Finish Reason. + /// + [JsonConstructor] + public ClaudeFinishReason(string label) + { + Verify.NotNullOrWhiteSpace(label, nameof(label)); + this.Label = label; + } + + /// + /// Represents the equality operator for comparing two instances of . + /// + /// The left instance to compare. + /// The right instance to compare. + /// true if the two instances are equal; otherwise, false. + public static bool operator ==(ClaudeFinishReason left, ClaudeFinishReason right) + => left.Equals(right); + + /// + /// Represents the inequality operator for comparing two instances of . + /// + /// The left instance to compare. + /// The right instance to compare. + /// true if the two instances are not equal; otherwise, false. + public static bool operator !=(ClaudeFinishReason left, ClaudeFinishReason right) + => !(left == right); + + /// + public bool Equals(ClaudeFinishReason other) + => string.Equals(this.Label, other.Label, StringComparison.OrdinalIgnoreCase); + + /// + public override bool Equals(object? obj) + => obj is ClaudeFinishReason other && this == other; + + /// + public override int GetHashCode() + => StringComparer.OrdinalIgnoreCase.GetHashCode(this.Label ?? string.Empty); + + /// + public override string ToString() => this.Label ?? string.Empty; +} + +internal sealed class ClaudeFinishReasonConverter : JsonConverter +{ + public override ClaudeFinishReason Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new(reader.GetString()!); + + public override void Write(Utf8JsonWriter writer, ClaudeFinishReason value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Label); +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/Claude/ClaudeMetadata.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/Claude/ClaudeMetadata.cs new file mode 100644 index 000000000000..4762df30cc97 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/Claude/ClaudeMetadata.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic; + +/// +/// Represents the metadata associated with a Claude response. +/// +public sealed class ClaudeMetadata : ReadOnlyDictionary +{ + internal ClaudeMetadata() : base(new Dictionary()) { } + + private ClaudeMetadata(IDictionary dictionary) : base(dictionary) { } + + /// + /// Unique message object identifier. + /// + public string MessageId + { + get => this.GetValueFromDictionary(nameof(this.MessageId)) as string ?? string.Empty; + internal init => this.SetValueInDictionary(value, nameof(this.MessageId)); + } + + /// + /// The reason generating was stopped. + /// + public ClaudeFinishReason? FinishReason + { + get => (ClaudeFinishReason?)this.GetValueFromDictionary(nameof(this.FinishReason)); + internal init => this.SetValueInDictionary(value, nameof(this.FinishReason)); + } + + /// + /// Which custom stop sequence was generated, if any. + /// + public string? StopSequence + { + get => this.GetValueFromDictionary(nameof(this.StopSequence)) as string; + internal init => this.SetValueInDictionary(value, nameof(this.StopSequence)); + } + + /// + /// The number of input tokens which were used. + /// + public int InputTokenCount + { + get => (this.GetValueFromDictionary(nameof(this.InputTokenCount)) as int?) ?? 0; + internal init => this.SetValueInDictionary(value, nameof(this.InputTokenCount)); + } + + /// + /// The number of output tokens which were used. + /// + public int OutputTokenCount + { + get => (this.GetValueFromDictionary(nameof(this.OutputTokenCount)) as int?) ?? 0; + internal init => this.SetValueInDictionary(value, nameof(this.OutputTokenCount)); + } + + /// + /// Converts a dictionary to a object. + /// + public static ClaudeMetadata FromDictionary(IReadOnlyDictionary dictionary) => dictionary switch + { + null => throw new ArgumentNullException(nameof(dictionary)), + ClaudeMetadata metadata => metadata, + IDictionary metadata => new ClaudeMetadata(metadata), + _ => new ClaudeMetadata(dictionary.ToDictionary(pair => pair.Key, pair => pair.Value)) + }; + + private void SetValueInDictionary(object? value, string propertyName) + => this.Dictionary[propertyName] = value; + + private object? GetValueFromDictionary(string propertyName) + => this.Dictionary.TryGetValue(propertyName, out var value) ? value : null; +} From d0b4b0f90a8bd61281fb392ec483d20d2b9a2cef Mon Sep 17 00:00:00 2001 From: Krzysztof Kasprowicz <60486987+Krzysztof318@users.noreply.github.com> Date: Tue, 16 Apr 2024 21:19:28 +0200 Subject: [PATCH 2/7] .Net: Claude service and DI (#5794) cc: @RogerBarreto ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone :smile: --- ...thropicServiceCollectionExtensionsTests.cs | 80 ++++++++ .../AnthropicChatCompletionServiceTests.cs | 21 ++ .../AnthropicClientOptions.cs | 40 ++++ .../Core/Claude/AnthropicClient.cs | 191 ++++++++++++++++++ .../Connectors.Anthropic/Core/ClientBase.cs | 112 ---------- .../AnthropicKernelBuilderExtensions.cs | 85 ++++++++ .../AnthropicServiceCollectionExtensions.cs | 81 ++++++++ .../AnthropicChatCompletionService.cs | 107 ++++++++++ 8 files changed, 605 insertions(+), 112 deletions(-) create mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Extensions/AnthropicServiceCollectionExtensionsTests.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Services/AnthropicChatCompletionServiceTests.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/AnthropicClientOptions.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/AnthropicClient.cs delete mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/ClientBase.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Extensions/AnthropicKernelBuilderExtensions.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Extensions/AnthropicServiceCollectionExtensions.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Services/AnthropicChatCompletionService.cs diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Extensions/AnthropicServiceCollectionExtensionsTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Extensions/AnthropicServiceCollectionExtensionsTests.cs new file mode 100644 index 000000000000..69b79a5d9283 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Extensions/AnthropicServiceCollectionExtensionsTests.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Anthropic; +using Xunit; + +namespace SemanticKernel.Connectors.Anthropic.UnitTests.Extensions; + +/// +/// Unit tests for and classes. +/// +public sealed class AnthropicServiceCollectionExtensionsTests +{ + [Fact] + public void AnthropicChatCompletionServiceShouldBeRegisteredInKernelServices() + { + // Arrange + var kernelBuilder = Kernel.CreateBuilder(); + + // Act + kernelBuilder.AddAnthropicChatCompletion("modelId", "apiKey"); + var kernel = kernelBuilder.Build(); + + // Assert + var chatCompletionService = kernel.GetRequiredService(); + Assert.NotNull(chatCompletionService); + Assert.IsType(chatCompletionService); + } + + [Fact] + public void AnthropicChatCompletionServiceShouldBeRegisteredInServiceCollection() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddAnthropicChatCompletion("modelId", "apiKey"); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var chatCompletionService = serviceProvider.GetRequiredService(); + Assert.NotNull(chatCompletionService); + Assert.IsType(chatCompletionService); + } + + [Fact] + public void AnthropicChatCompletionServiceCustomEndpointShouldBeRegisteredInKernelServices() + { + // Arrange + var kernelBuilder = Kernel.CreateBuilder(); + + // Act + kernelBuilder.AddAnthropicChatCompletion("modelId", new Uri("https://example.com"), null); + var kernel = kernelBuilder.Build(); + + // Assert + var chatCompletionService = kernel.GetRequiredService(); + Assert.NotNull(chatCompletionService); + Assert.IsType(chatCompletionService); + } + + [Fact] + public void AnthropicChatCompletionServiceCustomEndpointShouldBeRegisteredInServiceCollection() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddAnthropicChatCompletion("modelId", new Uri("https://example.com"), null); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var chatCompletionService = serviceProvider.GetRequiredService(); + Assert.NotNull(chatCompletionService); + Assert.IsType(chatCompletionService); + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Services/AnthropicChatCompletionServiceTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Services/AnthropicChatCompletionServiceTests.cs new file mode 100644 index 000000000000..94e8dca76b4f --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Services/AnthropicChatCompletionServiceTests.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel.Connectors.Anthropic; +using Microsoft.SemanticKernel.Services; +using Xunit; + +namespace SemanticKernel.Connectors.Anthropic.UnitTests.Services; + +public sealed class AnthropicChatCompletionServiceTests +{ + [Fact] + public void AttributesShouldContainModelId() + { + // Arrange & Act + string model = "fake-model"; + var service = new AnthropicChatCompletionService(model, "key"); + + // Assert + Assert.Equal(model, service.Attributes[AIServiceExtensions.ModelIdKey]); + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/AnthropicClientOptions.cs b/dotnet/src/Connectors/Connectors.Anthropic/AnthropicClientOptions.cs new file mode 100644 index 000000000000..1bbcecf1fcae --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/AnthropicClientOptions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic; + +#pragma warning disable CA1707 // Identifiers should not contain underscores + +/// +/// Represents the options for configuring the Anthropic client. +/// +public sealed class AnthropicClientOptions +{ + private const ServiceVersion LatestVersion = ServiceVersion.V2023_06_01; + + /// The version of the service to use. +#pragma warning disable CA1008 // Enums should have zero value + public enum ServiceVersion +#pragma warning restore CA1008 + { + /// Service version "2023-01-01". + V2023_01_01 = 1, + + /// Service version "2023-06-01". + V2023_06_01 = 2, + } + + internal string Version { get; } + + /// Initializes new instance of OpenAIClientOptions. + public AnthropicClientOptions(ServiceVersion version = LatestVersion) + { + this.Version = version switch + { + ServiceVersion.V2023_01_01 => "2023-01-01", + ServiceVersion.V2023_06_01 => "2023-06-01", + _ => throw new NotSupportedException("Unsupported service version") + }; + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/AnthropicClient.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/AnthropicClient.cs new file mode 100644 index 000000000000..a73783c4d942 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/AnthropicClient.cs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Http; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; + +/// +/// Represents a client for interacting with the Anthropic chat completion models. +/// +internal sealed class AnthropicClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly string _modelId; + private readonly string? _apiKey; + private readonly Uri _endpoint; + private readonly Func? _customRequestHandler; + private readonly AnthropicClientOptions _options; + + /// + /// Represents a client for interacting with the Anthropic chat completion models. + /// + /// HttpClient instance used to send HTTP requests + /// Id of the model supporting chat completion + /// Api key + /// Options for the client + /// Logger instance used for logging (optional) + public AnthropicClient( + HttpClient httpClient, + string modelId, + string apiKey, + AnthropicClientOptions? options, + ILogger? logger = null) + { + Verify.NotNull(httpClient); + Verify.NotNullOrWhiteSpace(modelId); + Verify.NotNullOrWhiteSpace(apiKey); + + this._httpClient = httpClient; + this._logger = logger ?? NullLogger.Instance; + this._modelId = modelId; + this._apiKey = apiKey; + this._options = options ?? new AnthropicClientOptions(); + this._endpoint = new Uri("https://api.anthropic.com/v1/messages"); + } + + /// + /// Represents a client for interacting with the Anthropic chat completion models. + /// + /// HttpClient instance used to send HTTP requests + /// Id of the model supporting chat completion + /// Endpoint for the chat completion model + /// A custom request handler to be used for sending HTTP requests + /// Options for the client + /// Logger instance used for logging (optional) + public AnthropicClient( + HttpClient httpClient, + string modelId, + Uri endpoint, + Func? requestHandler, + AnthropicClientOptions? options, + ILogger? logger = null) + { + Verify.NotNull(httpClient); + Verify.NotNullOrWhiteSpace(modelId); + Verify.NotNull(endpoint); + + this._httpClient = httpClient; + this._logger = logger ?? NullLogger.Instance; + this._modelId = modelId; + this._endpoint = endpoint; + this._customRequestHandler = requestHandler; + this._options = options ?? new AnthropicClientOptions(); + } + + /// + /// Generates a chat message asynchronously. + /// + /// The chat history containing the conversation data. + /// Optional settings for prompt execution. + /// A kernel instance. + /// A cancellation token to cancel the operation. + /// Returns a list of chat message contents. + public async Task> GenerateChatMessageAsync( + ChatHistory chatHistory, + PromptExecutionSettings? executionSettings = null, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + await Task.Yield(); + throw new NotImplementedException("Implement this method in next PR."); + } + + /// + /// Generates a stream of chat messages asynchronously. + /// + /// The chat history containing the conversation data. + /// Optional settings for prompt execution. + /// A kernel instance. + /// A cancellation token to cancel the operation. + /// An asynchronous enumerable of streaming chat contents. + public async IAsyncEnumerable StreamGenerateChatMessageAsync( + ChatHistory chatHistory, + PromptExecutionSettings? executionSettings = null, + Kernel? kernel = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + yield return new StreamingChatMessageContent(null, null); + throw new NotImplementedException("Implement this method in next PR."); + } + + private static void ValidateMaxTokens(int? maxTokens) + { + // If maxTokens is null, it means that the user wants to use the default model value + if (maxTokens is < 1) + { + throw new ArgumentException($"MaxTokens {maxTokens} is not valid, the value must be greater than zero"); + } + } + + private async Task SendRequestAndGetStringBodyAsync( + HttpRequestMessage httpRequestMessage, + CancellationToken cancellationToken) + { + using var response = await this._httpClient.SendWithSuccessCheckAsync(httpRequestMessage, cancellationToken) + .ConfigureAwait(false); + var body = await response.Content.ReadAsStringWithExceptionMappingAsync() + .ConfigureAwait(false); + return body; + } + + private async Task SendRequestAndGetResponseImmediatelyAfterHeadersReadAsync( + HttpRequestMessage httpRequestMessage, + CancellationToken cancellationToken) + { + var response = await this._httpClient.SendWithSuccessCheckAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + return response; + } + + private static T DeserializeResponse(string body) + { + try + { + return JsonSerializer.Deserialize(body) ?? throw new JsonException("Response is null"); + } + catch (JsonException exc) + { + throw new KernelException("Unexpected response from model", exc) + { + Data = { { "ResponseData", body } }, + }; + } + } + + private async Task CreateHttpRequestAsync(object requestData, Uri endpoint) + { + var httpRequestMessage = HttpRequest.CreatePostRequest(endpoint, requestData); + httpRequestMessage.Headers.Add("User-Agent", HttpHeaderConstant.Values.UserAgent); + httpRequestMessage.Headers.Add(HttpHeaderConstant.Names.SemanticKernelVersion, + HttpHeaderConstant.Values.GetAssemblyVersion(typeof(AnthropicClient))); + + if (this._customRequestHandler != null) + { + await this._customRequestHandler(httpRequestMessage).ConfigureAwait(false); + } + + return httpRequestMessage; + } + + private void Log(LogLevel logLevel, string? message, params object[] args) + { + if (this._logger.IsEnabled(logLevel)) + { +#pragma warning disable CA2254 // Template should be a constant string. + this._logger.Log(logLevel, message, args); +#pragma warning restore CA2254 + } + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/ClientBase.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/ClientBase.cs deleted file mode 100644 index d63741fae3fe..000000000000 --- a/dotnet/src/Connectors/Connectors.Anthropic/Core/ClientBase.cs +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.SemanticKernel.Http; - -namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; - -internal abstract class ClientBase -{ - private readonly Func>? _bearerTokenProvider; - - private readonly ILogger _logger; - - protected HttpClient HttpClient { get; } - - protected ClientBase( - HttpClient httpClient, - ILogger? logger, - Func> bearerTokenProvider) - : this(httpClient, logger) - { - Verify.NotNull(bearerTokenProvider); - this._bearerTokenProvider = bearerTokenProvider; - } - - protected ClientBase( - HttpClient httpClient, - ILogger? logger) - { - Verify.NotNull(httpClient); - - this.HttpClient = httpClient; - this._logger = logger ?? NullLogger.Instance; - } - - protected static void ValidateMaxTokens(int? maxTokens) - { - // If maxTokens is null, it means that the user wants to use the default model value - if (maxTokens is < 1) - { - throw new ArgumentException($"MaxTokens {maxTokens} is not valid, the value must be greater than zero"); - } - } - - protected async Task SendRequestAndGetStringBodyAsync( - HttpRequestMessage httpRequestMessage, - CancellationToken cancellationToken) - { - using var response = await this.HttpClient.SendWithSuccessCheckAsync(httpRequestMessage, cancellationToken) - .ConfigureAwait(false); - var body = await response.Content.ReadAsStringWithExceptionMappingAsync() - .ConfigureAwait(false); - return body; - } - - protected async Task SendRequestAndGetResponseImmediatelyAfterHeadersReadAsync( - HttpRequestMessage httpRequestMessage, - CancellationToken cancellationToken) - { - var response = await this.HttpClient.SendWithSuccessCheckAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken) - .ConfigureAwait(false); - return response; - } - - protected static T DeserializeResponse(string body) - { - try - { - return JsonSerializer.Deserialize(body) ?? throw new JsonException("Response is null"); - } - catch (JsonException exc) - { - throw new KernelException("Unexpected response from model", exc) - { - Data = { { "ResponseData", body } }, - }; - } - } - - protected async Task CreateHttpRequestAsync(object requestData, Uri endpoint) - { - var httpRequestMessage = HttpRequest.CreatePostRequest(endpoint, requestData); - httpRequestMessage.Headers.Add("User-Agent", HttpHeaderConstant.Values.UserAgent); - httpRequestMessage.Headers.Add(HttpHeaderConstant.Names.SemanticKernelVersion, - HttpHeaderConstant.Values.GetAssemblyVersion(typeof(ClientBase))); - - if (this._bearerTokenProvider != null && await this._bearerTokenProvider().ConfigureAwait(false) is { } bearerKey) - { - httpRequestMessage.Headers.Authorization = - new AuthenticationHeaderValue("Bearer", bearerKey); - } - - return httpRequestMessage; - } - - protected void Log(LogLevel logLevel, string? message, params object[] args) - { - if (this._logger.IsEnabled(logLevel)) - { -#pragma warning disable CA2254 // Template should be a constant string. - this._logger.Log(logLevel, message, args); -#pragma warning restore CA2254 - } - } -} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Extensions/AnthropicKernelBuilderExtensions.cs b/dotnet/src/Connectors/Connectors.Anthropic/Extensions/AnthropicKernelBuilderExtensions.cs new file mode 100644 index 000000000000..f5258d9b630f --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Extensions/AnthropicKernelBuilderExtensions.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Anthropic; +using Microsoft.SemanticKernel.Http; + +namespace Microsoft.SemanticKernel; + +/// +/// Extensions for adding Anthropic generation services to the application. +/// +public static class AnthropicKernelBuilderExtensions +{ + /// + /// Add Anthropic Chat Completion and Text Generation services to the kernel builder. + /// + /// The kernel builder. + /// The model for chat completion. + /// The API key for authentication Claude API. + /// Optional options for the anthropic client + /// The optional service ID. + /// The optional custom HttpClient. + /// The updated kernel builder. + public static IKernelBuilder AddAnthropicChatCompletion( + this IKernelBuilder builder, + string modelId, + string apiKey, + AnthropicClientOptions? options = null, + string? serviceId = null, + HttpClient? httpClient = null) + { + Verify.NotNull(builder); + Verify.NotNull(modelId); + Verify.NotNull(apiKey); + + builder.Services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new AnthropicChatCompletionService( + modelId: modelId, + apiKey: apiKey, + options: options, + httpClient: HttpClientProvider.GetHttpClient(httpClient, serviceProvider), + loggerFactory: serviceProvider.GetService())); + return builder; + } + + /// + /// Add Anthropic Chat Completion and Text Generation services to the kernel builder. + /// + /// The kernel builder. + /// The model for chat completion. + /// Endpoint for the chat completion model + /// A custom request handler to be used for sending HTTP requests + /// Optional options for the anthropic client + /// The optional service ID. + /// The optional custom HttpClient. + /// The updated kernel builder. + public static IKernelBuilder AddAnthropicChatCompletion( + this IKernelBuilder builder, + string modelId, + Uri endpoint, + Func? requestHandler, + AnthropicClientOptions? options = null, + string? serviceId = null, + HttpClient? httpClient = null) + { + Verify.NotNull(builder); + Verify.NotNull(modelId); + Verify.NotNull(endpoint); + + builder.Services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new AnthropicChatCompletionService( + modelId: modelId, + endpoint: endpoint, + requestHandler: requestHandler, + options: options, + httpClient: HttpClientProvider.GetHttpClient(httpClient, serviceProvider), + loggerFactory: serviceProvider.GetService())); + return builder; + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Extensions/AnthropicServiceCollectionExtensions.cs b/dotnet/src/Connectors/Connectors.Anthropic/Extensions/AnthropicServiceCollectionExtensions.cs new file mode 100644 index 000000000000..9e92b2ea8857 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Extensions/AnthropicServiceCollectionExtensions.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Anthropic; +using Microsoft.SemanticKernel.Http; + +namespace Microsoft.SemanticKernel; + +/// +/// Extensions for adding Anthropic generation services to the application. +/// +public static class AnthropicServiceCollectionExtensions +{ + /// + /// Add Anthropic Chat Completion and Text Generation services to the specified service collection. + /// + /// The service collection to add the Claude Text Generation service to. + /// The model for chat completion. + /// The API key for authentication Claude API. + /// Optional options for the anthropic client + /// Optional service ID. + /// The updated service collection. + public static IServiceCollection AddAnthropicChatCompletion( + this IServiceCollection services, + string modelId, + string apiKey, + AnthropicClientOptions? options = null, + string? serviceId = null) + { + Verify.NotNull(services); + Verify.NotNull(modelId); + Verify.NotNull(apiKey); + + services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new AnthropicChatCompletionService( + modelId: modelId, + apiKey: apiKey, + options: options, + httpClient: HttpClientProvider.GetHttpClient(serviceProvider), + loggerFactory: serviceProvider.GetService())); + return services; + } + + /// + /// Add Anthropic Chat Completion and Text Generation services to the specified service collection. + /// + /// The service collection to add the Claude Text Generation service to. + /// The model for chat completion. + /// Endpoint for the chat completion model + /// A custom request handler to be used for sending HTTP requests + /// Optional options for the anthropic client + /// Optional service ID. + /// The updated service collection. + public static IServiceCollection AddAnthropicChatCompletion( + this IServiceCollection services, + string modelId, + Uri endpoint, + Func? requestHandler, + AnthropicClientOptions? options = null, + string? serviceId = null) + { + Verify.NotNull(services); + Verify.NotNull(modelId); + Verify.NotNull(endpoint); + + services.AddKeyedSingleton(serviceId, (serviceProvider, _) => + new AnthropicChatCompletionService( + modelId: modelId, + endpoint: endpoint, + requestHandler: requestHandler, + options: options, + httpClient: HttpClientProvider.GetHttpClient(serviceProvider), + loggerFactory: serviceProvider.GetService())); + return services; + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Services/AnthropicChatCompletionService.cs b/dotnet/src/Connectors/Connectors.Anthropic/Services/AnthropicChatCompletionService.cs new file mode 100644 index 000000000000..0f94fafc82e1 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Services/AnthropicChatCompletionService.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core; +using Microsoft.SemanticKernel.Http; +using Microsoft.SemanticKernel.Services; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic; + +/// +/// Represents a chat completion service using Anthropic API. +/// +public sealed class AnthropicChatCompletionService : IChatCompletionService +{ + private readonly Dictionary _attributesInternal = new(); + private readonly AnthropicClient _client; + + /// + /// Initializes a new instance of the class. + /// + /// The model for the chat completion service. + /// The API key for authentication. + /// Optional options for the anthropic client + /// Optional HTTP client to be used for communication with the Claude API. + /// Optional logger factory to be used for logging. + public AnthropicChatCompletionService( + string modelId, + string apiKey, + AnthropicClientOptions? options = null, + HttpClient? httpClient = null, + ILoggerFactory? loggerFactory = null) + { + Verify.NotNullOrWhiteSpace(modelId); + Verify.NotNullOrWhiteSpace(apiKey); + + this._client = new AnthropicClient( +#pragma warning disable CA2000 + httpClient: HttpClientProvider.GetHttpClient(httpClient), +#pragma warning restore CA2000 + modelId: modelId, + apiKey: apiKey, + options: options, + logger: loggerFactory?.CreateLogger(typeof(AnthropicChatCompletionService))); + this._attributesInternal.Add(AIServiceExtensions.ModelIdKey, modelId); + } + + /// + /// Initializes a new instance of the class. + /// + /// The model for the chat completion service. + /// Endpoint for the chat completion model + /// A custom request handler to be used for sending HTTP requests + /// Optional options for the anthropic client + /// Optional HTTP client to be used for communication with the Claude API. + /// Optional logger factory to be used for logging. + public AnthropicChatCompletionService( + string modelId, + Uri endpoint, + Func? requestHandler, + AnthropicClientOptions? options = null, + HttpClient? httpClient = null, + ILoggerFactory? loggerFactory = null) + { + Verify.NotNullOrWhiteSpace(modelId); + Verify.NotNull(endpoint); + + this._client = new AnthropicClient( +#pragma warning disable CA2000 + httpClient: HttpClientProvider.GetHttpClient(httpClient), +#pragma warning restore CA2000 + modelId: modelId, + endpoint: endpoint, + requestHandler: requestHandler, + options: options, + logger: loggerFactory?.CreateLogger(typeof(AnthropicChatCompletionService))); + this._attributesInternal.Add(AIServiceExtensions.ModelIdKey, modelId); + } + + /// + public IReadOnlyDictionary Attributes => this._attributesInternal; + + /// + public Task> GetChatMessageContentsAsync( + ChatHistory chatHistory, + PromptExecutionSettings? executionSettings = null, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + return this._client.GenerateChatMessageAsync(chatHistory, executionSettings, kernel, cancellationToken); + } + + /// + public IAsyncEnumerable GetStreamingChatMessageContentsAsync( + ChatHistory chatHistory, + PromptExecutionSettings? executionSettings = null, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + return this._client.StreamGenerateChatMessageAsync(chatHistory, executionSettings, kernel, cancellationToken); + } +} From 1a475fba132ec3a4e687fab997fdc22dc70118b1 Mon Sep 17 00:00:00 2001 From: Krzysztof Kasprowicz <60486987+Krzysztof318@users.noreply.github.com> Date: Wed, 17 Apr 2024 18:29:07 +0200 Subject: [PATCH 3/7] .Net: Claude tools models (#5790) Claude tools models cc: @RogerBarreto ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone :smile: --- ... AnthropicPromptExecutionSettingsTests.cs} | 20 +- .../AnthropicToolCallBehaviorTests.cs | 222 +++++++++++++ .../Core/AnthropicRequestTests.cs | 308 ++++++++++++++++++ .../Core/AuthorRoleConverterTests.cs | 108 ++++++ .../Core/ClaudeRequestTests.cs | 174 ---------- .../Models/AnthropicFunctionTests.cs | 185 +++++++++++ .../Models/AnthropicFunctionToolCallTests.cs | 71 ++++ ...thropicKernelFunctionMetadataExtensions.cs | 52 +++ ...cs => AnthropicPromptExecutionSettings.cs} | 62 +++- .../AnthropicToolCallBehavior.cs | 228 +++++++++++++ .../Core/{Claude => }/AnthropicClient.cs | 0 .../Core/{Claude => }/AuthorRoleConverter.cs | 0 .../AnthropicRequest.cs} | 105 ++++-- .../AnthropicToolFunctionDeclaration.cs | 40 +++ .../Core/Models/Message/AnthropicContent.cs | 15 + .../Message/AnthropicImageContent.cs} | 26 +- .../Models/Message/AnthropicTextContent.cs | 20 ++ .../Message/AnthropicToolCallContent.cs | 30 ++ .../Message/AnthropicToolResultContent.cs | 19 ++ .../Models/AnthropicChatMessageContent.cs | 98 ++++++ ...nishReason.cs => AnthropicFinishReason.cs} | 36 +- .../Models/AnthropicFunction.cs | 181 ++++++++++ .../Models/AnthropicFunctionToolCall.cs | 89 +++++ .../Models/AnthropicFunctionToolResult.cs | 39 +++ ...ClaudeMetadata.cs => AnthropicMetadata.cs} | 20 +- 25 files changed, 1880 insertions(+), 268 deletions(-) rename dotnet/src/Connectors/Connectors.Anthropic.UnitTests/{ClaudePromptExecutionSettingsTests.cs => AnthropicPromptExecutionSettingsTests.cs} (80%) create mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/AnthropicToolCallBehaviorTests.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicRequestTests.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AuthorRoleConverterTests.cs delete mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/ClaudeRequestTests.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Models/AnthropicFunctionTests.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Models/AnthropicFunctionToolCallTests.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Utils/AnthropicKernelFunctionMetadataExtensions.cs rename dotnet/src/Connectors/Connectors.Anthropic/{ClaudePromptExecutionSettings.cs => AnthropicPromptExecutionSettings.cs} (57%) create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/AnthropicToolCallBehavior.cs rename dotnet/src/Connectors/Connectors.Anthropic/Core/{Claude => }/AnthropicClient.cs (100%) rename dotnet/src/Connectors/Connectors.Anthropic/Core/{Claude => }/AuthorRoleConverter.cs (100%) rename dotnet/src/Connectors/Connectors.Anthropic/Core/{Claude/Models/ClaudeRequest.cs => Models/AnthropicRequest.cs} (54%) create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicToolFunctionDeclaration.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicContent.cs rename dotnet/src/Connectors/Connectors.Anthropic/Core/{Claude/Models/ClaudeMessageContent.cs => Models/Message/AnthropicImageContent.cs} (61%) create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicTextContent.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicToolCallContent.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicToolResultContent.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicChatMessageContent.cs rename dotnet/src/Connectors/Connectors.Anthropic/Models/{Claude/ClaudeFinishReason.cs => AnthropicFinishReason.cs} (58%) create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunction.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunctionToolCall.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunctionToolResult.cs rename dotnet/src/Connectors/Connectors.Anthropic/Models/{Claude/ClaudeMetadata.cs => AnthropicMetadata.cs} (72%) diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/ClaudePromptExecutionSettingsTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/AnthropicPromptExecutionSettingsTests.cs similarity index 80% rename from dotnet/src/Connectors/Connectors.Anthropic.UnitTests/ClaudePromptExecutionSettingsTests.cs rename to dotnet/src/Connectors/Connectors.Anthropic.UnitTests/AnthropicPromptExecutionSettingsTests.cs index 55d7b95aaede..1e9a24afd0c1 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/ClaudePromptExecutionSettingsTests.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/AnthropicPromptExecutionSettingsTests.cs @@ -9,14 +9,14 @@ namespace SemanticKernel.Connectors.Anthropic.UnitTests; -public sealed class ClaudePromptExecutionSettingsTests +public sealed class AnthropicPromptExecutionSettingsTests { [Fact] public void ItCreatesExecutionSettingsWithCorrectDefaults() { // Arrange // Act - ClaudePromptExecutionSettings executionSettings = ClaudePromptExecutionSettings.FromExecutionSettings(null); + AnthropicPromptExecutionSettings executionSettings = AnthropicPromptExecutionSettings.FromExecutionSettings(null); // Assert Assert.NotNull(executionSettings); @@ -24,14 +24,14 @@ public void ItCreatesExecutionSettingsWithCorrectDefaults() Assert.Null(executionSettings.TopP); Assert.Null(executionSettings.TopK); Assert.Null(executionSettings.StopSequences); - Assert.Equal(ClaudePromptExecutionSettings.DefaultTextMaxTokens, executionSettings.MaxTokens); + Assert.Equal(AnthropicPromptExecutionSettings.DefaultTextMaxTokens, executionSettings.MaxTokens); } [Fact] public void ItUsesExistingExecutionSettings() { // Arrange - ClaudePromptExecutionSettings actualSettings = new() + AnthropicPromptExecutionSettings actualSettings = new() { Temperature = 0.7, TopP = 0.7f, @@ -41,7 +41,7 @@ public void ItUsesExistingExecutionSettings() }; // Act - ClaudePromptExecutionSettings executionSettings = ClaudePromptExecutionSettings.FromExecutionSettings(actualSettings); + AnthropicPromptExecutionSettings executionSettings = AnthropicPromptExecutionSettings.FromExecutionSettings(actualSettings); // Assert Assert.NotNull(executionSettings); @@ -62,7 +62,7 @@ public void ItCreatesExecutionSettingsFromExtensionDataSnakeCase() }; // Act - ClaudePromptExecutionSettings executionSettings = ClaudePromptExecutionSettings.FromExecutionSettings(actualSettings); + AnthropicPromptExecutionSettings executionSettings = AnthropicPromptExecutionSettings.FromExecutionSettings(actualSettings); // Assert Assert.NotNull(executionSettings); @@ -86,7 +86,7 @@ public void ItCreatesExecutionSettingsFromJsonSnakeCase() var actualSettings = JsonSerializer.Deserialize(json); // Act - ClaudePromptExecutionSettings executionSettings = ClaudePromptExecutionSettings.FromExecutionSettings(actualSettings); + AnthropicPromptExecutionSettings executionSettings = AnthropicPromptExecutionSettings.FromExecutionSettings(actualSettings); // Assert Assert.NotNull(executionSettings); @@ -111,10 +111,10 @@ public void PromptExecutionSettingsCloneWorksAsExpected() "max_tokens": 128 } """; - var executionSettings = JsonSerializer.Deserialize(json); + var executionSettings = JsonSerializer.Deserialize(json); // Act - var clone = executionSettings!.Clone() as ClaudePromptExecutionSettings; + var clone = executionSettings!.Clone() as AnthropicPromptExecutionSettings; // Assert Assert.NotNull(clone); @@ -138,7 +138,7 @@ public void PromptExecutionSettingsFreezeWorksAsExpected() "max_tokens": 128 } """; - var executionSettings = JsonSerializer.Deserialize(json); + var executionSettings = JsonSerializer.Deserialize(json); // Act executionSettings!.Freeze(); diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/AnthropicToolCallBehaviorTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/AnthropicToolCallBehaviorTests.cs new file mode 100644 index 000000000000..ed881a793c05 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/AnthropicToolCallBehaviorTests.cs @@ -0,0 +1,222 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.Anthropic; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core; +using Xunit; + +namespace SemanticKernel.Connectors.Anthropic.UnitTests; + +/// +/// Unit tests for +/// +public sealed class AnthropicToolCallBehaviorTests +{ + [Fact] + public void EnableKernelFunctionsReturnsCorrectKernelFunctionsInstance() + { + // Arrange & Act + var behavior = AnthropicToolCallBehavior.EnableKernelFunctions; + + // Assert + Assert.IsType(behavior); + Assert.Equal(0, behavior.MaximumAutoInvokeAttempts); + } + + [Fact] + public void AutoInvokeKernelFunctionsReturnsCorrectKernelFunctionsInstance() + { + // Arrange & Act + var behavior = AnthropicToolCallBehavior.AutoInvokeKernelFunctions; + + // Assert + Assert.IsType(behavior); + Assert.Equal(5, behavior.MaximumAutoInvokeAttempts); + } + + [Fact] + public void EnableFunctionsReturnsEnabledFunctionsInstance() + { + // Arrange & Act + List functions = + [new AnthropicFunction("Plugin", "Function", "description", [], null)]; + var behavior = AnthropicToolCallBehavior.EnableFunctions(functions); + + // Assert + Assert.IsType(behavior); + } + + [Fact] + public void KernelFunctionsConfigureClaudeRequestWithNullKernelDoesNotAddTools() + { + // Arrange + var kernelFunctions = new AnthropicToolCallBehavior.KernelFunctions(autoInvoke: false); + var claudeRequest = new AnthropicRequest(); + + // Act + kernelFunctions.ConfigureClaudeRequest(null, claudeRequest); + + // Assert + Assert.Null(claudeRequest.Tools); + } + + [Fact] + public void KernelFunctionsConfigureClaudeRequestWithoutFunctionsDoesNotAddTools() + { + // Arrange + var kernelFunctions = new AnthropicToolCallBehavior.KernelFunctions(autoInvoke: false); + var claudeRequest = new AnthropicRequest(); + var kernel = Kernel.CreateBuilder().Build(); + + // Act + kernelFunctions.ConfigureClaudeRequest(kernel, claudeRequest); + + // Assert + Assert.Null(claudeRequest.Tools); + } + + [Fact] + public void KernelFunctionsConfigureClaudeRequestWithFunctionsAddsTools() + { + // Arrange + var kernelFunctions = new AnthropicToolCallBehavior.KernelFunctions(autoInvoke: false); + var claudeRequest = new AnthropicRequest(); + var kernel = Kernel.CreateBuilder().Build(); + var plugin = GetTestPlugin(); + kernel.Plugins.Add(plugin); + + // Act + kernelFunctions.ConfigureClaudeRequest(kernel, claudeRequest); + + // Assert + AssertFunctions(claudeRequest); + } + + [Fact] + public void EnabledFunctionsConfigureClaudeRequestWithoutFunctionsDoesNotAddTools() + { + // Arrange + var enabledFunctions = new AnthropicToolCallBehavior.EnabledFunctions([], autoInvoke: false); + var claudeRequest = new AnthropicRequest(); + + // Act + enabledFunctions.ConfigureClaudeRequest(null, claudeRequest); + + // Assert + Assert.Null(claudeRequest.Tools); + } + + [Fact] + public void EnabledFunctionsConfigureClaudeRequestWithAutoInvokeAndNullKernelThrowsException() + { + // Arrange + var functions = GetTestPlugin().GetFunctionsMetadata().Select(function => AnthropicKernelFunctionMetadataExtensions.ToClaudeFunction(function)); + var enabledFunctions = new AnthropicToolCallBehavior.EnabledFunctions(functions, autoInvoke: true); + var claudeRequest = new AnthropicRequest(); + + // Act & Assert + var exception = Assert.Throws(() => enabledFunctions.ConfigureClaudeRequest(null, claudeRequest)); + Assert.Equal( + $"Auto-invocation with {nameof(AnthropicToolCallBehavior.EnabledFunctions)} is not supported when no kernel is provided.", + exception.Message); + } + + [Fact] + public void EnabledFunctionsConfigureClaudeRequestWithAutoInvokeAndEmptyKernelThrowsException() + { + // Arrange + var functions = GetTestPlugin().GetFunctionsMetadata().Select(function => function.ToClaudeFunction()); + var enabledFunctions = new AnthropicToolCallBehavior.EnabledFunctions(functions, autoInvoke: true); + var claudeRequest = new AnthropicRequest(); + var kernel = Kernel.CreateBuilder().Build(); + + // Act & Assert + var exception = Assert.Throws(() => enabledFunctions.ConfigureClaudeRequest(kernel, claudeRequest)); + Assert.Equal( + $"The specified {nameof(AnthropicToolCallBehavior.EnabledFunctions)} function MyPlugin{AnthropicFunction.NameSeparator}MyFunction is not available in the kernel.", + exception.Message); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void EnabledFunctionsConfigureClaudeRequestWithKernelAndPluginsAddsTools(bool autoInvoke) + { + // Arrange + var plugin = GetTestPlugin(); + var functions = plugin.GetFunctionsMetadata().Select(function => function.ToClaudeFunction()); + var enabledFunctions = new AnthropicToolCallBehavior.EnabledFunctions(functions, autoInvoke); + var claudeRequest = new AnthropicRequest(); + var kernel = Kernel.CreateBuilder().Build(); + + kernel.Plugins.Add(plugin); + + // Act + enabledFunctions.ConfigureClaudeRequest(kernel, claudeRequest); + + // Assert + AssertFunctions(claudeRequest); + } + + [Fact] + public void EnabledFunctionsCloneReturnsCorrectClone() + { + // Arrange + var functions = GetTestPlugin().GetFunctionsMetadata().Select(function => function.ToClaudeFunction()); + var toolcallbehavior = new AnthropicToolCallBehavior.EnabledFunctions(functions, autoInvoke: true); + + // Act + var clone = toolcallbehavior.Clone(); + + // Assert + Assert.IsType(clone); + Assert.NotSame(toolcallbehavior, clone); + Assert.Equivalent(toolcallbehavior, clone, strict: true); + } + + [Fact] + public void KernelFunctionsCloneReturnsCorrectClone() + { + // Arrange + var functions = GetTestPlugin().GetFunctionsMetadata().Select(function => function.ToClaudeFunction()); + var toolcallbehavior = new AnthropicToolCallBehavior.KernelFunctions(autoInvoke: true); + + // Act + var clone = toolcallbehavior.Clone(); + + // Assert + Assert.IsType(clone); + Assert.NotSame(toolcallbehavior, clone); + Assert.Equivalent(toolcallbehavior, clone, strict: true); + } + + private static KernelPlugin GetTestPlugin() + { + var function = KernelFunctionFactory.CreateFromMethod( + (string parameter1, string parameter2) => "Result1", + "MyFunction", + "Test Function", + [new KernelParameterMetadata("parameter1"), new KernelParameterMetadata("parameter2")], + new KernelReturnParameterMetadata { ParameterType = typeof(string), Description = "Function Result" }); + + return KernelPluginFactory.CreateFromFunctions("MyPlugin", [function]); + } + + private static void AssertFunctions(AnthropicRequest request) + { + Assert.NotNull(request.Tools); + Assert.Single(request.Tools); + + var function = request.Tools[0]; + + Assert.NotNull(function); + + Assert.Equal($"MyPlugin{AnthropicFunction.NameSeparator}MyFunction", function.Name); + Assert.Equal("Test Function", function.Description); + Assert.Equal("""{"type":"object","required":[],"properties":{"parameter1":{"type":"string"},"parameter2":{"type":"string"}}}""", + JsonSerializer.Serialize(function.Parameters)); + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicRequestTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicRequestTests.cs new file mode 100644 index 000000000000..489a3409ba33 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicRequestTests.cs @@ -0,0 +1,308 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Anthropic; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core; +using Xunit; + +namespace SemanticKernel.Connectors.Anthropic.UnitTests.Core; + +public sealed class AnthropicRequestTests +{ + [Fact] + public void FromChatHistoryItReturnsClaudeRequestWithConfiguration() + { + // Arrange + ChatHistory chatHistory = []; + chatHistory.AddUserMessage("user-message"); + chatHistory.AddAssistantMessage("assist-message"); + chatHistory.AddUserMessage("user-message2"); + var executionSettings = new AnthropicPromptExecutionSettings + { + Temperature = 1.5, + MaxTokens = 10, + TopP = 0.9f, + ModelId = "claude" + }; + + // Act + var request = AnthropicRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.Equal(executionSettings.Temperature, request.Temperature); + Assert.Equal(executionSettings.MaxTokens, request.MaxTokens); + Assert.Equal(executionSettings.TopP, request.TopP); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FromChatHistoryItReturnsClaudeRequestWithValidStreamingMode(bool streamMode) + { + // Arrange + ChatHistory chatHistory = []; + chatHistory.AddUserMessage("user-message"); + chatHistory.AddAssistantMessage("assist-message"); + chatHistory.AddUserMessage("user-message2"); + var executionSettings = new AnthropicPromptExecutionSettings + { + Temperature = 1.5, + MaxTokens = 10, + TopP = 0.9f, + ModelId = "claude" + }; + + // Act + var request = AnthropicRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings, streamMode); + + // Assert + Assert.Equal(streamMode, request.Stream); + } + + [Fact] + public void FromChatHistoryItReturnsClaudeRequestWithChatHistory() + { + // Arrange + ChatHistory chatHistory = []; + chatHistory.AddUserMessage("user-message"); + chatHistory.AddAssistantMessage("assist-message"); + chatHistory.AddUserMessage("user-message2"); + var executionSettings = new AnthropicPromptExecutionSettings + { + ModelId = "claude", + MaxTokens = 128, + }; + + // Act + var request = AnthropicRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.All(request.Messages, c => Assert.IsType(c.Contents[0])); + Assert.Collection(request.Messages, + c => Assert.Equal(chatHistory[0].Content, ((AnthropicTextContent)c.Contents[0]).Text), + c => Assert.Equal(chatHistory[1].Content, ((AnthropicTextContent)c.Contents[0]).Text), + c => Assert.Equal(chatHistory[2].Content, ((AnthropicTextContent)c.Contents[0]).Text)); + Assert.Collection(request.Messages, + c => Assert.Equal(chatHistory[0].Role, c.Role), + c => Assert.Equal(chatHistory[1].Role, c.Role), + c => Assert.Equal(chatHistory[2].Role, c.Role)); + } + + [Fact] + public void FromChatHistoryTextAsTextContentItReturnsClaudeRequestWithChatHistory() + { + // Arrange + ChatHistory chatHistory = []; + chatHistory.AddUserMessage("user-message"); + chatHistory.AddAssistantMessage("assist-message"); + chatHistory.AddUserMessage(contentItems: [new TextContent("user-message2")]); + var executionSettings = new AnthropicPromptExecutionSettings + { + ModelId = "claude", + MaxTokens = 128, + }; + + // Act + var request = AnthropicRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.All(request.Messages, c => Assert.IsType(c.Contents[0])); + Assert.Collection(request.Messages, + c => Assert.Equal(chatHistory[0].Content, ((AnthropicTextContent)c.Contents[0]).Text), + c => Assert.Equal(chatHistory[1].Content, ((AnthropicTextContent)c.Contents[0]).Text), + c => Assert.Equal(chatHistory[2].Items.Cast().Single().Text, ((AnthropicTextContent)c.Contents[0]).Text)); + } + + [Fact] + public void FromChatHistoryImageAsImageContentItReturnsClaudeRequestWithChatHistory() + { + // Arrange + ReadOnlyMemory imageAsBytes = new byte[] { 0x00, 0x01, 0x02, 0x03 }; + ChatHistory chatHistory = []; + chatHistory.AddUserMessage("user-message"); + chatHistory.AddAssistantMessage("assist-message"); + chatHistory.AddUserMessage(contentItems: + [new ImageContent(imageAsBytes) { MimeType = "image/png" }]); + var executionSettings = new AnthropicPromptExecutionSettings + { + ModelId = "claude", + MaxTokens = 128, + }; + + // Act + var request = AnthropicRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.Collection(request.Messages, + c => Assert.IsType(c.Contents[0]), + c => Assert.IsType(c.Contents[0]), + c => Assert.IsType(c.Contents[0])); + Assert.Collection(request.Messages, + c => Assert.Equal(chatHistory[0].Content, ((AnthropicTextContent)c.Contents[0]).Text), + c => Assert.Equal(chatHistory[1].Content, ((AnthropicTextContent)c.Contents[0]).Text), + c => + { + Assert.Equal(chatHistory[2].Items.Cast().Single().MimeType, ((AnthropicImageContent)c.Contents[0]).Source.MediaType); + Assert.True(imageAsBytes.ToArray().SequenceEqual(Convert.FromBase64String(((AnthropicImageContent)c.Contents[0]).Source.Data))); + }); + } + + [Fact] + public void FromChatHistoryUnsupportedContentItThrowsNotSupportedException() + { + // Arrange + ChatHistory chatHistory = []; + chatHistory.AddUserMessage("user-message"); + chatHistory.AddAssistantMessage("assist-message"); + chatHistory.AddUserMessage(contentItems: [new DummyContent("unsupported-content")]); + var executionSettings = new AnthropicPromptExecutionSettings + { + ModelId = "claude", + MaxTokens = 128, + }; + + // Act + void Act() => AnthropicRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.Throws(Act); + } + + [Fact] + public void AddFunctionItAddsFunctionToClaudeRequest() + { + // Arrange + var request = new AnthropicRequest(); + var function = new AnthropicFunction("function-name", "function-description", "desc", null, null); + + // Act + request.AddFunction(function); + + // Assert + Assert.NotNull(request.Tools); + Assert.Collection(request.Tools, + func => Assert.Equivalent(function.ToFunctionDeclaration(), func, strict: true)); + } + + [Fact] + public void AddMultipleFunctionsItAddsFunctionsToClaudeRequest() + { + // Arrange + var request = new AnthropicRequest(); + var functions = new[] + { + new AnthropicFunction("function-name", "function-description", "desc", null, null), + new AnthropicFunction("function-name2", "function-description2", "desc2", null, null) + }; + + // Act + request.AddFunction(functions[0]); + request.AddFunction(functions[1]); + + // Assert + Assert.NotNull(request.Tools); + Assert.Collection(request.Tools, + func => Assert.Equivalent(functions[0].ToFunctionDeclaration(), func, strict: true), + func => Assert.Equivalent(functions[1].ToFunctionDeclaration(), func, strict: true)); + } + + [Fact] + public void FromChatHistoryCalledToolNotNullAddsFunctionResponse() + { + // Arrange + ChatHistory chatHistory = []; + var kvp = KeyValuePair.Create("sampleKey", "sampleValue"); + var expectedArgs = new JsonObject { [kvp.Key] = kvp.Value }; + var kernelFunction = KernelFunctionFactory.CreateFromMethod(() => ""); + var functionResult = new FunctionResult(kernelFunction, expectedArgs); + var toolCall = new AnthropicFunctionToolCall(new AnthropicToolCallContent { ToolId = "any uid", FunctionName = "function-name" }); + AnthropicFunctionToolResult toolCallResult = new(toolCall, functionResult, toolCall.ToolUseId); + chatHistory.Add(new AnthropicChatMessageContent(AuthorRole.Assistant, string.Empty, "modelId", toolCallResult)); + var executionSettings = new AnthropicPromptExecutionSettings { ModelId = "model-id", MaxTokens = 128 }; + + // Act + var request = AnthropicRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.Single(request.Messages, + c => c.Role == AuthorRole.Assistant); + Assert.Single(request.Messages, + c => c.Contents[0] is AnthropicToolResultContent); + Assert.Single(request.Messages, + c => c.Contents[0] is AnthropicToolResultContent toolResult + && string.Equals(toolResult.ToolId, toolCallResult.ToolUseId, StringComparison.Ordinal) + && toolResult.Content is AnthropicTextContent textContent + && string.Equals(functionResult.ToString(), textContent.Text, StringComparison.Ordinal)); + } + + [Fact] + public void FromChatHistoryToolCallsNotNullAddsFunctionCalls() + { + // Arrange + ChatHistory chatHistory = []; + var kvp = KeyValuePair.Create("sampleKey", "sampleValue"); + var expectedArgs = new JsonObject { [kvp.Key] = kvp.Value }; + var toolCallPart = new AnthropicToolCallContent + { ToolId = "any uid1", FunctionName = "function-name", Arguments = expectedArgs }; + var toolCallPart2 = new AnthropicToolCallContent + { ToolId = "any uid2", FunctionName = "function2-name", Arguments = expectedArgs }; + chatHistory.Add(new AnthropicChatMessageContent(AuthorRole.Assistant, "tool-message", "model-id", functionsToolCalls: [toolCallPart])); + chatHistory.Add(new AnthropicChatMessageContent(AuthorRole.Assistant, "tool-message2", "model-id2", functionsToolCalls: [toolCallPart2])); + var executionSettings = new AnthropicPromptExecutionSettings { ModelId = "model-id", MaxTokens = 128 }; + + // Act + var request = AnthropicRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + // Assert + Assert.Collection(request.Messages, + c => Assert.Equal(chatHistory[0].Role, c.Role), + c => Assert.Equal(chatHistory[1].Role, c.Role)); + Assert.Collection(request.Messages, + c => Assert.IsType(c.Contents[0]), + c => Assert.IsType(c.Contents[0])); + Assert.Collection(request.Messages, + c => + { + Assert.Equal(((AnthropicToolCallContent)c.Contents[0]).FunctionName, toolCallPart.FunctionName); + Assert.Equal(((AnthropicToolCallContent)c.Contents[0]).ToolId, toolCallPart.ToolId); + }, + c => + { + Assert.Equal(((AnthropicToolCallContent)c.Contents[0]).FunctionName, toolCallPart2.FunctionName); + Assert.Equal(((AnthropicToolCallContent)c.Contents[0]).ToolId, toolCallPart2.ToolId); + }); + Assert.Collection(request.Messages, + c => Assert.Equal(expectedArgs.ToJsonString(), + ((AnthropicToolCallContent)c.Contents[0]).Arguments!.ToJsonString()), + c => Assert.Equal(expectedArgs.ToJsonString(), + ((AnthropicToolCallContent)c.Contents[0]).Arguments!.ToJsonString())); + } + + [Fact] + public void AddChatMessageToRequestItAddsChatMessageToGeminiRequest() + { + // Arrange + ChatHistory chat = []; + var request = AnthropicRequest.FromChatHistoryAndExecutionSettings(chat, new AnthropicPromptExecutionSettings { ModelId = "model-id", MaxTokens = 128 }); + var message = new AnthropicChatMessageContent(AuthorRole.User, "user-message", "model-id"); + + // Act + request.AddChatMessage(message); + + // Assert + Assert.Single(request.Messages, + c => c.Contents[0] is AnthropicTextContent content && string.Equals(message.Content, content.Text, StringComparison.Ordinal)); + Assert.Single(request.Messages, + c => Equals(message.Role, c.Role)); + } + + private sealed class DummyContent : KernelContent + { + public DummyContent(object? innerContent, string? modelId = null, IReadOnlyDictionary? metadata = null) + : base(innerContent, modelId, metadata) { } + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AuthorRoleConverterTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AuthorRoleConverterTests.cs new file mode 100644 index 000000000000..0bbb80d03cd6 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AuthorRoleConverterTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Buffers; +using System.Text.Json; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core; +using Xunit; + +namespace SemanticKernel.Connectors.Anthropic.UnitTests.Core; + +public sealed class AuthorRoleConverterTests +{ + [Fact] + public void ReadWhenRoleIsUserReturnsUser() + { + // Arrange + var converter = new AuthorRoleConverter(); + var reader = new Utf8JsonReader("\"user\""u8); + + // Act + reader.Read(); + var result = converter.Read(ref reader, typeof(AuthorRole?), JsonSerializerOptions.Default); + + // Assert + Assert.Equal(AuthorRole.User, result); + } + + [Fact] + public void ReadWhenRoleIsModelReturnsAssistant() + { + // Arrange + var converter = new AuthorRoleConverter(); + var reader = new Utf8JsonReader("\"assistant\""u8); + + // Act + reader.Read(); + var result = converter.Read(ref reader, typeof(AuthorRole?), JsonSerializerOptions.Default); + + // Assert + Assert.Equal(AuthorRole.Assistant, result); + } + + [Fact] + public void ReadWhenRoleIsUnknownThrows() + { + // Arrange + var converter = new AuthorRoleConverter(); + + // Act + void Act() + { + var reader = new Utf8JsonReader("\"unknown\""u8); + reader.Read(); + converter.Read(ref reader, typeof(AuthorRole?), JsonSerializerOptions.Default); + } + + // Assert + Assert.Throws(Act); + } + + [Fact] + public void WriteWhenRoleIsUserReturnsUser() + { + // Arrange + var converter = new AuthorRoleConverter(); + var bufferWriter = new ArrayBufferWriter(); + using var writer = new Utf8JsonWriter(bufferWriter); + + // Act + converter.Write(writer, AuthorRole.User, JsonSerializerOptions.Default); + + // Assert + Assert.Equal("\"user\""u8, bufferWriter.GetSpan().Trim((byte)'\0')); + } + + [Fact] + public void WriteWhenRoleIsAssistantReturnsModel() + { + // Arrange + var converter = new AuthorRoleConverter(); + var bufferWriter = new ArrayBufferWriter(); + using var writer = new Utf8JsonWriter(bufferWriter); + + // Act + converter.Write(writer, AuthorRole.Assistant, JsonSerializerOptions.Default); + + // Assert + Assert.Equal("\"assistant\""u8, bufferWriter.GetSpan().Trim((byte)'\0')); + } + + [Fact] + public void WriteWhenRoleIsNotUserOrAssistantThrows() + { + // Arrange + var converter = new AuthorRoleConverter(); + using var writer = new Utf8JsonWriter(new ArrayBufferWriter()); + + // Act + void Act() + { + converter.Write(writer, AuthorRole.System, JsonSerializerOptions.Default); + } + + // Assert + Assert.Throws(Act); + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/ClaudeRequestTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/ClaudeRequestTests.cs deleted file mode 100644 index aa139c5cfbc1..000000000000 --- a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/ClaudeRequestTests.cs +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.ChatCompletion; -using Microsoft.SemanticKernel.Connectors.Anthropic; -using Microsoft.SemanticKernel.Connectors.Anthropic.Core; -using Xunit; - -namespace SemanticKernel.Connectors.Anthropic.UnitTests.Core; - -public sealed class ClaudeRequestTests -{ - [Fact] - public void FromChatHistoryItReturnsClaudeRequestWithConfiguration() - { - // Arrange - ChatHistory chatHistory = []; - chatHistory.AddUserMessage("user-message"); - chatHistory.AddAssistantMessage("assist-message"); - chatHistory.AddUserMessage("user-message2"); - var executionSettings = new ClaudePromptExecutionSettings - { - Temperature = 1.5, - MaxTokens = 10, - TopP = 0.9f, - ModelId = "claude" - }; - - // Act - var request = ClaudeRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); - - // Assert - Assert.Equal(executionSettings.Temperature, request.Temperature); - Assert.Equal(executionSettings.MaxTokens, request.MaxTokens); - Assert.Equal(executionSettings.TopP, request.TopP); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void FromChatHistoryItReturnsClaudeRequestWithValidStreamingMode(bool streamMode) - { - // Arrange - ChatHistory chatHistory = []; - chatHistory.AddUserMessage("user-message"); - chatHistory.AddAssistantMessage("assist-message"); - chatHistory.AddUserMessage("user-message2"); - var executionSettings = new ClaudePromptExecutionSettings - { - Temperature = 1.5, - MaxTokens = 10, - TopP = 0.9f, - ModelId = "claude" - }; - - // Act - var request = ClaudeRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings, streamMode); - - // Assert - Assert.Equal(streamMode, request.Stream); - } - - [Fact] - public void FromChatHistoryItReturnsClaudeRequestWithChatHistory() - { - // Arrange - ChatHistory chatHistory = []; - chatHistory.AddUserMessage("user-message"); - chatHistory.AddAssistantMessage("assist-message"); - chatHistory.AddUserMessage("user-message2"); - var executionSettings = new ClaudePromptExecutionSettings() - { - ModelId = "claude", - MaxTokens = 128, - }; - - // Act - var request = ClaudeRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); - - // Assert - Assert.Collection(request.Messages, - c => Assert.Equal(chatHistory[0].Content, c.Contents[0].Text), - c => Assert.Equal(chatHistory[1].Content, c.Contents[0].Text), - c => Assert.Equal(chatHistory[2].Content, c.Contents[0].Text)); - Assert.Collection(request.Messages, - c => Assert.Equal(chatHistory[0].Role, c.Role), - c => Assert.Equal(chatHistory[1].Role, c.Role), - c => Assert.Equal(chatHistory[2].Role, c.Role)); - } - - [Fact] - public void FromChatHistoryTextAsTextContentItReturnsClaudeRequestWithChatHistory() - { - // Arrange - ChatHistory chatHistory = []; - chatHistory.AddUserMessage("user-message"); - chatHistory.AddAssistantMessage("assist-message"); - chatHistory.AddUserMessage(contentItems: [new TextContent("user-message2")]); - var executionSettings = new ClaudePromptExecutionSettings() - { - ModelId = "claude", - MaxTokens = 128, - }; - - // Act - var request = ClaudeRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); - - // Assert - Assert.Collection(request.Messages, - c => Assert.Equal(chatHistory[0].Content, c.Contents[0].Text), - c => Assert.Equal(chatHistory[1].Content, c.Contents[0].Text), - c => Assert.Equal(chatHistory[2].Items.Cast().Single().Text, c.Contents[0].Text)); - } - - [Fact] - public void FromChatHistoryImageAsImageContentItReturnsClaudeRequestWithChatHistory() - { - // Arrange - ReadOnlyMemory imageAsBytes = new byte[] { 0x00, 0x01, 0x02, 0x03 }; - ChatHistory chatHistory = []; - chatHistory.AddUserMessage("user-message"); - chatHistory.AddAssistantMessage("assist-message"); - chatHistory.AddUserMessage(contentItems: - [new ImageContent(imageAsBytes) { MimeType = "image/png" }]); - var executionSettings = new ClaudePromptExecutionSettings() - { - ModelId = "claude", - MaxTokens = 128, - }; - - // Act - var request = ClaudeRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); - - // Assert - Assert.Collection(request.Messages, - c => Assert.Equal(chatHistory[0].Content, c.Contents[0].Text), - c => Assert.Equal(chatHistory[1].Content, c.Contents[0].Text), - c => - { - Assert.Equal(chatHistory[2].Items.Cast().Single().MimeType, c.Contents[0].Image!.MediaType); - Assert.True(imageAsBytes.ToArray().SequenceEqual(Convert.FromBase64String(c.Contents[0].Image!.Data))); - }); - } - - [Fact] - public void FromChatHistoryUnsupportedContentItThrowsNotSupportedException() - { - // Arrange - ChatHistory chatHistory = []; - chatHistory.AddUserMessage("user-message"); - chatHistory.AddAssistantMessage("assist-message"); - chatHistory.AddUserMessage(contentItems: [new DummyContent("unsupported-content")]); - var executionSettings = new ClaudePromptExecutionSettings() - { - ModelId = "claude", - MaxTokens = 128, - }; - - // Act - void Act() => ClaudeRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); - - // Assert - Assert.Throws(Act); - } - - private sealed class DummyContent : KernelContent - { - public DummyContent(object? innerContent, string? modelId = null, IReadOnlyDictionary? metadata = null) - : base(innerContent, modelId, metadata) { } - } -} diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Models/AnthropicFunctionTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Models/AnthropicFunctionTests.cs new file mode 100644 index 000000000000..863b058a8c94 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Models/AnthropicFunctionTests.cs @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using System.Text.Json; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.Anthropic; +using Xunit; + +namespace SemanticKernel.Connectors.Anthropic.UnitTests.Models; + +public sealed class AnthropicFunctionTests +{ + [Theory] + [InlineData(null, null, "", "")] + [InlineData("name", "description", "name", "description")] + public void ItInitializesClaudeFunctionParameterCorrectly(string? name, string? description, string expectedName, string expectedDescription) + { + // Arrange & Act + var schema = KernelJsonSchema.Parse("""{"type": "object" }"""); + var functionParameter = new ClaudeFunctionParameter(name, description, true, typeof(string), schema); + + // Assert + Assert.Equal(expectedName, functionParameter.Name); + Assert.Equal(expectedDescription, functionParameter.Description); + Assert.True(functionParameter.IsRequired); + Assert.Equal(typeof(string), functionParameter.ParameterType); + Assert.Same(schema, functionParameter.Schema); + } + + [Theory] + [InlineData(null, "")] + [InlineData("description", "description")] + public void ItInitializesClaudeFunctionReturnParameterCorrectly(string? description, string expectedDescription) + { + // Arrange & Act + var schema = KernelJsonSchema.Parse("""{"type": "object" }"""); + var functionParameter = new ClaudeFunctionReturnParameter(description, typeof(string), schema); + + // Assert + Assert.Equal(expectedDescription, functionParameter.Description); + Assert.Equal(typeof(string), functionParameter.ParameterType); + Assert.Same(schema, functionParameter.Schema); + } + + [Fact] + public void ItCanConvertToFunctionDefinitionWithNoPluginName() + { + // Arrange + AnthropicFunction sut = KernelFunctionFactory.CreateFromMethod( + () => { }, "myfunc", "This is a description of the function.").Metadata.ToClaudeFunction(); + + // Act + var result = sut.ToFunctionDeclaration(); + + // Assert + Assert.Equal(sut.FunctionName, result.Name); + Assert.Equal(sut.Description, result.Description); + } + + [Fact] + public void ItCanConvertToFunctionDefinitionWithNullParameters() + { + // Arrange + AnthropicFunction sut = new("plugin", "function", "description", null, null); + + // Act + var result = sut.ToFunctionDeclaration(); + + // Assert + Assert.Null(result.Parameters); + } + + [Fact] + public void ItCanConvertToFunctionDefinitionWithPluginName() + { + // Arrange + AnthropicFunction sut = KernelPluginFactory.CreateFromFunctions("myplugin", new[] + { + KernelFunctionFactory.CreateFromMethod(() => { }, "myfunc", "This is a description of the function.") + }).GetFunctionsMetadata()[0].ToClaudeFunction(); + + // Act + var result = sut.ToFunctionDeclaration(); + + // Assert + Assert.Equal($"myplugin{AnthropicFunction.NameSeparator}myfunc", result.Name); + Assert.Equal(sut.Description, result.Description); + } + + [Fact] + public void ItCanConvertToFunctionDefinitionsWithParameterTypesAndReturnParameterType() + { + string expectedParameterSchema = """ + { "type": "object", + "required": ["param1", "param2"], + "properties": { + "param1": { "type": "string", "description": "String param 1" }, + "param2": { "type": "integer", "description": "Int param 2" } } } + """; + + KernelPlugin plugin = KernelPluginFactory.CreateFromFunctions("Tests", new[] + { + KernelFunctionFactory.CreateFromMethod( + [return: Description("My test Result")] + ([Description("String param 1")] string param1, [Description("Int param 2")] int param2) => "", + "TestFunction", + "My test function") + }); + + AnthropicFunction sut = plugin.GetFunctionsMetadata()[0].ToClaudeFunction(); + + var functionDefinition = sut.ToFunctionDeclaration(); + + Assert.NotNull(functionDefinition); + Assert.Equal($"Tests{AnthropicFunction.NameSeparator}TestFunction", functionDefinition.Name); + Assert.Equal("My test function", functionDefinition.Description); + Assert.Equal(JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedParameterSchema)), + JsonSerializer.Serialize(functionDefinition.Parameters)); + } + + [Fact] + public void ItCanConvertToFunctionDefinitionsWithParameterTypesAndNoReturnParameterType() + { + string expectedParameterSchema = """ + { "type": "object", + "required": ["param1", "param2"], + "properties": { + "param1": { "type": "string", "description": "String param 1" }, + "param2": { "type": "integer", "description": "Int param 2" } } } + """; + + KernelPlugin plugin = KernelPluginFactory.CreateFromFunctions("Tests", new[] + { + KernelFunctionFactory.CreateFromMethod( + [return: Description("My test Result")] + ([Description("String param 1")] string param1, [Description("Int param 2")] int param2) => { }, + "TestFunction", + "My test function") + }); + + AnthropicFunction sut = plugin.GetFunctionsMetadata()[0].ToClaudeFunction(); + + var functionDefinition = sut.ToFunctionDeclaration(); + + Assert.NotNull(functionDefinition); + Assert.Equal($"Tests{AnthropicFunction.NameSeparator}TestFunction", functionDefinition.Name); + Assert.Equal("My test function", functionDefinition.Description); + Assert.Equal(JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedParameterSchema)), + JsonSerializer.Serialize(functionDefinition.Parameters)); + } + + [Fact] + public void ItCanConvertToFunctionDefinitionsWithNoParameterTypes() + { + // Arrange + AnthropicFunction f = KernelFunctionFactory.CreateFromMethod( + () => { }, + parameters: new[] { new KernelParameterMetadata("param1") }).Metadata.ToClaudeFunction(); + + // Act + var result = f.ToFunctionDeclaration(); + + // Assert + Assert.Equal( + """{"type":"object","required":[],"properties":{"param1":{"type":"string"}}}""", + JsonSerializer.Serialize(result.Parameters)); + } + + [Fact] + public void ItCanConvertToFunctionDefinitionsWithNoParameterTypesButWithDescriptions() + { + // Arrange + AnthropicFunction f = KernelFunctionFactory.CreateFromMethod( + () => { }, + parameters: new[] { new KernelParameterMetadata("param1") { Description = "something neat" } }).Metadata.ToClaudeFunction(); + + // Act + var result = f.ToFunctionDeclaration(); + + // Assert + Assert.Equal( + """{"type":"object","required":[],"properties":{"param1":{"type":"string","description":"something neat"}}}""", + JsonSerializer.Serialize(result.Parameters)); + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Models/AnthropicFunctionToolCallTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Models/AnthropicFunctionToolCallTests.cs new file mode 100644 index 000000000000..e178393dac7b --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Models/AnthropicFunctionToolCallTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Globalization; +using System.Text.Json.Nodes; +using Microsoft.SemanticKernel.Connectors.Anthropic; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core; +using Xunit; + +namespace SemanticKernel.Connectors.Anthropic.UnitTests.Models; + +/// +/// Unit tests for class. +/// +public sealed class AnthropicFunctionToolCallTests +{ + [Theory] + [InlineData("MyFunction")] + [InlineData("MyPlugin_MyFunction")] + public void FullyQualifiedNameReturnsValidName(string toolCallName) + { + // Arrange + var toolCallPart = new AnthropicToolCallContent { FunctionName = toolCallName }; + var functionToolCall = new AnthropicFunctionToolCall(toolCallPart); + + // Act & Assert + Assert.Equal(toolCallName, functionToolCall.FullyQualifiedName); + } + + [Fact] + public void ArgumentsReturnsCorrectValue() + { + // Arrange + var toolCallPart = new AnthropicToolCallContent + { + FunctionName = "MyPlugin_MyFunction", + Arguments = new JsonObject + { + { "location", "San Diego" }, + { "max_price", 300 } + } + }; + var functionToolCall = new AnthropicFunctionToolCall(toolCallPart); + + // Act & Assert + Assert.NotNull(functionToolCall.Arguments); + Assert.Equal(2, functionToolCall.Arguments.Count); + Assert.Equal("San Diego", functionToolCall.Arguments["location"]!.ToString()); + Assert.Equal(300, + Convert.ToInt32(functionToolCall.Arguments["max_price"]!.ToString(), new NumberFormatInfo())); + } + + [Fact] + public void ToStringReturnsCorrectValue() + { + // Arrange + var toolCallPart = new AnthropicToolCallContent + { + FunctionName = "MyPlugin_MyFunction", + Arguments = new JsonObject + { + { "location", "San Diego" }, + { "max_price", 300 } + } + }; + var functionToolCall = new AnthropicFunctionToolCall(toolCallPart); + + // Act & Assert + Assert.Equal("MyPlugin_MyFunction(location:San Diego, max_price:300)", functionToolCall.ToString()); + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Utils/AnthropicKernelFunctionMetadataExtensions.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Utils/AnthropicKernelFunctionMetadataExtensions.cs new file mode 100644 index 000000000000..04607cc5b643 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Utils/AnthropicKernelFunctionMetadataExtensions.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.Anthropic; + +namespace SemanticKernel.Connectors.Anthropic.UnitTests; + +/// +/// Extensions for specific to the Claude connector. +/// +public static class AnthropicKernelFunctionMetadataExtensions +{ + /// + /// Convert a to an . + /// + /// The object to convert. + /// An object. + public static AnthropicFunction ToClaudeFunction(this KernelFunctionMetadata metadata) + { + IReadOnlyList metadataParams = metadata.Parameters; + + var openAIParams = new ClaudeFunctionParameter[metadataParams.Count]; + for (int i = 0; i < openAIParams.Length; i++) + { + var param = metadataParams[i]; + + openAIParams[i] = new ClaudeFunctionParameter( + param.Name, + GetDescription(param), + param.IsRequired, + param.ParameterType, + param.Schema); + } + + return new AnthropicFunction( + metadata.PluginName, + metadata.Name, + metadata.Description, + openAIParams, + new ClaudeFunctionReturnParameter( + metadata.ReturnParameter.Description, + metadata.ReturnParameter.ParameterType, + metadata.ReturnParameter.Schema)); + + static string GetDescription(KernelParameterMetadata param) + { + string? stringValue = InternalTypeConverter.ConvertToString(param.DefaultValue); + return !string.IsNullOrEmpty(stringValue) ? $"{param.Description} (default value: {stringValue})" : param.Description; + } + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/ClaudePromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.Anthropic/AnthropicPromptExecutionSettings.cs similarity index 57% rename from dotnet/src/Connectors/Connectors.Anthropic/ClaudePromptExecutionSettings.cs rename to dotnet/src/Connectors/Connectors.Anthropic/AnthropicPromptExecutionSettings.cs index 0cb0a2f670b4..1b5b8713d5e5 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/ClaudePromptExecutionSettings.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/AnthropicPromptExecutionSettings.cs @@ -5,6 +5,7 @@ using System.Collections.ObjectModel; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Text; namespace Microsoft.SemanticKernel.Connectors.Anthropic; @@ -13,13 +14,14 @@ namespace Microsoft.SemanticKernel.Connectors.Anthropic; /// Represents the settings for executing a prompt with the Claude models. /// [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] -public sealed class ClaudePromptExecutionSettings : PromptExecutionSettings +public sealed class AnthropicPromptExecutionSettings : PromptExecutionSettings { private double? _temperature; private float? _topP; private int? _topK; private int? _maxTokens; private IList? _stopSequences; + private AnthropicToolCallBehavior? _toolCallBehavior; /// /// Default max tokens for a text generation. @@ -101,6 +103,43 @@ public IList? StopSequences } } + /// + /// Gets or sets the behavior for how tool calls are handled. + /// + /// + /// + /// To disable all tool calling, set the property to null (the default). + /// + /// To allow the model to request one of any number of functions, set the property to an + /// instance returned from , called with + /// a list of the functions available. + /// + /// + /// To allow the model to request one of any of the functions in the supplied , + /// set the property to if the client should simply + /// send the information about the functions and not handle the response in any special manner, or + /// if the client should attempt to automatically + /// invoke the function and send the result back to the service. + /// + /// + /// For all options where an instance is provided, auto-invoke behavior may be selected. If the service + /// sends a request for a function call, if auto-invoke has been requested, the client will attempt to + /// resolve that function from the functions available in the , and if found, rather + /// than returning the response back to the caller, it will handle the request automatically, invoking + /// the function, and sending back the result. The intermediate messages will be retained in the + /// if an instance was provided. + /// + public AnthropicToolCallBehavior? ToolCallBehavior + { + get => this._toolCallBehavior; + + set + { + this.ThrowIfFrozen(); + this._toolCallBehavior = value; + } + } + /// public override void Freeze() { @@ -120,7 +159,7 @@ public override void Freeze() /// public override PromptExecutionSettings Clone() { - return new ClaudePromptExecutionSettings() + return new AnthropicPromptExecutionSettings() { ModelId = this.ModelId, ExtensionData = this.ExtensionData is not null ? new Dictionary(this.ExtensionData) : null, @@ -129,32 +168,33 @@ public override PromptExecutionSettings Clone() TopK = this.TopK, MaxTokens = this.MaxTokens, StopSequences = this.StopSequences is not null ? new List(this.StopSequences) : null, + ToolCallBehavior = this.ToolCallBehavior?.Clone(), }; } /// - /// Converts a object to a object. + /// Converts a object to a object. /// /// The object to convert. /// - /// The converted object. If is null, - /// a new instance of is returned. If - /// is already a object, it is cast and returned. Otherwise, the method - /// tries to deserialize to a object. + /// The converted object. If is null, + /// a new instance of is returned. If + /// is already a object, it is cast and returned. Otherwise, the method + /// tries to deserialize to a object. /// If deserialization is successful, the converted object is returned. If deserialization fails or the converted object /// is null, an is thrown. /// - public static ClaudePromptExecutionSettings FromExecutionSettings(PromptExecutionSettings? executionSettings) + public static AnthropicPromptExecutionSettings FromExecutionSettings(PromptExecutionSettings? executionSettings) { switch (executionSettings) { case null: - return new ClaudePromptExecutionSettings { MaxTokens = DefaultTextMaxTokens }; - case ClaudePromptExecutionSettings settings: + return new AnthropicPromptExecutionSettings { MaxTokens = DefaultTextMaxTokens }; + case AnthropicPromptExecutionSettings settings: return settings; } var json = JsonSerializer.Serialize(executionSettings); - return JsonSerializer.Deserialize(json, JsonOptionsCache.ReadPermissive)!; + return JsonSerializer.Deserialize(json, JsonOptionsCache.ReadPermissive)!; } } diff --git a/dotnet/src/Connectors/Connectors.Anthropic/AnthropicToolCallBehavior.cs b/dotnet/src/Connectors/Connectors.Anthropic/AnthropicToolCallBehavior.cs new file mode 100644 index 000000000000..241a90675c12 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/AnthropicToolCallBehavior.cs @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic; + +/// Represents a behavior for Claude tool calls. +public abstract class AnthropicToolCallBehavior +{ + // NOTE: Right now, the only tools that are available are for function calling. In the future, + // this class can be extended to support additional kinds of tools, including composite ones: + // the ClaudePromptExecutionSettings has a single ToolCallBehavior property, but we could + // expose a `public static ToolCallBehavior Composite(params ToolCallBehavior[] behaviors)` + // or the like to allow multiple distinct tools to be provided, should that be appropriate. + // We can also consider additional forms of tools, such as ones that dynamically examine + // the Kernel, KernelArguments, etc., and dynamically contribute tools to the ChatCompletionsOptions. + + /// + /// The default maximum number of tool-call auto-invokes that can be made in a single request. + /// + /// + /// After this number of iterations as part of a single user request is reached, auto-invocation + /// will be disabled (e.g. will behave like )). + /// This is a safeguard against possible runaway execution if the model routinely re-requests + /// the same function over and over. It is currently hardcoded, but in the future it could + /// be made configurable by the developer. Other configuration is also possible in the future, + /// such as a delegate on the instance that can be invoked upon function call failure (e.g. failure + /// to find the requested function, failure to invoke the function, etc.), with behaviors for + /// what to do in such a case, e.g. respond to the model telling it to try again. With parallel tool call + /// support, where the model can request multiple tools in a single response, it is significantly + /// less likely that this limit is reached, as most of the time only a single request is needed. + /// + private const int DefaultMaximumAutoInvokeAttempts = 5; + + /// + /// Gets an instance that will provide all of the 's plugins' function information. + /// Function call requests from the model will be propagated back to the caller. + /// + /// + /// If no is available, no function information will be provided to the model. + /// + public static AnthropicToolCallBehavior EnableKernelFunctions => new KernelFunctions(autoInvoke: false); + + /// + /// Gets an instance that will both provide all of the 's plugins' function information + /// to the model and attempt to automatically handle any function call requests. + /// + /// + /// When successful, tool call requests from the model become an implementation detail, with the service + /// handling invoking any requested functions and supplying the results back to the model. + /// If no is available, no function information will be provided to the model. + /// + public static AnthropicToolCallBehavior AutoInvokeKernelFunctions => new KernelFunctions(autoInvoke: true); + + /// Gets an instance that will provide the specified list of functions to the model. + /// The functions that should be made available to the model. + /// true to attempt to automatically handle function call requests; otherwise, false. + /// + /// The that may be set into + /// to indicate that the specified functions should be made available to the model. + /// + public static AnthropicToolCallBehavior EnableFunctions(IEnumerable functions, bool autoInvoke = false) + { + Verify.NotNull(functions); + return new EnabledFunctions(functions, autoInvoke); + } + + /// Initializes the instance; prevents external instantiation. + private AnthropicToolCallBehavior(bool autoInvoke) + { + this.MaximumAutoInvokeAttempts = autoInvoke ? DefaultMaximumAutoInvokeAttempts : 0; + } + + /// Gets how many requests are part of a single interaction should include this tool in the request. + /// + /// This should be greater than or equal to . It defaults to . + /// Once this limit is reached, the tools will no longer be included in subsequent retries as part of the operation, e.g. + /// if this is 1, the first request will include the tools, but the subsequent response sending back the tool's result + /// will not include the tools for further use. + /// + public int MaximumUseAttempts { get; } = int.MaxValue; + + /// Gets how many tool call request/response roundtrips are supported with auto-invocation. + /// + /// To disable auto invocation, this can be set to 0. + /// + public int MaximumAutoInvokeAttempts { get; } + + /// + /// Gets whether validation against a specified list is required before allowing the model to request a function from the kernel. + /// + /// true if it's ok to invoke any kernel function requested by the model if it's found; + /// false if a request needs to be validated against an allow list. + internal virtual bool AllowAnyRequestedKernelFunction => false; + + /// Configures the with any tools this provides. + /// The used for the operation. + /// This can be queried to determine what tools to provide into the . + /// The destination to configure. + internal abstract void ConfigureClaudeRequest(Kernel? kernel, AnthropicRequest request); + + internal AnthropicToolCallBehavior Clone() + { + return (AnthropicToolCallBehavior)this.MemberwiseClone(); + } + + /// + /// Represents a that will provide to the model all available functions from a + /// provided by the client. + /// + internal sealed class KernelFunctions : AnthropicToolCallBehavior + { + internal KernelFunctions(bool autoInvoke) : base(autoInvoke) { } + + public override string ToString() => $"{nameof(KernelFunctions)}(autoInvoke:{this.MaximumAutoInvokeAttempts != 0})"; + + internal override void ConfigureClaudeRequest(Kernel? kernel, AnthropicRequest request) + { + // If no kernel is provided, we don't have any tools to provide. + if (kernel is null) + { + return; + } + + // Provide all functions from the kernel. + foreach (var functionMetadata in kernel.Plugins.GetFunctionsMetadata()) + { + request.AddFunction(FunctionMetadataAsClaudeFunction(functionMetadata)); + } + } + + internal override bool AllowAnyRequestedKernelFunction => true; + + /// + /// Convert a to an . + /// + /// The object to convert. + /// An object. + private static AnthropicFunction FunctionMetadataAsClaudeFunction(KernelFunctionMetadata metadata) + { + IReadOnlyList metadataParams = metadata.Parameters; + + var openAIParams = new ClaudeFunctionParameter[metadataParams.Count]; + for (int i = 0; i < openAIParams.Length; i++) + { + var param = metadataParams[i]; + + openAIParams[i] = new ClaudeFunctionParameter( + param.Name, + GetDescription(param), + param.IsRequired, + param.ParameterType, + param.Schema); + } + + return new AnthropicFunction( + metadata.PluginName, + metadata.Name, + metadata.Description, + openAIParams, + new ClaudeFunctionReturnParameter( + metadata.ReturnParameter.Description, + metadata.ReturnParameter.ParameterType, + metadata.ReturnParameter.Schema)); + + static string GetDescription(KernelParameterMetadata param) + { + string? stringValue = InternalTypeConverter.ConvertToString(param.DefaultValue); + return !string.IsNullOrEmpty(stringValue) ? $"{param.Description} (default value: {stringValue})" : param.Description; + } + } + } + + /// + /// Represents a that provides a specified list of functions to the model. + /// + internal sealed class EnabledFunctions : AnthropicToolCallBehavior + { + private readonly AnthropicFunction[] _functions; + + public EnabledFunctions(IEnumerable functions, bool autoInvoke) : base(autoInvoke) + { + this._functions = functions.ToArray(); + } + + public override string ToString() => + $"{nameof(EnabledFunctions)}(autoInvoke:{this.MaximumAutoInvokeAttempts != 0}): " + + $"{string.Join(", ", this._functions.Select(f => f.FunctionName))}"; + + internal override void ConfigureClaudeRequest(Kernel? kernel, AnthropicRequest request) + { + if (this._functions.Length == 0) + { + return; + } + + bool autoInvoke = this.MaximumAutoInvokeAttempts > 0; + + // If auto-invocation is specified, we need a kernel to be able to invoke the functions. + // Lack of a kernel is fatal: we don't want to tell the model we can handle the functions + // and then fail to do so, so we fail before we get to that point. This is an error + // on the consumers behalf: if they specify auto-invocation with any functions, they must + // specify the kernel and the kernel must contain those functions. + if (autoInvoke && kernel is null) + { + throw new KernelException($"Auto-invocation with {nameof(EnabledFunctions)} is not supported when no kernel is provided."); + } + + foreach (var func in this._functions) + { + // Make sure that if auto-invocation is specified, every enabled function can be found in the kernel. + if (autoInvoke) + { + if (!kernel!.Plugins.TryGetFunction(func.PluginName, func.FunctionName, out _)) + { + throw new KernelException( + $"The specified {nameof(EnabledFunctions)} function {func.FullyQualifiedName} is not available in the kernel."); + } + } + + // Add the function. + request.AddFunction(func); + } + } + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/AnthropicClient.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/AnthropicClient.cs similarity index 100% rename from dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/AnthropicClient.cs rename to dotnet/src/Connectors/Connectors.Anthropic/Core/AnthropicClient.cs diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/AuthorRoleConverter.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/AuthorRoleConverter.cs similarity index 100% rename from dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/AuthorRoleConverter.cs rename to dotnet/src/Connectors/Connectors.Anthropic/Core/AuthorRoleConverter.cs diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/Models/ClaudeRequest.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicRequest.cs similarity index 54% rename from dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/Models/ClaudeRequest.cs rename to dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicRequest.cs index d0c8a5a04726..3f65e8ca2e95 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/Models/ClaudeRequest.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicRequest.cs @@ -3,12 +3,13 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.SemanticKernel.ChatCompletion; namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; -internal sealed class ClaudeRequest +internal sealed class AnthropicRequest { /// /// Input messages.
@@ -23,6 +24,10 @@ internal sealed class ClaudeRequest [JsonPropertyName("messages")] public IList Messages { get; set; } = null!; + [JsonPropertyName("tools")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Tools { get; set; } + [JsonPropertyName("model")] public string ModelId { get; set; } = null!; @@ -75,35 +80,52 @@ internal sealed class ClaudeRequest [JsonPropertyName("top_k")] public int? TopK { get; set; } + public void AddFunction(AnthropicFunction function) + { + this.Tools ??= new List(); + this.Tools.Add(function.ToFunctionDeclaration()); + } + + public void AddChatMessage(ChatMessageContent message) + { + Verify.NotNull(this.Messages); + Verify.NotNull(message); + + this.Messages.Add(CreateClaudeMessageFromChatMessage(message)); + } + /// - /// Creates a object from the given and . + /// Creates a object from the given and . /// - /// The chat history to be assigned to the . - /// The execution settings to be applied to the . + /// The chat history to be assigned to the . + /// The execution settings to be applied to the . /// Enables SSE streaming. (optional) - /// A new instance of . - internal static ClaudeRequest FromChatHistoryAndExecutionSettings( + /// A new instance of . + internal static AnthropicRequest FromChatHistoryAndExecutionSettings( ChatHistory chatHistory, - ClaudePromptExecutionSettings executionSettings, + AnthropicPromptExecutionSettings executionSettings, bool streamingMode = false) { - ClaudeRequest request = CreateRequest(chatHistory, executionSettings, streamingMode); + AnthropicRequest request = CreateRequest(chatHistory, executionSettings, streamingMode); AddMessages(chatHistory, request); return request; } - private static void AddMessages(ChatHistory chatHistory, ClaudeRequest request) + private static void AddMessages(ChatHistory chatHistory, AnthropicRequest request) + => request.Messages = chatHistory.Select(CreateClaudeMessageFromChatMessage).ToList(); + + private static Message CreateClaudeMessageFromChatMessage(ChatMessageContent message) { - request.Messages = chatHistory.Select(message => new Message + return new Message { Role = message.Role, - Contents = message.Items.Select(GetContentFromKernelContent).ToList() - }).ToList(); + Contents = CreateClaudeMessages(message) + }; } - private static ClaudeRequest CreateRequest(ChatHistory chatHistory, ClaudePromptExecutionSettings executionSettings, bool streamingMode) + private static AnthropicRequest CreateRequest(ChatHistory chatHistory, AnthropicPromptExecutionSettings executionSettings, bool streamingMode) { - ClaudeRequest request = new() + AnthropicRequest request = new() { ModelId = executionSettings.ModelId ?? throw new InvalidOperationException("Model ID must be provided."), MaxTokens = executionSettings.MaxTokens ?? throw new InvalidOperationException("Max tokens must be provided."), @@ -117,19 +139,50 @@ private static ClaudeRequest CreateRequest(ChatHistory chatHistory, ClaudePrompt return request; } - private static ClaudeMessageContent GetContentFromKernelContent(KernelContent content) => content switch + private static List CreateClaudeMessages(ChatMessageContent content) { - TextContent textContent => new ClaudeMessageContent { Type = "text", Text = textContent.Text }, - ImageContent imageContent => new ClaudeMessageContent + List messages = new(); + switch (content) + { + case AnthropicChatMessageContent { CalledToolResult: not null } contentWithCalledTool: + messages.Add(new AnthropicToolResultContent + { + ToolId = contentWithCalledTool.CalledToolResult.ToolUseId ?? throw new InvalidOperationException("Tool ID must be provided."), + Content = new AnthropicTextContent(contentWithCalledTool.CalledToolResult.FunctionResult.ToString()) + }); + break; + case AnthropicChatMessageContent { ToolCalls: not null } contentWithToolCalls: + messages.AddRange(contentWithToolCalls.ToolCalls.Select(toolCall => + new AnthropicToolCallContent + { + ToolId = toolCall.ToolUseId, + FunctionName = toolCall.FullyQualifiedName, + Arguments = JsonSerializer.SerializeToNode(toolCall.Arguments), + })); + break; + default: + messages.AddRange(content.Items.Select(GetClaudeMessageFromKernelContent)); + break; + } + + if (messages.Count == 0) { - Type = "image", Image = new ClaudeMessageContent.SourceEntity( - type: "base64", - mediaType: imageContent.MimeType ?? throw new InvalidOperationException("Image content must have a MIME type."), - data: imageContent.Data.HasValue - ? Convert.ToBase64String(imageContent.Data.Value.ToArray()) - : throw new InvalidOperationException("Image content must have a data.") - ) - }, + messages.Add(new AnthropicTextContent(content.Content ?? string.Empty)); + } + + return messages; + } + + private static AnthropicContent GetClaudeMessageFromKernelContent(KernelContent content) => content switch + { + TextContent textContent => new AnthropicTextContent(textContent.Text ?? string.Empty), + ImageContent imageContent => new AnthropicImageContent( + type: "base64", + mediaType: imageContent.MimeType ?? throw new InvalidOperationException("Image content must have a MIME type."), + data: imageContent.Data.HasValue + ? Convert.ToBase64String(imageContent.Data.Value.ToArray()) + : throw new InvalidOperationException("Image content must have a data.") + ), _ => throw new NotSupportedException($"Content type '{content.GetType().Name}' is not supported.") }; @@ -140,6 +193,6 @@ internal sealed class Message public AuthorRole Role { get; set; } [JsonPropertyName("content")] - public IList Contents { get; set; } = null!; + public IList Contents { get; set; } = null!; } } diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicToolFunctionDeclaration.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicToolFunctionDeclaration.cs new file mode 100644 index 000000000000..abfbbad17779 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicToolFunctionDeclaration.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; + +/// +/// A Tool is a piece of code that enables the system to interact with external systems to perform an action, +/// or set of actions, outside of knowledge and scope of the model. +/// Structured representation of a function declaration as defined by the OpenAPI 3.03 specification. +/// Included in this declaration are the function name and parameters. +/// This FunctionDeclaration is a representation of a block of code that can be used as a Tool by the model and executed by the client. +/// +internal sealed class AnthropicToolFunctionDeclaration +{ + /// + /// Required. Name of function. + /// + /// + /// Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 63. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = null!; + + /// + /// Required. A brief description of the function. + /// + [JsonPropertyName("description")] + public string Description { get; set; } = null!; + + /// + /// Optional. Describes the parameters to this function. + /// Reflects the Open API 3.03 Parameter Object string Key: the name of the parameter. + /// Parameter names are case-sensitive. Schema Value: the Schema defining the type used for the parameter. + /// + [JsonPropertyName("parameters")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonNode? Parameters { get; set; } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicContent.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicContent.cs new file mode 100644 index 000000000000..c27931519b16 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicContent.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; + +/// +/// Represents the request/response content of Claude. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(AnthropicTextContent), typeDiscriminator: "text")] +[JsonDerivedType(typeof(AnthropicImageContent), typeDiscriminator: "image")] +[JsonDerivedType(typeof(AnthropicToolCallContent), typeDiscriminator: "tool_use")] +[JsonDerivedType(typeof(AnthropicToolResultContent), typeDiscriminator: "tool_result")] +internal abstract class AnthropicContent { } diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/Models/ClaudeMessageContent.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicImageContent.cs similarity index 61% rename from dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/Models/ClaudeMessageContent.cs rename to dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicImageContent.cs index 42afd9a686fb..8dd517267cdf 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Core/Claude/Models/ClaudeMessageContent.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicImageContent.cs @@ -4,31 +4,19 @@ namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; -/// -/// Represents the request/response content of Claude. -/// -internal sealed class ClaudeMessageContent +internal sealed class AnthropicImageContent : AnthropicContent { - /// - /// Type of content. Possible values are "text" and "image". - /// - [JsonRequired] - [JsonPropertyName("type")] - public string Type { get; set; } = null!; - - /// - /// Only used when type is "text". The text content. - /// - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [JsonPropertyName("text")] - public string? Text { get; set; } + [JsonConstructor] + public AnthropicImageContent(string type, string mediaType, string data) + { + this.Source = new SourceEntity(type, mediaType, data); + } /// /// Only used when type is "image". The image content. /// - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("source")] - public SourceEntity? Image { get; set; } + public SourceEntity Source { get; set; } internal sealed class SourceEntity { diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicTextContent.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicTextContent.cs new file mode 100644 index 000000000000..ca565be761f6 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicTextContent.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; + +internal sealed class AnthropicTextContent : AnthropicContent +{ + [JsonConstructor] + public AnthropicTextContent(string text) + { + this.Text = text; + } + + /// + /// Only used when type is "text". The text content. + /// + [JsonPropertyName("text")] + public string Text { get; set; } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicToolCallContent.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicToolCallContent.cs new file mode 100644 index 000000000000..e738b3773221 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicToolCallContent.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; + +internal sealed class AnthropicToolCallContent : AnthropicContent +{ + [JsonPropertyName("id")] + [JsonRequired] + public string ToolId { get; set; } = null!; + + [JsonPropertyName("name")] + [JsonRequired] + public string FunctionName { get; set; } = null!; + + /// + /// Optional. The function parameters and values in JSON object format. + /// + [JsonPropertyName("input")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonNode? Arguments { get; set; } + + /// + public override string ToString() + { + return $"FunctionName={this.FunctionName}, Arguments={this.Arguments}"; + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicToolResultContent.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicToolResultContent.cs new file mode 100644 index 000000000000..dcf2c31f4965 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicToolResultContent.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; + +internal sealed class AnthropicToolResultContent : AnthropicContent +{ + [JsonPropertyName("tool_use_id")] + [JsonRequired] + public string ToolId { get; set; } = null!; + + [JsonPropertyName("content")] + [JsonRequired] + public AnthropicContent Content { get; set; } = null!; + + [JsonPropertyName("is_error")] + public bool IsError { get; set; } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicChatMessageContent.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicChatMessageContent.cs new file mode 100644 index 000000000000..f0a291226bef --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicChatMessageContent.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic; + +/// +/// Claude specialized chat message content +/// +public sealed class AnthropicChatMessageContent : ChatMessageContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The result of tool called by the kernel. + public AnthropicChatMessageContent(AnthropicFunctionToolResult calledToolResult) + : base( + role: AuthorRole.Assistant, + content: null, + modelId: null, + innerContent: null, + encoding: Encoding.UTF8, + metadata: null) + { + Verify.NotNull(calledToolResult); + + this.CalledToolResult = calledToolResult; + } + + /// + /// Initializes a new instance of the class. + /// + /// Role of the author of the message + /// Content of the message + /// The model ID used to generate the content + /// The result of tool called by the kernel. + /// Additional metadata + internal AnthropicChatMessageContent( + AuthorRole role, + string? content, + string modelId, + AnthropicFunctionToolResult? calledToolResult = null, + AnthropicMetadata? metadata = null) + : base( + role: role, + content: content, + modelId: modelId, + innerContent: content, + encoding: Encoding.UTF8, + metadata: metadata) + { + this.CalledToolResult = calledToolResult; + } + + /// + /// Initializes a new instance of the class. + /// + /// Role of the author of the message + /// Content of the message + /// The model ID used to generate the content + /// Tool calls parts returned by model + /// Additional metadata + internal AnthropicChatMessageContent( + AuthorRole role, + string? content, + string modelId, + IEnumerable? functionsToolCalls, + AnthropicMetadata? metadata = null) + : base( + role: role, + content: content, + modelId: modelId, + innerContent: content, + encoding: Encoding.UTF8, + metadata: metadata) + { + this.ToolCalls = functionsToolCalls?.Select(tool => new AnthropicFunctionToolCall(tool)).ToList(); + } + + /// + /// A list of the tools returned by the model with arguments. + /// + public IReadOnlyList? ToolCalls { get; } + + /// + /// The result of tool called by the kernel. + /// + public AnthropicFunctionToolResult? CalledToolResult { get; } + + /// + /// The metadata associated with the content. + /// + public new AnthropicMetadata? Metadata => (AnthropicMetadata?)base.Metadata; +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/Claude/ClaudeFinishReason.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFinishReason.cs similarity index 58% rename from dotnet/src/Connectors/Connectors.Anthropic/Models/Claude/ClaudeFinishReason.cs rename to dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFinishReason.cs index 69b915f7d651..d05f9bc69547 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Models/Claude/ClaudeFinishReason.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFinishReason.cs @@ -10,22 +10,22 @@ namespace Microsoft.SemanticKernel.Connectors.Anthropic; /// Represents a Claude Finish Reason. ///
[JsonConverter(typeof(ClaudeFinishReasonConverter))] -public readonly struct ClaudeFinishReason : IEquatable +public readonly struct AnthropicFinishReason : IEquatable { /// /// Natural stop point of the model or provided stop sequence. /// - public static ClaudeFinishReason Stop { get; } = new("end_turn"); + public static AnthropicFinishReason Stop { get; } = new("end_turn"); /// /// The maximum number of tokens as specified in the request was reached. /// - public static ClaudeFinishReason MaxTokens { get; } = new("max_tokens"); + public static AnthropicFinishReason MaxTokens { get; } = new("max_tokens"); /// /// One of your provided custom stop sequences was generated. /// - public static ClaudeFinishReason StopSequence { get; } = new("stop_sequence"); + public static AnthropicFinishReason StopSequence { get; } = new("stop_sequence"); /// /// Gets the label of the property. @@ -37,37 +37,37 @@ namespace Microsoft.SemanticKernel.Connectors.Anthropic; /// Represents a Claude Finish Reason. /// [JsonConstructor] - public ClaudeFinishReason(string label) + public AnthropicFinishReason(string label) { Verify.NotNullOrWhiteSpace(label, nameof(label)); this.Label = label; } /// - /// Represents the equality operator for comparing two instances of . + /// Represents the equality operator for comparing two instances of . /// - /// The left instance to compare. - /// The right instance to compare. + /// The left instance to compare. + /// The right instance to compare. /// true if the two instances are equal; otherwise, false. - public static bool operator ==(ClaudeFinishReason left, ClaudeFinishReason right) + public static bool operator ==(AnthropicFinishReason left, AnthropicFinishReason right) => left.Equals(right); /// - /// Represents the inequality operator for comparing two instances of . + /// Represents the inequality operator for comparing two instances of . /// - /// The left instance to compare. - /// The right instance to compare. + /// The left instance to compare. + /// The right instance to compare. /// true if the two instances are not equal; otherwise, false. - public static bool operator !=(ClaudeFinishReason left, ClaudeFinishReason right) + public static bool operator !=(AnthropicFinishReason left, AnthropicFinishReason right) => !(left == right); /// - public bool Equals(ClaudeFinishReason other) + public bool Equals(AnthropicFinishReason other) => string.Equals(this.Label, other.Label, StringComparison.OrdinalIgnoreCase); /// public override bool Equals(object? obj) - => obj is ClaudeFinishReason other && this == other; + => obj is AnthropicFinishReason other && this == other; /// public override int GetHashCode() @@ -77,11 +77,11 @@ public override int GetHashCode() public override string ToString() => this.Label ?? string.Empty; } -internal sealed class ClaudeFinishReasonConverter : JsonConverter +internal sealed class ClaudeFinishReasonConverter : JsonConverter { - public override ClaudeFinishReason Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override AnthropicFinishReason Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => new(reader.GetString()!); - public override void Write(Utf8JsonWriter writer, ClaudeFinishReason value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, AnthropicFinishReason value, JsonSerializerOptions options) => writer.WriteStringValue(value.Label); } diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunction.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunction.cs new file mode 100644 index 000000000000..55ad7872a423 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunction.cs @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic; + +// NOTE: Since this space is evolving rapidly, in order to reduce the risk of needing to take breaking +// changes as Gemini's APIs evolve, these types are not externally constructible. In the future, once +// things stabilize, and if need demonstrates, we could choose to expose those constructors. + +/// +/// Represents a function parameter that can be passed to an Gemini function tool call. +/// +public sealed class ClaudeFunctionParameter +{ + internal ClaudeFunctionParameter( + string? name, + string? description, + bool isRequired, + Type? parameterType, + KernelJsonSchema? schema) + { + this.Name = name ?? string.Empty; + this.Description = description ?? string.Empty; + this.IsRequired = isRequired; + this.ParameterType = parameterType; + this.Schema = schema; + } + + /// Gets the name of the parameter. + public string Name { get; } + + /// Gets a description of the parameter. + public string Description { get; } + + /// Gets whether the parameter is required vs optional. + public bool IsRequired { get; } + + /// Gets the of the parameter, if known. + public Type? ParameterType { get; } + + /// Gets a JSON schema for the parameter, if known. + public KernelJsonSchema? Schema { get; } +} + +/// +/// Represents a function return parameter that can be returned by a tool call to Gemini. +/// +public sealed class ClaudeFunctionReturnParameter +{ + internal ClaudeFunctionReturnParameter( + string? description, + Type? parameterType, + KernelJsonSchema? schema) + { + this.Description = description ?? string.Empty; + this.Schema = schema; + this.ParameterType = parameterType; + } + + /// Gets a description of the return parameter. + public string Description { get; } + + /// Gets the of the return parameter, if known. + public Type? ParameterType { get; } + + /// Gets a JSON schema for the return parameter, if known. + public KernelJsonSchema? Schema { get; } +} + +/// +/// Represents a function that can be passed to the Gemini API +/// +public sealed class AnthropicFunction +{ + /// + /// Cached schema for a description less string. + /// + private static readonly KernelJsonSchema s_stringNoDescriptionSchema = KernelJsonSchema.Parse("{\"type\":\"string\"}"); + + /// Initializes the . + internal AnthropicFunction( + string? pluginName, + string functionName, + string? description, + IReadOnlyList? parameters, + ClaudeFunctionReturnParameter? returnParameter) + { + Verify.NotNullOrWhiteSpace(functionName); + + this.PluginName = pluginName; + this.FunctionName = functionName; + this.Description = description; + this.Parameters = parameters; + this.ReturnParameter = returnParameter; + } + + /// Gets the separator used between the plugin name and the function name, if a plugin name is present. + /// Default is _
It can't be -, because Gemini truncates the plugin name if a dash is used
+ public static string NameSeparator { get; set; } = "_"; + + /// Gets the name of the plugin with which the function is associated, if any. + public string? PluginName { get; } + + /// Gets the name of the function. + public string FunctionName { get; } + + /// Gets the fully-qualified name of the function. + /// + /// This is the concatenation of the and the , + /// separated by . If there is no , this is + /// the same as . + /// + public string FullyQualifiedName => + string.IsNullOrEmpty(this.PluginName) ? this.FunctionName : $"{this.PluginName}{NameSeparator}{this.FunctionName}"; + + /// Gets a description of the function. + public string? Description { get; } + + /// Gets a list of parameters to the function, if any. + public IReadOnlyList? Parameters { get; } + + /// Gets the return parameter of the function, if any. + public ClaudeFunctionReturnParameter? ReturnParameter { get; } + + /// + /// Converts the representation to the Gemini API's + /// representation. + /// + /// A containing all the function information. + internal AnthropicToolFunctionDeclaration ToFunctionDeclaration() + { + Dictionary? resultParameters = null; + + if (this.Parameters is { Count: > 0 }) + { + var properties = new Dictionary(); + var required = new List(); + + foreach (var parameter in this.Parameters) + { + properties.Add(parameter.Name, parameter.Schema ?? GetDefaultSchemaForParameter(parameter)); + if (parameter.IsRequired) + { + required.Add(parameter.Name); + } + } + + resultParameters = new Dictionary + { + { "type", "object" }, + { "required", required }, + { "properties", properties }, + }; + } + + return new AnthropicToolFunctionDeclaration + { + Name = this.FullyQualifiedName, + Description = this.Description ?? throw new InvalidOperationException( + $"Function description is required. Please provide a description for the function {this.FullyQualifiedName}."), + Parameters = JsonSerializer.SerializeToNode(resultParameters), + }; + } + + /// Gets a for a typeless parameter with the specified description, defaulting to typeof(string) + private static KernelJsonSchema GetDefaultSchemaForParameter(ClaudeFunctionParameter parameter) + { + // If there's a description, incorporate it. + if (!string.IsNullOrWhiteSpace(parameter.Description)) + { + return KernelJsonSchemaBuilder.Build(null, parameter.ParameterType ?? typeof(string), parameter.Description); + } + + // Otherwise, we can use a cached schema for a string with no description. + return s_stringNoDescriptionSchema; + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunctionToolCall.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunctionToolCall.cs new file mode 100644 index 000000000000..7ed158020e35 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunctionToolCall.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic; + +/// +/// Represents an Gemini function tool call with deserialized function name and arguments. +/// +public sealed class AnthropicFunctionToolCall +{ + private string? _fullyQualifiedFunctionName; + + /// Initialize the from a . + internal AnthropicFunctionToolCall(AnthropicToolCallContent functionToolCall) + { + Verify.NotNull(functionToolCall); + Verify.NotNull(functionToolCall.FunctionName); + + string fullyQualifiedFunctionName = functionToolCall.FunctionName; + string functionName = fullyQualifiedFunctionName; + string? pluginName = null; + + int separatorPos = fullyQualifiedFunctionName.IndexOf(AnthropicFunction.NameSeparator, StringComparison.Ordinal); + if (separatorPos >= 0) + { + pluginName = fullyQualifiedFunctionName.AsSpan(0, separatorPos).Trim().ToString(); + functionName = fullyQualifiedFunctionName.AsSpan(separatorPos + AnthropicFunction.NameSeparator.Length).Trim().ToString(); + } + + this._fullyQualifiedFunctionName = fullyQualifiedFunctionName; + this.ToolUseId = functionToolCall.ToolId; + this.PluginName = pluginName; + this.FunctionName = functionName; + if (functionToolCall.Arguments is not null) + { + this.Arguments = functionToolCall.Arguments.Deserialize>(); + } + } + + /// + /// The id of tool returned by the claude. + /// + public string ToolUseId { get; } + + /// Gets the name of the plugin with which this function is associated, if any. + public string? PluginName { get; } + + /// Gets the name of the function. + public string FunctionName { get; } + + /// Gets a name/value collection of the arguments to the function, if any. + public IReadOnlyDictionary? Arguments { get; } + + /// Gets the fully-qualified name of the function. + /// + /// This is the concatenation of the and the , + /// separated by . If there is no , + /// this is the same as . + /// + public string FullyQualifiedName + => this._fullyQualifiedFunctionName + ??= string.IsNullOrEmpty(this.PluginName) ? this.FunctionName : $"{this.PluginName}{AnthropicFunction.NameSeparator}{this.FunctionName}"; + + /// + public override string ToString() + { + var sb = new StringBuilder(this.FullyQualifiedName); + + sb.Append('('); + if (this.Arguments is not null) + { + string separator = ""; + foreach (var arg in this.Arguments) + { + sb.Append(separator).Append(arg.Key).Append(':').Append(arg.Value); + separator = ", "; + } + } + + sb.Append(')'); + + return sb.ToString(); + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunctionToolResult.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunctionToolResult.cs new file mode 100644 index 000000000000..cf8157bcc2a5 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunctionToolResult.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.SemanticKernel.Connectors.Anthropic; + +/// +/// Represents the result of a Claude function tool call. +/// +public sealed class AnthropicFunctionToolResult +{ + /// + /// Initializes a new instance of the class. + /// + /// The called function. + /// The result of the function. + /// The id of tool returned by the claude. + public AnthropicFunctionToolResult(AnthropicFunctionToolCall toolCall, FunctionResult functionResult, string? toolUseId) + { + Verify.NotNull(toolCall); + Verify.NotNull(functionResult); + + this.FunctionResult = functionResult; + this.FullyQualifiedName = toolCall.FullyQualifiedName; + this.ToolUseId = toolUseId; + } + + /// + /// Gets the result of the function. + /// + public FunctionResult FunctionResult { get; } + + /// Gets the fully-qualified name of the function. + /// ClaudeFunctionToolCall.FullyQualifiedName + public string FullyQualifiedName { get; } + + /// + /// The id of tool returned by the claude. + /// + public string? ToolUseId { get; } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/Claude/ClaudeMetadata.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicMetadata.cs similarity index 72% rename from dotnet/src/Connectors/Connectors.Anthropic/Models/Claude/ClaudeMetadata.cs rename to dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicMetadata.cs index 4762df30cc97..3cc73f27b658 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Models/Claude/ClaudeMetadata.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicMetadata.cs @@ -10,11 +10,11 @@ namespace Microsoft.SemanticKernel.Connectors.Anthropic; /// /// Represents the metadata associated with a Claude response. /// -public sealed class ClaudeMetadata : ReadOnlyDictionary +public sealed class AnthropicMetadata : ReadOnlyDictionary { - internal ClaudeMetadata() : base(new Dictionary()) { } + internal AnthropicMetadata() : base(new Dictionary()) { } - private ClaudeMetadata(IDictionary dictionary) : base(dictionary) { } + private AnthropicMetadata(IDictionary dictionary) : base(dictionary) { } /// /// Unique message object identifier. @@ -28,9 +28,9 @@ public string MessageId /// /// The reason generating was stopped. /// - public ClaudeFinishReason? FinishReason + public AnthropicFinishReason? FinishReason { - get => (ClaudeFinishReason?)this.GetValueFromDictionary(nameof(this.FinishReason)); + get => (AnthropicFinishReason?)this.GetValueFromDictionary(nameof(this.FinishReason)); internal init => this.SetValueInDictionary(value, nameof(this.FinishReason)); } @@ -62,14 +62,14 @@ public int OutputTokenCount } /// - /// Converts a dictionary to a object. + /// Converts a dictionary to a object. /// - public static ClaudeMetadata FromDictionary(IReadOnlyDictionary dictionary) => dictionary switch + public static AnthropicMetadata FromDictionary(IReadOnlyDictionary dictionary) => dictionary switch { null => throw new ArgumentNullException(nameof(dictionary)), - ClaudeMetadata metadata => metadata, - IDictionary metadata => new ClaudeMetadata(metadata), - _ => new ClaudeMetadata(dictionary.ToDictionary(pair => pair.Key, pair => pair.Value)) + AnthropicMetadata metadata => metadata, + IDictionary metadata => new AnthropicMetadata(metadata), + _ => new AnthropicMetadata(dictionary.ToDictionary(pair => pair.Key, pair => pair.Value)) }; private void SetValueInDictionary(object? value, string propertyName) From 69867043f7e5e991750725efbcacb6ff792e3ea6 Mon Sep 17 00:00:00 2001 From: Krzysztof Kasprowicz Date: Sun, 30 Jun 2024 19:11:37 +0200 Subject: [PATCH 4/7] Fix unit tests after main merge --- .../Core/AnthropicRequestTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicRequestTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicRequestTests.cs index 489a3409ba33..fbc05591c9c1 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicRequestTests.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicRequestTests.cs @@ -127,7 +127,7 @@ public void FromChatHistoryImageAsImageContentItReturnsClaudeRequestWithChatHist chatHistory.AddUserMessage("user-message"); chatHistory.AddAssistantMessage("assist-message"); chatHistory.AddUserMessage(contentItems: - [new ImageContent(imageAsBytes) { MimeType = "image/png" }]); + [new ImageContent(imageAsBytes, "image/png")]); var executionSettings = new AnthropicPromptExecutionSettings { ModelId = "claude", From 0a4df864d861901b63a1191c8d5421682b5cbb6e Mon Sep 17 00:00:00 2001 From: Krzysztof Kasprowicz <60486987+Krzysztof318@users.noreply.github.com> Date: Mon, 29 Jul 2024 16:36:55 +0200 Subject: [PATCH 5/7] .Net: Anthropic chat generation (non-streaming) and removed FC (#7101) Part of: #5690 Anthropic chat generation (non-streaming) @RogerBarreto ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone :smile: --------- Co-authored-by: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> --- .../AnthropicToolCallBehaviorTests.cs | 222 -------- .../Core/AnthropicChatGenerationTests.cs | 478 ++++++++++++++++++ .../Core/AnthropicRequestTests.cs | 173 ++----- ...thropicServiceCollectionExtensionsTests.cs | 7 +- .../Models/AnthropicFunctionTests.cs | 185 ------- .../Models/AnthropicFunctionToolCallTests.cs | 71 --- .../TestData/chat_one_response.json | 18 + ...thropicKernelFunctionMetadataExtensions.cs | 52 -- .../AnthropicClientOptions.cs | 40 -- .../AnthropicToolCallBehavior.cs | 228 --------- .../Connectors.Anthropic.csproj | 14 +- .../Core/AnthropicClient.cs | 319 ++++++++++-- .../Core/AuthorRoleConverter.cs | 2 +- .../Core/Models/AnthropicRequest.cs | 106 ++-- .../Core/Models/AnthropicResponse.cs | 71 +++ .../AnthropicToolFunctionDeclaration.cs | 40 -- .../Core/Models/Message/AnthropicContent.cs | 58 ++- .../Models/Message/AnthropicImageContent.cs | 49 -- .../Models/Message/AnthropicTextContent.cs | 20 - .../Message/AnthropicToolCallContent.cs | 30 -- .../Message/AnthropicToolResultContent.cs | 19 - .../AnthropicKernelBuilderExtensions.cs | 44 +- .../AnthropicServiceCollectionExtensions.cs | 41 +- .../Models/AnthropicChatMessageContent.cs | 98 ---- .../Models/AnthropicFunction.cs | 181 ------- .../Models/AnthropicFunctionToolCall.cs | 89 ---- .../Models/AnthropicFunctionToolResult.cs | 39 -- .../Contents/AnthropicChatMessageContent.cs | 26 + .../{ => Contents}/AnthropicFinishReason.cs | 13 +- .../{ => Contents}/AnthropicMetadata.cs | 16 +- .../Models/Contents/AnthropicUsage.cs | 30 ++ .../AmazonBedrockAnthropicClientOptions.cs | 36 ++ .../Models/Options/AnthropicClientOptions.cs | 40 ++ .../Models/Options/ClientOptions.cs | 19 + .../Options/VertexAIAnthropicClientOptions.cs | 36 ++ .../AnthropicPromptExecutionSettings.cs | 42 +- .../AnthropicChatCompletionService.cs | 50 +- .../Anthropic/AnthropicChatCompletionTests.cs | 378 ++++++++++++++ .../Connectors/Anthropic/TestBase.cs | 46 ++ .../Google/EmbeddingGenerationTests.cs | 2 +- .../Gemini/GeminiChatCompletionTests.cs | 2 +- .../Gemini/GeminiFunctionCallingTests.cs | 2 +- .../Google/{TestsBase.cs => TestBase.cs} | 4 +- .../IntegrationTests/IntegrationTests.csproj | 1 + 44 files changed, 1698 insertions(+), 1739 deletions(-) delete mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/AnthropicToolCallBehaviorTests.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicChatGenerationTests.cs delete mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Models/AnthropicFunctionTests.cs delete mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Models/AnthropicFunctionToolCallTests.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/TestData/chat_one_response.json delete mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Utils/AnthropicKernelFunctionMetadataExtensions.cs delete mode 100644 dotnet/src/Connectors/Connectors.Anthropic/AnthropicClientOptions.cs delete mode 100644 dotnet/src/Connectors/Connectors.Anthropic/AnthropicToolCallBehavior.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicResponse.cs delete mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicToolFunctionDeclaration.cs delete mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicImageContent.cs delete mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicTextContent.cs delete mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicToolCallContent.cs delete mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicToolResultContent.cs delete mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicChatMessageContent.cs delete mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunction.cs delete mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunctionToolCall.cs delete mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunctionToolResult.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicChatMessageContent.cs rename dotnet/src/Connectors/Connectors.Anthropic/Models/{ => Contents}/AnthropicFinishReason.cs (89%) rename dotnet/src/Connectors/Connectors.Anthropic/Models/{ => Contents}/AnthropicMetadata.cs (82%) create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicUsage.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Models/Options/AmazonBedrockAnthropicClientOptions.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Models/Options/AnthropicClientOptions.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Models/Options/ClientOptions.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Models/Options/VertexAIAnthropicClientOptions.cs rename dotnet/src/Connectors/Connectors.Anthropic/{ => Models/Settings}/AnthropicPromptExecutionSettings.cs (70%) create mode 100644 dotnet/src/IntegrationTests/Connectors/Anthropic/AnthropicChatCompletionTests.cs create mode 100644 dotnet/src/IntegrationTests/Connectors/Anthropic/TestBase.cs rename dotnet/src/IntegrationTests/Connectors/Google/{TestsBase.cs => TestBase.cs} (97%) diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/AnthropicToolCallBehaviorTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/AnthropicToolCallBehaviorTests.cs deleted file mode 100644 index ed881a793c05..000000000000 --- a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/AnthropicToolCallBehaviorTests.cs +++ /dev/null @@ -1,222 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Connectors.Anthropic; -using Microsoft.SemanticKernel.Connectors.Anthropic.Core; -using Xunit; - -namespace SemanticKernel.Connectors.Anthropic.UnitTests; - -/// -/// Unit tests for -/// -public sealed class AnthropicToolCallBehaviorTests -{ - [Fact] - public void EnableKernelFunctionsReturnsCorrectKernelFunctionsInstance() - { - // Arrange & Act - var behavior = AnthropicToolCallBehavior.EnableKernelFunctions; - - // Assert - Assert.IsType(behavior); - Assert.Equal(0, behavior.MaximumAutoInvokeAttempts); - } - - [Fact] - public void AutoInvokeKernelFunctionsReturnsCorrectKernelFunctionsInstance() - { - // Arrange & Act - var behavior = AnthropicToolCallBehavior.AutoInvokeKernelFunctions; - - // Assert - Assert.IsType(behavior); - Assert.Equal(5, behavior.MaximumAutoInvokeAttempts); - } - - [Fact] - public void EnableFunctionsReturnsEnabledFunctionsInstance() - { - // Arrange & Act - List functions = - [new AnthropicFunction("Plugin", "Function", "description", [], null)]; - var behavior = AnthropicToolCallBehavior.EnableFunctions(functions); - - // Assert - Assert.IsType(behavior); - } - - [Fact] - public void KernelFunctionsConfigureClaudeRequestWithNullKernelDoesNotAddTools() - { - // Arrange - var kernelFunctions = new AnthropicToolCallBehavior.KernelFunctions(autoInvoke: false); - var claudeRequest = new AnthropicRequest(); - - // Act - kernelFunctions.ConfigureClaudeRequest(null, claudeRequest); - - // Assert - Assert.Null(claudeRequest.Tools); - } - - [Fact] - public void KernelFunctionsConfigureClaudeRequestWithoutFunctionsDoesNotAddTools() - { - // Arrange - var kernelFunctions = new AnthropicToolCallBehavior.KernelFunctions(autoInvoke: false); - var claudeRequest = new AnthropicRequest(); - var kernel = Kernel.CreateBuilder().Build(); - - // Act - kernelFunctions.ConfigureClaudeRequest(kernel, claudeRequest); - - // Assert - Assert.Null(claudeRequest.Tools); - } - - [Fact] - public void KernelFunctionsConfigureClaudeRequestWithFunctionsAddsTools() - { - // Arrange - var kernelFunctions = new AnthropicToolCallBehavior.KernelFunctions(autoInvoke: false); - var claudeRequest = new AnthropicRequest(); - var kernel = Kernel.CreateBuilder().Build(); - var plugin = GetTestPlugin(); - kernel.Plugins.Add(plugin); - - // Act - kernelFunctions.ConfigureClaudeRequest(kernel, claudeRequest); - - // Assert - AssertFunctions(claudeRequest); - } - - [Fact] - public void EnabledFunctionsConfigureClaudeRequestWithoutFunctionsDoesNotAddTools() - { - // Arrange - var enabledFunctions = new AnthropicToolCallBehavior.EnabledFunctions([], autoInvoke: false); - var claudeRequest = new AnthropicRequest(); - - // Act - enabledFunctions.ConfigureClaudeRequest(null, claudeRequest); - - // Assert - Assert.Null(claudeRequest.Tools); - } - - [Fact] - public void EnabledFunctionsConfigureClaudeRequestWithAutoInvokeAndNullKernelThrowsException() - { - // Arrange - var functions = GetTestPlugin().GetFunctionsMetadata().Select(function => AnthropicKernelFunctionMetadataExtensions.ToClaudeFunction(function)); - var enabledFunctions = new AnthropicToolCallBehavior.EnabledFunctions(functions, autoInvoke: true); - var claudeRequest = new AnthropicRequest(); - - // Act & Assert - var exception = Assert.Throws(() => enabledFunctions.ConfigureClaudeRequest(null, claudeRequest)); - Assert.Equal( - $"Auto-invocation with {nameof(AnthropicToolCallBehavior.EnabledFunctions)} is not supported when no kernel is provided.", - exception.Message); - } - - [Fact] - public void EnabledFunctionsConfigureClaudeRequestWithAutoInvokeAndEmptyKernelThrowsException() - { - // Arrange - var functions = GetTestPlugin().GetFunctionsMetadata().Select(function => function.ToClaudeFunction()); - var enabledFunctions = new AnthropicToolCallBehavior.EnabledFunctions(functions, autoInvoke: true); - var claudeRequest = new AnthropicRequest(); - var kernel = Kernel.CreateBuilder().Build(); - - // Act & Assert - var exception = Assert.Throws(() => enabledFunctions.ConfigureClaudeRequest(kernel, claudeRequest)); - Assert.Equal( - $"The specified {nameof(AnthropicToolCallBehavior.EnabledFunctions)} function MyPlugin{AnthropicFunction.NameSeparator}MyFunction is not available in the kernel.", - exception.Message); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void EnabledFunctionsConfigureClaudeRequestWithKernelAndPluginsAddsTools(bool autoInvoke) - { - // Arrange - var plugin = GetTestPlugin(); - var functions = plugin.GetFunctionsMetadata().Select(function => function.ToClaudeFunction()); - var enabledFunctions = new AnthropicToolCallBehavior.EnabledFunctions(functions, autoInvoke); - var claudeRequest = new AnthropicRequest(); - var kernel = Kernel.CreateBuilder().Build(); - - kernel.Plugins.Add(plugin); - - // Act - enabledFunctions.ConfigureClaudeRequest(kernel, claudeRequest); - - // Assert - AssertFunctions(claudeRequest); - } - - [Fact] - public void EnabledFunctionsCloneReturnsCorrectClone() - { - // Arrange - var functions = GetTestPlugin().GetFunctionsMetadata().Select(function => function.ToClaudeFunction()); - var toolcallbehavior = new AnthropicToolCallBehavior.EnabledFunctions(functions, autoInvoke: true); - - // Act - var clone = toolcallbehavior.Clone(); - - // Assert - Assert.IsType(clone); - Assert.NotSame(toolcallbehavior, clone); - Assert.Equivalent(toolcallbehavior, clone, strict: true); - } - - [Fact] - public void KernelFunctionsCloneReturnsCorrectClone() - { - // Arrange - var functions = GetTestPlugin().GetFunctionsMetadata().Select(function => function.ToClaudeFunction()); - var toolcallbehavior = new AnthropicToolCallBehavior.KernelFunctions(autoInvoke: true); - - // Act - var clone = toolcallbehavior.Clone(); - - // Assert - Assert.IsType(clone); - Assert.NotSame(toolcallbehavior, clone); - Assert.Equivalent(toolcallbehavior, clone, strict: true); - } - - private static KernelPlugin GetTestPlugin() - { - var function = KernelFunctionFactory.CreateFromMethod( - (string parameter1, string parameter2) => "Result1", - "MyFunction", - "Test Function", - [new KernelParameterMetadata("parameter1"), new KernelParameterMetadata("parameter2")], - new KernelReturnParameterMetadata { ParameterType = typeof(string), Description = "Function Result" }); - - return KernelPluginFactory.CreateFromFunctions("MyPlugin", [function]); - } - - private static void AssertFunctions(AnthropicRequest request) - { - Assert.NotNull(request.Tools); - Assert.Single(request.Tools); - - var function = request.Tools[0]; - - Assert.NotNull(function); - - Assert.Equal($"MyPlugin{AnthropicFunction.NameSeparator}MyFunction", function.Name); - Assert.Equal("Test Function", function.Description); - Assert.Equal("""{"type":"object","required":[],"properties":{"parameter1":{"type":"string"},"parameter2":{"type":"string"}}}""", - JsonSerializer.Serialize(function.Parameters)); - } -} diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicChatGenerationTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicChatGenerationTests.cs new file mode 100644 index 000000000000..7b9ce14ad150 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicChatGenerationTests.cs @@ -0,0 +1,478 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Anthropic; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core; +using Microsoft.SemanticKernel.Http; +using Xunit; + +namespace SemanticKernel.Connectors.Anthropic.UnitTests.Core; + +/// +/// Test for +/// +public sealed class AnthropicClientChatGenerationTests : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly HttpMessageHandlerStub _messageHandlerStub; + private const string ChatTestDataFilePath = "./TestData/chat_one_response.json"; + + public AnthropicClientChatGenerationTests() + { + this._messageHandlerStub = new HttpMessageHandlerStub(); + this._messageHandlerStub.ResponseToReturn.Content = new StringContent( + File.ReadAllText(ChatTestDataFilePath)); + + this._httpClient = new HttpClient(this._messageHandlerStub, false); + } + + [Fact] + public async Task ShouldPassModelIdToRequestContentAsync() + { + // Arrange + string modelId = "fake-model234"; + var client = this.CreateChatCompletionClient(modelId: modelId); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.GenerateChatMessageAsync(chatHistory); + + // Assert + AnthropicRequest? request = Deserialize(this._messageHandlerStub.RequestContent); + Assert.NotNull(request); + Assert.Contains(modelId, request.ModelId, StringComparison.Ordinal); + } + + [Fact] + public async Task ShouldContainRolesInRequestAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.GenerateChatMessageAsync(chatHistory); + + // Assert + AnthropicRequest? request = Deserialize(this._messageHandlerStub.RequestContent); + Assert.NotNull(request); + Assert.Collection(request.Messages, + item => Assert.Equal(chatHistory[1].Role, item.Role), + item => Assert.Equal(chatHistory[2].Role, item.Role), + item => Assert.Equal(chatHistory[3].Role, item.Role)); + } + + [Fact] + public async Task ShouldContainMessagesInRequestAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.GenerateChatMessageAsync(chatHistory); + + // Assert + AnthropicRequest? request = Deserialize(this._messageHandlerStub.RequestContent); + Assert.NotNull(request); + Assert.Collection(request.Messages, + item => Assert.Equal(chatHistory[1].Content, GetTextFrom(item.Contents[0])), + item => Assert.Equal(chatHistory[2].Content, GetTextFrom(item.Contents[0])), + item => Assert.Equal(chatHistory[3].Content, GetTextFrom(item.Contents[0]))); + + string? GetTextFrom(AnthropicContent content) => ((AnthropicContent)content).Text; + } + + [Fact] + public async Task ShouldReturnValidChatResponseAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + var response = await client.GenerateChatMessageAsync(chatHistory); + + // Assert + Assert.NotNull(response); + Assert.Equal("Hi! My name is Claude.", response[0].Content); + Assert.Equal(AuthorRole.Assistant, response[0].Role); + } + + [Fact] + public async Task ShouldReturnValidAnthropicMetadataAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + var chatMessageContents = await client.GenerateChatMessageAsync(chatHistory); + + // Assert + AnthropicResponse response = Deserialize( + await File.ReadAllTextAsync(ChatTestDataFilePath))!; + var textContent = chatMessageContents.SingleOrDefault(); + Assert.NotNull(textContent); + var metadata = textContent.Metadata as AnthropicMetadata; + Assert.NotNull(metadata); + Assert.Equal(response.StopReason, metadata.FinishReason); + Assert.Equal(response.Id, metadata.MessageId); + Assert.Equal(response.StopSequence, metadata.StopSequence); + Assert.Equal(response.Usage.InputTokens, metadata.InputTokenCount); + Assert.Equal(response.Usage.OutputTokens, metadata.OutputTokenCount); + } + + [Fact] + public async Task ShouldReturnValidDictionaryMetadataAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + var chatMessageContents = await client.GenerateChatMessageAsync(chatHistory); + + // Assert + AnthropicResponse response = Deserialize( + await File.ReadAllTextAsync(ChatTestDataFilePath))!; + var textContent = chatMessageContents.SingleOrDefault(); + Assert.NotNull(textContent); + var metadata = textContent.Metadata; + Assert.NotNull(metadata); + Assert.Equal(response.StopReason, metadata[nameof(AnthropicMetadata.FinishReason)]); + Assert.Equal(response.Id, metadata[nameof(AnthropicMetadata.MessageId)]); + Assert.Equal(response.StopSequence, metadata[nameof(AnthropicMetadata.StopSequence)]); + Assert.Equal(response.Usage.InputTokens, metadata[nameof(AnthropicMetadata.InputTokenCount)]); + Assert.Equal(response.Usage.OutputTokens, metadata[nameof(AnthropicMetadata.OutputTokenCount)]); + } + + [Fact] + public async Task ShouldReturnResponseWithModelIdAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + var chatMessageContents = await client.GenerateChatMessageAsync(chatHistory); + + // Assert + var response = Deserialize( + await File.ReadAllTextAsync(ChatTestDataFilePath))!; + var chatMessageContent = chatMessageContents.SingleOrDefault(); + Assert.NotNull(chatMessageContent); + Assert.Equal(response.ModelId, chatMessageContent.ModelId); + } + + [Fact] + public async Task ShouldUsePromptExecutionSettingsAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + var executionSettings = new AnthropicPromptExecutionSettings() + { + MaxTokens = 102, + Temperature = 0.45, + TopP = 0.6f + }; + + // Act + await client.GenerateChatMessageAsync(chatHistory, executionSettings: executionSettings); + + // Assert + var request = Deserialize(this._messageHandlerStub.RequestContent); + Assert.NotNull(request); + Assert.Equal(executionSettings.MaxTokens, request.MaxTokens); + Assert.Equal(executionSettings.Temperature, request.Temperature); + Assert.Equal(executionSettings.TopP, request.TopP); + } + + [Fact] + public async Task ShouldThrowInvalidOperationExceptionIfChatHistoryContainsOnlySystemMessageAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = new ChatHistory("System message"); + + // Act & Assert + await Assert.ThrowsAsync( + () => client.GenerateChatMessageAsync(chatHistory)); + } + + [Fact] + public async Task ShouldThrowInvalidOperationExceptionIfChatHistoryContainsOnlyManySystemMessagesAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = new ChatHistory("System message"); + chatHistory.AddSystemMessage("System message 2"); + chatHistory.AddSystemMessage("System message 3"); + + // Act & Assert + await Assert.ThrowsAsync( + () => client.GenerateChatMessageAsync(chatHistory)); + } + + [Fact] + public async Task ShouldPassSystemMessageToRequestAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + string[] messages = ["System message", "System message 2"]; + var chatHistory = new ChatHistory(messages[0]); + chatHistory.AddSystemMessage(messages[1]); + chatHistory.AddUserMessage("Hello"); + + // Act + await client.GenerateChatMessageAsync(chatHistory); + + // Assert + AnthropicRequest? request = Deserialize(this._messageHandlerStub.RequestContent); + Assert.NotNull(request); + Assert.NotNull(request.SystemPrompt); + Assert.All(messages, msg => Assert.Contains(msg, request.SystemPrompt, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task ShouldPassVersionToRequestBodyIfCustomHandlerUsedAsync() + { + // Arrange + var options = new AnthropicClientOptions(); + var client = new AnthropicClient("fake-model", "api-key", options: new(), httpClient: this._httpClient); + + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.GenerateChatMessageAsync(chatHistory); + + // Assert + AnthropicRequest? request = Deserialize(this._messageHandlerStub.RequestContent); + Assert.NotNull(request); + Assert.Equal(options.Version, request.Version); + } + + [Fact] + public async Task ShouldThrowArgumentExceptionIfChatHistoryIsEmptyAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = new ChatHistory(); + + // Act & Assert + await Assert.ThrowsAsync( + () => client.GenerateChatMessageAsync(chatHistory)); + } + + [Theory] + [InlineData(0)] + [InlineData(-15)] + public async Task ShouldThrowArgumentExceptionIfExecutionSettingMaxTokensIsLessThanOneAsync(int? maxTokens) + { + // Arrange + var client = this.CreateChatCompletionClient(); + AnthropicPromptExecutionSettings executionSettings = new() + { + MaxTokens = maxTokens + }; + + // Act & Assert + await Assert.ThrowsAsync( + () => client.GenerateChatMessageAsync(CreateSampleChatHistory(), executionSettings: executionSettings)); + } + + [Fact] + public async Task ItCreatesPostRequestAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.GenerateChatMessageAsync(chatHistory); + + // Assert + Assert.Equal(HttpMethod.Post, this._messageHandlerStub.Method); + } + + [Fact] + public async Task ItCreatesRequestWithValidUserAgentAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.GenerateChatMessageAsync(chatHistory); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestHeaders); + Assert.Equal(HttpHeaderConstant.Values.UserAgent, this._messageHandlerStub.RequestHeaders.UserAgent.ToString()); + } + + [Fact] + public async Task ItCreatesRequestWithSemanticKernelVersionHeaderAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + var expectedVersion = HttpHeaderConstant.Values.GetAssemblyVersion(typeof(AnthropicClient)); + + // Act + await client.GenerateChatMessageAsync(chatHistory); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestHeaders); + var header = this._messageHandlerStub.RequestHeaders.GetValues(HttpHeaderConstant.Names.SemanticKernelVersion).SingleOrDefault(); + Assert.NotNull(header); + Assert.Equal(expectedVersion, header); + } + + [Fact] + public async Task ItCreatesRequestWithValidAnthropicVersionAsync() + { + // Arrange + var options = new AnthropicClientOptions(); + var client = this.CreateChatCompletionClient(options: options); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.GenerateChatMessageAsync(chatHistory); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestHeaders); + Assert.Equal(options.Version, this._messageHandlerStub.RequestHeaders.GetValues("anthropic-version").SingleOrDefault()); + } + + [Fact] + public async Task ItCreatesRequestWithValidApiKeyAsync() + { + // Arrange + string apiKey = "fake-claude-key"; + var client = this.CreateChatCompletionClient(apiKey: apiKey); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.GenerateChatMessageAsync(chatHistory); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestHeaders); + Assert.Equal(apiKey, this._messageHandlerStub.RequestHeaders.GetValues("x-api-key").SingleOrDefault()); + } + + [Fact] + public async Task ItCreatesRequestWithJsonContentTypeAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.GenerateChatMessageAsync(chatHistory); + + // Assert + Assert.NotNull(this._messageHandlerStub.ContentHeaders); + Assert.NotNull(this._messageHandlerStub.ContentHeaders.ContentType); + Assert.Contains("application/json", this._messageHandlerStub.ContentHeaders.ContentType.ToString()); + } + + [Theory] + [InlineData("custom-header", "custom-value")] + public async Task ItCreatesRequestWithCustomUriAndCustomHeadersAsync(string headerName, string headerValue) + { + // Arrange + Uri uri = new("https://fake-uri.com"); + using var httpHandler = new CustomHeadersHandler(headerName, headerValue); + using var httpClient = new HttpClient(httpHandler); + httpClient.BaseAddress = uri; + var client = new AnthropicClient("fake-model", "api-key", options: new(), httpClient: httpClient); + + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.GenerateChatMessageAsync(chatHistory); + + // Assert + Assert.Equal(uri, httpHandler.RequestUri); + Assert.NotNull(httpHandler.RequestHeaders); + Assert.Equal(headerValue, httpHandler.RequestHeaders.GetValues(headerName).SingleOrDefault()); + } + + private static ChatHistory CreateSampleChatHistory() + { + var chatHistory = new ChatHistory("You are a chatbot"); + chatHistory.AddUserMessage("Hello"); + chatHistory.AddAssistantMessage("Hi"); + chatHistory.AddUserMessage("How are you?"); + return chatHistory; + } + + private AnthropicClient CreateChatCompletionClient( + string modelId = "fake-model", + string? apiKey = null, + AnthropicClientOptions? options = null, + HttpClient? httpClient = null) + { + return new AnthropicClient(modelId, apiKey ?? "fake-key", options: new(), httpClient: this._httpClient); + } + + private static T? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + private static T? Deserialize(ReadOnlySpan json) + { + return JsonSerializer.Deserialize(json); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } + + private sealed class CustomHeadersHandler : DelegatingHandler + { + private readonly string _headerName; + private readonly string _headerValue; + public HttpRequestHeaders? RequestHeaders { get; private set; } + + public HttpContentHeaders? ContentHeaders { get; private set; } + + public byte[]? RequestContent { get; private set; } + + public Uri? RequestUri { get; private set; } + + public HttpMethod? Method { get; private set; } + + public CustomHeadersHandler(string headerName, string headerValue) + { + this.InnerHandler = new HttpMessageHandlerStub + { + ResponseToReturn = { Content = new StringContent(File.ReadAllText(ChatTestDataFilePath)) } + }; + this._headerName = headerName; + this._headerValue = headerValue; + } + + protected override Task SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) + { + request.Headers.Add(this._headerName, this._headerValue); + this.Method = request.Method; + this.RequestUri = request.RequestUri; + this.RequestHeaders = request.Headers; + this.RequestContent = request.Content is null ? null : request.Content.ReadAsByteArrayAsync(cancellationToken).Result; + + return base.SendAsync(request, cancellationToken); + } + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicRequestTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicRequestTests.cs index fbc05591c9c1..d7925f4652bd 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicRequestTests.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicRequestTests.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text.Json.Nodes; +using System.Text; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.Anthropic; @@ -15,7 +15,7 @@ namespace SemanticKernel.Connectors.Anthropic.UnitTests.Core; public sealed class AnthropicRequestTests { [Fact] - public void FromChatHistoryItReturnsClaudeRequestWithConfiguration() + public void FromChatHistoryItReturnsWithConfiguration() { // Arrange ChatHistory chatHistory = []; @@ -42,7 +42,7 @@ public void FromChatHistoryItReturnsClaudeRequestWithConfiguration() [Theory] [InlineData(false)] [InlineData(true)] - public void FromChatHistoryItReturnsClaudeRequestWithValidStreamingMode(bool streamMode) + public void FromChatHistoryItReturnsWithValidStreamingMode(bool streamMode) { // Arrange ChatHistory chatHistory = []; @@ -65,7 +65,7 @@ public void FromChatHistoryItReturnsClaudeRequestWithValidStreamingMode(bool str } [Fact] - public void FromChatHistoryItReturnsClaudeRequestWithChatHistory() + public void FromChatHistoryItReturnsWithChatHistory() { // Arrange ChatHistory chatHistory = []; @@ -82,11 +82,11 @@ public void FromChatHistoryItReturnsClaudeRequestWithChatHistory() var request = AnthropicRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); // Assert - Assert.All(request.Messages, c => Assert.IsType(c.Contents[0])); + Assert.All(request.Messages, c => Assert.IsType(c.Contents[0])); Assert.Collection(request.Messages, - c => Assert.Equal(chatHistory[0].Content, ((AnthropicTextContent)c.Contents[0]).Text), - c => Assert.Equal(chatHistory[1].Content, ((AnthropicTextContent)c.Contents[0]).Text), - c => Assert.Equal(chatHistory[2].Content, ((AnthropicTextContent)c.Contents[0]).Text)); + c => Assert.Equal(chatHistory[0].Content, ((AnthropicContent)c.Contents[0]).Text), + c => Assert.Equal(chatHistory[1].Content, ((AnthropicContent)c.Contents[0]).Text), + c => Assert.Equal(chatHistory[2].Content, ((AnthropicContent)c.Contents[0]).Text)); Assert.Collection(request.Messages, c => Assert.Equal(chatHistory[0].Role, c.Role), c => Assert.Equal(chatHistory[1].Role, c.Role), @@ -94,7 +94,7 @@ public void FromChatHistoryItReturnsClaudeRequestWithChatHistory() } [Fact] - public void FromChatHistoryTextAsTextContentItReturnsClaudeRequestWithChatHistory() + public void FromChatHistoryTextAsTextContentItReturnsWithChatHistory() { // Arrange ChatHistory chatHistory = []; @@ -111,15 +111,15 @@ public void FromChatHistoryTextAsTextContentItReturnsClaudeRequestWithChatHistor var request = AnthropicRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); // Assert - Assert.All(request.Messages, c => Assert.IsType(c.Contents[0])); + Assert.All(request.Messages, c => Assert.IsType(c.Contents[0])); Assert.Collection(request.Messages, - c => Assert.Equal(chatHistory[0].Content, ((AnthropicTextContent)c.Contents[0]).Text), - c => Assert.Equal(chatHistory[1].Content, ((AnthropicTextContent)c.Contents[0]).Text), - c => Assert.Equal(chatHistory[2].Items.Cast().Single().Text, ((AnthropicTextContent)c.Contents[0]).Text)); + c => Assert.Equal(chatHistory[0].Content, ((AnthropicContent)c.Contents[0]).Text), + c => Assert.Equal(chatHistory[1].Content, ((AnthropicContent)c.Contents[0]).Text), + c => Assert.Equal(chatHistory[2].Items.Cast().Single().Text, ((AnthropicContent)c.Contents[0]).Text)); } [Fact] - public void FromChatHistoryImageAsImageContentItReturnsClaudeRequestWithChatHistory() + public void FromChatHistoryImageAsImageContentItReturnsWithChatHistory() { // Arrange ReadOnlyMemory imageAsBytes = new byte[] { 0x00, 0x01, 0x02, 0x03 }; @@ -139,16 +139,16 @@ public void FromChatHistoryImageAsImageContentItReturnsClaudeRequestWithChatHist // Assert Assert.Collection(request.Messages, - c => Assert.IsType(c.Contents[0]), - c => Assert.IsType(c.Contents[0]), - c => Assert.IsType(c.Contents[0])); + c => Assert.IsType(c.Contents[0]), + c => Assert.IsType(c.Contents[0]), + c => Assert.IsType(c.Contents[0])); Assert.Collection(request.Messages, - c => Assert.Equal(chatHistory[0].Content, ((AnthropicTextContent)c.Contents[0]).Text), - c => Assert.Equal(chatHistory[1].Content, ((AnthropicTextContent)c.Contents[0]).Text), + c => Assert.Equal(chatHistory[0].Content, ((AnthropicContent)c.Contents[0]).Text), + c => Assert.Equal(chatHistory[1].Content, ((AnthropicContent)c.Contents[0]).Text), c => { - Assert.Equal(chatHistory[2].Items.Cast().Single().MimeType, ((AnthropicImageContent)c.Contents[0]).Source.MediaType); - Assert.True(imageAsBytes.ToArray().SequenceEqual(Convert.FromBase64String(((AnthropicImageContent)c.Contents[0]).Source.Data))); + Assert.Equal(chatHistory[2].Items.Cast().Single().MimeType, ((AnthropicContent)c.Contents[0]).Source!.MediaType); + Assert.True(imageAsBytes.ToArray().SequenceEqual(Convert.FromBase64String(((AnthropicContent)c.Contents[0]).Source!.Data!))); }); } @@ -174,135 +174,56 @@ public void FromChatHistoryUnsupportedContentItThrowsNotSupportedException() } [Fact] - public void AddFunctionItAddsFunctionToClaudeRequest() + public void FromChatHistoryItReturnsWithSystemMessages() { // Arrange - var request = new AnthropicRequest(); - var function = new AnthropicFunction("function-name", "function-description", "desc", null, null); - - // Act - request.AddFunction(function); - - // Assert - Assert.NotNull(request.Tools); - Assert.Collection(request.Tools, - func => Assert.Equivalent(function.ToFunctionDeclaration(), func, strict: true)); - } - - [Fact] - public void AddMultipleFunctionsItAddsFunctionsToClaudeRequest() - { - // Arrange - var request = new AnthropicRequest(); - var functions = new[] + string[] systemMessages = ["system-message1", "system-message2", "system-message3", "system-message4"]; + ChatHistory chatHistory = new(systemMessages[0]); + chatHistory.AddSystemMessage(systemMessages[1]); + chatHistory.Add(new ChatMessageContent(AuthorRole.System, + items: [new TextContent(systemMessages[2]), new TextContent(systemMessages[3])])); + chatHistory.AddUserMessage("user-message"); + var executionSettings = new AnthropicPromptExecutionSettings { - new AnthropicFunction("function-name", "function-description", "desc", null, null), - new AnthropicFunction("function-name2", "function-description2", "desc2", null, null) + ModelId = "claude", + MaxTokens = 128, }; - // Act - request.AddFunction(functions[0]); - request.AddFunction(functions[1]); - - // Assert - Assert.NotNull(request.Tools); - Assert.Collection(request.Tools, - func => Assert.Equivalent(functions[0].ToFunctionDeclaration(), func, strict: true), - func => Assert.Equivalent(functions[1].ToFunctionDeclaration(), func, strict: true)); - } - - [Fact] - public void FromChatHistoryCalledToolNotNullAddsFunctionResponse() - { - // Arrange - ChatHistory chatHistory = []; - var kvp = KeyValuePair.Create("sampleKey", "sampleValue"); - var expectedArgs = new JsonObject { [kvp.Key] = kvp.Value }; - var kernelFunction = KernelFunctionFactory.CreateFromMethod(() => ""); - var functionResult = new FunctionResult(kernelFunction, expectedArgs); - var toolCall = new AnthropicFunctionToolCall(new AnthropicToolCallContent { ToolId = "any uid", FunctionName = "function-name" }); - AnthropicFunctionToolResult toolCallResult = new(toolCall, functionResult, toolCall.ToolUseId); - chatHistory.Add(new AnthropicChatMessageContent(AuthorRole.Assistant, string.Empty, "modelId", toolCallResult)); - var executionSettings = new AnthropicPromptExecutionSettings { ModelId = "model-id", MaxTokens = 128 }; - // Act var request = AnthropicRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); // Assert - Assert.Single(request.Messages, - c => c.Role == AuthorRole.Assistant); - Assert.Single(request.Messages, - c => c.Contents[0] is AnthropicToolResultContent); - Assert.Single(request.Messages, - c => c.Contents[0] is AnthropicToolResultContent toolResult - && string.Equals(toolResult.ToolId, toolCallResult.ToolUseId, StringComparison.Ordinal) - && toolResult.Content is AnthropicTextContent textContent - && string.Equals(functionResult.ToString(), textContent.Text, StringComparison.Ordinal)); - } - - [Fact] - public void FromChatHistoryToolCallsNotNullAddsFunctionCalls() - { - // Arrange - ChatHistory chatHistory = []; - var kvp = KeyValuePair.Create("sampleKey", "sampleValue"); - var expectedArgs = new JsonObject { [kvp.Key] = kvp.Value }; - var toolCallPart = new AnthropicToolCallContent - { ToolId = "any uid1", FunctionName = "function-name", Arguments = expectedArgs }; - var toolCallPart2 = new AnthropicToolCallContent - { ToolId = "any uid2", FunctionName = "function2-name", Arguments = expectedArgs }; - chatHistory.Add(new AnthropicChatMessageContent(AuthorRole.Assistant, "tool-message", "model-id", functionsToolCalls: [toolCallPart])); - chatHistory.Add(new AnthropicChatMessageContent(AuthorRole.Assistant, "tool-message2", "model-id2", functionsToolCalls: [toolCallPart2])); - var executionSettings = new AnthropicPromptExecutionSettings { ModelId = "model-id", MaxTokens = 128 }; - - // Act - var request = AnthropicRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); - // Assert - Assert.Collection(request.Messages, - c => Assert.Equal(chatHistory[0].Role, c.Role), - c => Assert.Equal(chatHistory[1].Role, c.Role)); - Assert.Collection(request.Messages, - c => Assert.IsType(c.Contents[0]), - c => Assert.IsType(c.Contents[0])); - Assert.Collection(request.Messages, - c => - { - Assert.Equal(((AnthropicToolCallContent)c.Contents[0]).FunctionName, toolCallPart.FunctionName); - Assert.Equal(((AnthropicToolCallContent)c.Contents[0]).ToolId, toolCallPart.ToolId); - }, - c => - { - Assert.Equal(((AnthropicToolCallContent)c.Contents[0]).FunctionName, toolCallPart2.FunctionName); - Assert.Equal(((AnthropicToolCallContent)c.Contents[0]).ToolId, toolCallPart2.ToolId); - }); - Assert.Collection(request.Messages, - c => Assert.Equal(expectedArgs.ToJsonString(), - ((AnthropicToolCallContent)c.Contents[0]).Arguments!.ToJsonString()), - c => Assert.Equal(expectedArgs.ToJsonString(), - ((AnthropicToolCallContent)c.Contents[0]).Arguments!.ToJsonString())); + Assert.NotNull(request.SystemPrompt); + Assert.All(systemMessages, msg => Assert.Contains(msg, request.SystemPrompt, StringComparison.OrdinalIgnoreCase)); } [Fact] - public void AddChatMessageToRequestItAddsChatMessageToGeminiRequest() + public void AddChatMessageToRequestItAddsChatMessage() { // Arrange ChatHistory chat = []; var request = AnthropicRequest.FromChatHistoryAndExecutionSettings(chat, new AnthropicPromptExecutionSettings { ModelId = "model-id", MaxTokens = 128 }); - var message = new AnthropicChatMessageContent(AuthorRole.User, "user-message", "model-id"); + var message = new AnthropicChatMessageContent + { + Role = AuthorRole.User, + Items = [new TextContent("user-message")], + ModelId = "model-id", + Encoding = Encoding.UTF8 + }; // Act request.AddChatMessage(message); // Assert Assert.Single(request.Messages, - c => c.Contents[0] is AnthropicTextContent content && string.Equals(message.Content, content.Text, StringComparison.Ordinal)); + c => c.Contents[0] is AnthropicContent content && string.Equals(message.Content, content.Text, StringComparison.Ordinal)); Assert.Single(request.Messages, c => Equals(message.Role, c.Role)); } - private sealed class DummyContent : KernelContent - { - public DummyContent(object? innerContent, string? modelId = null, IReadOnlyDictionary? metadata = null) - : base(innerContent, modelId, metadata) { } - } + private sealed class DummyContent( + object? innerContent, + string? modelId = null, + IReadOnlyDictionary? metadata = null) + : KernelContent(innerContent, modelId, metadata); } diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Extensions/AnthropicServiceCollectionExtensionsTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Extensions/AnthropicServiceCollectionExtensionsTests.cs index 69b79a5d9283..06622e2371dc 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Extensions/AnthropicServiceCollectionExtensionsTests.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Extensions/AnthropicServiceCollectionExtensionsTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; @@ -22,6 +23,7 @@ public void AnthropicChatCompletionServiceShouldBeRegisteredInKernelServices() // Act kernelBuilder.AddAnthropicChatCompletion("modelId", "apiKey"); + var kernel = kernelBuilder.Build(); // Assert @@ -53,7 +55,8 @@ public void AnthropicChatCompletionServiceCustomEndpointShouldBeRegisteredInKern var kernelBuilder = Kernel.CreateBuilder(); // Act - kernelBuilder.AddAnthropicChatCompletion("modelId", new Uri("https://example.com"), null); + kernelBuilder.AddAnthropicVertextAIChatCompletion("modelId", bearerTokenProvider: () => ValueTask.FromResult("token"), endpoint: new Uri("https://example.com")); + var kernel = kernelBuilder.Build(); // Assert @@ -69,7 +72,7 @@ public void AnthropicChatCompletionServiceCustomEndpointShouldBeRegisteredInServ var services = new ServiceCollection(); // Act - services.AddAnthropicChatCompletion("modelId", new Uri("https://example.com"), null); + services.AddAnthropicVertexAIChatCompletion("modelId", () => ValueTask.FromResult("token"), endpoint: new Uri("https://example.com")); var serviceProvider = services.BuildServiceProvider(); // Assert diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Models/AnthropicFunctionTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Models/AnthropicFunctionTests.cs deleted file mode 100644 index 863b058a8c94..000000000000 --- a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Models/AnthropicFunctionTests.cs +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ComponentModel; -using System.Text.Json; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Connectors.Anthropic; -using Xunit; - -namespace SemanticKernel.Connectors.Anthropic.UnitTests.Models; - -public sealed class AnthropicFunctionTests -{ - [Theory] - [InlineData(null, null, "", "")] - [InlineData("name", "description", "name", "description")] - public void ItInitializesClaudeFunctionParameterCorrectly(string? name, string? description, string expectedName, string expectedDescription) - { - // Arrange & Act - var schema = KernelJsonSchema.Parse("""{"type": "object" }"""); - var functionParameter = new ClaudeFunctionParameter(name, description, true, typeof(string), schema); - - // Assert - Assert.Equal(expectedName, functionParameter.Name); - Assert.Equal(expectedDescription, functionParameter.Description); - Assert.True(functionParameter.IsRequired); - Assert.Equal(typeof(string), functionParameter.ParameterType); - Assert.Same(schema, functionParameter.Schema); - } - - [Theory] - [InlineData(null, "")] - [InlineData("description", "description")] - public void ItInitializesClaudeFunctionReturnParameterCorrectly(string? description, string expectedDescription) - { - // Arrange & Act - var schema = KernelJsonSchema.Parse("""{"type": "object" }"""); - var functionParameter = new ClaudeFunctionReturnParameter(description, typeof(string), schema); - - // Assert - Assert.Equal(expectedDescription, functionParameter.Description); - Assert.Equal(typeof(string), functionParameter.ParameterType); - Assert.Same(schema, functionParameter.Schema); - } - - [Fact] - public void ItCanConvertToFunctionDefinitionWithNoPluginName() - { - // Arrange - AnthropicFunction sut = KernelFunctionFactory.CreateFromMethod( - () => { }, "myfunc", "This is a description of the function.").Metadata.ToClaudeFunction(); - - // Act - var result = sut.ToFunctionDeclaration(); - - // Assert - Assert.Equal(sut.FunctionName, result.Name); - Assert.Equal(sut.Description, result.Description); - } - - [Fact] - public void ItCanConvertToFunctionDefinitionWithNullParameters() - { - // Arrange - AnthropicFunction sut = new("plugin", "function", "description", null, null); - - // Act - var result = sut.ToFunctionDeclaration(); - - // Assert - Assert.Null(result.Parameters); - } - - [Fact] - public void ItCanConvertToFunctionDefinitionWithPluginName() - { - // Arrange - AnthropicFunction sut = KernelPluginFactory.CreateFromFunctions("myplugin", new[] - { - KernelFunctionFactory.CreateFromMethod(() => { }, "myfunc", "This is a description of the function.") - }).GetFunctionsMetadata()[0].ToClaudeFunction(); - - // Act - var result = sut.ToFunctionDeclaration(); - - // Assert - Assert.Equal($"myplugin{AnthropicFunction.NameSeparator}myfunc", result.Name); - Assert.Equal(sut.Description, result.Description); - } - - [Fact] - public void ItCanConvertToFunctionDefinitionsWithParameterTypesAndReturnParameterType() - { - string expectedParameterSchema = """ - { "type": "object", - "required": ["param1", "param2"], - "properties": { - "param1": { "type": "string", "description": "String param 1" }, - "param2": { "type": "integer", "description": "Int param 2" } } } - """; - - KernelPlugin plugin = KernelPluginFactory.CreateFromFunctions("Tests", new[] - { - KernelFunctionFactory.CreateFromMethod( - [return: Description("My test Result")] - ([Description("String param 1")] string param1, [Description("Int param 2")] int param2) => "", - "TestFunction", - "My test function") - }); - - AnthropicFunction sut = plugin.GetFunctionsMetadata()[0].ToClaudeFunction(); - - var functionDefinition = sut.ToFunctionDeclaration(); - - Assert.NotNull(functionDefinition); - Assert.Equal($"Tests{AnthropicFunction.NameSeparator}TestFunction", functionDefinition.Name); - Assert.Equal("My test function", functionDefinition.Description); - Assert.Equal(JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedParameterSchema)), - JsonSerializer.Serialize(functionDefinition.Parameters)); - } - - [Fact] - public void ItCanConvertToFunctionDefinitionsWithParameterTypesAndNoReturnParameterType() - { - string expectedParameterSchema = """ - { "type": "object", - "required": ["param1", "param2"], - "properties": { - "param1": { "type": "string", "description": "String param 1" }, - "param2": { "type": "integer", "description": "Int param 2" } } } - """; - - KernelPlugin plugin = KernelPluginFactory.CreateFromFunctions("Tests", new[] - { - KernelFunctionFactory.CreateFromMethod( - [return: Description("My test Result")] - ([Description("String param 1")] string param1, [Description("Int param 2")] int param2) => { }, - "TestFunction", - "My test function") - }); - - AnthropicFunction sut = plugin.GetFunctionsMetadata()[0].ToClaudeFunction(); - - var functionDefinition = sut.ToFunctionDeclaration(); - - Assert.NotNull(functionDefinition); - Assert.Equal($"Tests{AnthropicFunction.NameSeparator}TestFunction", functionDefinition.Name); - Assert.Equal("My test function", functionDefinition.Description); - Assert.Equal(JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedParameterSchema)), - JsonSerializer.Serialize(functionDefinition.Parameters)); - } - - [Fact] - public void ItCanConvertToFunctionDefinitionsWithNoParameterTypes() - { - // Arrange - AnthropicFunction f = KernelFunctionFactory.CreateFromMethod( - () => { }, - parameters: new[] { new KernelParameterMetadata("param1") }).Metadata.ToClaudeFunction(); - - // Act - var result = f.ToFunctionDeclaration(); - - // Assert - Assert.Equal( - """{"type":"object","required":[],"properties":{"param1":{"type":"string"}}}""", - JsonSerializer.Serialize(result.Parameters)); - } - - [Fact] - public void ItCanConvertToFunctionDefinitionsWithNoParameterTypesButWithDescriptions() - { - // Arrange - AnthropicFunction f = KernelFunctionFactory.CreateFromMethod( - () => { }, - parameters: new[] { new KernelParameterMetadata("param1") { Description = "something neat" } }).Metadata.ToClaudeFunction(); - - // Act - var result = f.ToFunctionDeclaration(); - - // Assert - Assert.Equal( - """{"type":"object","required":[],"properties":{"param1":{"type":"string","description":"something neat"}}}""", - JsonSerializer.Serialize(result.Parameters)); - } -} diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Models/AnthropicFunctionToolCallTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Models/AnthropicFunctionToolCallTests.cs deleted file mode 100644 index e178393dac7b..000000000000 --- a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Models/AnthropicFunctionToolCallTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Globalization; -using System.Text.Json.Nodes; -using Microsoft.SemanticKernel.Connectors.Anthropic; -using Microsoft.SemanticKernel.Connectors.Anthropic.Core; -using Xunit; - -namespace SemanticKernel.Connectors.Anthropic.UnitTests.Models; - -/// -/// Unit tests for class. -/// -public sealed class AnthropicFunctionToolCallTests -{ - [Theory] - [InlineData("MyFunction")] - [InlineData("MyPlugin_MyFunction")] - public void FullyQualifiedNameReturnsValidName(string toolCallName) - { - // Arrange - var toolCallPart = new AnthropicToolCallContent { FunctionName = toolCallName }; - var functionToolCall = new AnthropicFunctionToolCall(toolCallPart); - - // Act & Assert - Assert.Equal(toolCallName, functionToolCall.FullyQualifiedName); - } - - [Fact] - public void ArgumentsReturnsCorrectValue() - { - // Arrange - var toolCallPart = new AnthropicToolCallContent - { - FunctionName = "MyPlugin_MyFunction", - Arguments = new JsonObject - { - { "location", "San Diego" }, - { "max_price", 300 } - } - }; - var functionToolCall = new AnthropicFunctionToolCall(toolCallPart); - - // Act & Assert - Assert.NotNull(functionToolCall.Arguments); - Assert.Equal(2, functionToolCall.Arguments.Count); - Assert.Equal("San Diego", functionToolCall.Arguments["location"]!.ToString()); - Assert.Equal(300, - Convert.ToInt32(functionToolCall.Arguments["max_price"]!.ToString(), new NumberFormatInfo())); - } - - [Fact] - public void ToStringReturnsCorrectValue() - { - // Arrange - var toolCallPart = new AnthropicToolCallContent - { - FunctionName = "MyPlugin_MyFunction", - Arguments = new JsonObject - { - { "location", "San Diego" }, - { "max_price", 300 } - } - }; - var functionToolCall = new AnthropicFunctionToolCall(toolCallPart); - - // Act & Assert - Assert.Equal("MyPlugin_MyFunction(location:San Diego, max_price:300)", functionToolCall.ToString()); - } -} diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/TestData/chat_one_response.json b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/TestData/chat_one_response.json new file mode 100644 index 000000000000..ac0e04ce73a8 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/TestData/chat_one_response.json @@ -0,0 +1,18 @@ +{ + "content": [ + { + "text": "Hi! My name is Claude.", + "type": "text" + } + ], + "id": "msg_013Zva2CMHLNnXjNJJKqJ2EF", + "model": "claude-3-5-sonnet-20240620", + "role": "assistant", + "stop_reason": "end_turn", + "stop_sequence": null, + "type": "message", + "usage": { + "input_tokens": 10, + "output_tokens": 25 + } +} \ No newline at end of file diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Utils/AnthropicKernelFunctionMetadataExtensions.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Utils/AnthropicKernelFunctionMetadataExtensions.cs deleted file mode 100644 index 04607cc5b643..000000000000 --- a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Utils/AnthropicKernelFunctionMetadataExtensions.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Connectors.Anthropic; - -namespace SemanticKernel.Connectors.Anthropic.UnitTests; - -/// -/// Extensions for specific to the Claude connector. -/// -public static class AnthropicKernelFunctionMetadataExtensions -{ - /// - /// Convert a to an . - /// - /// The object to convert. - /// An object. - public static AnthropicFunction ToClaudeFunction(this KernelFunctionMetadata metadata) - { - IReadOnlyList metadataParams = metadata.Parameters; - - var openAIParams = new ClaudeFunctionParameter[metadataParams.Count]; - for (int i = 0; i < openAIParams.Length; i++) - { - var param = metadataParams[i]; - - openAIParams[i] = new ClaudeFunctionParameter( - param.Name, - GetDescription(param), - param.IsRequired, - param.ParameterType, - param.Schema); - } - - return new AnthropicFunction( - metadata.PluginName, - metadata.Name, - metadata.Description, - openAIParams, - new ClaudeFunctionReturnParameter( - metadata.ReturnParameter.Description, - metadata.ReturnParameter.ParameterType, - metadata.ReturnParameter.Schema)); - - static string GetDescription(KernelParameterMetadata param) - { - string? stringValue = InternalTypeConverter.ConvertToString(param.DefaultValue); - return !string.IsNullOrEmpty(stringValue) ? $"{param.Description} (default value: {stringValue})" : param.Description; - } - } -} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/AnthropicClientOptions.cs b/dotnet/src/Connectors/Connectors.Anthropic/AnthropicClientOptions.cs deleted file mode 100644 index 1bbcecf1fcae..000000000000 --- a/dotnet/src/Connectors/Connectors.Anthropic/AnthropicClientOptions.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.SemanticKernel.Connectors.Anthropic; - -#pragma warning disable CA1707 // Identifiers should not contain underscores - -/// -/// Represents the options for configuring the Anthropic client. -/// -public sealed class AnthropicClientOptions -{ - private const ServiceVersion LatestVersion = ServiceVersion.V2023_06_01; - - /// The version of the service to use. -#pragma warning disable CA1008 // Enums should have zero value - public enum ServiceVersion -#pragma warning restore CA1008 - { - /// Service version "2023-01-01". - V2023_01_01 = 1, - - /// Service version "2023-06-01". - V2023_06_01 = 2, - } - - internal string Version { get; } - - /// Initializes new instance of OpenAIClientOptions. - public AnthropicClientOptions(ServiceVersion version = LatestVersion) - { - this.Version = version switch - { - ServiceVersion.V2023_01_01 => "2023-01-01", - ServiceVersion.V2023_06_01 => "2023-06-01", - _ => throw new NotSupportedException("Unsupported service version") - }; - } -} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/AnthropicToolCallBehavior.cs b/dotnet/src/Connectors/Connectors.Anthropic/AnthropicToolCallBehavior.cs deleted file mode 100644 index 241a90675c12..000000000000 --- a/dotnet/src/Connectors/Connectors.Anthropic/AnthropicToolCallBehavior.cs +++ /dev/null @@ -1,228 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Linq; -using Microsoft.SemanticKernel.Connectors.Anthropic.Core; - -namespace Microsoft.SemanticKernel.Connectors.Anthropic; - -/// Represents a behavior for Claude tool calls. -public abstract class AnthropicToolCallBehavior -{ - // NOTE: Right now, the only tools that are available are for function calling. In the future, - // this class can be extended to support additional kinds of tools, including composite ones: - // the ClaudePromptExecutionSettings has a single ToolCallBehavior property, but we could - // expose a `public static ToolCallBehavior Composite(params ToolCallBehavior[] behaviors)` - // or the like to allow multiple distinct tools to be provided, should that be appropriate. - // We can also consider additional forms of tools, such as ones that dynamically examine - // the Kernel, KernelArguments, etc., and dynamically contribute tools to the ChatCompletionsOptions. - - /// - /// The default maximum number of tool-call auto-invokes that can be made in a single request. - /// - /// - /// After this number of iterations as part of a single user request is reached, auto-invocation - /// will be disabled (e.g. will behave like )). - /// This is a safeguard against possible runaway execution if the model routinely re-requests - /// the same function over and over. It is currently hardcoded, but in the future it could - /// be made configurable by the developer. Other configuration is also possible in the future, - /// such as a delegate on the instance that can be invoked upon function call failure (e.g. failure - /// to find the requested function, failure to invoke the function, etc.), with behaviors for - /// what to do in such a case, e.g. respond to the model telling it to try again. With parallel tool call - /// support, where the model can request multiple tools in a single response, it is significantly - /// less likely that this limit is reached, as most of the time only a single request is needed. - /// - private const int DefaultMaximumAutoInvokeAttempts = 5; - - /// - /// Gets an instance that will provide all of the 's plugins' function information. - /// Function call requests from the model will be propagated back to the caller. - /// - /// - /// If no is available, no function information will be provided to the model. - /// - public static AnthropicToolCallBehavior EnableKernelFunctions => new KernelFunctions(autoInvoke: false); - - /// - /// Gets an instance that will both provide all of the 's plugins' function information - /// to the model and attempt to automatically handle any function call requests. - /// - /// - /// When successful, tool call requests from the model become an implementation detail, with the service - /// handling invoking any requested functions and supplying the results back to the model. - /// If no is available, no function information will be provided to the model. - /// - public static AnthropicToolCallBehavior AutoInvokeKernelFunctions => new KernelFunctions(autoInvoke: true); - - /// Gets an instance that will provide the specified list of functions to the model. - /// The functions that should be made available to the model. - /// true to attempt to automatically handle function call requests; otherwise, false. - /// - /// The that may be set into - /// to indicate that the specified functions should be made available to the model. - /// - public static AnthropicToolCallBehavior EnableFunctions(IEnumerable functions, bool autoInvoke = false) - { - Verify.NotNull(functions); - return new EnabledFunctions(functions, autoInvoke); - } - - /// Initializes the instance; prevents external instantiation. - private AnthropicToolCallBehavior(bool autoInvoke) - { - this.MaximumAutoInvokeAttempts = autoInvoke ? DefaultMaximumAutoInvokeAttempts : 0; - } - - /// Gets how many requests are part of a single interaction should include this tool in the request. - /// - /// This should be greater than or equal to . It defaults to . - /// Once this limit is reached, the tools will no longer be included in subsequent retries as part of the operation, e.g. - /// if this is 1, the first request will include the tools, but the subsequent response sending back the tool's result - /// will not include the tools for further use. - /// - public int MaximumUseAttempts { get; } = int.MaxValue; - - /// Gets how many tool call request/response roundtrips are supported with auto-invocation. - /// - /// To disable auto invocation, this can be set to 0. - /// - public int MaximumAutoInvokeAttempts { get; } - - /// - /// Gets whether validation against a specified list is required before allowing the model to request a function from the kernel. - /// - /// true if it's ok to invoke any kernel function requested by the model if it's found; - /// false if a request needs to be validated against an allow list. - internal virtual bool AllowAnyRequestedKernelFunction => false; - - /// Configures the with any tools this provides. - /// The used for the operation. - /// This can be queried to determine what tools to provide into the . - /// The destination to configure. - internal abstract void ConfigureClaudeRequest(Kernel? kernel, AnthropicRequest request); - - internal AnthropicToolCallBehavior Clone() - { - return (AnthropicToolCallBehavior)this.MemberwiseClone(); - } - - /// - /// Represents a that will provide to the model all available functions from a - /// provided by the client. - /// - internal sealed class KernelFunctions : AnthropicToolCallBehavior - { - internal KernelFunctions(bool autoInvoke) : base(autoInvoke) { } - - public override string ToString() => $"{nameof(KernelFunctions)}(autoInvoke:{this.MaximumAutoInvokeAttempts != 0})"; - - internal override void ConfigureClaudeRequest(Kernel? kernel, AnthropicRequest request) - { - // If no kernel is provided, we don't have any tools to provide. - if (kernel is null) - { - return; - } - - // Provide all functions from the kernel. - foreach (var functionMetadata in kernel.Plugins.GetFunctionsMetadata()) - { - request.AddFunction(FunctionMetadataAsClaudeFunction(functionMetadata)); - } - } - - internal override bool AllowAnyRequestedKernelFunction => true; - - /// - /// Convert a to an . - /// - /// The object to convert. - /// An object. - private static AnthropicFunction FunctionMetadataAsClaudeFunction(KernelFunctionMetadata metadata) - { - IReadOnlyList metadataParams = metadata.Parameters; - - var openAIParams = new ClaudeFunctionParameter[metadataParams.Count]; - for (int i = 0; i < openAIParams.Length; i++) - { - var param = metadataParams[i]; - - openAIParams[i] = new ClaudeFunctionParameter( - param.Name, - GetDescription(param), - param.IsRequired, - param.ParameterType, - param.Schema); - } - - return new AnthropicFunction( - metadata.PluginName, - metadata.Name, - metadata.Description, - openAIParams, - new ClaudeFunctionReturnParameter( - metadata.ReturnParameter.Description, - metadata.ReturnParameter.ParameterType, - metadata.ReturnParameter.Schema)); - - static string GetDescription(KernelParameterMetadata param) - { - string? stringValue = InternalTypeConverter.ConvertToString(param.DefaultValue); - return !string.IsNullOrEmpty(stringValue) ? $"{param.Description} (default value: {stringValue})" : param.Description; - } - } - } - - /// - /// Represents a that provides a specified list of functions to the model. - /// - internal sealed class EnabledFunctions : AnthropicToolCallBehavior - { - private readonly AnthropicFunction[] _functions; - - public EnabledFunctions(IEnumerable functions, bool autoInvoke) : base(autoInvoke) - { - this._functions = functions.ToArray(); - } - - public override string ToString() => - $"{nameof(EnabledFunctions)}(autoInvoke:{this.MaximumAutoInvokeAttempts != 0}): " + - $"{string.Join(", ", this._functions.Select(f => f.FunctionName))}"; - - internal override void ConfigureClaudeRequest(Kernel? kernel, AnthropicRequest request) - { - if (this._functions.Length == 0) - { - return; - } - - bool autoInvoke = this.MaximumAutoInvokeAttempts > 0; - - // If auto-invocation is specified, we need a kernel to be able to invoke the functions. - // Lack of a kernel is fatal: we don't want to tell the model we can handle the functions - // and then fail to do so, so we fail before we get to that point. This is an error - // on the consumers behalf: if they specify auto-invocation with any functions, they must - // specify the kernel and the kernel must contain those functions. - if (autoInvoke && kernel is null) - { - throw new KernelException($"Auto-invocation with {nameof(EnabledFunctions)} is not supported when no kernel is provided."); - } - - foreach (var func in this._functions) - { - // Make sure that if auto-invocation is specified, every enabled function can be found in the kernel. - if (autoInvoke) - { - if (!kernel!.Plugins.TryGetFunction(func.PluginName, func.FunctionName, out _)) - { - throw new KernelException( - $"The specified {nameof(EnabledFunctions)} function {func.FullyQualifiedName} is not available in the kernel."); - } - } - - // Add the function. - request.AddFunction(func); - } - } - } -} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Connectors.Anthropic.csproj b/dotnet/src/Connectors/Connectors.Anthropic/Connectors.Anthropic.csproj index d851bca320ff..392a9844d8d4 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Connectors.Anthropic.csproj +++ b/dotnet/src/Connectors/Connectors.Anthropic/Connectors.Anthropic.csproj @@ -6,12 +6,12 @@ $(AssemblyName) netstandard2.0 alpha - SKEXP0001,SKEXP0070 + CA1707,SKEXP0001,SKEXP0070 - - + + @@ -20,13 +20,13 @@ - - + + - - + + \ No newline at end of file diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/AnthropicClient.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/AnthropicClient.cs index a73783c4d942..7f896389baca 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Core/AnthropicClient.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/AnthropicClient.cs @@ -2,15 +2,21 @@ using System; using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.Linq; using System.Net.Http; +using System.Net.Http.Headers; using System.Runtime.CompilerServices; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Diagnostics; using Microsoft.SemanticKernel.Http; +using Microsoft.SemanticKernel.Services; namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; @@ -19,68 +25,123 @@ namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; /// internal sealed class AnthropicClient { + private const string ModelProvider = "anthropic"; + private readonly Func>? _bearerTokenProvider; + private readonly Dictionary _attributesInternal = new(); + private readonly HttpClient _httpClient; private readonly ILogger _logger; private readonly string _modelId; private readonly string? _apiKey; private readonly Uri _endpoint; - private readonly Func? _customRequestHandler; - private readonly AnthropicClientOptions _options; + private readonly string? _version; + + private static readonly string s_namespace = typeof(AnthropicChatCompletionService).Namespace!; + + /// + /// Instance of for metrics. + /// + private static readonly Meter s_meter = new(s_namespace); + + /// + /// Instance of to keep track of the number of prompt tokens used. + /// + private static readonly Counter s_promptTokensCounter = + s_meter.CreateCounter( + name: $"{s_namespace}.tokens.prompt", + unit: "{token}", + description: "Number of prompt tokens used"); + + /// + /// Instance of to keep track of the number of completion tokens used. + /// + private static readonly Counter s_completionTokensCounter = + s_meter.CreateCounter( + name: $"{s_namespace}.tokens.completion", + unit: "{token}", + description: "Number of completion tokens used"); + + /// + /// Instance of to keep track of the total number of tokens used. + /// + private static readonly Counter s_totalTokensCounter = + s_meter.CreateCounter( + name: $"{s_namespace}.tokens.total", + unit: "{token}", + description: "Number of tokens used"); + + internal IReadOnlyDictionary Attributes => this._attributesInternal; /// /// Represents a client for interacting with the Anthropic chat completion models. /// - /// HttpClient instance used to send HTTP requests - /// Id of the model supporting chat completion - /// Api key + /// Model identifier + /// ApiKey for the client /// Options for the client + /// HttpClient instance used to send HTTP requests /// Logger instance used for logging (optional) - public AnthropicClient( - HttpClient httpClient, + internal AnthropicClient( string modelId, string apiKey, - AnthropicClientOptions? options, + AnthropicClientOptions options, + HttpClient httpClient, ILogger? logger = null) { - Verify.NotNull(httpClient); Verify.NotNullOrWhiteSpace(modelId); - Verify.NotNullOrWhiteSpace(apiKey); + Verify.NotNull(options); + Verify.NotNull(httpClient); + + Uri targetUri = httpClient.BaseAddress; + if (httpClient.BaseAddress is null) + { + // If a custom endpoint is not provided, the ApiKey is required + Verify.NotNullOrWhiteSpace(apiKey); + this._apiKey = apiKey; + targetUri = new Uri("https://api.anthropic.com/v1/messages"); + } this._httpClient = httpClient; this._logger = logger ?? NullLogger.Instance; this._modelId = modelId; - this._apiKey = apiKey; - this._options = options ?? new AnthropicClientOptions(); - this._endpoint = new Uri("https://api.anthropic.com/v1/messages"); + this._version = options.Version; + this._endpoint = targetUri; + + this._attributesInternal.Add(AIServiceExtensions.ModelIdKey, modelId); } /// /// Represents a client for interacting with the Anthropic chat completion models. /// - /// HttpClient instance used to send HTTP requests - /// Id of the model supporting chat completion - /// Endpoint for the chat completion model - /// A custom request handler to be used for sending HTTP requests + /// Model identifier + /// Endpoint for the client + /// Bearer token provider /// Options for the client + /// HttpClient instance used to send HTTP requests /// Logger instance used for logging (optional) - public AnthropicClient( - HttpClient httpClient, + internal AnthropicClient( string modelId, - Uri endpoint, - Func? requestHandler, - AnthropicClientOptions? options, + Uri? endpoint, + Func> bearerTokenProvider, + ClientOptions options, + HttpClient httpClient, ILogger? logger = null) { - Verify.NotNull(httpClient); + this._version = options.Version; + Verify.NotNullOrWhiteSpace(modelId); - Verify.NotNull(endpoint); + Verify.NotNull(bearerTokenProvider); + Verify.NotNull(options); + Verify.NotNull(httpClient); + + Uri targetUri = endpoint ?? httpClient.BaseAddress + ?? throw new ArgumentException("Endpoint is required if HttpClient.BaseAddress is not set."); this._httpClient = httpClient; this._logger = logger ?? NullLogger.Instance; + this._bearerTokenProvider = bearerTokenProvider; this._modelId = modelId; - this._endpoint = endpoint; - this._customRequestHandler = requestHandler; - this._options = options ?? new AnthropicClientOptions(); + this._version = options?.Version; + this._endpoint = targetUri; } /// @@ -91,14 +152,148 @@ public AnthropicClient( /// A kernel instance. /// A cancellation token to cancel the operation. /// Returns a list of chat message contents. - public async Task> GenerateChatMessageAsync( + internal async Task> GenerateChatMessageAsync( ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default) { - await Task.Yield(); - throw new NotImplementedException("Implement this method in next PR."); + var state = this.ValidateInputAndCreateChatCompletionState(chatHistory, executionSettings); + + using var activity = ModelDiagnostics.StartCompletionActivity( + this._endpoint, this._modelId, ModelProvider, chatHistory, state.ExecutionSettings); + + List chatResponses; + AnthropicResponse anthropicResponse; + try + { + anthropicResponse = await this.SendRequestAndReturnValidResponseAsync( + this._endpoint, + state.AnthropicRequest, + cancellationToken) + .ConfigureAwait(false); + + chatResponses = this.GetChatResponseFrom(anthropicResponse); + } + catch (Exception ex) when (activity is not null) + { + activity.SetError(ex); + throw; + } + + activity?.SetCompletionResponse( + chatResponses, + anthropicResponse.Usage?.InputTokens, + anthropicResponse.Usage?.OutputTokens); + + return chatResponses; + } + + private List GetChatResponseFrom(AnthropicResponse response) + { + var chatMessageContents = this.GetChatMessageContentsFromResponse(response); + this.LogUsage(chatMessageContents); + return chatMessageContents; + } + + private void LogUsage(List chatMessageContents) + { + if (chatMessageContents[0].Metadata is not { TotalTokenCount: > 0 } metadata) + { + this.Log(LogLevel.Debug, "Token usage information unavailable."); + return; + } + + this.Log(LogLevel.Information, + "Prompt tokens: {PromptTokens}. Completion tokens: {CompletionTokens}. Total tokens: {TotalTokens}.", + metadata.InputTokenCount, + metadata.OutputTokenCount, + metadata.TotalTokenCount); + + if (metadata.InputTokenCount.HasValue) + { + s_promptTokensCounter.Add(metadata.InputTokenCount.Value); + } + + if (metadata.OutputTokenCount.HasValue) + { + s_completionTokensCounter.Add(metadata.OutputTokenCount.Value); + } + + if (metadata.TotalTokenCount.HasValue) + { + s_totalTokensCounter.Add(metadata.TotalTokenCount.Value); + } + } + + private List GetChatMessageContentsFromResponse(AnthropicResponse response) + => response.Contents.Select(content => this.GetChatMessageContentFromAnthropicContent(response, content)).ToList(); + + private AnthropicChatMessageContent GetChatMessageContentFromAnthropicContent(AnthropicResponse response, AnthropicContent content) + { + if (!string.Equals(content.Type, "text", StringComparison.OrdinalIgnoreCase)) + { + throw new NotSupportedException($"Content type {content.Type} is not supported yet."); + } + + return new AnthropicChatMessageContent + { + Role = response.Role, + Items = [new TextContent(content.Text ?? string.Empty)], + ModelId = response.ModelId ?? this._modelId, + InnerContent = response, + Metadata = GetResponseMetadata(response) + }; + } + + private static AnthropicMetadata GetResponseMetadata(AnthropicResponse response) + => new() + { + MessageId = response.Id, + FinishReason = response.StopReason, + StopSequence = response.StopSequence, + InputTokenCount = response.Usage?.InputTokens ?? 0, + OutputTokenCount = response.Usage?.OutputTokens ?? 0 + }; + + private async Task SendRequestAndReturnValidResponseAsync( + Uri endpoint, + AnthropicRequest anthropicRequest, + CancellationToken cancellationToken) + { + using var httpRequestMessage = await this.CreateHttpRequestAsync(anthropicRequest, endpoint).ConfigureAwait(false); + var body = await this.SendRequestAndGetStringBodyAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); + var response = DeserializeResponse(body); + return response; + } + + private ChatCompletionState ValidateInputAndCreateChatCompletionState( + ChatHistory chatHistory, + PromptExecutionSettings? executionSettings) + { + ValidateChatHistory(chatHistory); + + var anthropicExecutionSettings = AnthropicPromptExecutionSettings.FromExecutionSettings(executionSettings); + ValidateMaxTokens(anthropicExecutionSettings.MaxTokens); + anthropicExecutionSettings.ModelId ??= this._modelId; + + this.Log(LogLevel.Trace, "ChatHistory: {ChatHistory}, Settings: {Settings}", + JsonSerializer.Serialize(chatHistory), + JsonSerializer.Serialize(anthropicExecutionSettings)); + + var filteredChatHistory = new ChatHistory(chatHistory.Where(IsAssistantOrUserOrSystem)); + var anthropicRequest = AnthropicRequest.FromChatHistoryAndExecutionSettings(filteredChatHistory, anthropicExecutionSettings); + anthropicRequest.Version = this._version; + + return new ChatCompletionState + { + ChatHistory = chatHistory, + ExecutionSettings = anthropicExecutionSettings, + AnthropicRequest = anthropicRequest + }; + + static bool IsAssistantOrUserOrSystem(ChatMessageContent msg) + => msg.Role == AuthorRole.Assistant || msg.Role == AuthorRole.User || msg.Role == AuthorRole.System; } /// @@ -109,7 +304,7 @@ public async Task> GenerateChatMessageAsync( /// A kernel instance. /// A cancellation token to cancel the operation. /// An asynchronous enumerable of streaming chat contents. - public async IAsyncEnumerable StreamGenerateChatMessageAsync( + internal async IAsyncEnumerable StreamGenerateChatMessageAsync( ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, @@ -129,6 +324,15 @@ private static void ValidateMaxTokens(int? maxTokens) } } + private static void ValidateChatHistory(ChatHistory chatHistory) + { + Verify.NotNullOrEmpty(chatHistory); + if (chatHistory.All(msg => msg.Role == AuthorRole.System)) + { + throw new InvalidOperationException("Chat history can't contain only system messages."); + } + } + private async Task SendRequestAndGetStringBodyAsync( HttpRequestMessage httpRequestMessage, CancellationToken cancellationToken) @@ -167,19 +371,53 @@ private static T DeserializeResponse(string body) private async Task CreateHttpRequestAsync(object requestData, Uri endpoint) { var httpRequestMessage = HttpRequest.CreatePostRequest(endpoint, requestData); - httpRequestMessage.Headers.Add("User-Agent", HttpHeaderConstant.Values.UserAgent); - httpRequestMessage.Headers.Add(HttpHeaderConstant.Names.SemanticKernelVersion, - HttpHeaderConstant.Values.GetAssemblyVersion(typeof(AnthropicClient))); + if (!httpRequestMessage.Headers.Contains("User-Agent")) + { + httpRequestMessage.Headers.Add("User-Agent", HttpHeaderConstant.Values.UserAgent); + } + + if (!httpRequestMessage.Headers.Contains(HttpHeaderConstant.Names.SemanticKernelVersion)) + { + httpRequestMessage.Headers.Add( + HttpHeaderConstant.Names.SemanticKernelVersion, + HttpHeaderConstant.Values.GetAssemblyVersion(typeof(AnthropicClient))); + } + + if (!httpRequestMessage.Headers.Contains("anthropic-version")) + { + httpRequestMessage.Headers.Add("anthropic-version", this._version); + } - if (this._customRequestHandler != null) + if (this._apiKey is not null && !httpRequestMessage.Headers.Contains("x-api-key")) + { + httpRequestMessage.Headers.Add("x-api-key", this._apiKey); + } + else + if (this._bearerTokenProvider is not null && !httpRequestMessage.Headers.Contains("Authentication") && await this._bearerTokenProvider().ConfigureAwait(false) is { } bearerKey) { - await this._customRequestHandler(httpRequestMessage).ConfigureAwait(false); + httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerKey); } return httpRequestMessage; } - private void Log(LogLevel logLevel, string? message, params object[] args) + private static HttpContent? CreateJsonContent(object? payload) + { + HttpContent? content = null; + if (payload is not null) + { + byte[] utf8Bytes = payload is string s + ? Encoding.UTF8.GetBytes(s) + : JsonSerializer.SerializeToUtf8Bytes(payload); + + content = new ByteArrayContent(utf8Bytes); + content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" }; + } + + return content; + } + + private void Log(LogLevel logLevel, string? message, params object?[] args) { if (this._logger.IsEnabled(logLevel)) { @@ -188,4 +426,11 @@ private void Log(LogLevel logLevel, string? message, params object[] args) #pragma warning restore CA2254 } } + + private sealed class ChatCompletionState + { + internal ChatHistory ChatHistory { get; set; } = null!; + internal AnthropicRequest AnthropicRequest { get; set; } = null!; + internal AnthropicPromptExecutionSettings ExecutionSettings { get; set; } = null!; + } } diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/AuthorRoleConverter.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/AuthorRoleConverter.cs index eb4369533bdd..d0f5d51f6a76 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Core/AuthorRoleConverter.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/AuthorRoleConverter.cs @@ -42,7 +42,7 @@ public override void Write(Utf8JsonWriter writer, AuthorRole value, JsonSerializ } else { - throw new JsonException($"Claude API doesn't support author role: {value}"); + throw new JsonException($"Anthropic API doesn't support author role: {value}"); } } } diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicRequest.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicRequest.cs index 3f65e8ca2e95..cec43a1531b9 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicRequest.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicRequest.cs @@ -3,14 +3,17 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Text; namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; internal sealed class AnthropicRequest { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Version { get; set; } + /// /// Input messages.
/// Our models are trained to operate on alternating user and assistant conversational turns. @@ -22,11 +25,7 @@ internal sealed class AnthropicRequest /// from the content in that message. This can be used to constrain part of the model's response. ///
[JsonPropertyName("messages")] - public IList Messages { get; set; } = null!; - - [JsonPropertyName("tools")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList? Tools { get; set; } + public IList Messages { get; set; } = []; [JsonPropertyName("model")] public string ModelId { get; set; } = null!; @@ -35,7 +34,7 @@ internal sealed class AnthropicRequest public int MaxTokens { get; set; } /// - /// A system prompt is a way of providing context and instructions to Claude, such as specifying a particular goal or persona. + /// A system prompt is a way of providing context and instructions to Anthropic, such as specifying a particular goal or persona. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("system")] @@ -80,18 +79,15 @@ internal sealed class AnthropicRequest [JsonPropertyName("top_k")] public int? TopK { get; set; } - public void AddFunction(AnthropicFunction function) - { - this.Tools ??= new List(); - this.Tools.Add(function.ToFunctionDeclaration()); - } + [JsonConstructor] + internal AnthropicRequest() { } public void AddChatMessage(ChatMessageContent message) { Verify.NotNull(this.Messages); Verify.NotNull(message); - this.Messages.Add(CreateClaudeMessageFromChatMessage(message)); + this.Messages.Add(CreateAnthropicMessageFromChatMessage(message)); } /// @@ -107,19 +103,19 @@ internal static AnthropicRequest FromChatHistoryAndExecutionSettings( bool streamingMode = false) { AnthropicRequest request = CreateRequest(chatHistory, executionSettings, streamingMode); - AddMessages(chatHistory, request); + AddMessages(chatHistory.Where(msg => msg.Role != AuthorRole.System), request); return request; } - private static void AddMessages(ChatHistory chatHistory, AnthropicRequest request) - => request.Messages = chatHistory.Select(CreateClaudeMessageFromChatMessage).ToList(); + private static void AddMessages(IEnumerable chatHistory, AnthropicRequest request) + => request.Messages.AddRange(chatHistory.Select(CreateAnthropicMessageFromChatMessage)); - private static Message CreateClaudeMessageFromChatMessage(ChatMessageContent message) + private static Message CreateAnthropicMessageFromChatMessage(ChatMessageContent message) { return new Message { Role = message.Role, - Contents = CreateClaudeMessages(message) + Contents = CreateAnthropicMessages(message) }; } @@ -129,7 +125,11 @@ private static AnthropicRequest CreateRequest(ChatHistory chatHistory, Anthropic { ModelId = executionSettings.ModelId ?? throw new InvalidOperationException("Model ID must be provided."), MaxTokens = executionSettings.MaxTokens ?? throw new InvalidOperationException("Max tokens must be provided."), - SystemPrompt = chatHistory.SingleOrDefault(c => c.Role == AuthorRole.System)?.Content, + SystemPrompt = string.Join("\n", chatHistory + .Where(msg => msg.Role == AuthorRole.System) + .SelectMany(msg => msg.Items) + .OfType() + .Select(content => content.Text)), StopSequences = executionSettings.StopSequences, Stream = streamingMode, Temperature = executionSettings.Temperature, @@ -139,60 +139,44 @@ private static AnthropicRequest CreateRequest(ChatHistory chatHistory, Anthropic return request; } - private static List CreateClaudeMessages(ChatMessageContent content) + private static List CreateAnthropicMessages(ChatMessageContent content) { - List messages = new(); - switch (content) - { - case AnthropicChatMessageContent { CalledToolResult: not null } contentWithCalledTool: - messages.Add(new AnthropicToolResultContent - { - ToolId = contentWithCalledTool.CalledToolResult.ToolUseId ?? throw new InvalidOperationException("Tool ID must be provided."), - Content = new AnthropicTextContent(contentWithCalledTool.CalledToolResult.FunctionResult.ToString()) - }); - break; - case AnthropicChatMessageContent { ToolCalls: not null } contentWithToolCalls: - messages.AddRange(contentWithToolCalls.ToolCalls.Select(toolCall => - new AnthropicToolCallContent - { - ToolId = toolCall.ToolUseId, - FunctionName = toolCall.FullyQualifiedName, - Arguments = JsonSerializer.SerializeToNode(toolCall.Arguments), - })); - break; - default: - messages.AddRange(content.Items.Select(GetClaudeMessageFromKernelContent)); - break; - } - - if (messages.Count == 0) - { - messages.Add(new AnthropicTextContent(content.Content ?? string.Empty)); - } - - return messages; + return content.Items.Select(GetAnthropicMessageFromKernelContent).ToList(); } - private static AnthropicContent GetClaudeMessageFromKernelContent(KernelContent content) => content switch + private static AnthropicContent GetAnthropicMessageFromKernelContent(KernelContent content) => content switch { - TextContent textContent => new AnthropicTextContent(textContent.Text ?? string.Empty), - ImageContent imageContent => new AnthropicImageContent( - type: "base64", - mediaType: imageContent.MimeType ?? throw new InvalidOperationException("Image content must have a MIME type."), - data: imageContent.Data.HasValue - ? Convert.ToBase64String(imageContent.Data.Value.ToArray()) - : throw new InvalidOperationException("Image content must have a data.") - ), + TextContent textContent => new AnthropicContent("text") { Text = textContent.Text ?? string.Empty }, + ImageContent imageContent => CreateAnthropicImageContent(imageContent), _ => throw new NotSupportedException($"Content type '{content.GetType().Name}' is not supported.") }; + private static AnthropicContent CreateAnthropicImageContent(ImageContent imageContent) + { + var dataUri = DataUriParser.Parse(imageContent.DataUri); + if (dataUri.DataFormat?.Equals("base64", StringComparison.OrdinalIgnoreCase) != true) + { + throw new InvalidOperationException("Image content must be base64 encoded."); + } + + return new AnthropicContent("image") + { + Source = new() + { + Type = dataUri.DataFormat, + MediaType = imageContent.MimeType ?? throw new InvalidOperationException("Image content must have a MIME type."), + Data = dataUri.Data ?? throw new InvalidOperationException("Image content must have a data.") + } + }; + } + internal sealed class Message { [JsonConverter(typeof(AuthorRoleConverter))] [JsonPropertyName("role")] - public AuthorRole Role { get; set; } + public AuthorRole Role { get; init; } [JsonPropertyName("content")] - public IList Contents { get; set; } = null!; + public IList Contents { get; init; } = null!; } } diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicResponse.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicResponse.cs new file mode 100644 index 000000000000..0c21e18de0cb --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicResponse.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; + +/// +/// Represents the response from the Anthropic API. +/// https://docs.anthropic.com/en/api/messages +/// +internal sealed class AnthropicResponse +{ + /// + /// Unique object identifier. + /// + [JsonRequired] + [JsonPropertyName("id")] + public string Id { get; init; } = null!; + + /// + /// Object type. + /// + [JsonRequired] + [JsonPropertyName("type")] + public string Type { get; init; } = null!; + + /// + /// Conversational role of the generated message. + /// + [JsonRequired] + [JsonPropertyName("role")] + [JsonConverter(typeof(AuthorRoleConverter))] + public AuthorRole Role { get; init; } + + /// + /// Content generated by the model. + /// This is an array of content blocks, each of which has a type that determines its shape. + /// + [JsonRequired] + [JsonPropertyName("content")] + public IReadOnlyList Contents { get; init; } = null!; + + /// + /// The model that handled the request. + /// + [JsonRequired] + [JsonPropertyName("model")] + public string ModelId { get; init; } = null!; + + /// + /// The reason that we stopped. + /// + [JsonPropertyName("stop_reason")] + public AnthropicFinishReason? StopReason { get; init; } + + /// + /// Which custom stop sequence was generated, if any. + /// This value will be a non-null string if one of your custom stop sequences was generated. + /// + [JsonPropertyName("stop_sequence")] + public string? StopSequence { get; init; } + + /// + /// Billing and rate-limit usage. + /// + [JsonRequired] + [JsonPropertyName("usage")] + public AnthropicUsage Usage { get; init; } = null!; +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicToolFunctionDeclaration.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicToolFunctionDeclaration.cs deleted file mode 100644 index abfbbad17779..000000000000 --- a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicToolFunctionDeclaration.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; - -namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; - -/// -/// A Tool is a piece of code that enables the system to interact with external systems to perform an action, -/// or set of actions, outside of knowledge and scope of the model. -/// Structured representation of a function declaration as defined by the OpenAPI 3.03 specification. -/// Included in this declaration are the function name and parameters. -/// This FunctionDeclaration is a representation of a block of code that can be used as a Tool by the model and executed by the client. -/// -internal sealed class AnthropicToolFunctionDeclaration -{ - /// - /// Required. Name of function. - /// - /// - /// Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 63. - /// - [JsonPropertyName("name")] - public string Name { get; set; } = null!; - - /// - /// Required. A brief description of the function. - /// - [JsonPropertyName("description")] - public string Description { get; set; } = null!; - - /// - /// Optional. Describes the parameters to this function. - /// Reflects the Open API 3.03 Parameter Object string Key: the name of the parameter. - /// Parameter names are case-sensitive. Schema Value: the Schema defining the type used for the parameter. - /// - [JsonPropertyName("parameters")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonNode? Parameters { get; set; } -} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicContent.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicContent.cs index c27931519b16..fab9f2b380f1 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicContent.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicContent.cs @@ -4,12 +4,52 @@ namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; -/// -/// Represents the request/response content of Claude. -/// -[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] -[JsonDerivedType(typeof(AnthropicTextContent), typeDiscriminator: "text")] -[JsonDerivedType(typeof(AnthropicImageContent), typeDiscriminator: "image")] -[JsonDerivedType(typeof(AnthropicToolCallContent), typeDiscriminator: "tool_use")] -[JsonDerivedType(typeof(AnthropicToolResultContent), typeDiscriminator: "tool_result")] -internal abstract class AnthropicContent { } +internal sealed class AnthropicContent +{ + /// + /// Currently supported only base64. + /// + [JsonPropertyName("type")] + public string Type { get; set; } + + /// + /// When type is "text", the text content. + /// + [JsonPropertyName("text")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Text { get; set; } + + /// + /// When type is "image", the source of the image. + /// + [JsonPropertyName("source")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SourceEntity? Source { get; set; } + + [JsonConstructor] + public AnthropicContent(string type) + { + this.Type = type; + } + + internal sealed class SourceEntity + { + /// + /// Currently supported only base64. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// The media type of the image. + /// + [JsonPropertyName("media_type")] + public string? MediaType { get; set; } + + /// + /// The base64 encoded image data. + /// + [JsonPropertyName("data")] + public string? Data { get; set; } + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicImageContent.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicImageContent.cs deleted file mode 100644 index 8dd517267cdf..000000000000 --- a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicImageContent.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Serialization; - -namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; - -internal sealed class AnthropicImageContent : AnthropicContent -{ - [JsonConstructor] - public AnthropicImageContent(string type, string mediaType, string data) - { - this.Source = new SourceEntity(type, mediaType, data); - } - - /// - /// Only used when type is "image". The image content. - /// - [JsonPropertyName("source")] - public SourceEntity Source { get; set; } - - internal sealed class SourceEntity - { - [JsonConstructor] - internal SourceEntity(string type, string mediaType, string data) - { - this.Type = type; - this.MediaType = mediaType; - this.Data = data; - } - - /// - /// Currently supported only base64. - /// - [JsonPropertyName("type")] - public string Type { get; set; } - - /// - /// The media type of the image. - /// - [JsonPropertyName("media_type")] - public string MediaType { get; set; } - - /// - /// The base64 encoded image data. - /// - [JsonPropertyName("data")] - public string Data { get; set; } - } -} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicTextContent.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicTextContent.cs deleted file mode 100644 index ca565be761f6..000000000000 --- a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicTextContent.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Serialization; - -namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; - -internal sealed class AnthropicTextContent : AnthropicContent -{ - [JsonConstructor] - public AnthropicTextContent(string text) - { - this.Text = text; - } - - /// - /// Only used when type is "text". The text content. - /// - [JsonPropertyName("text")] - public string Text { get; set; } -} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicToolCallContent.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicToolCallContent.cs deleted file mode 100644 index e738b3773221..000000000000 --- a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicToolCallContent.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; - -namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; - -internal sealed class AnthropicToolCallContent : AnthropicContent -{ - [JsonPropertyName("id")] - [JsonRequired] - public string ToolId { get; set; } = null!; - - [JsonPropertyName("name")] - [JsonRequired] - public string FunctionName { get; set; } = null!; - - /// - /// Optional. The function parameters and values in JSON object format. - /// - [JsonPropertyName("input")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonNode? Arguments { get; set; } - - /// - public override string ToString() - { - return $"FunctionName={this.FunctionName}, Arguments={this.Arguments}"; - } -} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicToolResultContent.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicToolResultContent.cs deleted file mode 100644 index dcf2c31f4965..000000000000 --- a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicToolResultContent.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Serialization; - -namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; - -internal sealed class AnthropicToolResultContent : AnthropicContent -{ - [JsonPropertyName("tool_use_id")] - [JsonRequired] - public string ToolId { get; set; } = null!; - - [JsonPropertyName("content")] - [JsonRequired] - public AnthropicContent Content { get; set; } = null!; - - [JsonPropertyName("is_error")] - public bool IsError { get; set; } -} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Extensions/AnthropicKernelBuilderExtensions.cs b/dotnet/src/Connectors/Connectors.Anthropic/Extensions/AnthropicKernelBuilderExtensions.cs index f5258d9b630f..dbd70a2ca5db 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Extensions/AnthropicKernelBuilderExtensions.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Extensions/AnthropicKernelBuilderExtensions.cs @@ -20,31 +20,30 @@ public static class AnthropicKernelBuilderExtensions /// Add Anthropic Chat Completion and Text Generation services to the kernel builder. /// /// The kernel builder. - /// The model for chat completion. - /// The API key for authentication Claude API. + /// Model identifier. + /// API key. /// Optional options for the anthropic client - /// The optional service ID. /// The optional custom HttpClient. + /// Service identifier. /// The updated kernel builder. public static IKernelBuilder AddAnthropicChatCompletion( this IKernelBuilder builder, string modelId, string apiKey, AnthropicClientOptions? options = null, - string? serviceId = null, - HttpClient? httpClient = null) + HttpClient? httpClient = null, + string? serviceId = null) { Verify.NotNull(builder); - Verify.NotNull(modelId); - Verify.NotNull(apiKey); builder.Services.AddKeyedSingleton(serviceId, (serviceProvider, _) => new AnthropicChatCompletionService( modelId: modelId, apiKey: apiKey, - options: options, + options: options ?? new AnthropicClientOptions(), httpClient: HttpClientProvider.GetHttpClient(httpClient, serviceProvider), loggerFactory: serviceProvider.GetService())); + return builder; } @@ -52,34 +51,31 @@ public static IKernelBuilder AddAnthropicChatCompletion( /// Add Anthropic Chat Completion and Text Generation services to the kernel builder. ///
/// The kernel builder. - /// The model for chat completion. - /// Endpoint for the chat completion model - /// A custom request handler to be used for sending HTTP requests + /// Model identifier. + /// Bearer token provider. + /// Vertex AI Anthropic endpoint. /// Optional options for the anthropic client - /// The optional service ID. - /// The optional custom HttpClient. + /// Service identifier. /// The updated kernel builder. - public static IKernelBuilder AddAnthropicChatCompletion( + public static IKernelBuilder AddAnthropicVertextAIChatCompletion( this IKernelBuilder builder, string modelId, - Uri endpoint, - Func? requestHandler, - AnthropicClientOptions? options = null, - string? serviceId = null, - HttpClient? httpClient = null) + Func> bearerTokenProvider, + Uri? endpoint = null, + VertexAIAnthropicClientOptions? options = null, + string? serviceId = null) { Verify.NotNull(builder); - Verify.NotNull(modelId); - Verify.NotNull(endpoint); builder.Services.AddKeyedSingleton(serviceId, (serviceProvider, _) => new AnthropicChatCompletionService( modelId: modelId, + bearerTokenProvider: bearerTokenProvider, + options: options ?? new VertexAIAnthropicClientOptions(), endpoint: endpoint, - requestHandler: requestHandler, - options: options, - httpClient: HttpClientProvider.GetHttpClient(httpClient, serviceProvider), + httpClient: HttpClientProvider.GetHttpClient(serviceProvider), loggerFactory: serviceProvider.GetService())); + return builder; } } diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Extensions/AnthropicServiceCollectionExtensions.cs b/dotnet/src/Connectors/Connectors.Anthropic/Extensions/AnthropicServiceCollectionExtensions.cs index 9e92b2ea8857..83ed98bfafcf 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Extensions/AnthropicServiceCollectionExtensions.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Extensions/AnthropicServiceCollectionExtensions.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Net.Http; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -17,13 +16,13 @@ namespace Microsoft.SemanticKernel; public static class AnthropicServiceCollectionExtensions { /// - /// Add Anthropic Chat Completion and Text Generation services to the specified service collection. + /// Add Anthropic Chat Completion to the added in service collection. /// - /// The service collection to add the Claude Text Generation service to. - /// The model for chat completion. - /// The API key for authentication Claude API. + /// The target service collection. + /// Model identifier. + /// API key. /// Optional options for the anthropic client - /// Optional service ID. + /// Service identifier. /// The updated service collection. public static IServiceCollection AddAnthropicChatCompletion( this IServiceCollection services, @@ -33,8 +32,6 @@ public static IServiceCollection AddAnthropicChatCompletion( string? serviceId = null) { Verify.NotNull(services); - Verify.NotNull(modelId); - Verify.NotNull(apiKey); services.AddKeyedSingleton(serviceId, (serviceProvider, _) => new AnthropicChatCompletionService( @@ -43,39 +40,39 @@ public static IServiceCollection AddAnthropicChatCompletion( options: options, httpClient: HttpClientProvider.GetHttpClient(serviceProvider), loggerFactory: serviceProvider.GetService())); + return services; } /// - /// Add Anthropic Chat Completion and Text Generation services to the specified service collection. + /// Add Anthropic Chat Completion to the added in service collection. /// - /// The service collection to add the Claude Text Generation service to. - /// The model for chat completion. - /// Endpoint for the chat completion model - /// A custom request handler to be used for sending HTTP requests + /// The target service collection. + /// Model identifier. + /// Bearer token provider. + /// Vertex AI Anthropic endpoint. /// Optional options for the anthropic client - /// Optional service ID. + /// Service identifier. /// The updated service collection. - public static IServiceCollection AddAnthropicChatCompletion( + public static IServiceCollection AddAnthropicVertexAIChatCompletion( this IServiceCollection services, string modelId, - Uri endpoint, - Func? requestHandler, - AnthropicClientOptions? options = null, + Func> bearerTokenProvider, + Uri? endpoint = null, + VertexAIAnthropicClientOptions? options = null, string? serviceId = null) { Verify.NotNull(services); - Verify.NotNull(modelId); - Verify.NotNull(endpoint); services.AddKeyedSingleton(serviceId, (serviceProvider, _) => new AnthropicChatCompletionService( modelId: modelId, + bearerTokenProvider: bearerTokenProvider, endpoint: endpoint, - requestHandler: requestHandler, - options: options, + options: options ?? new VertexAIAnthropicClientOptions(), httpClient: HttpClientProvider.GetHttpClient(serviceProvider), loggerFactory: serviceProvider.GetService())); + return services; } } diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicChatMessageContent.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicChatMessageContent.cs deleted file mode 100644 index f0a291226bef..000000000000 --- a/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicChatMessageContent.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Microsoft.SemanticKernel.ChatCompletion; -using Microsoft.SemanticKernel.Connectors.Anthropic.Core; - -namespace Microsoft.SemanticKernel.Connectors.Anthropic; - -/// -/// Claude specialized chat message content -/// -public sealed class AnthropicChatMessageContent : ChatMessageContent -{ - /// - /// Initializes a new instance of the class. - /// - /// The result of tool called by the kernel. - public AnthropicChatMessageContent(AnthropicFunctionToolResult calledToolResult) - : base( - role: AuthorRole.Assistant, - content: null, - modelId: null, - innerContent: null, - encoding: Encoding.UTF8, - metadata: null) - { - Verify.NotNull(calledToolResult); - - this.CalledToolResult = calledToolResult; - } - - /// - /// Initializes a new instance of the class. - /// - /// Role of the author of the message - /// Content of the message - /// The model ID used to generate the content - /// The result of tool called by the kernel. - /// Additional metadata - internal AnthropicChatMessageContent( - AuthorRole role, - string? content, - string modelId, - AnthropicFunctionToolResult? calledToolResult = null, - AnthropicMetadata? metadata = null) - : base( - role: role, - content: content, - modelId: modelId, - innerContent: content, - encoding: Encoding.UTF8, - metadata: metadata) - { - this.CalledToolResult = calledToolResult; - } - - /// - /// Initializes a new instance of the class. - /// - /// Role of the author of the message - /// Content of the message - /// The model ID used to generate the content - /// Tool calls parts returned by model - /// Additional metadata - internal AnthropicChatMessageContent( - AuthorRole role, - string? content, - string modelId, - IEnumerable? functionsToolCalls, - AnthropicMetadata? metadata = null) - : base( - role: role, - content: content, - modelId: modelId, - innerContent: content, - encoding: Encoding.UTF8, - metadata: metadata) - { - this.ToolCalls = functionsToolCalls?.Select(tool => new AnthropicFunctionToolCall(tool)).ToList(); - } - - /// - /// A list of the tools returned by the model with arguments. - /// - public IReadOnlyList? ToolCalls { get; } - - /// - /// The result of tool called by the kernel. - /// - public AnthropicFunctionToolResult? CalledToolResult { get; } - - /// - /// The metadata associated with the content. - /// - public new AnthropicMetadata? Metadata => (AnthropicMetadata?)base.Metadata; -} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunction.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunction.cs deleted file mode 100644 index 55ad7872a423..000000000000 --- a/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunction.cs +++ /dev/null @@ -1,181 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Text.Json; -using Microsoft.SemanticKernel.Connectors.Anthropic.Core; - -namespace Microsoft.SemanticKernel.Connectors.Anthropic; - -// NOTE: Since this space is evolving rapidly, in order to reduce the risk of needing to take breaking -// changes as Gemini's APIs evolve, these types are not externally constructible. In the future, once -// things stabilize, and if need demonstrates, we could choose to expose those constructors. - -/// -/// Represents a function parameter that can be passed to an Gemini function tool call. -/// -public sealed class ClaudeFunctionParameter -{ - internal ClaudeFunctionParameter( - string? name, - string? description, - bool isRequired, - Type? parameterType, - KernelJsonSchema? schema) - { - this.Name = name ?? string.Empty; - this.Description = description ?? string.Empty; - this.IsRequired = isRequired; - this.ParameterType = parameterType; - this.Schema = schema; - } - - /// Gets the name of the parameter. - public string Name { get; } - - /// Gets a description of the parameter. - public string Description { get; } - - /// Gets whether the parameter is required vs optional. - public bool IsRequired { get; } - - /// Gets the of the parameter, if known. - public Type? ParameterType { get; } - - /// Gets a JSON schema for the parameter, if known. - public KernelJsonSchema? Schema { get; } -} - -/// -/// Represents a function return parameter that can be returned by a tool call to Gemini. -/// -public sealed class ClaudeFunctionReturnParameter -{ - internal ClaudeFunctionReturnParameter( - string? description, - Type? parameterType, - KernelJsonSchema? schema) - { - this.Description = description ?? string.Empty; - this.Schema = schema; - this.ParameterType = parameterType; - } - - /// Gets a description of the return parameter. - public string Description { get; } - - /// Gets the of the return parameter, if known. - public Type? ParameterType { get; } - - /// Gets a JSON schema for the return parameter, if known. - public KernelJsonSchema? Schema { get; } -} - -/// -/// Represents a function that can be passed to the Gemini API -/// -public sealed class AnthropicFunction -{ - /// - /// Cached schema for a description less string. - /// - private static readonly KernelJsonSchema s_stringNoDescriptionSchema = KernelJsonSchema.Parse("{\"type\":\"string\"}"); - - /// Initializes the . - internal AnthropicFunction( - string? pluginName, - string functionName, - string? description, - IReadOnlyList? parameters, - ClaudeFunctionReturnParameter? returnParameter) - { - Verify.NotNullOrWhiteSpace(functionName); - - this.PluginName = pluginName; - this.FunctionName = functionName; - this.Description = description; - this.Parameters = parameters; - this.ReturnParameter = returnParameter; - } - - /// Gets the separator used between the plugin name and the function name, if a plugin name is present. - /// Default is _
It can't be -, because Gemini truncates the plugin name if a dash is used
- public static string NameSeparator { get; set; } = "_"; - - /// Gets the name of the plugin with which the function is associated, if any. - public string? PluginName { get; } - - /// Gets the name of the function. - public string FunctionName { get; } - - /// Gets the fully-qualified name of the function. - /// - /// This is the concatenation of the and the , - /// separated by . If there is no , this is - /// the same as . - /// - public string FullyQualifiedName => - string.IsNullOrEmpty(this.PluginName) ? this.FunctionName : $"{this.PluginName}{NameSeparator}{this.FunctionName}"; - - /// Gets a description of the function. - public string? Description { get; } - - /// Gets a list of parameters to the function, if any. - public IReadOnlyList? Parameters { get; } - - /// Gets the return parameter of the function, if any. - public ClaudeFunctionReturnParameter? ReturnParameter { get; } - - /// - /// Converts the representation to the Gemini API's - /// representation. - /// - /// A containing all the function information. - internal AnthropicToolFunctionDeclaration ToFunctionDeclaration() - { - Dictionary? resultParameters = null; - - if (this.Parameters is { Count: > 0 }) - { - var properties = new Dictionary(); - var required = new List(); - - foreach (var parameter in this.Parameters) - { - properties.Add(parameter.Name, parameter.Schema ?? GetDefaultSchemaForParameter(parameter)); - if (parameter.IsRequired) - { - required.Add(parameter.Name); - } - } - - resultParameters = new Dictionary - { - { "type", "object" }, - { "required", required }, - { "properties", properties }, - }; - } - - return new AnthropicToolFunctionDeclaration - { - Name = this.FullyQualifiedName, - Description = this.Description ?? throw new InvalidOperationException( - $"Function description is required. Please provide a description for the function {this.FullyQualifiedName}."), - Parameters = JsonSerializer.SerializeToNode(resultParameters), - }; - } - - /// Gets a for a typeless parameter with the specified description, defaulting to typeof(string) - private static KernelJsonSchema GetDefaultSchemaForParameter(ClaudeFunctionParameter parameter) - { - // If there's a description, incorporate it. - if (!string.IsNullOrWhiteSpace(parameter.Description)) - { - return KernelJsonSchemaBuilder.Build(null, parameter.ParameterType ?? typeof(string), parameter.Description); - } - - // Otherwise, we can use a cached schema for a string with no description. - return s_stringNoDescriptionSchema; - } -} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunctionToolCall.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunctionToolCall.cs deleted file mode 100644 index 7ed158020e35..000000000000 --- a/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunctionToolCall.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Text; -using System.Text.Json; -using Microsoft.SemanticKernel.Connectors.Anthropic.Core; - -namespace Microsoft.SemanticKernel.Connectors.Anthropic; - -/// -/// Represents an Gemini function tool call with deserialized function name and arguments. -/// -public sealed class AnthropicFunctionToolCall -{ - private string? _fullyQualifiedFunctionName; - - /// Initialize the from a . - internal AnthropicFunctionToolCall(AnthropicToolCallContent functionToolCall) - { - Verify.NotNull(functionToolCall); - Verify.NotNull(functionToolCall.FunctionName); - - string fullyQualifiedFunctionName = functionToolCall.FunctionName; - string functionName = fullyQualifiedFunctionName; - string? pluginName = null; - - int separatorPos = fullyQualifiedFunctionName.IndexOf(AnthropicFunction.NameSeparator, StringComparison.Ordinal); - if (separatorPos >= 0) - { - pluginName = fullyQualifiedFunctionName.AsSpan(0, separatorPos).Trim().ToString(); - functionName = fullyQualifiedFunctionName.AsSpan(separatorPos + AnthropicFunction.NameSeparator.Length).Trim().ToString(); - } - - this._fullyQualifiedFunctionName = fullyQualifiedFunctionName; - this.ToolUseId = functionToolCall.ToolId; - this.PluginName = pluginName; - this.FunctionName = functionName; - if (functionToolCall.Arguments is not null) - { - this.Arguments = functionToolCall.Arguments.Deserialize>(); - } - } - - /// - /// The id of tool returned by the claude. - /// - public string ToolUseId { get; } - - /// Gets the name of the plugin with which this function is associated, if any. - public string? PluginName { get; } - - /// Gets the name of the function. - public string FunctionName { get; } - - /// Gets a name/value collection of the arguments to the function, if any. - public IReadOnlyDictionary? Arguments { get; } - - /// Gets the fully-qualified name of the function. - /// - /// This is the concatenation of the and the , - /// separated by . If there is no , - /// this is the same as . - /// - public string FullyQualifiedName - => this._fullyQualifiedFunctionName - ??= string.IsNullOrEmpty(this.PluginName) ? this.FunctionName : $"{this.PluginName}{AnthropicFunction.NameSeparator}{this.FunctionName}"; - - /// - public override string ToString() - { - var sb = new StringBuilder(this.FullyQualifiedName); - - sb.Append('('); - if (this.Arguments is not null) - { - string separator = ""; - foreach (var arg in this.Arguments) - { - sb.Append(separator).Append(arg.Key).Append(':').Append(arg.Value); - separator = ", "; - } - } - - sb.Append(')'); - - return sb.ToString(); - } -} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunctionToolResult.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunctionToolResult.cs deleted file mode 100644 index cf8157bcc2a5..000000000000 --- a/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFunctionToolResult.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.SemanticKernel.Connectors.Anthropic; - -/// -/// Represents the result of a Claude function tool call. -/// -public sealed class AnthropicFunctionToolResult -{ - /// - /// Initializes a new instance of the class. - /// - /// The called function. - /// The result of the function. - /// The id of tool returned by the claude. - public AnthropicFunctionToolResult(AnthropicFunctionToolCall toolCall, FunctionResult functionResult, string? toolUseId) - { - Verify.NotNull(toolCall); - Verify.NotNull(functionResult); - - this.FunctionResult = functionResult; - this.FullyQualifiedName = toolCall.FullyQualifiedName; - this.ToolUseId = toolUseId; - } - - /// - /// Gets the result of the function. - /// - public FunctionResult FunctionResult { get; } - - /// Gets the fully-qualified name of the function. - /// ClaudeFunctionToolCall.FullyQualifiedName - public string FullyQualifiedName { get; } - - /// - /// The id of tool returned by the claude. - /// - public string? ToolUseId { get; } -} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicChatMessageContent.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicChatMessageContent.cs new file mode 100644 index 000000000000..4f70b5879d83 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicChatMessageContent.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic; + +/// +/// Anthropic specialized chat message content +/// +public sealed class AnthropicChatMessageContent : ChatMessageContent +{ + /// + /// Creates a new instance of the class + /// + [JsonConstructor] + internal AnthropicChatMessageContent() { } + + /// + /// The metadata associated with the content. + /// + public new AnthropicMetadata? Metadata + { + get => base.Metadata as AnthropicMetadata; + init => base.Metadata = value; + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFinishReason.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicFinishReason.cs similarity index 89% rename from dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFinishReason.cs rename to dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicFinishReason.cs index d05f9bc69547..ae1313d95663 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicFinishReason.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicFinishReason.cs @@ -7,9 +7,9 @@ namespace Microsoft.SemanticKernel.Connectors.Anthropic; /// -/// Represents a Claude Finish Reason. +/// Represents a Anthropic Finish Reason. /// -[JsonConverter(typeof(ClaudeFinishReasonConverter))] +[JsonConverter(typeof(AnthropicFinishReasonConverter))] public readonly struct AnthropicFinishReason : IEquatable { /// @@ -27,6 +27,11 @@ namespace Microsoft.SemanticKernel.Connectors.Anthropic; /// public static AnthropicFinishReason StopSequence { get; } = new("stop_sequence"); + /// + /// The model invoked one or more tools + /// + public static AnthropicFinishReason ToolUse { get; } = new("tool_use"); + /// /// Gets the label of the property. /// Label is used for serialization. @@ -34,7 +39,7 @@ namespace Microsoft.SemanticKernel.Connectors.Anthropic; public string Label { get; } /// - /// Represents a Claude Finish Reason. + /// Represents a Anthropic Finish Reason. /// [JsonConstructor] public AnthropicFinishReason(string label) @@ -77,7 +82,7 @@ public override int GetHashCode() public override string ToString() => this.Label ?? string.Empty; } -internal sealed class ClaudeFinishReasonConverter : JsonConverter +internal sealed class AnthropicFinishReasonConverter : JsonConverter { public override AnthropicFinishReason Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => new(reader.GetString()!); diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicMetadata.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicMetadata.cs similarity index 82% rename from dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicMetadata.cs rename to dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicMetadata.cs index 3cc73f27b658..c7786537ddd0 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Models/AnthropicMetadata.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicMetadata.cs @@ -8,7 +8,7 @@ namespace Microsoft.SemanticKernel.Connectors.Anthropic; /// -/// Represents the metadata associated with a Claude response. +/// Represents the metadata associated with a Anthropic response. /// public sealed class AnthropicMetadata : ReadOnlyDictionary { @@ -46,21 +46,27 @@ public string? StopSequence /// /// The number of input tokens which were used. /// - public int InputTokenCount + public int? InputTokenCount { - get => (this.GetValueFromDictionary(nameof(this.InputTokenCount)) as int?) ?? 0; + get => this.GetValueFromDictionary(nameof(this.InputTokenCount)) as int?; internal init => this.SetValueInDictionary(value, nameof(this.InputTokenCount)); } /// /// The number of output tokens which were used. /// - public int OutputTokenCount + public int? OutputTokenCount { - get => (this.GetValueFromDictionary(nameof(this.OutputTokenCount)) as int?) ?? 0; + get => this.GetValueFromDictionary(nameof(this.OutputTokenCount)) as int?; internal init => this.SetValueInDictionary(value, nameof(this.OutputTokenCount)); } + /// + /// Represents the total count of tokens in the Anthropic response, + /// which is calculated by summing the input token count and the output token count. + /// + public int? TotalTokenCount => this.InputTokenCount + this.OutputTokenCount; + /// /// Converts a dictionary to a object. /// diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicUsage.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicUsage.cs new file mode 100644 index 000000000000..54a2f9db3853 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicUsage.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic; + +/// +/// Billing and rate-limit usage.
+/// Anthropic's API bills and rate-limits by token counts, as tokens represent the underlying cost to our systems.
+/// Under the hood, the API transforms requests into a format suitable for the model. +/// The model's output then goes through a parsing stage before becoming an API response. +/// As a result, the token counts in usage will not match one-to-one with the exact visible content of an API request or response.
+/// For example, OutputTokens will be non-zero, even for an empty string response from Anthropic. +///
+public sealed class AnthropicUsage +{ + /// + /// The number of input tokens which were used. + /// + [JsonRequired] + [JsonPropertyName("input_tokens")] + public int? InputTokens { get; init; } + + /// + /// The number of output tokens which were used + /// + [JsonRequired] + [JsonPropertyName("output_tokens")] + public int? OutputTokens { get; init; } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/Options/AmazonBedrockAnthropicClientOptions.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/Options/AmazonBedrockAnthropicClientOptions.cs new file mode 100644 index 000000000000..e9b4d1c4ea99 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/Options/AmazonBedrockAnthropicClientOptions.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic; + +/// +/// Represents the options for configuring the Anthropic client with Amazon Bedrock provider. +/// +public sealed class AmazonBedrockAnthropicClientOptions : ClientOptions +{ + private const ServiceVersion LatestVersion = ServiceVersion.V2023_05_31; + + /// The version of the service to use. + public enum ServiceVersion + { + /// Service version "bedrock-2023-05-31". + V2023_05_31, + } + + /// + /// Initializes new instance of + /// + /// + /// This parameter is optional. + /// Default value is .
+ /// + /// Provided version is not supported. + public AmazonBedrockAnthropicClientOptions(ServiceVersion version = LatestVersion) : base(version switch + { + ServiceVersion.V2023_05_31 => "bedrock-2023-05-31", + _ => throw new NotSupportedException("Unsupported service version") + }) + { + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/Options/AnthropicClientOptions.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/Options/AnthropicClientOptions.cs new file mode 100644 index 000000000000..ad070b036b1e --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/Options/AnthropicClientOptions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic; + +/// +/// Represents the options for configuring the Anthropic client with Anthropic provider. +/// +public sealed class AnthropicClientOptions : ClientOptions +{ + internal const ServiceVersion LatestVersion = ServiceVersion.V2023_06_01; + + /// The version of the service to use. + public enum ServiceVersion + { + /// Service version "2023-01-01". + V2023_01_01, + + /// Service version "2023-06-01". + V2023_06_01, + } + + /// + /// Initializes new instance of + /// + /// + /// This parameter is optional. + /// Default value is .
+ /// + /// Provided version is not supported. + public AnthropicClientOptions(ServiceVersion version = LatestVersion) : base(version switch + { + ServiceVersion.V2023_01_01 => "2023-01-01", + ServiceVersion.V2023_06_01 => "2023-06-01", + _ => throw new NotSupportedException("Unsupported service version") + }) + { + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/Options/ClientOptions.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/Options/ClientOptions.cs new file mode 100644 index 000000000000..bd04ee4345e9 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/Options/ClientOptions.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.SemanticKernel.Connectors.Anthropic; + +/// +/// Represents the options for configuring the Anthropic client. +/// +public abstract class ClientOptions +{ + internal string Version { get; init; } + + /// + /// Represents the options for configuring the Anthropic client. + /// + internal protected ClientOptions(string version) + { + this.Version = version; + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/Options/VertexAIAnthropicClientOptions.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/Options/VertexAIAnthropicClientOptions.cs new file mode 100644 index 000000000000..4f8075226795 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/Options/VertexAIAnthropicClientOptions.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic; + +/// +/// Represents the options for configuring the Anthropic client with Google VertexAI provider. +/// +public sealed class VertexAIAnthropicClientOptions : ClientOptions +{ + private const ServiceVersion LatestVersion = ServiceVersion.V2023_10_16; + + /// The version of the service to use. + public enum ServiceVersion + { + /// Service version "vertex-2023-10-16". + V2023_10_16, + } + + /// + /// Initializes new instance of + /// + /// + /// This parameter is optional. + /// Default value is .
+ /// + /// Provided version is not supported. + public VertexAIAnthropicClientOptions(ServiceVersion version = LatestVersion) : base(version switch + { + ServiceVersion.V2023_10_16 => "vertex-2023-10-16", + _ => throw new NotSupportedException("Unsupported service version") + }) + { + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/AnthropicPromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/Settings/AnthropicPromptExecutionSettings.cs similarity index 70% rename from dotnet/src/Connectors/Connectors.Anthropic/AnthropicPromptExecutionSettings.cs rename to dotnet/src/Connectors/Connectors.Anthropic/Models/Settings/AnthropicPromptExecutionSettings.cs index 1b5b8713d5e5..e1af01ef5865 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/AnthropicPromptExecutionSettings.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/Settings/AnthropicPromptExecutionSettings.cs @@ -5,13 +5,12 @@ using System.Collections.ObjectModel; using System.Text.Json; using System.Text.Json.Serialization; -using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Text; namespace Microsoft.SemanticKernel.Connectors.Anthropic; /// -/// Represents the settings for executing a prompt with the Claude models. +/// Represents the settings for executing a prompt with the Anthropic models. /// [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public sealed class AnthropicPromptExecutionSettings : PromptExecutionSettings @@ -21,7 +20,6 @@ public sealed class AnthropicPromptExecutionSettings : PromptExecutionSettings private int? _topK; private int? _maxTokens; private IList? _stopSequences; - private AnthropicToolCallBehavior? _toolCallBehavior; /// /// Default max tokens for a text generation. @@ -103,43 +101,6 @@ public IList? StopSequences } } - /// - /// Gets or sets the behavior for how tool calls are handled. - /// - /// - /// - /// To disable all tool calling, set the property to null (the default). - /// - /// To allow the model to request one of any number of functions, set the property to an - /// instance returned from , called with - /// a list of the functions available. - /// - /// - /// To allow the model to request one of any of the functions in the supplied , - /// set the property to if the client should simply - /// send the information about the functions and not handle the response in any special manner, or - /// if the client should attempt to automatically - /// invoke the function and send the result back to the service. - /// - /// - /// For all options where an instance is provided, auto-invoke behavior may be selected. If the service - /// sends a request for a function call, if auto-invoke has been requested, the client will attempt to - /// resolve that function from the functions available in the , and if found, rather - /// than returning the response back to the caller, it will handle the request automatically, invoking - /// the function, and sending back the result. The intermediate messages will be retained in the - /// if an instance was provided. - /// - public AnthropicToolCallBehavior? ToolCallBehavior - { - get => this._toolCallBehavior; - - set - { - this.ThrowIfFrozen(); - this._toolCallBehavior = value; - } - } - /// public override void Freeze() { @@ -168,7 +129,6 @@ public override PromptExecutionSettings Clone() TopK = this.TopK, MaxTokens = this.MaxTokens, StopSequences = this.StopSequences is not null ? new List(this.StopSequences) : null, - ToolCallBehavior = this.ToolCallBehavior?.Clone(), }; } diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Services/AnthropicChatCompletionService.cs b/dotnet/src/Connectors/Connectors.Anthropic/Services/AnthropicChatCompletionService.cs index 0f94fafc82e1..ac52bde8aeaf 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Services/AnthropicChatCompletionService.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Services/AnthropicChatCompletionService.cs @@ -9,7 +9,6 @@ using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.Anthropic.Core; using Microsoft.SemanticKernel.Http; -using Microsoft.SemanticKernel.Services; namespace Microsoft.SemanticKernel.Connectors.Anthropic; @@ -18,15 +17,17 @@ namespace Microsoft.SemanticKernel.Connectors.Anthropic; /// public sealed class AnthropicChatCompletionService : IChatCompletionService { - private readonly Dictionary _attributesInternal = new(); private readonly AnthropicClient _client; + /// + public IReadOnlyDictionary Attributes => this._client.Attributes; + /// /// Initializes a new instance of the class. /// - /// The model for the chat completion service. - /// The API key for authentication. - /// Optional options for the anthropic client + /// Model identifier. + /// API key. + /// Options for the anthropic client /// Optional HTTP client to be used for communication with the Claude API. /// Optional logger factory to be used for logging. public AnthropicChatCompletionService( @@ -36,55 +37,40 @@ public AnthropicChatCompletionService( HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null) { - Verify.NotNullOrWhiteSpace(modelId); - Verify.NotNullOrWhiteSpace(apiKey); - this._client = new AnthropicClient( -#pragma warning disable CA2000 - httpClient: HttpClientProvider.GetHttpClient(httpClient), -#pragma warning restore CA2000 modelId: modelId, apiKey: apiKey, - options: options, + options: options ?? new AnthropicClientOptions(), + httpClient: HttpClientProvider.GetHttpClient(httpClient), logger: loggerFactory?.CreateLogger(typeof(AnthropicChatCompletionService))); - this._attributesInternal.Add(AIServiceExtensions.ModelIdKey, modelId); } /// /// Initializes a new instance of the class. /// - /// The model for the chat completion service. - /// Endpoint for the chat completion model - /// A custom request handler to be used for sending HTTP requests - /// Optional options for the anthropic client + /// Model identifier. + /// Bearer token provider. + /// Options for the anthropic client + /// Claude API endpoint. /// Optional HTTP client to be used for communication with the Claude API. /// Optional logger factory to be used for logging. public AnthropicChatCompletionService( string modelId, - Uri endpoint, - Func? requestHandler, - AnthropicClientOptions? options = null, + Func> bearerTokenProvider, + ClientOptions options, + Uri? endpoint = null, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null) { - Verify.NotNullOrWhiteSpace(modelId); - Verify.NotNull(endpoint); - this._client = new AnthropicClient( -#pragma warning disable CA2000 - httpClient: HttpClientProvider.GetHttpClient(httpClient), -#pragma warning restore CA2000 modelId: modelId, - endpoint: endpoint, - requestHandler: requestHandler, + bearerTokenProvider: bearerTokenProvider, options: options, + endpoint: endpoint, + httpClient: HttpClientProvider.GetHttpClient(httpClient), logger: loggerFactory?.CreateLogger(typeof(AnthropicChatCompletionService))); - this._attributesInternal.Add(AIServiceExtensions.ModelIdKey, modelId); } - /// - public IReadOnlyDictionary Attributes => this._attributesInternal; - /// public Task> GetChatMessageContentsAsync( ChatHistory chatHistory, diff --git a/dotnet/src/IntegrationTests/Connectors/Anthropic/AnthropicChatCompletionTests.cs b/dotnet/src/IntegrationTests/Connectors/Anthropic/AnthropicChatCompletionTests.cs new file mode 100644 index 000000000000..6e791d7aa5f9 --- /dev/null +++ b/dotnet/src/IntegrationTests/Connectors/Anthropic/AnthropicChatCompletionTests.cs @@ -0,0 +1,378 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Anthropic; +using xRetry; +using Xunit; +using Xunit.Abstractions; + +namespace SemanticKernel.IntegrationTests.Connectors.Anthropic; + +public sealed class AnthropicChatCompletionTests(ITestOutputHelper output) : TestBase(output) +{ + [RetryTheory] + [InlineData(ServiceType.Anthropic, Skip = "This test is for manual verification.")] + [InlineData(ServiceType.VertexAI, Skip = "This test is for manual verification.")] + [InlineData(ServiceType.AmazonBedrock, Skip = "This test is for manual verification.")] + public async Task ChatGenerationReturnsValidResponseAsync(ServiceType serviceType) + { + // Arrange + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage("Hello, I'm Brandon, how are you?"); + chatHistory.AddAssistantMessage("I'm doing well, thanks for asking."); + chatHistory.AddUserMessage("Call me by my name and expand this abbreviation: LLM"); + + var sut = this.GetChatService(serviceType); + + // Act + var response = await sut.GetChatMessageContentAsync(chatHistory); + + // Assert + Assert.NotNull(response.Content); + this.Output.WriteLine(response.Content); + Assert.Contains("Large Language Model", response.Content, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Brandon", response.Content, StringComparison.OrdinalIgnoreCase); + } + + [RetryTheory] + [InlineData(ServiceType.Anthropic, Skip = "This test is for manual verification.")] + [InlineData(ServiceType.VertexAI, Skip = "This test is for manual verification.")] + [InlineData(ServiceType.AmazonBedrock, Skip = "This test is for manual verification.")] + public async Task ChatStreamingReturnsValidResponseAsync(ServiceType serviceType) + { + // Arrange + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage("Hello, I'm Brandon, how are you?"); + chatHistory.AddAssistantMessage("I'm doing well, thanks for asking."); + chatHistory.AddUserMessage("Call me by my name and write a long story about my name."); + + var sut = this.GetChatService(serviceType); + + // Act + var response = + await sut.GetStreamingChatMessageContentsAsync(chatHistory).ToListAsync(); + + // Assert + Assert.NotEmpty(response); + Assert.True(response.Count > 1); + var message = string.Concat(response.Select(c => c.Content)); + Assert.False(string.IsNullOrWhiteSpace(message)); + this.Output.WriteLine(message); + } + + [RetryTheory] + [InlineData(ServiceType.Anthropic, Skip = "This test is for manual verification.")] + [InlineData(ServiceType.VertexAI, Skip = "This test is for manual verification.")] + [InlineData(ServiceType.AmazonBedrock, Skip = "This test is for manual verification.")] + public async Task ChatGenerationVisionBinaryDataAsync(ServiceType serviceType) + { + // Arrange + Memory image = await File.ReadAllBytesAsync("./TestData/test_image_001.jpg"); + var chatHistory = new ChatHistory(); + var messageContent = new ChatMessageContent(AuthorRole.User, items: + [ + new TextContent("This is an image with a car. Which color is it? You can chose from red, blue, green, and yellow"), + new ImageContent(image, "image/jpeg") + ]); + chatHistory.Add(messageContent); + + var sut = this.GetChatService(serviceType); + + // Act + var response = await sut.GetChatMessageContentAsync(chatHistory); + + // Assert + Assert.NotNull(response.Content); + this.Output.WriteLine(response.Content); + Assert.Contains("green", response.Content, StringComparison.OrdinalIgnoreCase); + } + + [RetryTheory] + [InlineData(ServiceType.Anthropic, Skip = "This test is for manual verification.")] + [InlineData(ServiceType.VertexAI, Skip = "This test is for manual verification.")] + [InlineData(ServiceType.AmazonBedrock, Skip = "This test is for manual verification.")] + public async Task ChatStreamingVisionBinaryDataAsync(ServiceType serviceType) + { + // Arrange + Memory image = await File.ReadAllBytesAsync("./TestData/test_image_001.jpg"); + var chatHistory = new ChatHistory(); + var messageContent = new ChatMessageContent(AuthorRole.User, items: + [ + new TextContent("This is an image with a car. Which color is it? You can chose from red, blue, green, and yellow"), + new ImageContent(image, "image/jpeg") + ]); + chatHistory.Add(messageContent); + + var sut = this.GetChatService(serviceType); + + // Act + var responses = await sut.GetStreamingChatMessageContentsAsync(chatHistory).ToListAsync(); + + // Assert + Assert.NotEmpty(responses); + var message = string.Concat(responses.Select(c => c.Content)); + Assert.False(string.IsNullOrWhiteSpace(message)); + this.Output.WriteLine(message); + Assert.Contains("green", message, StringComparison.OrdinalIgnoreCase); + } + + [RetryTheory] + [InlineData(ServiceType.Anthropic, Skip = "This test needs setup first.")] + [InlineData(ServiceType.VertexAI, Skip = "This test needs setup first.")] + [InlineData(ServiceType.AmazonBedrock, Skip = "This test needs setup first.")] + public async Task ChatGenerationVisionUriAsync(ServiceType serviceType) + { + // Arrange + Uri imageUri = new("gs://generativeai-downloads/images/scones.jpg"); // needs setup + var chatHistory = new ChatHistory(); + var messageContent = new ChatMessageContent(AuthorRole.User, items: + [ + new TextContent("This is an image with a car. Which color is it? You can chose from red, blue, green, and yellow"), + new ImageContent(imageUri) { MimeType = "image/jpeg" } + ]); + chatHistory.Add(messageContent); + + var sut = this.GetChatService(serviceType); + + // Act + var response = await sut.GetChatMessageContentAsync(chatHistory); + + // Assert + Assert.NotNull(response.Content); + this.Output.WriteLine(response.Content); + Assert.Contains("green", response.Content, StringComparison.OrdinalIgnoreCase); + } + + [RetryTheory] + [InlineData(ServiceType.Anthropic, Skip = "This test needs setup first.")] + [InlineData(ServiceType.VertexAI, Skip = "This test needs setup first.")] + [InlineData(ServiceType.AmazonBedrock, Skip = "This test needs setup first.")] + public async Task ChatStreamingVisionUriAsync(ServiceType serviceType) + { + // Arrange + Uri imageUri = new("gs://generativeai-downloads/images/scones.jpg"); // needs setup + var chatHistory = new ChatHistory(); + var messageContent = new ChatMessageContent(AuthorRole.User, items: + [ + new TextContent("This is an image with a car. Which color is it? You can chose from red, blue, green, and yellow"), + new ImageContent(imageUri) { MimeType = "image/jpeg" } + ]); + chatHistory.Add(messageContent); + + var sut = this.GetChatService(serviceType); + + // Act + var responses = await sut.GetStreamingChatMessageContentsAsync(chatHistory).ToListAsync(); + + // Assert + Assert.NotEmpty(responses); + var message = string.Concat(responses.Select(c => c.Content)); + Assert.False(string.IsNullOrWhiteSpace(message)); + this.Output.WriteLine(message); + Assert.Contains("green", message, StringComparison.OrdinalIgnoreCase); + } + + [RetryTheory] + [InlineData(ServiceType.Anthropic, Skip = "This test is for manual verification.")] + [InlineData(ServiceType.VertexAI, Skip = "This test is for manual verification.")] + [InlineData(ServiceType.AmazonBedrock, Skip = "This test is for manual verification.")] + public async Task ChatGenerationReturnsUsedTokensAsync(ServiceType serviceType) + { + // Arrange + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage("Hello, I'm Brandon, how are you?"); + chatHistory.AddAssistantMessage("I'm doing well, thanks for asking."); + chatHistory.AddUserMessage("Call me by my name and expand this abbreviation: LLM"); + + var sut = this.GetChatService(serviceType); + + // Act + var response = await sut.GetChatMessageContentAsync(chatHistory); + + // Assert + var metadata = response.Metadata as AnthropicMetadata; + Assert.NotNull(metadata); + foreach ((string? key, object? value) in metadata) + { + this.Output.WriteLine($"{key}: {JsonSerializer.Serialize(value)}"); + } + + Assert.True(metadata.TotalTokenCount > 0); + Assert.True(metadata.InputTokenCount > 0); + Assert.True(metadata.OutputTokenCount > 0); + } + + [RetryTheory] + [InlineData(ServiceType.Anthropic, Skip = "This test is for manual verification.")] + [InlineData(ServiceType.VertexAI, Skip = "This test is for manual verification.")] + [InlineData(ServiceType.AmazonBedrock, Skip = "This test is for manual verification.")] + public async Task ChatStreamingReturnsUsedTokensAsync(ServiceType serviceType) + { + // Arrange + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage("Hello, I'm Brandon, how are you?"); + chatHistory.AddAssistantMessage("I'm doing well, thanks for asking."); + chatHistory.AddUserMessage("Call me by my name and expand this abbreviation: LLM"); + + var sut = this.GetChatService(serviceType); + + // Act + var responses = await sut.GetStreamingChatMessageContentsAsync(chatHistory).ToListAsync(); + + // Assert + var metadata = responses.Last().Metadata as AnthropicMetadata; + Assert.NotNull(metadata); + this.Output.WriteLine($"TotalTokenCount: {metadata.TotalTokenCount}"); + this.Output.WriteLine($"InputTokenCount: {metadata.InputTokenCount}"); + this.Output.WriteLine($"OutputTokenCount: {metadata.OutputTokenCount}"); + Assert.True(metadata.TotalTokenCount > 0); + Assert.True(metadata.InputTokenCount > 0); + Assert.True(metadata.OutputTokenCount > 0); + } + + [RetryTheory] + [InlineData(ServiceType.Anthropic, Skip = "This test is for manual verification.")] + [InlineData(ServiceType.VertexAI, Skip = "This test is for manual verification.")] + [InlineData(ServiceType.AmazonBedrock, Skip = "This test is for manual verification.")] + public async Task ChatGenerationReturnsStopFinishReasonAsync(ServiceType serviceType) + { + // Arrange + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage("Hello, I'm Brandon, how are you?"); + chatHistory.AddAssistantMessage("I'm doing well, thanks for asking."); + chatHistory.AddUserMessage("Call me by my name and expand this abbreviation: LLM"); + + var sut = this.GetChatService(serviceType); + + // Act + var response = await sut.GetChatMessageContentAsync(chatHistory); + + // Assert + var metadata = response.Metadata as AnthropicMetadata; + Assert.NotNull(metadata); + this.Output.WriteLine($"FinishReason: {metadata.FinishReason}"); + Assert.Equal(AnthropicFinishReason.Stop, metadata.FinishReason); + } + + [RetryTheory] + [InlineData(ServiceType.Anthropic, Skip = "This test is for manual verification.")] + [InlineData(ServiceType.VertexAI, Skip = "This test is for manual verification.")] + [InlineData(ServiceType.AmazonBedrock, Skip = "This test is for manual verification.")] + public async Task ChatStreamingReturnsStopFinishReasonAsync(ServiceType serviceType) + { + // Arrange + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage("Hello, I'm Brandon, how are you?"); + chatHistory.AddAssistantMessage("I'm doing well, thanks for asking."); + chatHistory.AddUserMessage("Call me by my name and expand this abbreviation: LLM"); + + var sut = this.GetChatService(serviceType); + + // Act + var responses = await sut.GetStreamingChatMessageContentsAsync(chatHistory).ToListAsync(); + + // Assert + var metadata = responses.Last().Metadata as AnthropicMetadata; + Assert.NotNull(metadata); + this.Output.WriteLine($"FinishReason: {metadata.FinishReason}"); + Assert.Equal(AnthropicFinishReason.Stop, metadata.FinishReason); + } + + [RetryTheory] + [InlineData(ServiceType.Anthropic, Skip = "This can fail. Anthropic does not support this feature yet.")] + [InlineData(ServiceType.VertexAI, Skip = "This can fail. Anthropic does not support this feature yet.")] + [InlineData(ServiceType.AmazonBedrock, Skip = "This can fail. Anthropic does not support this feature yet.")] + public async Task ChatGenerationOnlyAssistantMessagesAsync(ServiceType serviceType) + { + // Arrange + var chatHistory = new ChatHistory(); + chatHistory.AddAssistantMessage("I'm very thirsty."); + chatHistory.AddAssistantMessage("Could you give me a glass of..."); + + var sut = this.GetChatService(serviceType); + + // Act + var response = await sut.GetChatMessageContentAsync(chatHistory); + + // Assert + string[] words = ["water", "juice", "milk", "soda", "tea", "coffee", "beer", "wine"]; + this.Output.WriteLine(response.Content); + Assert.Contains(words, word => response.Content!.Contains(word, StringComparison.OrdinalIgnoreCase)); + } + + [RetryTheory] + [InlineData(ServiceType.Anthropic, Skip = "This can fail. Anthropic does not support this feature yet.")] + [InlineData(ServiceType.VertexAI, Skip = "This can fail. Anthropic does not support this feature yet.")] + [InlineData(ServiceType.AmazonBedrock, Skip = "This can fail. Anthropic does not support this feature yet.")] + public async Task ChatStreamingOnlyAssistantMessagesAsync(ServiceType serviceType) + { + // Arrange + var chatHistory = new ChatHistory(); + chatHistory.AddAssistantMessage("I'm very thirsty."); + chatHistory.AddAssistantMessage("Could you give me a glass of..."); + + var sut = this.GetChatService(serviceType); + + // Act + var responses = await sut.GetStreamingChatMessageContentsAsync(chatHistory).ToListAsync(); + + // Assert + string[] words = ["water", "juice", "milk", "soda", "tea", "coffee", "beer", "wine"]; + Assert.NotEmpty(responses); + var message = string.Concat(responses.Select(c => c.Content)); + this.Output.WriteLine(message); + Assert.Contains(words, word => message.Contains(word, StringComparison.OrdinalIgnoreCase)); + } + + [RetryTheory] + [InlineData(ServiceType.Anthropic, Skip = "This can fail. Anthropic does not support this feature yet.")] + [InlineData(ServiceType.VertexAI, Skip = "This can fail. Anthropic does not support this feature yet.")] + [InlineData(ServiceType.AmazonBedrock, Skip = "This can fail. Anthropic does not support this feature yet.")] + public async Task ChatGenerationOnlyUserMessagesAsync(ServiceType serviceType) + { + // Arrange + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage("I'm very thirsty."); + chatHistory.AddUserMessage("Could you give me a glass of..."); + + var sut = this.GetChatService(serviceType); + + // Act + var response = await sut.GetChatMessageContentAsync(chatHistory); + + // Assert + string[] words = ["water", "juice", "milk", "soda", "tea", "coffee", "beer", "wine"]; + this.Output.WriteLine(response.Content); + Assert.Contains(words, word => response.Content!.Contains(word, StringComparison.OrdinalIgnoreCase)); + } + + [RetryTheory] + [InlineData(ServiceType.Anthropic, Skip = "This can fail. Anthropic does not support this feature yet.")] + [InlineData(ServiceType.VertexAI, Skip = "This can fail. Anthropic does not support this feature yet.")] + [InlineData(ServiceType.AmazonBedrock, Skip = "This can fail. Anthropic does not support this feature yet.")] + public async Task ChatStreamingOnlyUserMessagesAsync(ServiceType serviceType) + { + // Arrange + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage("I'm very thirsty."); + chatHistory.AddUserMessage("Could you give me a glass of..."); + + var sut = this.GetChatService(serviceType); + + // Act + var responses = await sut.GetStreamingChatMessageContentsAsync(chatHistory).ToListAsync(); + + // Assert + string[] words = ["water", "juice", "milk", "soda", "tea", "coffee", "beer", "wine"]; + Assert.NotEmpty(responses); + var message = string.Concat(responses.Select(c => c.Content)); + this.Output.WriteLine(message); + Assert.Contains(words, word => message.Contains(word, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/dotnet/src/IntegrationTests/Connectors/Anthropic/TestBase.cs b/dotnet/src/IntegrationTests/Connectors/Anthropic/TestBase.cs new file mode 100644 index 000000000000..963b719503ee --- /dev/null +++ b/dotnet/src/IntegrationTests/Connectors/Anthropic/TestBase.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Anthropic; +using Xunit.Abstractions; + +namespace SemanticKernel.IntegrationTests.Connectors.Anthropic; + +public abstract class TestBase(ITestOutputHelper output) +{ + private readonly IConfigurationRoot _configuration = new ConfigurationBuilder() + .AddJsonFile(path: "testsettings.json", optional: true, reloadOnChange: true) + .AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true) + .AddUserSecrets() + .AddEnvironmentVariables() + .Build(); + + protected ITestOutputHelper Output { get; } = output; + + protected IChatCompletionService GetChatService(ServiceType serviceType) => serviceType switch + { + ServiceType.Anthropic => new AnthropicChatCompletionService(this.AnthropicGetModel(), this.AnthropicGetApiKey(), new()), + ServiceType.VertexAI => new AnthropicChatCompletionService(this.VertexAIGetModel(), this.VertexAIGetBearerKey(), new VertexAIAnthropicClientOptions(), this.VertexAIGetEndpoint()), + ServiceType.AmazonBedrock => new AnthropicChatCompletionService(this.VertexAIGetModel(), this.AmazonBedrockGetBearerKey(), new AmazonBedrockAnthropicClientOptions(), this.VertexAIGetEndpoint()), + _ => throw new ArgumentOutOfRangeException(nameof(serviceType), serviceType, null) + }; + + public enum ServiceType + { + Anthropic, + VertexAI, + AmazonBedrock + } + + private string AnthropicGetModel() => this._configuration.GetSection("Anthropic:ModelId").Get()!; + private string AnthropicGetApiKey() => this._configuration.GetSection("Anthropic:ApiKey").Get()!; + private string VertexAIGetModel() => this._configuration.GetSection("VertexAI:Anthropic:ModelId").Get()!; + private Uri VertexAIGetEndpoint() => new(this._configuration.GetSection("VertexAI:Anthropic:Endpoint").Get()!); + private Func> VertexAIGetBearerKey() => () => ValueTask.FromResult(this._configuration.GetSection("VertexAI:BearerKey").Get()!); + private Func> AmazonBedrockGetBearerKey() => () => ValueTask.FromResult(this._configuration.GetSection("AmazonBedrock:Anthropic:BearerKey").Get()!); + private string AmazonBedrockGetModel() => this._configuration.GetSection("AmazonBedrock:Anthropic:ModelId").Get()!; + private Uri AmazonBedrockGetEndpoint() => new(this._configuration.GetSection("AmazonBedrock:Anthropic:Endpoint").Get()!); +} diff --git a/dotnet/src/IntegrationTests/Connectors/Google/EmbeddingGenerationTests.cs b/dotnet/src/IntegrationTests/Connectors/Google/EmbeddingGenerationTests.cs index 79fc5db80aff..a3b4716174db 100644 --- a/dotnet/src/IntegrationTests/Connectors/Google/EmbeddingGenerationTests.cs +++ b/dotnet/src/IntegrationTests/Connectors/Google/EmbeddingGenerationTests.cs @@ -8,7 +8,7 @@ namespace SemanticKernel.IntegrationTests.Connectors.Google; -public sealed class EmbeddingGenerationTests(ITestOutputHelper output) : TestsBase(output) +public sealed class EmbeddingGenerationTests(ITestOutputHelper output) : TestBase(output) { [RetryTheory] [InlineData(ServiceType.GoogleAI, Skip = "This test is for manual verification.")] diff --git a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiChatCompletionTests.cs b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiChatCompletionTests.cs index 5732a3e4719a..615bb29f0dc8 100644 --- a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiChatCompletionTests.cs +++ b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiChatCompletionTests.cs @@ -14,7 +14,7 @@ namespace SemanticKernel.IntegrationTests.Connectors.Google.Gemini; -public sealed class GeminiChatCompletionTests(ITestOutputHelper output) : TestsBase(output) +public sealed class GeminiChatCompletionTests(ITestOutputHelper output) : TestBase(output) { [RetryTheory] [InlineData(ServiceType.GoogleAI, Skip = "This test is for manual verification.")] diff --git a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingTests.cs b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingTests.cs index 37c48f0842b4..53629fe191da 100644 --- a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingTests.cs +++ b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingTests.cs @@ -14,7 +14,7 @@ namespace SemanticKernel.IntegrationTests.Connectors.Google.Gemini; -public sealed class GeminiFunctionCallingTests(ITestOutputHelper output) : TestsBase(output) +public sealed class GeminiFunctionCallingTests(ITestOutputHelper output) : TestBase(output) { [RetryTheory] [InlineData(ServiceType.GoogleAI, Skip = "This test is for manual verification.")] diff --git a/dotnet/src/IntegrationTests/Connectors/Google/TestsBase.cs b/dotnet/src/IntegrationTests/Connectors/Google/TestBase.cs similarity index 97% rename from dotnet/src/IntegrationTests/Connectors/Google/TestsBase.cs rename to dotnet/src/IntegrationTests/Connectors/Google/TestBase.cs index 6b932727f4a6..8cf794d473b1 100644 --- a/dotnet/src/IntegrationTests/Connectors/Google/TestsBase.cs +++ b/dotnet/src/IntegrationTests/Connectors/Google/TestBase.cs @@ -9,12 +9,12 @@ namespace SemanticKernel.IntegrationTests.Connectors.Google; -public abstract class TestsBase(ITestOutputHelper output) +public abstract class TestBase(ITestOutputHelper output) { private readonly IConfigurationRoot _configuration = new ConfigurationBuilder() .AddJsonFile(path: "testsettings.json", optional: false, reloadOnChange: true) .AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true) - .AddUserSecrets() + .AddUserSecrets() .AddEnvironmentVariables() .Build(); diff --git a/dotnet/src/IntegrationTests/IntegrationTests.csproj b/dotnet/src/IntegrationTests/IntegrationTests.csproj index df5afa473ce7..8a7ae84bacef 100644 --- a/dotnet/src/IntegrationTests/IntegrationTests.csproj +++ b/dotnet/src/IntegrationTests/IntegrationTests.csproj @@ -59,6 +59,7 @@ + From 28cadcc081d4b1402474f6f6cbd8b4ee6ce6a0b1 Mon Sep 17 00:00:00 2001 From: Krzysztof Kasprowicz <60486987+Krzysztof318@users.noreply.github.com> Date: Tue, 10 Sep 2024 18:56:06 +0200 Subject: [PATCH 6/7] .Net: Anthropic - samples (#8585) #5690 Added samples @RogerBarreto --------- Co-authored-by: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> --- .../Anthropic_ChatCompletion.cs | 68 ++++++++++++++ .../Anthropic_ChatCompletionStreaming.cs | 90 +++++++++++++++++++ .../Anthropic_ProvidersSetup.cs | 33 +++++++ .../ChatCompletion/Anthropic_Vision.cs | 52 +++++++++++ dotnet/samples/Concepts/Concepts.csproj | 1 + dotnet/samples/Concepts/README.md | 4 + .../Demos/AIModelRouter/AIModelRouter.csproj | 1 + dotnet/samples/Demos/AIModelRouter/Program.cs | 12 ++- dotnet/samples/Demos/AIModelRouter/README.md | 24 ++++- .../InternalUtilities/TestConfiguration.cs | 7 ++ 10 files changed, 288 insertions(+), 4 deletions(-) create mode 100644 dotnet/samples/Concepts/ChatCompletion/Anthropic_ChatCompletion.cs create mode 100644 dotnet/samples/Concepts/ChatCompletion/Anthropic_ChatCompletionStreaming.cs create mode 100644 dotnet/samples/Concepts/ChatCompletion/Anthropic_ProvidersSetup.cs create mode 100644 dotnet/samples/Concepts/ChatCompletion/Anthropic_Vision.cs diff --git a/dotnet/samples/Concepts/ChatCompletion/Anthropic_ChatCompletion.cs b/dotnet/samples/Concepts/ChatCompletion/Anthropic_ChatCompletion.cs new file mode 100644 index 000000000000..c3bf9a0a19d8 --- /dev/null +++ b/dotnet/samples/Concepts/ChatCompletion/Anthropic_ChatCompletion.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace ChatCompletion; + +public sealed class Anthropic_ChatCompletion(ITestOutputHelper output) : BaseTest(output) +{ + [Fact] + public async Task SampleAsync() + { + Console.WriteLine("============= Anthropic - Claude Chat Completion ============="); + + string apiKey = TestConfiguration.AnthropicAI.ApiKey; + string modelId = TestConfiguration.AnthropicAI.ModelId; + + Assert.NotNull(apiKey); + Assert.NotNull(modelId); + + Kernel kernel = Kernel.CreateBuilder() + .AddAnthropicChatCompletion( + modelId: modelId, + apiKey: apiKey) + .Build(); + + await SimpleChatAsync(kernel); + } + + private async Task SimpleChatAsync(Kernel kernel) + { + Console.WriteLine("======== Simple Chat ========"); + + var chatHistory = new ChatHistory("You are an expert in the tool shop."); + var chat = kernel.GetRequiredService(); + + // First user message + chatHistory.AddUserMessage("Hi, I'm looking for new power tools, any suggestion?"); + await MessageOutputAsync(chatHistory); + + // First bot assistant message + var reply = await chat.GetChatMessageContentAsync(chatHistory); + chatHistory.Add(reply); + await MessageOutputAsync(chatHistory); + + // Second user message + chatHistory.AddUserMessage("I'm looking for a drill, a screwdriver and a hammer."); + await MessageOutputAsync(chatHistory); + + // Second bot assistant message + reply = await chat.GetChatMessageContentAsync(chatHistory); + chatHistory.Add(reply); + await MessageOutputAsync(chatHistory); + } + + /// + /// Outputs the last message of the chat history + /// + private Task MessageOutputAsync(ChatHistory chatHistory) + { + var message = chatHistory.Last(); + + Console.WriteLine($"{message.Role}: {message.Content}"); + Console.WriteLine("------------------------"); + + return Task.CompletedTask; + } +} diff --git a/dotnet/samples/Concepts/ChatCompletion/Anthropic_ChatCompletionStreaming.cs b/dotnet/samples/Concepts/ChatCompletion/Anthropic_ChatCompletionStreaming.cs new file mode 100644 index 000000000000..471107d8281b --- /dev/null +++ b/dotnet/samples/Concepts/ChatCompletion/Anthropic_ChatCompletionStreaming.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace ChatCompletion; + +public sealed class Anthropic_ChatCompletionStreaming(ITestOutputHelper output) : BaseTest(output) +{ + [Fact] + public async Task SampleAsync() + { + Console.WriteLine("============= Anthropic - Claude Chat Streaming ============="); + + string apiKey = TestConfiguration.AnthropicAI.ApiKey; + string modelId = TestConfiguration.AnthropicAI.ModelId; + + Assert.NotNull(apiKey); + Assert.NotNull(modelId); + + Kernel kernel = Kernel.CreateBuilder() + .AddAnthropicChatCompletion( + modelId: modelId, + apiKey: apiKey) + .Build(); + + await this.StreamingChatAsync(kernel); + } + + private async Task StreamingChatAsync(Kernel kernel) + { + Console.WriteLine("======== Streaming Chat ========"); + + var chatHistory = new ChatHistory("You are an expert in the tool shop."); + var chat = kernel.GetRequiredService(); + + // First user message + chatHistory.AddUserMessage("Hi, I'm looking for alternative coffee brew methods, can you help me?"); + await MessageOutputAsync(chatHistory); + + // First bot assistant message + var streamingChat = chat.GetStreamingChatMessageContentsAsync(chatHistory); + var reply = await MessageOutputAsync(streamingChat); + chatHistory.Add(reply); + + // Second user message + chatHistory.AddUserMessage("Give me the best speciality coffee roasters."); + await MessageOutputAsync(chatHistory); + + // Second bot assistant message + streamingChat = chat.GetStreamingChatMessageContentsAsync(chatHistory); + reply = await MessageOutputAsync(streamingChat); + chatHistory.Add(reply); + } + + /// + /// Outputs the last message of the chat history + /// + private Task MessageOutputAsync(ChatHistory chatHistory) + { + var message = chatHistory.Last(); + + Console.WriteLine($"{message.Role}: {message.Content}"); + Console.WriteLine("------------------------"); + + return Task.CompletedTask; + } + + private async Task MessageOutputAsync(IAsyncEnumerable streamingChat) + { + bool first = true; + StringBuilder messageBuilder = new(); + await foreach (var chatMessage in streamingChat) + { + if (first) + { + Console.Write($"{chatMessage.Role}: "); + first = false; + } + + Console.Write(chatMessage.Content); + messageBuilder.Append(chatMessage.Content); + } + + Console.WriteLine(); + Console.WriteLine("------------------------"); + return new ChatMessageContent(AuthorRole.Assistant, messageBuilder.ToString()); + } +} diff --git a/dotnet/samples/Concepts/ChatCompletion/Anthropic_ProvidersSetup.cs b/dotnet/samples/Concepts/ChatCompletion/Anthropic_ProvidersSetup.cs new file mode 100644 index 000000000000..753d7af61c79 --- /dev/null +++ b/dotnet/samples/Concepts/ChatCompletion/Anthropic_ProvidersSetup.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; + +namespace ChatCompletion; + +/// +/// This sample shows how to setup different providers for anthropic. +/// +public sealed class Anthropic_ProvidersSetup(ITestOutputHelper output) : BaseTest(output) +{ + public void AnthropicProvider() + { + var kernel = Kernel.CreateBuilder() + .AddAnthropicChatCompletion( + modelId: "modelId", + apiKey: "apiKey") + .Build(); + } + + /// + /// For more information on how to setup the Vertex AI provider, go to sample. + /// + public void VertexAiProvider() + { + var kernel = Kernel.CreateBuilder() + .AddAnthropicVertextAIChatCompletion( + modelId: "modelId", + bearerTokenProvider: () => ValueTask.FromResult("bearer"), + endpoint: new Uri("https://your-endpoint")) + .Build(); + } +} diff --git a/dotnet/samples/Concepts/ChatCompletion/Anthropic_Vision.cs b/dotnet/samples/Concepts/ChatCompletion/Anthropic_Vision.cs new file mode 100644 index 000000000000..324992ed17df --- /dev/null +++ b/dotnet/samples/Concepts/ChatCompletion/Anthropic_Vision.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Resources; + +namespace ChatCompletion; + +public sealed class Anthropic_Vision(ITestOutputHelper output) : BaseTest(output) +{ + [Fact] + public async Task SampleAsync() + { + Console.WriteLine("============= Anthropic - Claude Chat Completion ============="); + + string apiKey = TestConfiguration.AnthropicAI.ApiKey; + string modelId = TestConfiguration.AnthropicAI.ModelId; + + Assert.NotNull(apiKey); + Assert.NotNull(modelId); + + Kernel kernel = Kernel.CreateBuilder() + .AddAnthropicChatCompletion( + modelId: modelId, + apiKey: apiKey) + .Build(); + + var chatHistory = new ChatHistory("Your job is describing images."); + var chatCompletionService = kernel.GetRequiredService(); + + // Load the image from the resources + await using var stream = EmbeddedResource.ReadStream("sample_image.jpg")!; + using var binaryReader = new BinaryReader(stream); + var bytes = binaryReader.ReadBytes((int)stream.Length); + + chatHistory.AddUserMessage( + [ + new TextContent("What’s in this image?"), + // Vertex AI Gemini API supports both base64 and URI format + // You have to always provide the mimeType for the image + new ImageContent(bytes, "image/jpeg"), + // The Cloud Storage URI of the image to include in the prompt. + // The bucket that stores the file must be in the same Google Cloud project that's sending the request. + // new ImageContent(new Uri("gs://generativeai-downloads/images/scones.jpg"), + // metadata: new Dictionary { { "mimeType", "image/jpeg" } }) + ]); + + var reply = await chatCompletionService.GetChatMessageContentAsync(chatHistory); + + Console.WriteLine(reply.Content); + } +} diff --git a/dotnet/samples/Concepts/Concepts.csproj b/dotnet/samples/Concepts/Concepts.csproj index 25724b822243..eacb90f66d35 100644 --- a/dotnet/samples/Concepts/Concepts.csproj +++ b/dotnet/samples/Concepts/Concepts.csproj @@ -55,6 +55,7 @@ + diff --git a/dotnet/samples/Concepts/README.md b/dotnet/samples/Concepts/README.md index cbbcc2539288..05876bd0dc5e 100644 --- a/dotnet/samples/Concepts/README.md +++ b/dotnet/samples/Concepts/README.md @@ -59,6 +59,10 @@ dotnet test -l "console;verbosity=detailed" --filter "FullyQualifiedName=ChatCom - [Google_GeminiChatCompletionStreaming](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/ChatCompletion/Google_GeminiChatCompletionStreaming.cs) - [Google_GeminiGetModelResult](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/ChatCompletion/Google_GeminiGetModelResult.cs) - [Google_GeminiVision](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/ChatCompletion/Google_GeminiVision.cs) +- [Anthropic_ChatCompletion](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/ChatCompletion/Anthropic_ChatCompletion.cs) +- [Anthropic_ChatCompletionStreaming](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/ChatCompletion/Anthropic_ChatCompletionStreaming.cs) +- [Anthropic_Vision](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/ChatCompletion/Anthropic_Vision.cs) +- [Anthropic_ProvidersSetup](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/ChatCompletion/Anthropic_ProvidersSetup.cs) - [OpenAI_ChatCompletion](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/ChatCompletion/OpenAI_ChatCompletion.cs) - [OpenAI_ChatCompletionMultipleChoices](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/ChatCompletion/OpenAI_ChatCompletionMultipleChoices.cs) - [OpenAI_ChatCompletionStreaming](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/ChatCompletion/OpenAI_ChatCompletionStreaming.cs) diff --git a/dotnet/samples/Demos/AIModelRouter/AIModelRouter.csproj b/dotnet/samples/Demos/AIModelRouter/AIModelRouter.csproj index 4ce04e354cc8..e53aa55a2a79 100644 --- a/dotnet/samples/Demos/AIModelRouter/AIModelRouter.csproj +++ b/dotnet/samples/Demos/AIModelRouter/AIModelRouter.csproj @@ -15,6 +15,7 @@ + diff --git a/dotnet/samples/Demos/AIModelRouter/Program.cs b/dotnet/samples/Demos/AIModelRouter/Program.cs index 9d3631dbcb90..612c13b251c3 100644 --- a/dotnet/samples/Demos/AIModelRouter/Program.cs +++ b/dotnet/samples/Demos/AIModelRouter/Program.cs @@ -74,6 +74,16 @@ private static async Task Main(string[] args) Console.WriteLine("• Azure AI Inference Added - Use \"azureai\" in the prompt."); } + if (config["Anthropic:ApiKey"] is not null) + { + services.AddAnthropicChatCompletion( + serviceId: "anthropic", + modelId: config["Anthropic:ModelId"] ?? "claude-3-5-sonnet-20240620", + apiKey: config["Anthropic:ApiKey"]!); + + Console.WriteLine("• Anthropic Added - Use \"anthropic\" in the prompt."); + } + // Adding a custom filter to capture router selected service id services.AddSingleton(new SelectedServiceFilter()); @@ -92,7 +102,7 @@ private static async Task Main(string[] args) // Find the best service to use based on the user's input KernelArguments arguments = new(new PromptExecutionSettings() { - ServiceId = router.FindService(userMessage, ["lmstudio", "ollama", "openai", "onnx", "azureai"]) + ServiceId = router.FindService(userMessage, ["lmstudio", "ollama", "openai", "onnx", "azureai", "anthropic"]) }); // Invoke the prompt and print the response diff --git a/dotnet/samples/Demos/AIModelRouter/README.md b/dotnet/samples/Demos/AIModelRouter/README.md index afb061ced3c2..7d0269977a3a 100644 --- a/dotnet/samples/Demos/AIModelRouter/README.md +++ b/dotnet/samples/Demos/AIModelRouter/README.md @@ -22,11 +22,17 @@ The sample can be configured by using the command line with .NET [Secret Manager ```powershell dotnet user-secrets set "OpenAI:ApiKey" "... your api key ... " -dotnet user-secrets set "OpenAI:ModelId" ".. Openai model .. " (default: gpt-4o) +dotnet user-secrets set "OpenAI:ModelId" ".. Openai model id .. " (default: gpt-4o) + +dotnet user-secrets set "Anthropic:ApiKey" "... your api key ... " +dotnet user-secrets set "Anthropic:ModelId" "... Anthropic model id .. " (default: claude-3-5-sonnet-20240620) + dotnet user-secrets set "Ollama:ModelId" ".. Ollama model id .. " dotnet user-secrets set "Ollama:Endpoint" ".. Ollama endpoint .. " (default: http://localhost:11434) + dotnet user-secrets set "LMStudio:Endpoint" ".. LM Studio endpoint .. " (default: http://localhost:1234) -dotnet user-secrets set "Onnx:ModelId" ".. Onnx model id" + +dotnet user-secrets set "Onnx:ModelId" ".. Onnx model id .. " dotnet user-secrets set "Onnx:ModelPath" ".. your Onnx model folder path .." ``` @@ -53,4 +59,16 @@ dotnet run > **User** > LMStudio, what is Jupiter? Keep it simple. -> **Assistant** > Jupiter is the fifth planet from the Sun in our Solar System and one of its gas giants alongside Saturn, Uranus, and Neptune. It's famous for having a massive storm called the Great Red Spot that has been raging for hundreds of years. \ No newline at end of file +> **Assistant** > Jupiter is the fifth planet from the Sun in our Solar System and one of its gas giants alongside Saturn, Uranus, and Neptune. It's famous for having a massive storm called the Great Red Spot that has been raging for hundreds of years. + +> **User** > AzureAI, what is Jupiter? Keep it simple. + +> **Assistant** > Jupiter is the fifth planet from the Sun in our Solar System and one of its gas giants alongside Saturn, Uranus, and Neptune. It's famous for having a massive storm called the Great Red Spot that has been raging for hundreds of years. + +> **User** > Anthropic, what is Jupiter? Keep it simple. + +> **Assistant** > Jupiter is the fifth planet from the Sun in our Solar System and one of its gas giants alongside Saturn, Uranus, and Neptune. It's famous for having a massive storm called the Great Red Spot that has been raging for hundreds of years. + +> **User** > ONNX, what is Jupiter? Keep it simple. + +> **Assistant** > Jupiter is the fifth planet from the Sun in our Solar System and one of its gas giants alongside Saturn, Uranus, and Neptune. It's famous for having a massive storm called the Great Red Spot that has been raging for hundreds of years. diff --git a/dotnet/src/InternalUtilities/samples/InternalUtilities/TestConfiguration.cs b/dotnet/src/InternalUtilities/samples/InternalUtilities/TestConfiguration.cs index 01b60b08c9cb..07bdaf313ef2 100644 --- a/dotnet/src/InternalUtilities/samples/InternalUtilities/TestConfiguration.cs +++ b/dotnet/src/InternalUtilities/samples/InternalUtilities/TestConfiguration.cs @@ -44,6 +44,7 @@ public static void Initialize(IConfigurationRoot configRoot) public static ChatGPTRetrievalPluginConfig ChatGPTRetrievalPlugin => LoadSection(); public static MsGraphConfiguration MSGraph => LoadSection(); public static MistralAIConfig MistralAI => LoadSection(); + public static AnthropicAIConfig AnthropicAI => LoadSection(); public static GoogleAIConfig GoogleAI => LoadSection(); public static VertexAIConfig VertexAI => LoadSection(); public static AzureCosmosDbMongoDbConfig AzureCosmosDbMongoDb => LoadSection(); @@ -213,6 +214,12 @@ public class MistralAIConfig public string EmbeddingModelId { get; set; } } + public class AnthropicAIConfig + { + public string ApiKey { get; set; } + public string ModelId { get; set; } + } + public class GoogleAIConfig { public string ApiKey { get; set; } From 17cfd02724c40fb5dd7c31a69fb67d95edaa1ef4 Mon Sep 17 00:00:00 2001 From: Krzysztof Kasprowicz <60486987+Krzysztof318@users.noreply.github.com> Date: Tue, 29 Oct 2024 10:57:57 +0100 Subject: [PATCH 7/7] .Net: Anthropic - streaming (#8560) ### Motivation and Context #5690 ### Description Added streaming functionality and related tests. @RogerBarreto @RogerBarret0 ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone :smile: --- .../Core/AnthropicChatGenerationTests.cs | 53 +- .../Core/AnthropicChatStreamingTests.cs | 467 ++++++++++++++++++ .../Core/AnthropicRequestTests.cs | 1 + .../TestData/chat_stream_response.txt | 24 + .../Utils/CustomHeadersHandler.cs | 45 ++ .../Core/AnthropicClient.cs | 165 +++++-- .../Models/{Message => }/AnthropicContent.cs | 2 +- .../Core/Models/AnthropicRequest.cs | 6 +- .../Core/Models/AnthropicResponse.cs | 4 +- .../Core/Models/AnthropicStreamingResponse.cs | 86 ++++ .../AnthropicStreamingChatMessageContent.cs | 44 ++ .../Models/Contents/AnthropicUsage.cs | 1 - .../Anthropic/AnthropicChatCompletionTests.cs | 19 +- 13 files changed, 818 insertions(+), 99 deletions(-) create mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicChatStreamingTests.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/TestData/chat_stream_response.txt create mode 100644 dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Utils/CustomHeadersHandler.cs rename dotnet/src/Connectors/Connectors.Anthropic/Core/Models/{Message => }/AnthropicContent.cs (95%) create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicStreamingResponse.cs create mode 100644 dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicStreamingChatMessageContent.cs diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicChatGenerationTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicChatGenerationTests.cs index 7b9ce14ad150..f77f4b3a9a3a 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicChatGenerationTests.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicChatGenerationTests.cs @@ -4,13 +4,14 @@ using System.IO; using System.Linq; using System.Net.Http; -using System.Net.Http.Headers; using System.Text.Json; using System.Threading.Tasks; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.Anthropic; using Microsoft.SemanticKernel.Connectors.Anthropic.Core; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core.Models; using Microsoft.SemanticKernel.Http; +using SemanticKernel.Connectors.Anthropic.UnitTests.Utils; using Xunit; namespace SemanticKernel.Connectors.Anthropic.UnitTests.Core; @@ -18,13 +19,13 @@ namespace SemanticKernel.Connectors.Anthropic.UnitTests.Core; /// /// Test for /// -public sealed class AnthropicClientChatGenerationTests : IDisposable +public sealed class AnthropicChatGenerationTests : IDisposable { private readonly HttpClient _httpClient; private readonly HttpMessageHandlerStub _messageHandlerStub; private const string ChatTestDataFilePath = "./TestData/chat_one_response.json"; - public AnthropicClientChatGenerationTests() + public AnthropicChatGenerationTests() { this._messageHandlerStub = new HttpMessageHandlerStub(); this._messageHandlerStub.ResponseToReturn.Content = new StringContent( @@ -243,11 +244,13 @@ public async Task ShouldPassSystemMessageToRequestAsync() } [Fact] - public async Task ShouldPassVersionToRequestBodyIfCustomHandlerUsedAsync() + public async Task ShouldPassVersionToRequestBodyIfThirdVendorIsUsedAsync() { // Arrange - var options = new AnthropicClientOptions(); - var client = new AnthropicClient("fake-model", "api-key", options: new(), httpClient: this._httpClient); + var options = new AmazonBedrockAnthropicClientOptions(); + var client = new AnthropicClient("fake-model", new Uri("https://fake-uri.com"), + bearerTokenProvider: () => ValueTask.FromResult("fake-token"), + options: options, httpClient: this._httpClient); var chatHistory = CreateSampleChatHistory(); @@ -390,7 +393,7 @@ public async Task ItCreatesRequestWithCustomUriAndCustomHeadersAsync(string head { // Arrange Uri uri = new("https://fake-uri.com"); - using var httpHandler = new CustomHeadersHandler(headerName, headerValue); + using var httpHandler = new CustomHeadersHandler(headerName, headerValue, ChatTestDataFilePath); using var httpClient = new HttpClient(httpHandler); httpClient.BaseAddress = uri; var client = new AnthropicClient("fake-model", "api-key", options: new(), httpClient: httpClient); @@ -439,40 +442,4 @@ public void Dispose() this._httpClient.Dispose(); this._messageHandlerStub.Dispose(); } - - private sealed class CustomHeadersHandler : DelegatingHandler - { - private readonly string _headerName; - private readonly string _headerValue; - public HttpRequestHeaders? RequestHeaders { get; private set; } - - public HttpContentHeaders? ContentHeaders { get; private set; } - - public byte[]? RequestContent { get; private set; } - - public Uri? RequestUri { get; private set; } - - public HttpMethod? Method { get; private set; } - - public CustomHeadersHandler(string headerName, string headerValue) - { - this.InnerHandler = new HttpMessageHandlerStub - { - ResponseToReturn = { Content = new StringContent(File.ReadAllText(ChatTestDataFilePath)) } - }; - this._headerName = headerName; - this._headerValue = headerValue; - } - - protected override Task SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) - { - request.Headers.Add(this._headerName, this._headerValue); - this.Method = request.Method; - this.RequestUri = request.RequestUri; - this.RequestHeaders = request.Headers; - this.RequestContent = request.Content is null ? null : request.Content.ReadAsByteArrayAsync(cancellationToken).Result; - - return base.SendAsync(request, cancellationToken); - } - } } diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicChatStreamingTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicChatStreamingTests.cs new file mode 100644 index 000000000000..d8d5b04a0d05 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicChatStreamingTests.cs @@ -0,0 +1,467 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Anthropic; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core.Models; +using Microsoft.SemanticKernel.Http; +using SemanticKernel.Connectors.Anthropic.UnitTests.Utils; +using Xunit; + +namespace SemanticKernel.Connectors.Anthropic.UnitTests.Core; + +/// +/// Test for +/// +public sealed class AnthropicChatStreamingTests : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly HttpMessageHandlerStub _messageHandlerStub; + private const string ChatTestDataFilePath = "./TestData/chat_stream_response.txt"; + + public AnthropicChatStreamingTests() + { + this._messageHandlerStub = new HttpMessageHandlerStub(); + this._messageHandlerStub.ResponseToReturn.Content = new StringContent( + File.ReadAllText(ChatTestDataFilePath)); + + this._httpClient = new HttpClient(this._messageHandlerStub, false); + } + + [Fact] + public async Task ShouldSetStreamTrueInRequestContentAsync() + { + // Arrange + string modelId = "fake-model234"; + var client = this.CreateChatCompletionClient(modelId: modelId); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + AnthropicRequest? request = JsonSerializer.Deserialize(this._messageHandlerStub.RequestContent); + Assert.NotNull(request); + Assert.True(request.Stream); + } + + [Fact] + public async Task ShouldPassModelIdToRequestContentAsync() + { + // Arrange + string modelId = "fake-model234"; + var client = this.CreateChatCompletionClient(modelId: modelId); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + AnthropicRequest? request = JsonSerializer.Deserialize(this._messageHandlerStub.RequestContent); + Assert.NotNull(request); + Assert.Contains(modelId, request.ModelId, StringComparison.Ordinal); + } + + [Fact] + public async Task ShouldContainRolesInRequestAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + AnthropicRequest? request = JsonSerializer.Deserialize(this._messageHandlerStub.RequestContent); + Assert.NotNull(request); + Assert.Collection(request.Messages, + item => Assert.Equal(chatHistory[1].Role, item.Role), + item => Assert.Equal(chatHistory[2].Role, item.Role), + item => Assert.Equal(chatHistory[3].Role, item.Role)); + } + + [Fact] + public async Task ShouldContainMessagesInRequestAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + AnthropicRequest? request = JsonSerializer.Deserialize(this._messageHandlerStub.RequestContent); + Assert.NotNull(request); + Assert.Collection(request.Messages, + item => Assert.Equal(chatHistory[1].Content, GetTextFrom(item.Contents[0])), + item => Assert.Equal(chatHistory[2].Content, GetTextFrom(item.Contents[0])), + item => Assert.Equal(chatHistory[3].Content, GetTextFrom(item.Contents[0]))); + + string? GetTextFrom(AnthropicContent content) => content.Text; + } + + [Fact] + public async Task ShouldReturnValidChatResponseAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + var responses = await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + Assert.NotNull(responses); + Assert.NotEmpty(responses); + string content = string.Concat(responses.Select(streamingContent => streamingContent.Content)); + Assert.Equal("Hi! My name is Claude.", content); + Assert.All(responses, response => Assert.Equal(AuthorRole.Assistant, response.Role)); + } + + [Fact] + public async Task ShouldReturnValidAnthropicMetadataStartMessageAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + var streamingChatMessageContents = await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + Assert.NotNull(streamingChatMessageContents); + Assert.NotEmpty(streamingChatMessageContents); + var messageContent = streamingChatMessageContents.First(); + var metadata = messageContent.Metadata as AnthropicMetadata; + Assert.NotNull(metadata); + Assert.Null(metadata.FinishReason); + Assert.Equal("msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", metadata.MessageId); + Assert.Null(metadata.StopSequence); + Assert.Equal(25, metadata.InputTokenCount); + Assert.Equal(1, metadata.OutputTokenCount); + } + + [Fact] + public async Task ShouldReturnNullAnthropicMetadataDeltaMessagesAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + var streamingChatMessageContents = await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + Assert.NotNull(streamingChatMessageContents); + Assert.NotEmpty(streamingChatMessageContents); + var deltaMessages = streamingChatMessageContents[1..^1]; + Assert.All(deltaMessages, messageContent => Assert.Null(messageContent.Metadata)); + } + + [Fact] + public async Task ShouldReturnValidAnthropicMetadataEndMessageAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + var streamingChatMessageContents = await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + Assert.NotNull(streamingChatMessageContents); + Assert.NotEmpty(streamingChatMessageContents); + var messageContent = streamingChatMessageContents.Last(); + var metadata = messageContent.Metadata as AnthropicMetadata; + Assert.NotNull(metadata); + Assert.Equal(AnthropicFinishReason.StopSequence, metadata.FinishReason); + Assert.Equal("msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", metadata.MessageId); + Assert.Equal("claude", metadata.StopSequence); + Assert.Equal(0, metadata.InputTokenCount); + Assert.Equal(15, metadata.OutputTokenCount); + } + + [Fact] + public async Task ShouldReturnResponseWithModelIdAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + var streamingChatMessageContents = await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + Assert.NotNull(streamingChatMessageContents); + Assert.NotEmpty(streamingChatMessageContents); + Assert.All(streamingChatMessageContents, chatMessageContent => Assert.Equal("claude-3-5-sonnet-20240620", chatMessageContent.ModelId)); + } + + [Fact] + public async Task ShouldUsePromptExecutionSettingsAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + var executionSettings = new AnthropicPromptExecutionSettings() + { + MaxTokens = 102, + Temperature = 0.45, + TopP = 0.6f + }; + + // Act + await client.StreamGenerateChatMessageAsync(chatHistory, executionSettings: executionSettings).ToListAsync(); + + // Assert + var request = JsonSerializer.Deserialize(this._messageHandlerStub.RequestContent); + Assert.NotNull(request); + Assert.Equal(executionSettings.MaxTokens, request.MaxTokens); + Assert.Equal(executionSettings.Temperature, request.Temperature); + Assert.Equal(executionSettings.TopP, request.TopP); + } + + [Fact] + public async Task ShouldThrowInvalidOperationExceptionIfChatHistoryContainsOnlySystemMessageAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = new ChatHistory("System message"); + + // Act & Assert + await Assert.ThrowsAsync( + () => client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync().AsTask()); + } + + [Fact] + public async Task ShouldThrowInvalidOperationExceptionIfChatHistoryContainsOnlyManySystemMessagesAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = new ChatHistory("System message"); + chatHistory.AddSystemMessage("System message 2"); + chatHistory.AddSystemMessage("System message 3"); + + // Act & Assert + await Assert.ThrowsAsync( + () => client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync().AsTask()); + } + + [Fact] + public async Task ShouldPassSystemMessageToRequestAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + string[] messages = ["System message", "System message 2"]; + var chatHistory = new ChatHistory(messages[0]); + chatHistory.AddSystemMessage(messages[1]); + chatHistory.AddUserMessage("Hello"); + + // Act + await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + AnthropicRequest? request = JsonSerializer.Deserialize(this._messageHandlerStub.RequestContent); + Assert.NotNull(request); + Assert.NotNull(request.SystemPrompt); + Assert.All(messages, msg => Assert.Contains(msg, request.SystemPrompt, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task ShouldPassVersionToRequestBodyIfCustomHandlerUsedAsync() + { + // Arrange + var options = new AmazonBedrockAnthropicClientOptions(); + var client = new AnthropicClient("fake-model", new Uri("https://fake-uri.com"), + bearerTokenProvider: () => ValueTask.FromResult("fake-token"), + options: options, httpClient: this._httpClient); + + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + AnthropicRequest? request = JsonSerializer.Deserialize(this._messageHandlerStub.RequestContent); + Assert.NotNull(request); + Assert.Equal(options.Version, request.Version); + } + + [Fact] + public async Task ShouldThrowArgumentExceptionIfChatHistoryIsEmptyAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = new ChatHistory(); + + // Act & Assert + await Assert.ThrowsAsync( + () => client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync().AsTask()); + } + + [Theory] + [InlineData(0)] + [InlineData(-15)] + public async Task ShouldThrowArgumentExceptionIfExecutionSettingMaxTokensIsLessThanOneAsync(int? maxTokens) + { + // Arrange + var client = this.CreateChatCompletionClient(); + AnthropicPromptExecutionSettings executionSettings = new() + { + MaxTokens = maxTokens + }; + + // Act & Assert + await Assert.ThrowsAsync( + () => client.StreamGenerateChatMessageAsync(CreateSampleChatHistory(), executionSettings: executionSettings).ToListAsync().AsTask()); + } + + [Fact] + public async Task ItCreatesPostRequestAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + Assert.Equal(HttpMethod.Post, this._messageHandlerStub.Method); + } + + [Fact] + public async Task ItCreatesRequestWithValidUserAgentAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestHeaders); + Assert.Equal(HttpHeaderConstant.Values.UserAgent, this._messageHandlerStub.RequestHeaders.UserAgent.ToString()); + } + + [Fact] + public async Task ItCreatesRequestWithSemanticKernelVersionHeaderAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + var expectedVersion = HttpHeaderConstant.Values.GetAssemblyVersion(typeof(AnthropicClient)); + + // Act + await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestHeaders); + var header = this._messageHandlerStub.RequestHeaders.GetValues(HttpHeaderConstant.Names.SemanticKernelVersion).SingleOrDefault(); + Assert.NotNull(header); + Assert.Equal(expectedVersion, header); + } + + [Fact] + public async Task ItCreatesRequestWithValidAnthropicVersionAsync() + { + // Arrange + var options = new AnthropicClientOptions(); + var client = this.CreateChatCompletionClient(options: options); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestHeaders); + Assert.Equal(options.Version, this._messageHandlerStub.RequestHeaders.GetValues("anthropic-version").SingleOrDefault()); + } + + [Fact] + public async Task ItCreatesRequestWithValidApiKeyAsync() + { + // Arrange + string apiKey = "fake-claude-key"; + var client = this.CreateChatCompletionClient(apiKey: apiKey); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestHeaders); + Assert.Equal(apiKey, this._messageHandlerStub.RequestHeaders.GetValues("x-api-key").SingleOrDefault()); + } + + [Fact] + public async Task ItCreatesRequestWithJsonContentTypeAsync() + { + // Arrange + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + Assert.NotNull(this._messageHandlerStub.ContentHeaders); + Assert.NotNull(this._messageHandlerStub.ContentHeaders.ContentType); + Assert.Contains("application/json", this._messageHandlerStub.ContentHeaders.ContentType.ToString()); + } + + [Theory] + [InlineData("custom-header", "custom-value")] + public async Task ItCreatesRequestWithCustomUriAndCustomHeadersAsync(string headerName, string headerValue) + { + // Arrange + Uri uri = new("https://fake-uri.com"); + using var httpHandler = new CustomHeadersHandler(headerName, headerValue, ChatTestDataFilePath); + using var httpClient = new HttpClient(httpHandler); + httpClient.BaseAddress = uri; + var client = new AnthropicClient("fake-model", "api-key", options: new(), httpClient: httpClient); + + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.StreamGenerateChatMessageAsync(chatHistory).ToListAsync(); + + // Assert + Assert.Equal(uri, httpHandler.RequestUri); + Assert.NotNull(httpHandler.RequestHeaders); + Assert.Equal(headerValue, httpHandler.RequestHeaders.GetValues(headerName).SingleOrDefault()); + } + + private static ChatHistory CreateSampleChatHistory() + { + var chatHistory = new ChatHistory("You are a chatbot"); + chatHistory.AddUserMessage("Hello"); + chatHistory.AddAssistantMessage("Hi"); + chatHistory.AddUserMessage("How are you?"); + return chatHistory; + } + + private AnthropicClient CreateChatCompletionClient( + string modelId = "fake-model", + string? apiKey = null, + AnthropicClientOptions? options = null, + HttpClient? httpClient = null) + { + return new AnthropicClient(modelId, apiKey ?? "fake-key", options: new(), httpClient: this._httpClient); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicRequestTests.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicRequestTests.cs index d7925f4652bd..e741764c90cb 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicRequestTests.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Core/AnthropicRequestTests.cs @@ -8,6 +8,7 @@ using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.Anthropic; using Microsoft.SemanticKernel.Connectors.Anthropic.Core; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core.Models; using Xunit; namespace SemanticKernel.Connectors.Anthropic.UnitTests.Core; diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/TestData/chat_stream_response.txt b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/TestData/chat_stream_response.txt new file mode 100644 index 000000000000..61bfd832c304 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/TestData/chat_stream_response.txt @@ -0,0 +1,24 @@ +event: message_start +data: {"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-3-5-sonnet-20240620", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25, "output_tokens": 1}}} + +event: content_block_start +data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} + +event: ping +data: {"type": "ping"} + +event: content_block_delta +data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hi! "}} + +event: content_block_delta +data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "My name is Claude."}} + +event: content_block_stop +data: {"type": "content_block_stop", "index": 0} + +event: message_delta +data: {"type": "message_delta", "delta": {"stop_reason": "stop_sequence", "stop_sequence": "claude"}, "usage": {"output_tokens": 15}} + +event: message_stop +data: {"type": "message_stop"} + diff --git a/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Utils/CustomHeadersHandler.cs b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Utils/CustomHeadersHandler.cs new file mode 100644 index 000000000000..67fea752a1df --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic.UnitTests/Utils/CustomHeadersHandler.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; + +namespace SemanticKernel.Connectors.Anthropic.UnitTests.Utils; + +internal sealed class CustomHeadersHandler : DelegatingHandler +{ + private readonly string _headerName; + private readonly string _headerValue; + public HttpRequestHeaders? RequestHeaders { get; private set; } + + public HttpContentHeaders? ContentHeaders { get; private set; } + + public byte[]? RequestContent { get; private set; } + + public Uri? RequestUri { get; private set; } + + public HttpMethod? Method { get; private set; } + + public CustomHeadersHandler(string headerName, string headerValue, string testDataFilePath) + { + this.InnerHandler = new HttpMessageHandlerStub + { + ResponseToReturn = { Content = new StringContent(File.ReadAllText(testDataFilePath)) } + }; + this._headerName = headerName; + this._headerValue = headerValue; + } + + protected override Task SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) + { + request.Headers.Add(this._headerName, this._headerValue); + this.Method = request.Method; + this.RequestUri = request.RequestUri; + this.RequestHeaders = request.Headers; + this.RequestContent = request.Content is null ? null : request.Content.ReadAsByteArrayAsync(cancellationToken).Result; + + return base.SendAsync(request, cancellationToken); + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/AnthropicClient.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/AnthropicClient.cs index 7f896389baca..456eadbda68a 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Core/AnthropicClient.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/AnthropicClient.cs @@ -3,20 +3,22 @@ using System; using System.Collections.Generic; using System.Diagnostics.Metrics; +using System.IO; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Runtime.CompilerServices; -using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core.Models; using Microsoft.SemanticKernel.Diagnostics; using Microsoft.SemanticKernel.Http; using Microsoft.SemanticKernel.Services; +using Microsoft.SemanticKernel.Text; namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; @@ -26,6 +28,7 @@ namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; internal sealed class AnthropicClient { private const string ModelProvider = "anthropic"; + private const string AnthropicUrl = "https://api.anthropic.com/v1/messages"; private readonly Func>? _bearerTokenProvider; private readonly Dictionary _attributesInternal = new(); @@ -88,6 +91,7 @@ internal AnthropicClient( ILogger? logger = null) { Verify.NotNullOrWhiteSpace(modelId); + Verify.NotNull(options); Verify.NotNull(httpClient); @@ -97,7 +101,7 @@ internal AnthropicClient( // If a custom endpoint is not provided, the ApiKey is required Verify.NotNullOrWhiteSpace(apiKey); this._apiKey = apiKey; - targetUri = new Uri("https://api.anthropic.com/v1/messages"); + targetUri = new Uri(AnthropicUrl); } this._httpClient = httpClient; @@ -189,6 +193,97 @@ internal async Task> GenerateChatMessageAsync( return chatResponses; } + /// + /// Generates a stream of chat messages asynchronously. + /// + /// The chat history containing the conversation data. + /// Optional settings for prompt execution. + /// A kernel instance. + /// A cancellation token to cancel the operation. + /// An asynchronous enumerable of streaming chat contents. + internal async IAsyncEnumerable StreamGenerateChatMessageAsync( + ChatHistory chatHistory, + PromptExecutionSettings? executionSettings = null, + Kernel? kernel = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var state = this.ValidateInputAndCreateChatCompletionState(chatHistory, executionSettings); + state.AnthropicRequest.Stream = true; + + using var activity = ModelDiagnostics.StartCompletionActivity( + this._endpoint, this._modelId, ModelProvider, chatHistory, state.ExecutionSettings); + + List chatResponses = []; + + HttpRequestMessage? httpRequestMessage = null; + HttpResponseMessage? httpResponseMessage = null; + Stream? responseStream = null; + try + { + try + { + httpRequestMessage = await this.CreateHttpRequestAsync(state.AnthropicRequest, this._endpoint).ConfigureAwait(false); + httpResponseMessage = await this.SendRequestAndGetResponseImmediatelyAfterHeadersReadAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); + responseStream = await httpResponseMessage.Content.ReadAsStreamAndTranslateExceptionAsync().ConfigureAwait(false); + } + catch (Exception ex) when (activity is not null) + { + activity.SetError(ex); + throw; + } + + AnthropicResponse? lastAnthropicResponse = null; + await foreach (var streamingResponse in SseJsonParser.ParseAsync(responseStream, cancellationToken).ConfigureAwait(false)) + { + string? content = null; + AnthropicMetadata? metadata = null; + switch (streamingResponse.Type) + { + case "message_start": + Verify.NotNull(streamingResponse.Response); + lastAnthropicResponse = streamingResponse.Response; + metadata = GetResponseMetadata(lastAnthropicResponse); + content = string.Empty; + break; + case "content_block_start" or "content_block_delta": + content = streamingResponse.ContentDelta?.Text ?? string.Empty; + break; + case "message_delta": + Verify.NotNull(lastAnthropicResponse); + metadata = GetResponseMetadata(streamingResponse, lastAnthropicResponse); + content = string.Empty; + break; + case "message_stop": + lastAnthropicResponse = null; + break; + } + + if (lastAnthropicResponse is null || content is null) + { + continue; + } + + var streamingChatMessageContent = new AnthropicStreamingChatMessageContent( + role: lastAnthropicResponse.Role, + content: content, + innerContent: lastAnthropicResponse, + modelId: lastAnthropicResponse.ModelId ?? this._modelId, + choiceIndex: streamingResponse.Index, + metadata: metadata); + chatResponses.Add(streamingChatMessageContent); + yield return streamingChatMessageContent; + } + + activity?.EndStreaming(chatResponses); + } + finally + { + httpRequestMessage?.Dispose(); + httpResponseMessage?.Dispose(); + responseStream?.Dispose(); + } + } + private List GetChatResponseFrom(AnthropicResponse response) { var chatMessageContents = this.GetChatMessageContentsFromResponse(response); @@ -198,7 +293,7 @@ private List GetChatResponseFrom(AnthropicResponse private void LogUsage(List chatMessageContents) { - if (chatMessageContents[0].Metadata is not { TotalTokenCount: > 0 } metadata) + if (chatMessageContents[0]?.Metadata is not { TotalTokenCount: > 0 } metadata) { this.Log(LogLevel.Debug, "Token usage information unavailable."); return; @@ -227,7 +322,7 @@ private void LogUsage(List chatMessageContents) } private List GetChatMessageContentsFromResponse(AnthropicResponse response) - => response.Contents.Select(content => this.GetChatMessageContentFromAnthropicContent(response, content)).ToList(); + => response.Contents is null ? [] : response.Contents.Select(content => this.GetChatMessageContentFromAnthropicContent(response, content)).ToList(); private AnthropicChatMessageContent GetChatMessageContentFromAnthropicContent(AnthropicResponse response, AnthropicContent content) { @@ -256,6 +351,16 @@ private static AnthropicMetadata GetResponseMetadata(AnthropicResponse response) OutputTokenCount = response.Usage?.OutputTokens ?? 0 }; + private static AnthropicMetadata GetResponseMetadata(AnthropicStreamingResponse deltaResponse, AnthropicResponse rootResponse) + => new() + { + MessageId = rootResponse.Id, + FinishReason = deltaResponse.StopMetadata?.StopReason, + StopSequence = deltaResponse.StopMetadata?.StopSequence, + InputTokenCount = deltaResponse.Usage?.InputTokens ?? 0, + OutputTokenCount = deltaResponse.Usage?.OutputTokens ?? 0 + }; + private async Task SendRequestAndReturnValidResponseAsync( Uri endpoint, AnthropicRequest anthropicRequest, @@ -283,7 +388,17 @@ private ChatCompletionState ValidateInputAndCreateChatCompletionState( var filteredChatHistory = new ChatHistory(chatHistory.Where(IsAssistantOrUserOrSystem)); var anthropicRequest = AnthropicRequest.FromChatHistoryAndExecutionSettings(filteredChatHistory, anthropicExecutionSettings); - anthropicRequest.Version = this._version; + if (this._endpoint.OriginalString.Equals(AnthropicUrl, StringComparison.Ordinal)) + { + anthropicRequest.Version = null; + anthropicRequest.ModelId = anthropicExecutionSettings.ModelId ?? throw new InvalidOperationException("Model ID must be provided."); + } + else + { + // Vertex and Bedrock require the model ID to be null and version to be set + anthropicRequest.Version = this._version; + anthropicRequest.ModelId = null; + } return new ChatCompletionState { @@ -296,25 +411,6 @@ static bool IsAssistantOrUserOrSystem(ChatMessageContent msg) => msg.Role == AuthorRole.Assistant || msg.Role == AuthorRole.User || msg.Role == AuthorRole.System; } - /// - /// Generates a stream of chat messages asynchronously. - /// - /// The chat history containing the conversation data. - /// Optional settings for prompt execution. - /// A kernel instance. - /// A cancellation token to cancel the operation. - /// An asynchronous enumerable of streaming chat contents. - internal async IAsyncEnumerable StreamGenerateChatMessageAsync( - ChatHistory chatHistory, - PromptExecutionSettings? executionSettings = null, - Kernel? kernel = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.Yield(); - yield return new StreamingChatMessageContent(null, null); - throw new NotImplementedException("Implement this method in next PR."); - } - private static void ValidateMaxTokens(int? maxTokens) { // If maxTokens is null, it means that the user wants to use the default model value @@ -392,8 +488,9 @@ private async Task CreateHttpRequestAsync(object requestData { httpRequestMessage.Headers.Add("x-api-key", this._apiKey); } - else - if (this._bearerTokenProvider is not null && !httpRequestMessage.Headers.Contains("Authentication") && await this._bearerTokenProvider().ConfigureAwait(false) is { } bearerKey) + else if (this._bearerTokenProvider is not null + && !httpRequestMessage.Headers.Contains("Authentication") + && await this._bearerTokenProvider().ConfigureAwait(false) is { } bearerKey) { httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerKey); } @@ -401,22 +498,6 @@ private async Task CreateHttpRequestAsync(object requestData return httpRequestMessage; } - private static HttpContent? CreateJsonContent(object? payload) - { - HttpContent? content = null; - if (payload is not null) - { - byte[] utf8Bytes = payload is string s - ? Encoding.UTF8.GetBytes(s) - : JsonSerializer.SerializeToUtf8Bytes(payload); - - content = new ByteArrayContent(utf8Bytes); - content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" }; - } - - return content; - } - private void Log(LogLevel logLevel, string? message, params object?[] args) { if (this._logger.IsEnabled(logLevel)) diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicContent.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicContent.cs similarity index 95% rename from dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicContent.cs rename to dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicContent.cs index fab9f2b380f1..845f81fc366f 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/Message/AnthropicContent.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicContent.cs @@ -2,7 +2,7 @@ using System.Text.Json.Serialization; -namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; +namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core.Models; internal sealed class AnthropicContent { diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicRequest.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicRequest.cs index cec43a1531b9..10dc30c74789 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicRequest.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicRequest.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Text.Json.Serialization; using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core.Models; using Microsoft.SemanticKernel.Text; namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; @@ -12,6 +13,7 @@ namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; internal sealed class AnthropicRequest { [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("anthropic_version")] public string? Version { get; set; } /// @@ -28,7 +30,8 @@ internal sealed class AnthropicRequest public IList Messages { get; set; } = []; [JsonPropertyName("model")] - public string ModelId { get; set; } = null!; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ModelId { get; set; } [JsonPropertyName("max_tokens")] public int MaxTokens { get; set; } @@ -123,7 +126,6 @@ private static AnthropicRequest CreateRequest(ChatHistory chatHistory, Anthropic { AnthropicRequest request = new() { - ModelId = executionSettings.ModelId ?? throw new InvalidOperationException("Model ID must be provided."), MaxTokens = executionSettings.MaxTokens ?? throw new InvalidOperationException("Max tokens must be provided."), SystemPrompt = string.Join("\n", chatHistory .Where(msg => msg.Role == AuthorRole.System) diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicResponse.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicResponse.cs index 0c21e18de0cb..9585da07f56a 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicResponse.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicResponse.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Text.Json.Serialization; using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Anthropic.Core.Models; namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core; @@ -38,9 +39,8 @@ internal sealed class AnthropicResponse /// Content generated by the model. /// This is an array of content blocks, each of which has a type that determines its shape. /// - [JsonRequired] [JsonPropertyName("content")] - public IReadOnlyList Contents { get; init; } = null!; + public IReadOnlyList? Contents { get; init; } /// /// The model that handled the request. diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicStreamingResponse.cs b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicStreamingResponse.cs new file mode 100644 index 000000000000..1a41fa3edf91 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Core/Models/AnthropicStreamingResponse.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic.Core.Models; + +/// +/// Represents the response from the Anthropic streaming API. +/// +/// +internal sealed class AnthropicStreamingResponse +{ + /// + /// SSE data type. + /// + [JsonRequired] + [JsonPropertyName("type")] + public string Type { get; init; } = null!; + + /// + /// Response message, only if the type is "message_start", otherwise null. + /// + [JsonPropertyName("message")] + public AnthropicResponse? Response { get; init; } + + /// + /// Index of a message. + /// + [JsonPropertyName("index")] + public int Index { get; init; } + + // Fields are assigned via reflection +#pragma warning disable CS0649 // Field is never assigned to, and will always have its default value +#pragma warning disable IDE0044 // Add readonly modifier + [JsonPropertyName("content_block")] + [JsonInclude] + private AnthropicContent? _contentBlock; + + [JsonPropertyName("delta")] + [JsonInclude] + private JsonNode? _delta; +#pragma warning restore IDE0044 +#pragma warning restore CS0649 + + /// + /// Delta of anthropic content, only if the type is "content_block_start" or "content_block_delta", otherwise null. + /// + public AnthropicContent? ContentDelta => + this.Type switch + { + "content_block_start" => this._contentBlock, + "content_block_delta" => this._delta?.Deserialize(), + _ => null + }; + + /// + /// Usage metadata, only if the type is "message_delta", otherwise null. + /// + public AnthropicUsage? Usage { get; init; } + + /// + /// Stop reason metadata, only if the type is "message_delta", otherwise null. + /// + public StopDelta? StopMetadata => this.Type == "message_delta" ? this._delta?.Deserialize() : null; + + /// + /// Represents the reason that message streaming stopped. + /// + public sealed class StopDelta + { + /// + /// The reason that we stopped. + /// + [JsonPropertyName("stop_reason")] + public AnthropicFinishReason? StopReason { get; init; } + + /// + /// Which custom stop sequence was generated, if any. + /// This value will be a non-null string if one of your custom stop sequences was generated. + /// + [JsonPropertyName("stop_sequence")] + public string? StopSequence { get; init; } + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicStreamingChatMessageContent.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicStreamingChatMessageContent.cs new file mode 100644 index 000000000000..37fd28be42cf --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicStreamingChatMessageContent.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text; +using System.Text.Json.Serialization; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Connectors.Anthropic; + +/// +/// Anthropic specialized streaming chat message content +/// +public sealed class AnthropicStreamingChatMessageContent : StreamingChatMessageContent +{ + /// + /// Creates a new instance of the class + /// + /// Role of the author of the message + /// Content of the message + /// Inner content object reference + /// Choice index + /// The model ID used to generate the content + /// Encoding of the chat + /// Additional metadata + [JsonConstructor] + public AnthropicStreamingChatMessageContent( + AuthorRole? role, + string? content, + object? innerContent = null, + int choiceIndex = 0, + string? modelId = null, + Encoding? encoding = null, + IReadOnlyDictionary? metadata = null) + : base(role, content, innerContent, choiceIndex, modelId, encoding, metadata) { } + + /// + /// The metadata associated with the content. + /// + public new AnthropicMetadata? Metadata + { + get => base.Metadata as AnthropicMetadata; + init => base.Metadata = value; + } +} diff --git a/dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicUsage.cs b/dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicUsage.cs index 54a2f9db3853..e7451046c3dd 100644 --- a/dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicUsage.cs +++ b/dotnet/src/Connectors/Connectors.Anthropic/Models/Contents/AnthropicUsage.cs @@ -17,7 +17,6 @@ public sealed class AnthropicUsage /// /// The number of input tokens which were used. /// - [JsonRequired] [JsonPropertyName("input_tokens")] public int? InputTokens { get; init; } diff --git a/dotnet/src/IntegrationTests/Connectors/Anthropic/AnthropicChatCompletionTests.cs b/dotnet/src/IntegrationTests/Connectors/Anthropic/AnthropicChatCompletionTests.cs index 6e791d7aa5f9..aa0a572ea1e9 100644 --- a/dotnet/src/IntegrationTests/Connectors/Anthropic/AnthropicChatCompletionTests.cs +++ b/dotnet/src/IntegrationTests/Connectors/Anthropic/AnthropicChatCompletionTests.cs @@ -226,14 +226,17 @@ public async Task ChatStreamingReturnsUsedTokensAsync(ServiceType serviceType) var responses = await sut.GetStreamingChatMessageContentsAsync(chatHistory).ToListAsync(); // Assert - var metadata = responses.Last().Metadata as AnthropicMetadata; - Assert.NotNull(metadata); - this.Output.WriteLine($"TotalTokenCount: {metadata.TotalTokenCount}"); - this.Output.WriteLine($"InputTokenCount: {metadata.InputTokenCount}"); - this.Output.WriteLine($"OutputTokenCount: {metadata.OutputTokenCount}"); - Assert.True(metadata.TotalTokenCount > 0); - Assert.True(metadata.InputTokenCount > 0); - Assert.True(metadata.OutputTokenCount > 0); + var metadata = responses + .Where(c => c.Metadata is not null) + .Select(c => c.Metadata) + .Cast().ToList(); + Assert.NotEmpty(metadata); + this.Output.WriteLine($"TotalTokenCount: {metadata.Sum(m => m.TotalTokenCount)}"); + this.Output.WriteLine($"InputTokenCount: {metadata.Sum(m => m.InputTokenCount)}"); + this.Output.WriteLine($"OutputTokenCount: {metadata.Sum(m => m.OutputTokenCount)}"); + Assert.True(metadata.Sum(m => m.TotalTokenCount) > 0); + Assert.True(metadata.Sum(m => m.InputTokenCount) > 0); + Assert.True(metadata.Sum(m => m.OutputTokenCount) > 0); } [RetryTheory]