From d08713f94e5b19a3df466730e189ad9ee6f78a65 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 2 Dec 2025 17:42:20 -0500 Subject: [PATCH] Update IChatClient with support from latest bedrock runtime / M.E.AI - Adds support for multi-modal tool returns. - Adds support for citations with URIs. - Adds a ton of tests verifying IChatClient behavior around the underlying IAmazonBedrockRuntime. --- ...xtensions.Bedrock.MEAI.NetFramework.csproj | 2 +- ...Extensions.Bedrock.MEAI.NetStandard.csproj | 2 +- .../AWSSDK.Extensions.Bedrock.MEAI.nuspec | 12 +- .../BedrockChatClient.cs | 62 +- .../BedrockChatClientTests.cs | 3571 ++++++++++++++++- .../BedrockMEAITests.NetFramework.csproj | 1 - .../BedrockMEAITests/MockBedrockRuntime.cs | 57 + 7 files changed, 3679 insertions(+), 28 deletions(-) create mode 100644 extensions/test/BedrockMEAITests/MockBedrockRuntime.cs diff --git a/extensions/src/AWSSDK.Extensions.Bedrock.MEAI/AWSSDK.Extensions.Bedrock.MEAI.NetFramework.csproj b/extensions/src/AWSSDK.Extensions.Bedrock.MEAI/AWSSDK.Extensions.Bedrock.MEAI.NetFramework.csproj index 446626bb8afa..95596501546a 100644 --- a/extensions/src/AWSSDK.Extensions.Bedrock.MEAI/AWSSDK.Extensions.Bedrock.MEAI.NetFramework.csproj +++ b/extensions/src/AWSSDK.Extensions.Bedrock.MEAI/AWSSDK.Extensions.Bedrock.MEAI.NetFramework.csproj @@ -37,7 +37,7 @@ - + diff --git a/extensions/src/AWSSDK.Extensions.Bedrock.MEAI/AWSSDK.Extensions.Bedrock.MEAI.NetStandard.csproj b/extensions/src/AWSSDK.Extensions.Bedrock.MEAI/AWSSDK.Extensions.Bedrock.MEAI.NetStandard.csproj index 97b1a20a55e0..a64f9223fe24 100644 --- a/extensions/src/AWSSDK.Extensions.Bedrock.MEAI/AWSSDK.Extensions.Bedrock.MEAI.NetStandard.csproj +++ b/extensions/src/AWSSDK.Extensions.Bedrock.MEAI/AWSSDK.Extensions.Bedrock.MEAI.NetStandard.csproj @@ -41,7 +41,7 @@ - + diff --git a/extensions/src/AWSSDK.Extensions.Bedrock.MEAI/AWSSDK.Extensions.Bedrock.MEAI.nuspec b/extensions/src/AWSSDK.Extensions.Bedrock.MEAI/AWSSDK.Extensions.Bedrock.MEAI.nuspec index 11d12c364bc4..e77cd1288587 100644 --- a/extensions/src/AWSSDK.Extensions.Bedrock.MEAI/AWSSDK.Extensions.Bedrock.MEAI.nuspec +++ b/extensions/src/AWSSDK.Extensions.Bedrock.MEAI/AWSSDK.Extensions.Bedrock.MEAI.nuspec @@ -14,18 +14,18 @@ - - + + - - + + - - + + diff --git a/extensions/src/AWSSDK.Extensions.Bedrock.MEAI/BedrockChatClient.cs b/extensions/src/AWSSDK.Extensions.Bedrock.MEAI/BedrockChatClient.cs index bfe33dbda368..3ca35f8490ba 100644 --- a/extensions/src/AWSSDK.Extensions.Bedrock.MEAI/BedrockChatClient.cs +++ b/extensions/src/AWSSDK.Extensions.Bedrock.MEAI/BedrockChatClient.cs @@ -108,8 +108,9 @@ public async Task GetResponseAsync( TextContent tc = new(citations.Content[i]?.Text) { RawRepresentation = citations.Content[i] }; tc.Annotations = [new CitationAnnotation() { + Snippet = citations.Citations[i].SourceContent?.Select(c => c.Text).FirstOrDefault() ?? citations.Citations[i].Source, Title = citations.Citations[i].Title, - Snippet = citations.Citations[i].SourceContent?.Select(c => c.Text).FirstOrDefault(), + Url = Uri.TryCreate(citations.Citations[i].Location?.Web?.Url, UriKind.Absolute, out Uri? uri) ? uri : null, }]; result.Contents.Add(tc); } @@ -398,8 +399,7 @@ private static List CreateSystem(List? r }); } - foreach (var message in messages - .Where(m => m.Role == ChatRole.System && m.Contents.Any(c => c is TextContent))) + foreach (var message in messages.Where(m => m.Role == ChatRole.System && m.Contents.Any(c => c is TextContent))) { system.Add(new SystemContentBlock() { @@ -500,6 +500,10 @@ private static List CreateContents(ChatMessage message) { switch (content) { + case AIContent when content.RawRepresentation is ContentBlock cb: + contents.Add(cb); + break; + case TextContent tc: if (message.Role == ChatRole.Assistant) { @@ -582,32 +586,54 @@ private static List CreateContents(ChatMessage message) break; case FunctionResultContent frc: - Document result = frc.Result switch - { - int i => i, - long l => l, - float f => f, - double d => d, - string s => s, - bool b => b, - JsonElement json => ToDocument(json), - { } other => ToDocument(JsonSerializer.SerializeToElement(other, BedrockJsonContext.DefaultOptions.GetTypeInfo(other.GetType()))), - _ => default, - }; - contents.Add(new() { ToolResult = new() { ToolUseId = frc.CallId, - Content = [new() { Json = new Document(new Dictionary() { ["result"] = result }) }], + Content = ToToolResultContentBlocks(frc.Result), }, }); break; } + static List ToToolResultContentBlocks(object? result) => + result switch + { + AIContent aic => [ToolResultContentBlockFromAIContent(aic)], + IEnumerable aics => [.. aics.Select(ToolResultContentBlockFromAIContent)], + string s => [new () { Text = s }], + _ => [new() + { + Json = new Document(new Dictionary() + { + ["result"] = result switch + { + int i => i, + long l => l, + float f => f, + double d => d, + bool b => b, + JsonElement json => ToDocument(json), + { } other => ToDocument(JsonSerializer.SerializeToElement(other, BedrockJsonContext.DefaultOptions.GetTypeInfo(other.GetType()))), + _ => default, + } + }) + }], + }; + + static ToolResultContentBlock ToolResultContentBlockFromAIContent(AIContent aic) => + aic switch + { + TextContent tc => new() { Text = tc.Text }, + TextReasoningContent trc => new() { Text = trc.Text }, + DataContent dc when GetImageFormat(dc.MediaType) is { } imageFormat => new() { Image = new() { Source = new() { Bytes = new(dc.Data.ToArray()) }, Format = imageFormat } }, + DataContent dc when GetVideoFormat(dc.MediaType) is { } videoFormat => new() { Video = new() { Source = new() { Bytes = new(dc.Data.ToArray()) }, Format = videoFormat } }, + DataContent dc when GetDocumentFormat(dc.MediaType) is { } docFormat => new() { Document = new() { Source = new() { Bytes = new(dc.Data.ToArray()) }, Format = docFormat, Name = dc.Name ?? "file" } }, + _ => ToToolResultContentBlocks(JsonSerializer.SerializeToElement(aic, BedrockJsonContext.DefaultOptions.GetTypeInfo(typeof(object)))).First(), + }; - if (content.AdditionalProperties?.TryGetValue(nameof(ContentBlock.CachePoint), out var maybeCachePoint) == true) + if (content.AdditionalProperties?.TryGetValue(nameof(ContentBlock.CachePoint), out var maybeCachePoint) is true) { if (maybeCachePoint is CachePointBlock cachePointBlock) { diff --git a/extensions/test/BedrockMEAITests/BedrockChatClientTests.cs b/extensions/test/BedrockMEAITests/BedrockChatClientTests.cs index 8f5099c973d8..2fc25876a504 100644 --- a/extensions/test/BedrockMEAITests/BedrockChatClientTests.cs +++ b/extensions/test/BedrockMEAITests/BedrockChatClientTests.cs @@ -1,5 +1,14 @@ -using Microsoft.Extensions.AI; +using Amazon.BedrockRuntime.Model; +using Amazon.Runtime.Documents; +using Amazon.Runtime.EventStreams; +using Microsoft.Extensions.AI; using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; using Xunit; namespace Amazon.BedrockRuntime; @@ -44,4 +53,3564 @@ public void AsIChatClient_GetService() Assert.Null(client.GetService("key")); Assert.Null(client.GetService("key")); } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public void AsIChatClient_ValidArguments_CreatesIChatClientSuccessfully() + { + MockBedrockRuntime mock = new(); + IChatClient chatClient = mock.AsIChatClient(); + Assert.NotNull(chatClient); + Assert.Same(mock, chatClient.GetService()); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public void IChatClient_GetService_InvalidArguments_Throws() + { + MockBedrockRuntime mock = new(); + IChatClient chatClient = mock.AsIChatClient(); + Assert.NotNull(chatClient); + + Assert.Throws("serviceType", () => chatClient.GetService(null!)); + } + + [Theory] + [Trait("UnitTest", "BedrockRuntime")] + [InlineData(null)] + [InlineData("anthropic.claude-3-sonnet-20240229-v1:0")] + public void IChatClient_GetService_ReturnsExpectedInstance(string defaultModelId) + { + MockBedrockRuntime mock = new(); + IChatClient chatClient = mock.AsIChatClient(defaultModelId); + Assert.NotNull(chatClient); + + Assert.Same(mock, chatClient.GetService()); + Assert.Same(chatClient, chatClient.GetService()); + + ChatClientMetadata metadata = chatClient.GetService(); + Assert.NotNull(metadata); + Assert.Equal("aws.bedrock", metadata.ProviderName); + Assert.Equal(defaultModelId, metadata.DefaultModelId); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public void IChatClient_Dispose_Nop() + { + MockBedrockRuntime mock = new(); + IChatClient chatClient = mock.AsIChatClient(); + Assert.NotNull(chatClient); + + chatClient.Dispose(); + + Assert.Same(mock, chatClient.GetService()); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_BasicRequest() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => CreateResponse("Hello") + }; + + IChatClient chatClient = mock.AsIChatClient("anthropic.claude-3-sonnet-20240229-v1:0"); + ChatResponse result = await chatClient.GetResponseAsync("Hello"); + Assert.NotNull(result); + Assert.NotNull(result.Messages); + Assert.Single(result.Messages); + Assert.Equal(ChatRole.Assistant, result.Messages[0].Role); + Assert.NotNull(result.Messages[0].MessageId); + Assert.NotNull(result.ResponseId); + Assert.NotNull(result.CreatedAt); + Assert.Equal("Hello", ((TextContent)result.Messages[0].Contents[0]).Text); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_TextContent() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.Single(request.Messages); + Assert.Equal(ConversationRole.User, request.Messages[0].Role); + Assert.Single(request.Messages[0].Content); + Assert.Equal("What is the weather like?", request.Messages[0].Content[0].Text); + + var response = CreateResponse("It's sunny today."); + response.StopReason = StopReason.End_turn; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = [new(ChatRole.User, "What is the weather like?")]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal(ChatRole.Assistant, result.Messages[0].Role); + Assert.IsType(Assert.Single(result.Messages[0].Contents)); + Assert.Equal("It's sunny today.", ((TextContent)result.Messages[0].Contents[0]).Text); + Assert.Equal(ChatFinishReason.Stop, result.FinishReason); + Assert.NotNull(result.Messages[0].RawRepresentation); + Assert.NotNull(((TextContent)result.Messages[0].Contents[0]).RawRepresentation); + Assert.NotNull(result.RawRepresentation); + Assert.NotNull(result.Usage); + Assert.Equal(10, result.Usage.InputTokenCount); + Assert.Equal(5, result.Usage.OutputTokenCount); + Assert.Equal(15, result.Usage.TotalTokenCount); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_EmptyMessages_CreatesDefaultMessage() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.Single(request.Messages); + Assert.Equal(ConversationRole.User, request.Messages[0].Role); + Assert.Single(request.Messages[0].Content); + Assert.Equal("\u200B", request.Messages[0].Content[0].Text); + + return CreateResponse("Empty input received"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = []; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal("Empty input received", ((TextContent)result.Messages[0].Contents[0]).Text); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_NullMessages_Throws() + { + MockBedrockRuntime mock = new(); + IChatClient chatClient = mock.AsIChatClient("claude"); + + await Assert.ThrowsAsync("messages", () => chatClient.GetResponseAsync(null!)); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_DataContent_Image() + { + byte[] imageData = [0x89, 0x50, 0x4E, 0x47]; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.Single(request.Messages); + Assert.Equal(ConversationRole.User, request.Messages[0].Role); + Assert.Equal(2, request.Messages[0].Content.Count); + Assert.Equal("Describe this image", request.Messages[0].Content[0].Text); + Assert.NotNull(request.Messages[0].Content[1].Image); + Assert.Equal(ImageFormat.Png, request.Messages[0].Content[1].Image.Format); + Assert.True(request.Messages[0].Content[1].Image.Source.Bytes.ToArray().SequenceEqual(imageData)); + + return CreateResponse("I see an image."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, + [ + new TextContent("Describe this image"), + new DataContent(imageData, "image/png") + ]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal("I see an image.", ((TextContent)result.Messages[0].Contents[0]).Text); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_DataContent_AllImageFormats() + { + var formats = new[] + { + ("image/jpeg", ImageFormat.Jpeg), + ("image/png", ImageFormat.Png), + ("image/gif", ImageFormat.Gif), + ("image/webp", ImageFormat.Webp) + }; + + foreach (var (mimeType, expectedFormat) in formats) + { + byte[] imageData = [1, 2, 3, 4]; + bool verified = false; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.NotNull(request.Messages[0].Content[0].Image); + Assert.Equal(expectedFormat, request.Messages[0].Content[0].Image.Format); + verified = true; + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + await chatClient.GetResponseAsync([new(ChatRole.User, [new DataContent(imageData, mimeType)])]); + Assert.True(verified, $"Format {mimeType} not verified"); + } + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_DataContent_Document() + { + byte[] pdfData = [0x25, 0x50, 0x44, 0x46]; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.Single(request.Messages); + Assert.Equal(2, request.Messages[0].Content.Count); + Assert.Equal("Analyze this document", request.Messages[0].Content[0].Text); + Assert.NotNull(request.Messages[0].Content[1].Document); + Assert.Equal(DocumentFormat.Pdf, request.Messages[0].Content[1].Document.Format); + Assert.True(request.Messages[0].Content[1].Document.Source.Bytes.ToArray().SequenceEqual(pdfData)); + Assert.Equal("file", request.Messages[0].Content[1].Document.Name); + + return CreateResponse("Document analyzed."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, + [ + new TextContent("Analyze this document"), + new DataContent(pdfData, "application/pdf") + ]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal("Document analyzed.", ((TextContent)result.Messages[0].Contents[0]).Text); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_DataContent_DocumentWithName() + { + byte[] pdfData = [1, 2, 3]; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.NotNull(request.Messages[0].Content[0].Document); + Assert.Equal("report.pdf", request.Messages[0].Content[0].Document.Name); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + DataContent dataContent = new(pdfData, "application/pdf") { Name = "report.pdf" }; + await chatClient.GetResponseAsync([new(ChatRole.User, [dataContent])]); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_DataContent_Video() + { + byte[] videoData = [0x00, 0x00, 0x00, 0x18]; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.Single(request.Messages); + Assert.Equal(2, request.Messages[0].Content.Count); + Assert.NotNull(request.Messages[0].Content[1].Video); + Assert.Equal(VideoFormat.Mp4, request.Messages[0].Content[1].Video.Format); + + return CreateResponse("Video processed."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, + [ + new TextContent("Analyze this video"), + new DataContent(videoData, "video/mp4") + ]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal("Video processed.", ((TextContent)result.Messages[0].Contents[0]).Text); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_ReceivesImageContent() + { + byte[] imageData = [1, 2, 3, 4]; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new() + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { + Image = new ImageBlock + { + Format = ImageFormat.Png, + Source = new ImageSource + { + Bytes = new System.IO.MemoryStream(imageData) + } + } + } + ] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Send me an image")]); + + Assert.NotNull(result); + Assert.Single(result.Messages); + var dataContent = Assert.IsType(Assert.Single(result.Messages[0].Contents)); + Assert.Equal("image/png", dataContent.MediaType); + Assert.True(dataContent.Data.ToArray().SequenceEqual(imageData)); + Assert.NotNull(dataContent.RawRepresentation); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_ReceivesVideoContent() + { + byte[] videoData = [5, 6, 7, 8]; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new() + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { + Video = new VideoBlock + { + Format = VideoFormat.Mp4, + Source = new VideoSource + { + Bytes = new System.IO.MemoryStream(videoData) + } + } + } + ] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Send me a video")]); + + Assert.NotNull(result); + Assert.Single(result.Messages); + var dataContent = Assert.IsType(Assert.Single(result.Messages[0].Contents)); + Assert.Equal("video/mp4", dataContent.MediaType); + Assert.True(dataContent.Data.ToArray().SequenceEqual(videoData)); + Assert.NotNull(dataContent.RawRepresentation); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_ReceivesDocumentContent() + { + byte[] docData = [9, 10, 11]; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new() + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { + Document = new DocumentBlock + { + Format = DocumentFormat.Pdf, + Name = "result.pdf", + Source = new DocumentSource + { + Bytes = new System.IO.MemoryStream(docData) + } + } + } + ] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Send me a document")]); + + Assert.NotNull(result); + Assert.Single(result.Messages); + var dataContent = Assert.IsType(Assert.Single(result.Messages[0].Contents)); + Assert.Equal("application/pdf", dataContent.MediaType); + Assert.Equal("result.pdf", dataContent.Name); + Assert.True(dataContent.Data.ToArray().SequenceEqual(docData)); + Assert.NotNull(dataContent.RawRepresentation); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public void IChatClient_GetService_WithServiceKey_ReturnsNull() + { + MockBedrockRuntime mock = new(); + IChatClient chatClient = mock.AsIChatClient(); + + // When serviceKey is not null, should return null + Assert.Null(chatClient.GetService(typeof(IAmazonBedrockRuntime), "someKey")); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public void IChatClient_GetService_UnknownType_ReturnsNull() + { + MockBedrockRuntime mock = new(); + IChatClient chatClient = mock.AsIChatClient(); + + // Unknown type should return null + Assert.Null(chatClient.GetService()); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_UsageWithCacheTokens() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var response = CreateResponse("OK"); + response.Usage = new TokenUsage + { + InputTokens = 100, + OutputTokens = 50, + TotalTokens = 150, + CacheReadInputTokens = 25, + CacheWriteInputTokens = 10 + }; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.NotNull(result.Usage); + Assert.Equal(100, result.Usage.InputTokenCount); + Assert.Equal(50, result.Usage.OutputTokenCount); + Assert.Equal(150, result.Usage.TotalTokenCount); + Assert.NotNull(result.Usage.AdditionalCounts); + Assert.Equal(25, result.Usage.AdditionalCounts["CacheReadInputTokens"]); + Assert.Equal(10, result.Usage.AdditionalCounts["CacheWriteInputTokens"]); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_CustomFinishReason() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var response = CreateResponse("Custom"); + response.StopReason = new StopReason("custom_reason"); + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.Equal("custom_reason", result.FinishReason?.Value); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_AdditionalProperties_AllTypes() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var dict = request.AdditionalModelRequestFields.AsDictionary(); + + // Verify all types were converted + Assert.True(dict["boolProp"].AsBool()); + Assert.Equal(42, dict["intProp"].AsInt()); + Assert.Equal(9999999999L, dict["longProp"].AsLong()); + Assert.Equal(1.5, dict["doubleProp"].AsDouble(), 1); + Assert.Equal("hello", dict["stringProp"].AsString()); + Assert.True(dict["nullProp"].IsNull()); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + ChatOptions options = new() + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["boolProp"] = true, + ["intProp"] = 42, + ["longProp"] = 9999999999L, + ["doubleProp"] = 1.5, + ["stringProp"] = "hello", + ["nullProp"] = null + } + }; + + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")], options); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_AdditionalProperties_JsonElement() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var dict = request.AdditionalModelRequestFields.AsDictionary(); + Assert.True(dict.ContainsKey("jsonProp")); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + JsonDocument jsonDoc = System.Text.Json.JsonDocument.Parse("{\"nested\": true}"); + ChatOptions options = new() + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["jsonProp"] = jsonDoc.RootElement + } + }; + + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")], options); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_StopSequences_MergesWithExisting() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + // Should have merged stop sequences + Assert.Contains("STOP1", request.InferenceConfig.StopSequences); + Assert.Contains("STOP2", request.InferenceConfig.StopSequences); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + ChatOptions options = new() + { + StopSequences = ["STOP1", "STOP2"] + }; + + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")], options); + Assert.NotNull(result); + } + + [Theory] + [Trait("UnitTest", "BedrockRuntime")] + [InlineData("text/csv")] + [InlineData("text/html")] + [InlineData("text/markdown")] + [InlineData("text/plain")] + [InlineData("application/msword")] + [InlineData("application/vnd.openxmlformats-officedocument.wordprocessingml.document")] + [InlineData("application/vnd.ms-excel")] + [InlineData("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")] + public async Task IChatClient_GetResponseAsync_SendsDocumentContent_AllFormats(string mimeType) + { + byte[] docData = [1, 2, 3]; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.NotNull(request.Messages[0].Content[0].Document); + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, [new DataContent(docData, mimeType) { Name = "file" }]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Theory] + [Trait("UnitTest", "BedrockRuntime")] + [InlineData("image/gif")] + [InlineData("image/webp")] + public async Task IChatClient_GetResponseAsync_SendsImageContent_AllFormats(string mimeType) + { + byte[] imageData = [1, 2, 3]; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.NotNull(request.Messages[0].Content[0].Image); + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, [new DataContent(imageData, mimeType)]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Theory] + [Trait("UnitTest", "BedrockRuntime")] + [InlineData("video/x-flv")] + [InlineData("video/x-matroska")] + [InlineData("video/quicktime")] + [InlineData("video/mpeg")] + [InlineData("video/webm")] + [InlineData("video/3gpp")] + public async Task IChatClient_GetResponseAsync_SendsVideoContent_AllFormats(string mimeType) + { + byte[] videoData = [1, 2, 3]; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.NotNull(request.Messages[0].Content[0].Video); + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, [new DataContent(videoData, mimeType)]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_SendsFunctionCallContent() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.Equal(2, request.Messages.Count); + + // First message is user + Assert.Equal(ConversationRole.User, request.Messages[0].Role); + + // Second message is assistant with tool use + Assert.Equal(ConversationRole.Assistant, request.Messages[1].Role); + var toolUse = request.Messages[1].Content[0].ToolUse; + Assert.NotNull(toolUse); + Assert.Equal("call_123", toolUse.ToolUseId); + Assert.Equal("get_weather", toolUse.Name); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + FunctionCallContent funcCallContent = new("call_123", "get_weather", + new Dictionary { ["location"] = "Seattle" }); + + ChatMessage[] messages = + [ + new(ChatRole.User, "What's the weather?"), + new(ChatRole.Assistant, [funcCallContent]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Theory] + [Trait("UnitTest", "BedrockRuntime")] + [InlineData("csv", "text/csv")] + [InlineData("html", "text/html")] + [InlineData("md", "text/markdown")] + [InlineData("doc", "application/msword")] + [InlineData("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")] + [InlineData("xls", "application/vnd.ms-excel")] + [InlineData("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")] + public async Task IChatClient_GetResponseAsync_ReceivesDocumentContent_AllFormats(string formatValue, string expectedMimeType) + { + byte[] docData = [9, 10, 11]; + DocumentFormat format = new(formatValue); + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new() + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { + Document = new DocumentBlock + { + Format = format, + Name = "result.doc", + Source = new DocumentSource { Bytes = new System.IO.MemoryStream(docData) } + } + } + ] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.NotNull(result); + var dataContent = Assert.IsType(Assert.Single(result.Messages[0].Contents)); + Assert.Equal(expectedMimeType, dataContent.MediaType); + } + + [Theory] + [Trait("UnitTest", "BedrockRuntime")] + [InlineData("gif", "image/gif")] + [InlineData("webp", "image/webp")] + public async Task IChatClient_GetResponseAsync_ReceivesImageContent_AllFormats(string formatValue, string expectedMimeType) + { + byte[] imageData = [1, 2, 3]; + ImageFormat format = new(formatValue); + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new() + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { + Image = new ImageBlock + { + Format = format, + Source = new ImageSource { Bytes = new System.IO.MemoryStream(imageData) } + } + } + ] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.NotNull(result); + var dataContent = Assert.IsType(Assert.Single(result.Messages[0].Contents)); + Assert.Equal(expectedMimeType, dataContent.MediaType); + } + + [Theory] + [Trait("UnitTest", "BedrockRuntime")] + [InlineData("flv", "video/x-flv")] + [InlineData("mkv", "video/x-matroska")] + [InlineData("mov", "video/quicktime")] + [InlineData("mpeg", "video/mpeg")] + [InlineData("webm", "video/webm")] + [InlineData("three_gp", "video/3gpp")] + public async Task IChatClient_GetResponseAsync_ReceivesVideoContent_AllFormats(string formatValue, string expectedMimeType) + { + byte[] videoData = [5, 6, 7, 8]; + VideoFormat format = new(formatValue); + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new() + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { + Video = new VideoBlock + { + Format = format, + Source = new VideoSource { Bytes = new System.IO.MemoryStream(videoData) } + } + } + ] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.NotNull(result); + var dataContent = Assert.IsType(Assert.Single(result.Messages[0].Contents)); + Assert.Equal(expectedMimeType, dataContent.MediaType); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_ReceivesDocument_UnknownFormat() + { + byte[] docData = [9, 10, 11]; + DocumentFormat format = new("unknown_format"); + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new() + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { + Document = new DocumentBlock + { + Format = format, + Name = "result.doc", + Source = new DocumentSource { Bytes = new System.IO.MemoryStream(docData) } + } + } + ] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.NotNull(result); + var dataContent = Assert.IsType(Assert.Single(result.Messages[0].Contents)); + // Unknown format defaults to text/plain + Assert.Equal("text/plain", dataContent.MediaType); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_ReceivesImage_UnknownFormat() + { + byte[] imageData = [1, 2, 3]; + ImageFormat format = new("unknown_format"); + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new() + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { + Image = new ImageBlock + { + Format = format, + Source = new ImageSource { Bytes = new System.IO.MemoryStream(imageData) } + } + } + ] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.NotNull(result); + var dataContent = Assert.IsType(Assert.Single(result.Messages[0].Contents)); + // Unknown format defaults to image/jpeg + Assert.Equal("image/jpeg", dataContent.MediaType); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_ReceivesVideo_UnknownFormat() + { + byte[] videoData = [5, 6, 7, 8]; + VideoFormat format = new("unknown_format"); + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new() + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { + Video = new VideoBlock + { + Format = format, + Source = new VideoSource { Bytes = new System.IO.MemoryStream(videoData) } + } + } + ] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.NotNull(result); + var dataContent = Assert.IsType(Assert.Single(result.Messages[0].Contents)); + // Unknown format defaults to video/mp4 + Assert.Equal("video/mp4", dataContent.MediaType); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_SendsUnknownMimeType_SkipsContent() + { + byte[] data = [1, 2, 3]; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + // Unknown MIME type content should not be in the request + // since it doesn't match any known format + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, [new DataContent(data, "application/unknown-type")]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_TextReasoningContent() + { + string reasoningText = "Let me think step by step..."; + string signature = "sig123"; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new() + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { + ReasoningContent = new ReasoningContentBlock + { + ReasoningText = new ReasoningTextBlock + { + Text = reasoningText, + Signature = signature + } + } + } + ] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = [new(ChatRole.User, "Think step by step about this problem.")]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.IsType(Assert.Single(result.Messages[0].Contents)); + + TextReasoningContent reasoningContent = (TextReasoningContent)result.Messages[0].Contents[0]; + Assert.Equal(reasoningText, reasoningContent.Text); + Assert.Equal(signature, reasoningContent.ProtectedData); + Assert.NotNull(reasoningContent.RawRepresentation); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_SendsTextReasoningContent() + { + string reasoningText = "I reasoned about this"; + string signature = "sig456"; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.Equal(2, request.Messages.Count); + Assert.Equal(ConversationRole.User, request.Messages[0].Role); + Assert.Equal(ConversationRole.Assistant, request.Messages[1].Role); + Assert.Single(request.Messages[1].Content); + + var reasoningBlock = request.Messages[1].Content[0]; + Assert.NotNull(reasoningBlock.ReasoningContent); + Assert.Equal(reasoningText, reasoningBlock.ReasoningContent.ReasoningText.Text); + Assert.Equal(signature, reasoningBlock.ReasoningContent.ReasoningText.Signature); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + ChatMessage[] messages = + [ + new(ChatRole.User, "Question"), + new(ChatRole.Assistant, [new TextReasoningContent(reasoningText) { ProtectedData = signature }]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_TextReasoningContent_WithRedactedContent() + { + byte[] redactedData = [1, 2, 3, 4]; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var reasoningBlock = request.Messages[0].Content[0]; + Assert.NotNull(reasoningBlock.ReasoningContent); + Assert.NotNull(reasoningBlock.ReasoningContent.RedactedContent); + Assert.True(reasoningBlock.ReasoningContent.RedactedContent.ToArray().SequenceEqual(redactedData)); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + TextReasoningContent reasoningContent = new("Reasoning") + { + ProtectedData = "sig", + AdditionalProperties = new AdditionalPropertiesDictionary() + { + [nameof(ReasoningContentBlock.RedactedContent)] = redactedData + } + }; + + ChatMessage[] messages = [new(ChatRole.User, [reasoningContent])]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_ReceivesReasoningContent_WithRedactedContent() + { + byte[] redactedData = [5, 6, 7]; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new() + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { + ReasoningContent = new ReasoningContentBlock + { + ReasoningText = new ReasoningTextBlock { Text = "Thinking...", Signature = "sig" }, + RedactedContent = new System.IO.MemoryStream(redactedData) + } + } + ] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Think")]); + + Assert.NotNull(result); + var reasoningContent = Assert.IsType(Assert.Single(result.Messages[0].Contents)); + Assert.NotNull(reasoningContent.AdditionalProperties); + Assert.True(reasoningContent.AdditionalProperties.ContainsKey(nameof(ReasoningContentBlock.RedactedContent))); + + var received = (byte[])reasoningContent.AdditionalProperties[nameof(ReasoningContentBlock.RedactedContent)]; + Assert.True(received.SequenceEqual(redactedData)); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_WithCitationMetadata() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new() + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { + CitationsContent = new CitationsContentBlock + { + Content = + [ + new() { Text = "This is cited content." } + ], + Citations = + [ + new() { + Title = "Example Source", + Source = "https://example.com", + Location = new CitationLocation + { + Web = new WebLocation + { + Url = "https://example.com" + } + }, + SourceContent = + [ + new() { Text = "Source snippet" } + ] + } + ] + } + } + ] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = [new(ChatRole.User, "Cite your sources")]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + + Assert.NotNull(result); + Assert.Single(result.Messages); + TextContent textContent = Assert.IsType(result.Messages[0].Contents[0]); + Assert.Equal("This is cited content.", textContent.Text); + Assert.NotNull(textContent.RawRepresentation); + Assert.NotNull(textContent.Annotations); + Assert.Single(textContent.Annotations); + + CitationAnnotation citation = Assert.IsType(textContent.Annotations[0]); + Assert.Equal("Example Source", citation.Title); + Assert.Equal("https://example.com/", citation.Url?.ToString()); + Assert.Equal("Source snippet", citation.Snippet); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_WithCitation_NoSourceContent() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new() + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { + CitationsContent = new CitationsContentBlock + { + Content = + [ + new() { Text = "Cited text." } + ], + Citations = + [ + new() { + Title = "My Source", + Source = "fallback-source" + } + ] + } + } + ] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.NotNull(result); + TextContent textContent = Assert.IsType(result.Messages[0].Contents[0]); + CitationAnnotation citation = Assert.IsType(Assert.Single(textContent.Annotations)); + Assert.Equal("fallback-source", citation.Snippet); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_WithSystemInstructions() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.NotNull(request.System); + Assert.Single(request.System); + Assert.Equal("You are a helpful assistant.", request.System[0].Text); + + Assert.Single(request.Messages); + Assert.Equal(ConversationRole.User, request.Messages[0].Role); + + return CreateResponse("I'm here to help!"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.System, "You are a helpful assistant."), + new(ChatRole.User, "Hello") + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal("I'm here to help!", ((TextContent)result.Messages[0].Contents[0]).Text); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_WithInstructions_InOptions() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.NotNull(request.System); + Assert.Single(request.System); + Assert.Equal("Be concise.", request.System[0].Text); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = [new(ChatRole.User, "Hello")]; + ChatOptions options = new() { Instructions = "Be concise." }; + + ChatResponse result = await chatClient.GetResponseAsync(messages, options); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_WithChatOptions() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.Equal("custom-model", request.ModelId); + + Assert.NotNull(request.InferenceConfig); + Assert.Equal(0.7f, request.InferenceConfig.Temperature); + Assert.Equal(0.9f, request.InferenceConfig.TopP); + Assert.Equal(100, request.InferenceConfig.MaxTokens); + Assert.NotNull(request.InferenceConfig.StopSequences); + Assert.Contains("STOP", request.InferenceConfig.StopSequences); + + return CreateResponse("Response with options applied."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("default-model"); + ChatMessage[] messages = [new(ChatRole.User, "Test message")]; + + ChatOptions options = new() + { + ModelId = "custom-model", + Temperature = 0.7f, + TopP = 0.9f, + MaxOutputTokens = 100, + StopSequences = ["STOP"] + }; + + ChatResponse result = await chatClient.GetResponseAsync(messages, options); + + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal("Response with options applied.", ((TextContent)result.Messages[0].Contents[0]).Text); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_WithAdditionalModelRequestFields() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.True(request.AdditionalModelRequestFields.IsDictionary()); + + var dict = request.AdditionalModelRequestFields.AsDictionary(); + Assert.Equal(40, dict["k"].AsInt()); + Assert.Equal(0.5, dict["frequency_penalty"].AsDouble(), 5); // tolerance for float precision + Assert.Equal(0.3, dict["presence_penalty"].AsDouble(), 5); // tolerance for float precision + Assert.Equal(42, dict["seed"].AsLong()); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = [new(ChatRole.User, "Test")]; + + ChatOptions options = new() + { + TopK = 40, + FrequencyPenalty = 0.5f, + PresencePenalty = 0.3f, + Seed = 42 + }; + + ChatResponse result = await chatClient.GetResponseAsync(messages, options); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_WithFinishReasons() + { + var finishReasons = new[] + { + (StopReason.End_turn, ChatFinishReason.Stop), + (StopReason.Max_tokens, ChatFinishReason.Length), + (StopReason.Stop_sequence, ChatFinishReason.Stop), + (StopReason.Tool_use, ChatFinishReason.ToolCalls), + (StopReason.Content_filtered, ChatFinishReason.ContentFilter), + (StopReason.Guardrail_intervened, ChatFinishReason.ContentFilter) + }; + + foreach (var (stopReason, expectedFinishReason) in finishReasons) + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var response = CreateResponse("Test"); + response.StopReason = stopReason; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = [new(ChatRole.User, "Test")]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.Equal(expectedFinishReason, result.FinishReason); + } + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_WithAdditionalModelResponseFields() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var response = CreateResponse("Test"); + response.AdditionalModelResponseFields = new Document(new Dictionary + { + ["custom_field"] = "custom_value", + ["number_field"] = 123 + }); + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.NotNull(result); + Assert.NotNull(result.Messages[0].AdditionalProperties); + // Values are JsonElement when deserialized from Document + Assert.Equal("custom_value", ((System.Text.Json.JsonElement)result.Messages[0].AdditionalProperties["custom_field"]).GetString()); + Assert.Equal(123, ((System.Text.Json.JsonElement)result.Messages[0].AdditionalProperties["number_field"]).GetInt32()); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_SystemMessageWithCachePoint() + { + CachePointBlock cachePoint = new() { Type = CachePointType.Default }; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + // Should have system messages including cache point + Assert.True(request.System.Count >= 2); + Assert.NotNull(request.System.Last().CachePoint); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + ChatMessage systemMessage = new(ChatRole.System, "System instruction") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + [nameof(ContentBlock.CachePoint)] = cachePoint + } + }; + + ChatMessage[] messages = [systemMessage, new(ChatRole.User, "Test")]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_ToolWithoutProperties() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.NotNull(request.ToolConfig); + Assert.Single(request.ToolConfig.Tools); + Assert.Equal("simple_tool", request.ToolConfig.Tools[0].ToolSpec.Name); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + // Create a simple tool with no properties + var tool = AIFunctionFactory.Create(() => "result", "simple_tool"); + + ChatOptions options = new() + { + Tools = [tool] + }; + + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")], options); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetStreamingResponseAsync_WithRawRepresentationFactory() + { + MockBedrockRuntime mock = new() + { + OnConverseStreamRequest = request => + { + // Verify the custom model ID was used + Assert.Equal("custom-model", request.ModelId); + + // Return empty stream + MemoryStream stream = new(); + return new ConverseStreamResponse { Stream = new ConverseStreamOutput(stream) }; + } + }; + + IChatClient chatClient = mock.AsIChatClient("default-model"); + + ChatOptions options = new() + { + RawRepresentationFactory = client => new ConverseStreamRequest { ModelId = "custom-model" } + }; + + // Should not throw + await foreach (var _ in chatClient.GetStreamingResponseAsync([new(ChatRole.User, "Test")], options)) + { + // Consume stream + } + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_AllContentTypesHaveRawRepresentation() + { + byte[] imageData = [1, 2, 3, 4]; + byte[] videoData = [5, 6, 7, 8]; + byte[] docData = [9, 10, 11]; + string reasoningText = "Thinking..."; + string signature = "sig123"; + + ContentBlock textBlock = new() { Text = "Hello" }; + ContentBlock imageBlock = new() + { + Image = new ImageBlock + { + Format = ImageFormat.Png, + Source = new ImageSource { Bytes = new System.IO.MemoryStream(imageData) } + } + }; + ContentBlock videoBlock = new() + { + Video = new VideoBlock + { + Format = VideoFormat.Mp4, + Source = new VideoSource { Bytes = new System.IO.MemoryStream(videoData) } + } + }; + ContentBlock docBlock = new() + { + Document = new DocumentBlock + { + Format = DocumentFormat.Pdf, + Name = "file.pdf", + Source = new DocumentSource { Bytes = new System.IO.MemoryStream(docData) } + } + }; + ContentBlock toolUseBlock = new() + { + ToolUse = new ToolUseBlock + { + ToolUseId = "tool_1", + Name = "func", + Input = new Document(new Dictionary()) + } + }; + ContentBlock citationBlock = new() + { + CitationsContent = new CitationsContentBlock + { + Content = [new() { Text = "Cited" }], + Citations = [new() { Title = "Source" }] + } + }; + ContentBlock reasoningBlock = new() + { + ReasoningContent = new ReasoningContentBlock + { + ReasoningText = new ReasoningTextBlock + { + Text = reasoningText, + Signature = signature + } + } + }; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new() + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + textBlock, + imageBlock, + videoBlock, + docBlock, + toolUseBlock, + citationBlock, + reasoningBlock + ] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.NotNull(result); + Assert.Equal(7, result.Messages[0].Contents.Count); + + Assert.Same(textBlock, result.Messages[0].Contents[0].RawRepresentation); + Assert.Same(imageBlock, result.Messages[0].Contents[1].RawRepresentation); + Assert.Same(videoBlock, result.Messages[0].Contents[2].RawRepresentation); + Assert.Same(docBlock, result.Messages[0].Contents[3].RawRepresentation); + Assert.Same(toolUseBlock, result.Messages[0].Contents[4].RawRepresentation); + // Citation content RawRepresentation is the CitationGeneratedContent, not the ContentBlock + Assert.Same(citationBlock.CitationsContent.Content[0], result.Messages[0].Contents[5].RawRepresentation); + Assert.Same(reasoningBlock, result.Messages[0].Contents[6].RawRepresentation); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_RawRepresentation_Message() + { + Message rawMessage = null; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new(); + rawMessage = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { Text = "Test" } + ] + }; + response.Output = new ConverseOutput + { + Message = rawMessage + }; + response.Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 }; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.NotNull(result); + Assert.Same(rawMessage, result.Messages[0].RawRepresentation); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_RawRepresentation_Response() + { + ConverseResponse rawResponse = null; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + rawResponse = new ConverseResponse + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { Text = "Test" } + ] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + return rawResponse; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.NotNull(result); + Assert.Same(rawResponse, result.RawRepresentation); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_UsesRawRepresentation_WhenSending() + { + ContentBlock originalContentBlock = new() { Text = "Original text from raw" }; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.Single(request.Messages); + Assert.Single(request.Messages[0].Content); + Assert.Same(originalContentBlock, request.Messages[0].Content[0]); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + TextContent content = new("This text should be ignored") + { + RawRepresentation = originalContentBlock + }; + + ChatMessage[] messages = [new(ChatRole.User, [content])]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_HandlesWhitespaceOnlyText() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.Single(request.Messages); + Assert.Single(request.Messages[0].Content); + Assert.Equal("\u200b", request.Messages[0].Content[0].Text); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = [new(ChatRole.User, " ")]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_TrimsAssistantText() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.Equal(2, request.Messages.Count); + Assert.Equal(ConversationRole.User, request.Messages[0].Role); + Assert.Equal(ConversationRole.Assistant, request.Messages[1].Role); + + Assert.Single(request.Messages[1].Content); + Assert.Equal("Trimmed text", request.Messages[1].Content[0].Text); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Trimmed text \n\n") + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_SkipsEmptyAssistantText() + { + // When an assistant message contains only whitespace, it should be skipped entirely + // because sending an assistant message with empty content would fail the service. + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + // Only the user message should be sent; the whitespace-only assistant message is dropped + Assert.Single(request.Messages); + Assert.Equal(ConversationRole.User, request.Messages[0].Role); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, " \n\n ") + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_AdditionalProperties_InChatOptions() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var dict = request.AdditionalModelRequestFields.AsDictionary(); + + Assert.Equal("string_value", dict["string_prop"].AsString()); + Assert.Equal(42, dict["int_prop"].AsInt()); + Assert.Equal(3.14, dict["double_prop"].AsDouble(), 2); + Assert.True(dict["bool_prop"].AsBool()); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + ChatOptions options = new() + { + AdditionalProperties = new AdditionalPropertiesDictionary() + { + ["string_prop"] = "string_value", + ["int_prop"] = 42, + ["double_prop"] = 3.14, + ["bool_prop"] = true, + ["null_prop"] = null + } + }; + + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")], options); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_AdditionalProperties_WithJsonElement() + { + var jsonObject = JsonSerializer.SerializeToElement(new { nested = new { value = 123 } }); + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var dict = request.AdditionalModelRequestFields.AsDictionary(); + + Assert.True(dict.ContainsKey("json_prop")); + var nested = dict["json_prop"].AsDictionary(); + Assert.True(nested.ContainsKey("nested")); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + ChatOptions options = new() + { + AdditionalProperties = new AdditionalPropertiesDictionary() + { + ["json_prop"] = jsonObject + } + }; + + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")], options); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_CachePointBlock_InMessages() + { + CachePointBlock cachePoint = new() { Type = CachePointType.Default }; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.Single(request.Messages); + Assert.Equal(2, request.Messages[0].Content.Count); + Assert.Equal("Text before cache", request.Messages[0].Content[0].Text); + Assert.NotNull(request.Messages[0].Content[1].CachePoint); + Assert.Equal(CachePointType.Default, request.Messages[0].Content[1].CachePoint.Type); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + ChatMessage chatMessage = new(ChatRole.User, "Text before cache") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + [nameof(ContentBlock.CachePoint)] = cachePoint + } + }; + + ChatResponse result = await chatClient.GetResponseAsync([chatMessage]); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_CachePointBlock_InSystemMessages() + { + CachePointBlock cachePoint = new() { Type = CachePointType.Default }; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.NotNull(request.System); + Assert.Equal(2, request.System.Count); + Assert.Equal("System instruction", request.System[0].Text); + Assert.NotNull(request.System[1].CachePoint); + Assert.Equal(CachePointType.Default, request.System[1].CachePoint.Type); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + ChatMessage systemMessage = new(ChatRole.System, "System instruction") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + [nameof(ContentBlock.CachePoint)] = cachePoint + } + }; + + ChatResponse result = await chatClient.GetResponseAsync([systemMessage, new(ChatRole.User, "Hello")]); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_CachePointBlock_InContent() + { + CachePointBlock cachePoint = new() { Type = CachePointType.Default }; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.Single(request.Messages); + Assert.Equal(3, request.Messages[0].Content.Count); + Assert.Equal("Text 1", request.Messages[0].Content[0].Text); + Assert.NotNull(request.Messages[0].Content[1].CachePoint); + Assert.Equal("Text 2", request.Messages[0].Content[2].Text); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + TextContent content1 = new("Text 1") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + [nameof(ContentBlock.CachePoint)] = cachePoint + } + }; + + TextContent content2 = new("Text 2"); + + ChatMessage[] messages = [new(ChatRole.User, [content1, content2])]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_WithRawRepresentationFactory() + { + ConverseRequest factoryRequest = null; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.Same(factoryRequest, request); + Assert.Equal("factory-model", request.ModelId); + Assert.NotNull(request.InferenceConfig); + Assert.Equal(0.5f, request.InferenceConfig.Temperature); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("default-model"); + + ChatOptions options = new() + { + RawRepresentationFactory = (client) => + { + factoryRequest = new ConverseRequest + { + ModelId = "factory-model", + InferenceConfig = new InferenceConfiguration { Temperature = 0.5f } + }; + return factoryRequest; + } + }; + + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")], options); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_MultipleContentInCitations() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new() + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { + CitationsContent = new CitationsContentBlock + { + Content = + [ + new() { Text = "Content 1" }, + new() { Text = "Content 2" } + ], + Citations = + [ + new() { Title = "Citation 1" }, + new() { Title = "Citation 2" } + ] + } + } + ] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.NotNull(result); + Assert.Equal(2, result.Messages[0].Contents.Count); + + var content1 = Assert.IsType(result.Messages[0].Contents[0]); + Assert.Equal("Content 1", content1.Text); + var citation1 = Assert.IsType(Assert.Single(content1.Annotations)); + Assert.Equal("Citation 1", citation1.Title); + + var content2 = Assert.IsType(result.Messages[0].Contents[1]); + Assert.Equal("Content 2", content2.Text); + var citation2 = Assert.IsType(Assert.Single(content2.Annotations)); + Assert.Equal("Citation 2", citation2.Title); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_MismatchedCitationCounts_UsesMinimum() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new() + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { + CitationsContent = new CitationsContentBlock + { + Content = + [ + new() { Text = "Content 1" }, + new() { Text = "Content 2" }, + new() { Text = "Content 3" } + ], + Citations = + [ + new() { Title = "Citation 1" } + ] + } + } + ] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.NotNull(result); + Assert.Single(result.Messages[0].Contents); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_SendsFunctionCall_WithComplexArguments() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + // Verify the tool definition was created correctly + var toolSpec = request.ToolConfig?.Tools?[0]?.ToolSpec; + Assert.NotNull(toolSpec); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + // Create a function with array parameters + var tool = AIFunctionFactory.Create( + (string[] items, int count) => "result", + "process_items", + "Processes an array of items"); + + ChatOptions options = new() + { + Tools = [tool] + }; + + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")], options); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_DocumentWithArrayValues() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var response = CreateResponse("OK"); + response.Output.Message.Content.Add(new ContentBlock + { + ToolUse = new ToolUseBlock + { + ToolUseId = "tool_arr", + Name = "array_func", + Input = new Document(new Dictionary + { + ["items"] = new Document(new List { "a", "b", "c" }) + }) + } + }); + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.NotNull(result); + var funcCall = result.Messages[0].Contents.OfType().FirstOrDefault(); + Assert.NotNull(funcCall); + Assert.NotNull(funcCall.Arguments); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_ReceivesNestedDictionary() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var response = CreateResponse("OK"); + response.AdditionalModelResponseFields = new Document(new Dictionary + { + ["outer"] = new Document(new Dictionary + { + ["inner"] = "nested_value" + }) + }); + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.NotNull(result); + Assert.NotNull(result.Messages[0].AdditionalProperties); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_AdditionalProperties_WithJsonArray() + { + var jsonArray = JsonSerializer.SerializeToElement(new[] { 1, 2, 3 }); + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var dict = request.AdditionalModelRequestFields.AsDictionary(); + Assert.True(dict["array_prop"].IsList()); + var list = dict["array_prop"].AsList(); + Assert.Equal(3, list.Count); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + ChatOptions options = new() + { + AdditionalProperties = new AdditionalPropertiesDictionary() + { + ["array_prop"] = jsonArray + } + }; + + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")], options); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_AdditionalProperties_WithJsonNull() + { + var jsonNull = JsonSerializer.SerializeToElement(null); + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var dict = request.AdditionalModelRequestFields.AsDictionary(); + // Null JSON element becomes empty string according to the implementation + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + ChatOptions options = new() + { + AdditionalProperties = new AdditionalPropertiesDictionary() + { + ["null_prop"] = jsonNull + } + }; + + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")], options); + Assert.NotNull(result); + } + + [Theory] + [Trait("UnitTest", "BedrockRuntime")] + [InlineData(true)] + [InlineData(false)] + public async Task IChatClient_GetResponseAsync_AdditionalProperties_WithJsonBoolean(bool boolValue) + { + var jsonBool = JsonSerializer.SerializeToElement(boolValue); + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var dict = request.AdditionalModelRequestFields.AsDictionary(); + Assert.Equal(boolValue, dict["bool_prop"].AsBool()); + + return CreateResponse("OK"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + ChatOptions options = new() + { + AdditionalProperties = new AdditionalPropertiesDictionary() + { + ["bool_prop"] = jsonBool + } + }; + + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")], options); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_FunctionCallContent() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new(); + Document document = new(new Dictionary + { + ["location"] = "San Francisco" + }); + + response.Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { + ToolUse = new ToolUseBlock + { + ToolUseId = "tool_123", + Name = "get_weather", + Input = document + } + } + ] + } + }; + response.Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 }; + + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = [new(ChatRole.User, "What's the weather in San Francisco?")]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.IsType(Assert.Single(result.Messages[0].Contents)); + + FunctionCallContent functionCall = (FunctionCallContent)result.Messages[0].Contents[0]; + Assert.Equal("get_weather", functionCall.Name); + Assert.Equal("tool_123", functionCall.CallId); + Assert.NotNull(functionCall.Arguments); + Assert.Null(functionCall.Exception); + Assert.NotNull(functionCall.RawRepresentation); + // Arguments values are JsonElement when deserialized from Document + Assert.Equal("San Francisco", ((System.Text.Json.JsonElement)functionCall.Arguments["location"]).GetString()); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_FunctionCallContent_WithDeeplyNestedDocument() + { + // Note: JSON serialization has a default max depth of 64. Documents nested deeper than that + // will fail during conversion. This test uses depth 50 which is within limits. + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + ConverseResponse response = new(); + var document = CreateDeeplyNestedDocument(50); + + response.Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = + [ + new() { + ToolUse = new ToolUseBlock + { + ToolUseId = "tool_nested", + Name = "nested_tool", + Input = document + } + } + ] + } + }; + response.Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 }; + + return response; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatResponse result = await chatClient.GetResponseAsync([new(ChatRole.User, "Test")]); + + Assert.NotNull(result); + FunctionCallContent functionCall = Assert.IsType(Assert.Single(result.Messages[0].Contents)); + + Assert.Equal("nested_tool", functionCall.Name); + Assert.Equal("tool_nested", functionCall.CallId); + Assert.NotNull(functionCall.Arguments); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_FunctionResultContent() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.Single(request.Messages); + Assert.Equal(ConversationRole.User, request.Messages[0].Role); + Assert.Single(request.Messages[0].Content); + Assert.NotNull(request.Messages[0].Content[0].ToolResult); + Assert.Equal("call_123", request.Messages[0].Content[0].ToolResult.ToolUseId); + + var toolResult = request.Messages[0].Content[0].ToolResult; + Assert.NotNull(toolResult.Content); + Assert.Single(toolResult.Content); + + return CreateResponse("Based on the weather data, it's sunny."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, + [ + new FunctionResultContent("call_123", new { temperature = 72, condition = "sunny" }) + ]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + + Assert.NotNull(result); + Assert.Equal("Based on the weather data, it's sunny.", ((TextContent)result.Messages[0].Contents[0]).Text); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_FunctionResultContent_WithString() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var toolResult = request.Messages[0].Content[0].ToolResult; + Assert.NotNull(toolResult); + Assert.Equal("call_str", toolResult.ToolUseId); + Assert.Single(toolResult.Content); + Assert.Equal("Result text", toolResult.Content[0].Text); + + return CreateResponse("Got your result"); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, + [ + new FunctionResultContent("call_str", "Result text") + ]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_FunctionResultContent_WithDataContent() + { + byte[] imageData = [0x89, 0x50, 0x4E, 0x47]; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.Single(request.Messages); + var toolResult = request.Messages[0].Content[0].ToolResult; + Assert.NotNull(toolResult); + Assert.Equal("call_456", toolResult.ToolUseId); + Assert.Single(toolResult.Content); + Assert.NotNull(toolResult.Content[0].Image); + Assert.Equal(ImageFormat.Png, toolResult.Content[0].Image.Format); + Assert.True(toolResult.Content[0].Image.Source.Bytes.ToArray().SequenceEqual(imageData)); + + return CreateResponse("Image processed."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, + [ + new FunctionResultContent("call_456", new DataContent(imageData, "image/png")) + ]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + Assert.Equal("Image processed.", ((TextContent)result.Messages[0].Contents[0]).Text); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_FunctionResultContent_WithTextContent() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var toolResult = request.Messages[0].Content[0].ToolResult; + Assert.NotNull(toolResult); + Assert.Equal("call_text", toolResult.ToolUseId); + Assert.NotNull(toolResult.Content); + Assert.Single(toolResult.Content); + + // TextContent should be converted to ToolResultContentBlock with Text property + Assert.Equal("Simple text result", toolResult.Content[0].Text); + + return CreateResponse("Text result processed."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, + [ + new FunctionResultContent("call_text", new TextContent("Simple text result")) + ]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + Assert.Equal("Text result processed.", ((TextContent)result.Messages[0].Contents[0]).Text); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_FunctionResultContent_WithMultipleAIContents() + { + byte[] data = [1, 2, 3]; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var toolResult = request.Messages[0].Content[0].ToolResult; + Assert.NotNull(toolResult); + Assert.Equal("call_multi", toolResult.ToolUseId); + Assert.Equal(2, toolResult.Content.Count); + + Assert.NotNull(toolResult.Content[0].Image); + Assert.True(toolResult.Content[0].Image.Source.Bytes.ToArray().SequenceEqual(data)); + + Assert.NotNull(toolResult.Content[1].Document); + Assert.Equal(DocumentFormat.Pdf, toolResult.Content[1].Document.Format); + + return CreateResponse("Multi-content processed."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + List multiContent = + [ + new DataContent(data, "image/png"), + new DataContent(new byte[] { 4, 5 }, "application/pdf") + ]; + + ChatMessage[] messages = + [ + new(ChatRole.User, + [ + new FunctionResultContent("call_multi", multiContent) + ]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + Assert.Equal("Multi-content processed.", ((TextContent)result.Messages[0].Contents[0]).Text); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_WithTools() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.NotNull(request.ToolConfig); + Assert.NotNull(request.ToolConfig.Tools); + Assert.Single(request.ToolConfig.Tools); + + var tool = request.ToolConfig.Tools[0]; + Assert.NotNull(tool.ToolSpec); + Assert.Equal("get_weather", tool.ToolSpec.Name); + Assert.Equal("Gets weather information", tool.ToolSpec.Description); + Assert.NotNull(tool.ToolSpec.InputSchema); + + var json = tool.ToolSpec.InputSchema.Json; + Assert.True(json.IsDictionary()); + var dict = json.AsDictionary(); + Assert.Equal("object", dict["type"].AsString()); + + return CreateResponse("I can use tools to help you."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = [new(ChatRole.User, "What tools do you have?")]; + + ChatOptions options = new() + { + Tools = + [ + AIFunctionFactory.Create((string location) => "the weather", "get_weather", "Gets weather information") + ] + }; + + ChatResponse result = await chatClient.GetResponseAsync(messages, options); + + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal("I can use tools to help you.", ((TextContent)result.Messages[0].Contents[0]).Text); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_WithToolMode_RequireSpecific() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.NotNull(request.ToolConfig); + Assert.NotNull(request.ToolConfig.ToolChoice); + Assert.NotNull(request.ToolConfig.ToolChoice.Tool); + Assert.Equal("get_weather", request.ToolConfig.ToolChoice.Tool.Name); + + return CreateResponse("Required mode with specific function."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = [new(ChatRole.User, "Test required tool mode")]; + + ChatOptions options = new() + { + Tools = [AIFunctionFactory.Create((string location) => "the weather", "get_weather", "Gets weather information")], + ToolMode = ChatToolMode.RequireSpecific("get_weather") + }; + + ChatResponse result = await chatClient.GetResponseAsync(messages, options); + + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal("Required mode with specific function.", ((TextContent)result.Messages[0].Contents[0]).Text); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_WithToolMode_RequireAny() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + Assert.NotNull(request.ToolConfig); + Assert.NotNull(request.ToolConfig.ToolChoice); + Assert.NotNull(request.ToolConfig.ToolChoice.Any); + + return CreateResponse("Required mode any function."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = [new(ChatRole.User, "Test")]; + + ChatOptions options = new() + { + Tools = [AIFunctionFactory.Create((string location) => "the weather", "get_weather", "Gets weather")], + ToolMode = ChatToolMode.RequireAny + }; + + ChatResponse result = await chatClient.GetResponseAsync(messages, options); + Assert.NotNull(result); + } + + private static Document CreateDeeplyNestedDocument(int depth) + { + Dictionary dict = []; + var current = dict; + + for (int i = 0; i < depth; i++) + { + Dictionary next = []; + current[$"level{i}"] = new Document(next); + current = next; + } + + current["value"] = "final"; + return new Document(dict); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_FunctionResultContent_WithReasoningContent() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var toolResult = request.Messages[0].Content[0].ToolResult; + Assert.NotNull(toolResult); + Assert.Equal("call_reason", toolResult.ToolUseId); + Assert.Single(toolResult.Content); + Assert.NotNull(toolResult.Content[0].Text); + + return CreateResponse("Reasoning result processed."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, + [ + new FunctionResultContent("call_reason", new TextReasoningContent("Here's my reasoning...")) + ]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_FunctionResultContent_WithVideoContent() + { + byte[] videoData = [1, 2, 3, 4, 5]; + + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var toolResult = request.Messages[0].Content[0].ToolResult; + Assert.NotNull(toolResult); + Assert.NotNull(toolResult.Content[0].Video); + Assert.True(toolResult.Content[0].Video.Source.Bytes.ToArray().SequenceEqual(videoData)); + + return CreateResponse("Video result processed."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, + [ + new FunctionResultContent("call_video", new DataContent(videoData, "video/mp4")) + ]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_FunctionResultContent_WithIntResult() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var toolResult = request.Messages[0].Content[0].ToolResult; + Assert.NotNull(toolResult); + Assert.True(toolResult.Content[0].Json.IsDictionary()); + var dict = toolResult.Content[0].Json.AsDictionary(); + // The value is stored as double since JsonSerializer uses double for numbers + Assert.True(dict["result"].IsDouble() || dict["result"].IsInt()); + + return CreateResponse("Int result processed."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, + [ + new FunctionResultContent("call_int", 42) + ]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_FunctionResultContent_WithBoolResult() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var toolResult = request.Messages[0].Content[0].ToolResult; + Assert.NotNull(toolResult); + Assert.True(toolResult.Content[0].Json.IsDictionary()); + var dict = toolResult.Content[0].Json.AsDictionary(); + Assert.True(dict["result"].AsBool()); + + return CreateResponse("Bool result processed."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, + [ + new FunctionResultContent("call_bool", true) + ]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_FunctionResultContent_WithNullResult() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var toolResult = request.Messages[0].Content[0].ToolResult; + Assert.NotNull(toolResult); + Assert.True(toolResult.Content[0].Json.IsDictionary()); + + return CreateResponse("Null result processed."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, + [ + new FunctionResultContent("call_null", (object)null) + ]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_FunctionResultContent_WithJsonElementResult() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var toolResult = request.Messages[0].Content[0].ToolResult; + Assert.NotNull(toolResult); + Assert.True(toolResult.Content[0].Json.IsDictionary()); + + return CreateResponse("JsonElement result processed."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + JsonDocument jsonDoc = System.Text.Json.JsonDocument.Parse("{\"key\": \"value\"}"); + ChatMessage[] messages = + [ + new(ChatRole.User, + [ + new FunctionResultContent("call_json", jsonDoc.RootElement) + ]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetResponseAsync_FunctionResultContent_WithLongResult() + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var toolResult = request.Messages[0].Content[0].ToolResult; + Assert.NotNull(toolResult); + Assert.True(toolResult.Content[0].Json.IsDictionary()); + var dict = toolResult.Content[0].Json.AsDictionary(); + Assert.True(dict["result"].IsLong() || dict["result"].IsDouble()); + + return CreateResponse("Long result processed."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, + [ + new FunctionResultContent("call_long", 9999999999L) + ]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Theory] + [Trait("UnitTest", "BedrockRuntime")] + [InlineData(3.14f)] + [InlineData(2.718281828)] + public async Task IChatClient_GetResponseAsync_FunctionResultContent_WithFloatingPointResult(double value) + { + MockBedrockRuntime mock = new() + { + OnConverseRequest = request => + { + var toolResult = request.Messages[0].Content[0].ToolResult; + Assert.NotNull(toolResult); + Assert.True(toolResult.Content[0].Json.IsDictionary()); + + return CreateResponse("Floating point result processed."); + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = + [ + new(ChatRole.User, + [ + new FunctionResultContent("call_fp", value) + ]) + ]; + + ChatResponse result = await chatClient.GetResponseAsync(messages); + Assert.NotNull(result); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetStreamingResponseAsync_NullMessages_Throws() + { + MockBedrockRuntime mock = new(); + IChatClient chatClient = mock.AsIChatClient("claude"); + + var enumerator = chatClient.GetStreamingResponseAsync(null).GetAsyncEnumerator(); + await Assert.ThrowsAsync("messages", () => enumerator.MoveNextAsync().AsTask()); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetStreamingResponseAsync_BasicTextStreaming() + { + MockBedrockRuntime mock = new() + { + OnConverseStreamRequest = request => + { + var stream = CreateEventStream( + CreateMessageStartEvent(), + CreateContentBlockStartEvent(0), + CreateContentBlockDeltaEvent(0, "Hello"), + CreateContentBlockDeltaEvent(0, " world"), + CreateContentBlockDeltaEvent(0, "!"), + CreateContentBlockStopEvent(0), + CreateMessageStopEvent("end_turn"), + CreateMetadataEvent(10, 5) + ); + return new ConverseStreamResponse { Stream = new ConverseStreamOutput(stream) }; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + ChatMessage[] messages = [new(ChatRole.User, "Say hello")]; + + List updates = []; + await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync(messages)) + { + updates.Add(update); + } + + Assert.NotEmpty(updates); + + // Verify all updates have consistent messageId and responseId + var messageIds = updates.Select(u => u.MessageId).Distinct().ToList(); + Assert.Single(messageIds); + Assert.NotNull(messageIds[0]); + + var responseIds = updates.Select(u => u.ResponseId).Distinct().ToList(); + Assert.Single(responseIds); + Assert.NotNull(responseIds[0]); + + // Verify role is set on updates + Assert.All(updates.Where(u => u.Role.HasValue), u => Assert.Equal(ChatRole.Assistant, u.Role)); + + List textUpdates = updates.Where(u => u.Contents.Any(c => c is TextContent)).ToList(); + Assert.Equal(3, textUpdates.Count); + + string fullText = string.Concat(textUpdates.Select(u => ((TextContent)u.Contents[0]).Text)); + Assert.Equal("Hello world!", fullText); + + Assert.Equal(ChatFinishReason.Stop, updates.Last(u => u.FinishReason != null).FinishReason); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetStreamingResponseAsync_WithUsageMetadata() + { + MockBedrockRuntime mock = new() + { + OnConverseStreamRequest = request => + { + var stream = CreateEventStream( + CreateMessageStartEvent(), + CreateContentBlockStartEvent(0), + CreateContentBlockDeltaEvent(0, "Test"), + CreateContentBlockStopEvent(0), + CreateMessageStopEvent("end_turn"), + CreateMetadataEvent(100, 50) + ); + return new ConverseStreamResponse { Stream = new ConverseStreamOutput(stream) }; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + List updates = []; + await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync([new(ChatRole.User, "Test")])) + { + updates.Add(update); + } + + var usageUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is UsageContent)); + Assert.NotNull(usageUpdate); + + UsageContent usageContent = (UsageContent)usageUpdate.Contents.First(c => c is UsageContent); + Assert.Equal(100, usageContent.Details.InputTokenCount); + Assert.Equal(50, usageContent.Details.OutputTokenCount); + // TotalTokenCount is only set if the API returns it; the streaming API may not include it + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetStreamingResponseAsync_WithToolUse() + { + MockBedrockRuntime mock = new() + { + OnConverseStreamRequest = request => + { + var stream = CreateEventStream( + CreateMessageStartEvent(), + CreateContentBlockStartEventWithToolUse(0, "tool_123", "get_weather"), + CreateContentBlockDeltaEventWithToolUse(0, "{\"location\":"), + CreateContentBlockDeltaEventWithToolUse(0, "\"Seattle\"}"), + CreateContentBlockStopEvent(0), + CreateMessageStopEvent("tool_use"), + CreateMetadataEvent(10, 5) + ); + return new ConverseStreamResponse { Stream = new ConverseStreamOutput(stream) }; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + List updates = []; + await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync([new(ChatRole.User, "Weather?")])) + { + updates.Add(update); + } + + var functionCallUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is FunctionCallContent)); + Assert.NotNull(functionCallUpdate); + + FunctionCallContent functionCall = (FunctionCallContent)functionCallUpdate.Contents.First(c => c is FunctionCallContent); + Assert.Equal("get_weather", functionCall.Name); + Assert.Equal("tool_123", functionCall.CallId); + Assert.NotNull(functionCall.Arguments); + Assert.Null(functionCall.Exception); + Assert.Equal("Seattle", ((System.Text.Json.JsonElement)functionCall.Arguments["location"]).GetString()); + + // Verify finish reason is ToolCalls + Assert.Equal(ChatFinishReason.ToolCalls, updates.Last(u => u.FinishReason != null).FinishReason); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetStreamingResponseAsync_WithCitation() + { + MockBedrockRuntime mock = new() + { + OnConverseStreamRequest = request => + { + var stream = CreateEventStream( + CreateMessageStartEvent(), + CreateContentBlockStartEvent(0), + CreateContentBlockDeltaEventWithCitation(0, "Cited text", "Source Title", "Source snippet"), + CreateContentBlockStopEvent(0), + CreateMessageStopEvent("end_turn"), + CreateMetadataEvent(10, 5) + ); + return new ConverseStreamResponse { Stream = new ConverseStreamOutput(stream) }; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + List updates = []; + await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync([new(ChatRole.User, "Test")])) + { + updates.Add(update); + } + + var textUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is TextContent tc && tc.Text == "Cited text")); + Assert.NotNull(textUpdate); + + TextContent textContent = (TextContent)textUpdate.Contents.First(c => c is TextContent); + Assert.NotNull(textContent.Annotations); + Assert.Single(textContent.Annotations); + var citation = Assert.IsType(textContent.Annotations[0]); + Assert.Equal("Source Title", citation.Title); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetStreamingResponseAsync_WithReasoningContent() + { + MockBedrockRuntime mock = new() + { + OnConverseStreamRequest = request => + { + var stream = CreateEventStream( + CreateMessageStartEvent(), + CreateContentBlockStartEvent(0), + CreateContentBlockDeltaEventWithReasoning(0, "Thinking...", "sig123", null), + CreateContentBlockStopEvent(0), + CreateMessageStopEvent("end_turn"), + CreateMetadataEvent(10, 5) + ); + return new ConverseStreamResponse { Stream = new ConverseStreamOutput(stream) }; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + List updates = []; + await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync([new(ChatRole.User, "Test")])) + { + updates.Add(update); + } + + var reasoningUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is TextReasoningContent)); + Assert.NotNull(reasoningUpdate); + + TextReasoningContent reasoningContent = (TextReasoningContent)reasoningUpdate.Contents.First(c => c is TextReasoningContent); + Assert.Equal("Thinking...", reasoningContent.Text); + Assert.Equal("sig123", reasoningContent.ProtectedData); + Assert.Equal(ChatRole.Assistant, reasoningUpdate.Role); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetStreamingResponseAsync_WithReasoningContentAndRedacted() + { + MockBedrockRuntime mock = new() + { + OnConverseStreamRequest = request => + { + var stream = CreateEventStream( + CreateMessageStartEvent(), + CreateContentBlockStartEvent(0), + CreateContentBlockDeltaEventWithReasoning(0, "Thinking...", null, "cmVkYWN0ZWQ="), // base64 "redacted" + CreateContentBlockStopEvent(0), + CreateMessageStopEvent("end_turn"), + CreateMetadataEvent(10, 5) + ); + return new ConverseStreamResponse { Stream = new ConverseStreamOutput(stream) }; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + List updates = []; + await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync([new(ChatRole.User, "Test")])) + { + updates.Add(update); + } + + var reasoningUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is TextReasoningContent)); + Assert.NotNull(reasoningUpdate); + + TextReasoningContent reasoningContent = (TextReasoningContent)reasoningUpdate.Contents.First(c => c is TextReasoningContent); + Assert.NotNull(reasoningContent.AdditionalProperties); + Assert.True(reasoningContent.AdditionalProperties.ContainsKey("RedactedContent")); + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetStreamingResponseAsync_WithInvalidToolJson() + { + MockBedrockRuntime mock = new() + { + OnConverseStreamRequest = request => + { + var stream = CreateEventStream( + CreateMessageStartEvent(), + CreateContentBlockStartEventWithToolUse(0, "tool_err", "bad_tool"), + CreateContentBlockDeltaEventWithToolUse(0, "not valid json {{{"), + CreateContentBlockStopEvent(0), + CreateMessageStopEvent("tool_use"), + CreateMetadataEvent(10, 5) + ); + return new ConverseStreamResponse { Stream = new ConverseStreamOutput(stream) }; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + List updates = []; + await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync([new(ChatRole.User, "Test")])) + { + updates.Add(update); + } + + var functionCallUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is FunctionCallContent)); + Assert.NotNull(functionCallUpdate); + + FunctionCallContent functionCall = (FunctionCallContent)functionCallUpdate.Contents.First(c => c is FunctionCallContent); + Assert.Equal("bad_tool", functionCall.Name); + Assert.NotNull(functionCall.Exception); // Should have parse error + } + + [Fact] + [Trait("UnitTest", "BedrockRuntime")] + public async Task IChatClient_GetStreamingResponseAsync_WithAdditionalResponseFields() + { + MockBedrockRuntime mock = new() + { + OnConverseStreamRequest = request => + { + var stream = CreateEventStream( + CreateMessageStartEvent(), + CreateContentBlockStartEvent(0), + CreateContentBlockDeltaEvent(0, "Test"), + CreateContentBlockStopEvent(0), + CreateMessageStopEventWithAdditionalFields("end_turn"), + CreateMetadataEvent(10, 5) + ); + return new ConverseStreamResponse { Stream = new ConverseStreamOutput(stream) }; + } + }; + + IChatClient chatClient = mock.AsIChatClient("claude"); + + List updates = []; + await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync([new(ChatRole.User, "Test")])) + { + updates.Add(update); + } + + // Should have received an update with additional properties + var stopUpdate = updates.FirstOrDefault(u => u.FinishReason == ChatFinishReason.Stop && u.AdditionalProperties != null); + Assert.NotNull(stopUpdate); + } + + private static byte[] CreateContentBlockDeltaEventWithCitation(int contentBlockIndex, string text, string title, string snippet) + { + return CreateEventMessage("ContentBlockDelta", Encoding.UTF8.GetBytes($"{{\"contentBlockIndex\":{contentBlockIndex},\"delta\":{{\"text\":\"{text}\",\"citation\":{{\"title\":\"{title}\",\"sourceContent\":[{{\"text\":\"{snippet}\"}}]}}}}}}")); + } + + private static byte[] CreateContentBlockDeltaEventWithReasoning(int contentBlockIndex, string text, string signature, string redactedContentBase64) + { + var sigPart = signature != null ? $",\"signature\":\"{signature}\"" : ""; + var redactedPart = redactedContentBase64 != null ? $",\"redactedContent\":\"{redactedContentBase64}\"" : ""; + return CreateEventMessage("ContentBlockDelta", Encoding.UTF8.GetBytes($"{{\"contentBlockIndex\":{contentBlockIndex},\"delta\":{{\"reasoningContent\":{{\"text\":\"{text}\"{sigPart}{redactedPart}}}}}}}")); + } + + private static byte[] CreateMessageStopEventWithAdditionalFields(string stopReason) => + CreateEventMessage("MessageStop", Encoding.UTF8.GetBytes($"{{\"stopReason\":\"{stopReason}\",\"additionalModelResponseFields\":{{\"custom\":\"value\"}}}}")); + + private static Stream CreateEventStream(params byte[][] events) + { + MemoryStream ms = new(); + foreach (var evt in events) + { + ms.Write(evt, 0, evt.Length); + } + ms.Position = 0; + return ms; + } + + private static byte[] CreateMessageStartEvent() => + CreateEventMessage("MessageStart", Encoding.UTF8.GetBytes("""{"role":"assistant"}""")); + + private static byte[] CreateContentBlockStartEvent(int contentBlockIndex) => + CreateEventMessage("ContentBlockStart", Encoding.UTF8.GetBytes($"{{\"contentBlockIndex\":{contentBlockIndex},\"start\":{{\"text\":\"\"}}}}")); + + private static byte[] CreateContentBlockStartEventWithToolUse(int contentBlockIndex, string toolUseId, string name) + { + return CreateEventMessage("ContentBlockStart", Encoding.UTF8.GetBytes($"{{\"contentBlockIndex\":{contentBlockIndex},\"start\":{{\"toolUse\":{{\"toolUseId\":\"{toolUseId}\",\"name\":\"{name}\"}}}}}}")); + } + + private static byte[] CreateContentBlockDeltaEvent(int contentBlockIndex, string text) + { + var escapedText = text.Replace("\"", "\\\""); + return CreateEventMessage("ContentBlockDelta", Encoding.UTF8.GetBytes($"{{\"contentBlockIndex\":{contentBlockIndex},\"delta\":{{\"text\":\"{escapedText}\"}}}}")); + } + + private static byte[] CreateContentBlockDeltaEventWithToolUse(int contentBlockIndex, string input) + { + var escapedInput = input.Replace("\"", "\\\""); + return CreateEventMessage("ContentBlockDelta", Encoding.UTF8.GetBytes($"{{\"contentBlockIndex\":{contentBlockIndex},\"delta\":{{\"toolUse\":{{\"input\":\"{escapedInput}\"}}}}}}")); + } + + private static byte[] CreateContentBlockStopEvent(int contentBlockIndex) => + CreateEventMessage("ContentBlockStop", Encoding.UTF8.GetBytes($"{{\"contentBlockIndex\":{contentBlockIndex}}}")); + + private static byte[] CreateMessageStopEvent(string stopReason) => + CreateEventMessage("MessageStop", Encoding.UTF8.GetBytes($"{{\"stopReason\":\"{stopReason}\"}}")); + + private static byte[] CreateMetadataEvent(int inputTokens, int outputTokens) => + CreateEventMessage("Metadata", Encoding.UTF8.GetBytes($"{{\"usage\":{{\"inputTokens\":{inputTokens},\"outputTokens\":{outputTokens}}}}}")); + + private static byte[] GetUtf8(string s) => Encoding.UTF8.GetBytes(s); + + private static byte[] CreateEventMessage(string eventType, byte[] payload) + { + EventStreamHeader messageTypeHeader = new(":message-type"); + messageTypeHeader.SetString("event"); + + EventStreamHeader eventTypeHeader = new(":event-type"); + eventTypeHeader.SetString(eventType); + + EventStreamHeader contentTypeHeader = new(":content-type"); + contentTypeHeader.SetString("application/json"); + + List headers = + [ + messageTypeHeader, + eventTypeHeader, + contentTypeHeader + ]; + + return new EventStreamMessage(headers, payload).ToByteArray(); + } + + private static ConverseResponse CreateResponse(string text) + { + ConverseResponse response = new() + { + Output = new ConverseOutput + { + Message = new Message + { + Role = ConversationRole.Assistant, + Content = [new() { Text = text }] + } + }, + Usage = new TokenUsage { InputTokens = 10, OutputTokens = 5, TotalTokens = 15 } + }; + return response; + } } diff --git a/extensions/test/BedrockMEAITests/BedrockMEAITests.NetFramework.csproj b/extensions/test/BedrockMEAITests/BedrockMEAITests.NetFramework.csproj index dd9de35ce4a5..747bd57eead2 100644 --- a/extensions/test/BedrockMEAITests/BedrockMEAITests.NetFramework.csproj +++ b/extensions/test/BedrockMEAITests/BedrockMEAITests.NetFramework.csproj @@ -18,7 +18,6 @@ - diff --git a/extensions/test/BedrockMEAITests/MockBedrockRuntime.cs b/extensions/test/BedrockMEAITests/MockBedrockRuntime.cs new file mode 100644 index 000000000000..1c1dcbf1b654 --- /dev/null +++ b/extensions/test/BedrockMEAITests/MockBedrockRuntime.cs @@ -0,0 +1,57 @@ +using Amazon.BedrockRuntime.Model; +using Amazon.Runtime; +using Amazon.Runtime.Endpoints; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Amazon.BedrockRuntime; + +internal sealed class MockBedrockRuntime : IAmazonBedrockRuntime +{ + public Func OnConverseRequest { get; set; } + public Func OnConverseStreamRequest { get; set; } + + public IClientConfig Config => throw new NotImplementedException(); + public IBedrockRuntimePaginatorFactory Paginators => throw new NotImplementedException(); + + public async Task ConverseAsync(ConverseRequest request, CancellationToken cancellationToken = default) + { + if (OnConverseRequest is null) + { + throw new NotSupportedException($"{nameof(ConverseAsync)} was invoked but no {nameof(OnConverseRequest)} was provided."); + } + + return OnConverseRequest(request); + } + + public async Task ConverseStreamAsync(ConverseStreamRequest request, CancellationToken cancellationToken = default) + { + if (OnConverseStreamRequest is null) + { + throw new NotSupportedException($"{nameof(ConverseStreamAsync)} was invoked but no {nameof(OnConverseStreamRequest)} was provided."); + } + + return OnConverseStreamRequest(request); + } + + public void Dispose() { } + + public ConverseResponse Converse(ConverseRequest request) => throw new NotImplementedException(); + public ConverseStreamResponse ConverseStream(ConverseStreamRequest request) => throw new NotImplementedException(); + public ApplyGuardrailResponse ApplyGuardrail(ApplyGuardrailRequest request) => throw new NotImplementedException(); + public Task ApplyGuardrailAsync(ApplyGuardrailRequest request, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public CountTokensResponse CountTokens(CountTokensRequest request) => throw new NotImplementedException(); + public Task CountTokensAsync(CountTokensRequest request, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public GetAsyncInvokeResponse GetAsyncInvoke(GetAsyncInvokeRequest request) => throw new NotImplementedException(); + public Task GetAsyncInvokeAsync(GetAsyncInvokeRequest request, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public InvokeModelResponse InvokeModel(InvokeModelRequest request) => throw new NotImplementedException(); + public Task InvokeModelAsync(InvokeModelRequest request, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public InvokeModelWithResponseStreamResponse InvokeModelWithResponseStream(InvokeModelWithResponseStreamRequest request) => throw new NotImplementedException(); + public Task InvokeModelWithResponseStreamAsync(InvokeModelWithResponseStreamRequest request, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public ListAsyncInvokesResponse ListAsyncInvokes(ListAsyncInvokesRequest request) => throw new NotImplementedException(); + public Task ListAsyncInvokesAsync(ListAsyncInvokesRequest request, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public StartAsyncInvokeResponse StartAsyncInvoke(StartAsyncInvokeRequest request) => throw new NotImplementedException(); + public Task StartAsyncInvokeAsync(StartAsyncInvokeRequest request, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Endpoint DetermineServiceOperationEndpoint(AmazonWebServiceRequest request) => throw new NotImplementedException(); +}