-
Notifications
You must be signed in to change notification settings - Fork 873
Support for ChatOptions.ResponseFormat in AWSSDK.Extensions.Bedrock.MEAI #4113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Support for ChatOptions.ResponseFormat in AWSSDK.Extensions.Bedrock.MEAI #4113
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR adds ResponseFormat support to the AWS Bedrock ChatClient for Microsoft.Extensions.AI, enabling structured JSON output from Bedrock models. The implementation uses Bedrock's tool mechanism with a synthetic tool to enforce structured responses, requiring models with ToolChoice support (Claude 3+ and Mistral Large).
Key changes:
- Implemented ResponseFormat handling via synthetic tool creation that forces models to return structured JSON
- Added error handling for unsupported models and missing structured responses
- Added Document-to-JSON conversion utilities for extracting structured content from tool use responses
Reviewed Changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| extensions/src/AWSSDK.Extensions.Bedrock.MEAI/BedrockChatClient.cs | Core implementation of ResponseFormat support including synthetic tool creation, error handling for unsupported models, Document-to-JSON conversion, and validation that ResponseFormat conflicts with user-provided tools |
| extensions/test/BedrockMEAITests/BedrockChatClientTests.cs | Added MockBedrockRuntime test infrastructure and two tests validating schema conversion and JSON extraction from tool use responses |
| private static string DocumentToJsonString(Document document) | ||
| { | ||
| using var stream = new MemoryStream(); | ||
| using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false })) | ||
| { | ||
| WriteDocumentAsJson(writer, document); | ||
| } // Explicit scope to ensure writer is flushed before reading buffer | ||
|
|
||
| return Encoding.UTF8.GetString(stream.ToArray()); | ||
| } | ||
|
|
||
| /// <summary>Recursively writes a <see cref="Document"/> as JSON.</summary> | ||
| private static void WriteDocumentAsJson(Utf8JsonWriter writer, Document document, int depth = 0) | ||
| { | ||
| // Check depth to prevent stack overflow from deeply nested or circular structures | ||
| if (depth > MaxDocumentNestingDepth) | ||
| { | ||
| throw new InvalidOperationException( | ||
| $"Document nesting depth exceeds maximum of {MaxDocumentNestingDepth}. " + | ||
| $"This may indicate a circular reference or excessively nested data structure."); | ||
| } | ||
|
|
||
| if (document.IsBool()) | ||
| { | ||
| writer.WriteBooleanValue(document.AsBool()); | ||
| } | ||
| else if (document.IsInt()) | ||
| { | ||
| writer.WriteNumberValue(document.AsInt()); | ||
| } | ||
| else if (document.IsLong()) | ||
| { | ||
| writer.WriteNumberValue(document.AsLong()); | ||
| } | ||
| else if (document.IsDouble()) | ||
| { | ||
| writer.WriteNumberValue(document.AsDouble()); | ||
| } | ||
| else if (document.IsString()) | ||
| { | ||
| writer.WriteStringValue(document.AsString()); | ||
| } | ||
| else if (document.IsDictionary()) | ||
| { | ||
| writer.WriteStartObject(); | ||
| foreach (var kvp in document.AsDictionary()) | ||
| { | ||
| writer.WritePropertyName(kvp.Key); | ||
| WriteDocumentAsJson(writer, kvp.Value, depth + 1); | ||
| } | ||
| writer.WriteEndObject(); | ||
| } | ||
| else if (document.IsList()) | ||
| { | ||
| writer.WriteStartArray(); | ||
| foreach (var item in document.AsList()) | ||
| { | ||
| WriteDocumentAsJson(writer, item, depth + 1); | ||
| } | ||
| writer.WriteEndArray(); | ||
| } | ||
| else | ||
| { | ||
| writer.WriteNullValue(); | ||
| } | ||
| } | ||
|
|
Copilot
AI
Nov 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This PR appears to be missing a DevConfig file, which is required according to the CONTRIBUTING.md guidelines. This change adds new functionality (ResponseFormat support) to the Bedrock MEAI extension, which constitutes a minor version bump. A DevConfig file should be created in the generator/.DevConfigs directory with appropriate changelog messages and version type specification.
| // Check depth to prevent stack overflow from deeply nested or circular structures | ||
| if (depth > MaxDocumentNestingDepth) | ||
| { | ||
| throw new InvalidOperationException( | ||
| $"Document nesting depth exceeds maximum of {MaxDocumentNestingDepth}. " + | ||
| $"This may indicate a circular reference or excessively nested data structure."); | ||
| } |
Copilot
AI
Nov 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The depth limit check for preventing stack overflow in recursive JSON conversion only protects against excessive nesting but doesn't protect against actual circular references in the Document structure. If a Document contains a circular reference (e.g., a dictionary that references itself), this will still cause infinite recursion until the depth limit is reached.
Consider tracking visited Document instances to detect actual circular references earlier, or document that Document structures are expected to be acyclic.
| (ex.Message.IndexOf("toolChoice", StringComparison.OrdinalIgnoreCase) >= 0 || | ||
| ex.Message.IndexOf("tool_choice", StringComparison.OrdinalIgnoreCase) >= 0 || | ||
| ex.Message.IndexOf("ToolChoice", StringComparison.OrdinalIgnoreCase) >= 0); |
Copilot
AI
Nov 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error detection logic uses multiple string checks for variations of "toolChoice", but this approach is fragile and could produce false positives. For example, if an error message contains "toolChoice" in a different context (e.g., "Invalid parameter value, not related to toolChoice functionality"), it would incorrectly match.
Consider either:
- Using a more specific error code if one exists for this scenario
- Using a regular expression with word boundaries to ensure "toolChoice" is a distinct term
- Checking for more specific error message patterns that uniquely identify this error
GarrettBeatty
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can you explain more a little more in the PR why we need make this synthetic tool and what not
| /// <param name="runtime">The <see cref="IAmazonBedrockRuntime"/> instance to wrap.</param> | ||
| /// <param name="defaultModelId">Model ID to use as the default when no model ID is specified in a request.</param> | ||
| /// <summary>Initializes a new instance of the <see cref="BedrockChatClient"/> class.</summary> | ||
| public BedrockChatClient(IAmazonBedrockRuntime runtime, string? defaultModelId) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why are these docs deleted
| } | ||
|
|
||
| // Check if ResponseFormat is set - not supported for streaming yet | ||
| if (options?.ResponseFormat is ChatResponseFormatJson) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
just wondering why this is
| } | ||
|
|
||
| /// <summary>Converts a <see cref="Document"/> to a JSON string.</summary> | ||
| private static string DocumentToJsonString(Document document) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
all of this stuff i would be surprised if it doesnt exist already in a jsonutils or utils file. either way it shouldnt be in this class
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree with garret, it should live in a utils class at the least
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't looked at the entire PR, but we've had requests in the past to make the Document class interop better with JSON.
It's something we should do, but we have to be aware the document type is meant to be agnostic (the service could start returning CBOR tomorrow for example). See this comment from Norm: #3915 (comment)
It'd probably make more sense to include this functionality in Core, but now I'm even wondering if it's better to do that first (and separately) from this PR.
| { | ||
| response = await _runtime.ConverseAsync(request, cancellationToken).ConfigureAwait(false); | ||
| } | ||
| catch (AmazonBedrockRuntimeException ex) when (options?.ResponseFormat is ChatResponseFormatJson) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why are we checking bedrockruntimeexception here but down below i see we are throwing InvalidOperationException when it fails?
peterrsongg
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My long comment on testing different json responses returned by the service might not make sense depending on what this feature is supposed to do. So if this option is set, that tells Bedrock to return the response in a certain way?
I still think we shouldn't have a MockBedrockRuntime that implements IAmazonBedrockRuntime. We will have to update this class every time a new operation is released.
| namespace Amazon.BedrockRuntime; | ||
|
|
||
| // Mock implementation to capture requests and control responses | ||
| internal sealed class MockBedrockRuntime : IAmazonBedrockRuntime |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So anytime BedrockRuntime adds a new operation, we have to remember to go here and update this mock class so that the operation throws NotImplementedException right? I think we should go with a different approach to mocking the service, because if that's the case then this isn't very scalable.
Also the types of responses you can test here are severely limited. The ConverseAsync just returns a default response, so these test cases are only testing the happy path. My suggestion is to mock the httpLayer so that you can test out edge cases and different responses returned by Converse
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here is my suggestion (sorry it is a lot). This is what we did for fuzz testing
- Create a pipeline customizer
namespace NetSdkMock
{
public class PipelineCustomizer : IRuntimePipelineCustomizer
{
public string UniqueName => "MockPipeline";
public void Customize(Type type, RuntimePipeline pipeline)
{
pipeline.ReplaceHandler<HttpHandler<System.Net.Http.HttpContent>>(new HttpHandler<HttpContent>(new MockHttpRequestMessageFactory(), new object()));
}
}
public class MockHttpRequestMessageFactory : IHttpRequestFactory<HttpContent>
{
public IHttpRequest<HttpContent> CreateHttpRequest(Uri requestUri)
{
return new MockHttpRequest(new HttpClient(), requestUri, null);
}
public void Dispose()
{
throw new NotImplementedException();
}
}
public class MockHttpRequest : HttpWebRequestMessage, IHttpRequest<HttpContent>
{
private IWebResponseData _webResponseData;
public MockHttpRequest(HttpClient httpClient, Uri requestUri, IClientConfig config) : base(httpClient, requestUri, config)
{
}
public new IWebResponseData GetResponse()
{
return this.GetResponseAsync(CancellationToken.None).Result;
}
public new void ConfigureRequest(IRequestContext requestContext)
{
_webResponseData = (IWebResponseData)((IAmazonWebServiceRequest)requestContext.OriginalRequest).RequestState["response"];
}
public new Task<IWebResponseData> GetResponseAsync(CancellationToken cancellationToken)
{
return Task.FromResult(_webResponseData);
}
}
}
- Stub the web response data so that you can control the type of responses that you get
using Amazon.Runtime.Internal.Transform;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;
namespace NetSdkMock
{
public class StubWebResponseData : IWebResponseData
{
public StubWebResponseData(string jsonResponse, Dictionary<string, string> headers)
{
this.StatusCode = HttpStatusCode.OK;
this.IsSuccessStatusCode = true;
JsonResponse = jsonResponse;
this.Headers = headers;
_httpResponseBody = new HttpResponseBody(jsonResponse);
}
public Dictionary<string, string> Headers { get; set; }
public string JsonResponse { get; }
private IHttpResponseBody _httpResponseBody;
public long ContentLength { get; set; }
public string ContentType { get; set; }
public HttpStatusCode StatusCode { get; set; }
public bool IsSuccessStatusCode { get; set; }
public IHttpResponseBody ResponseBody
{
get
{
return _httpResponseBody;
}
}
public string[] GetHeaderNames()
{
return Headers.Keys.ToArray();
}
public bool IsHeaderPresent(string headerName)
{
return this.Headers.ContainsKey(headerName);
}
public string GetHeaderValue(string headerName)
{
if (this.Headers.ContainsKey(headerName))
return this.Headers[headerName];
else
return null;
}
}
public class HttpResponseBody : IHttpResponseBody
{
private readonly string _jsonResponse;
private Stream stream;
public HttpResponseBody(string jsonResponse)
{
_jsonResponse = jsonResponse;
}
public void Dispose()
{
stream.Dispose();
}
public Stream OpenResponse()
{
stream = new MemoryStream(UTF8Encoding.UTF8.GetBytes(_jsonResponse));
return stream;
}
public Task<Stream> OpenResponseAsync()
{
return Task.FromResult(OpenResponse());
}
}
}
Now in your test you can call it as normal and pass in different types of responses:
using Amazon.BedrockRuntime;
using Amazon.BedrockRuntime.Model;
using Amazon.Runtime;
using Amazon.Runtime.Internal;
using Microsoft.Extensions.AI;
using Moq;
using System.Text.Json;
namespace NetSdkMock
{
public class MockTests : IClassFixture<ClientFixture>
{
private readonly ClientFixture _fixture;
public MockTests()
{
_fixture = new ClientFixture();
}
[Fact]
public async Task Test1()
{
var messages = new[] { new ChatMessage(ChatRole.User, "Test") };
// try to test different schemas too.
var schemaJson = """
{
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "number" }
},
"required": ["name"]
}
""";
var schemaElement = JsonDocument.Parse(schemaJson).RootElement;
var options = new ChatOptions
{
ResponseFormat = ChatResponseFormat.ForJsonSchema(schemaElement,
schemaName: "PersonSchema",
schemaDescription: "A person object")
};
var chatClient = _fixture.BedrockRuntimeClient.AsIChatClient("claude-3");
ConverseRequest request = new ConverseRequest();
var interfaceType = typeof(IAmazonWebServiceRequest);
var requestStatePropertyInfo = interfaceType.GetProperty("RequestState");
var requestState = (Dictionary<string, object>)requestStatePropertyInfo.GetValue(request);
//var schemaElement = JsonDocument.Parse(schemaJson).RootElement;
// now you can test out all different types of json responses
var jsonResponse = """
{
"name":"Bob",
"age" : 15
}
""";
ChatOptions options = new ChatOptions();
options.RawRepresentationFactory = chatClient => request;
var webResponseData = new StubWebResponseData(jsonResponse, new Dictionary<string, string>());
// this is where we are injecting the stubbed web response data.
requestState["response"] = webResponseData;
var response = await chatClient.GetResponseAsync(messages, options).ConfigureAwait(false);
}
}
public class ClientFixture: IDisposable
{
public ClientFixture()
{
RuntimePipelineCustomizerRegistry.Instance.Register(new PipelineCustomizer());
BedrockRuntimeClient = new AmazonBedrockRuntimeClient();
}
public IAmazonBedrockRuntime BedrockRuntimeClient { get; private set; }
public void Dispose()
{
// Cleanup after all tests in this class
BedrockRuntimeClient.Dispose();
}
}
}
I spent a few hours creating a test project to make sure this code will work and it does, so I think you can use this to create many more test cases and not just test the happy path.
| } | ||
|
|
||
| /// <summary>Converts a <see cref="Document"/> to a JSON string.</summary> | ||
| private static string DocumentToJsonString(Document document) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree with garret, it should live in a utils class at the least
| // Check if this is a ToolChoice validation error (model doesn't support it) | ||
| bool isToolChoiceNotSupported = | ||
| ex.ErrorCode == "ValidationException" && | ||
| (ex.Message.IndexOf("toolChoice", StringComparison.OrdinalIgnoreCase) >= 0 || |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is checking the error message the only way to achieve this? error messages aren't gauranteed to stay the same.
| } | ||
|
|
||
| // Assert | ||
| var tool = mock.CapturedRequest.ToolConfig.Tools[0]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i'm a bit confused, and maybe it is because i don't understand who is supposed to return the response in the provided schema (bedrock or us), but this test case just seems to be asserting that the tool has the correct schema set on it. Is there no way to test the actual functionality?
Add ChatOptions.ResponseFormat support for Bedrock MEAI
Description
Implements support for
ChatOptions.ResponseFormatin the AWSSDK.Extensions.Bedrock.MEAI implementation ofIChatClient. WhenResponseFormatis set toJsonorForJsonSchema, the client now uses Bedrock's tool mechanism to enforce structured JSON responses from models.Implementation approach:
toolChoiceDocumentobjects to standard JSONKey behavior:
ResponseFormat.Json: Requests JSON with generic object schemaResponseFormat.ForJsonSchema: Requests JSON conforming to custom schemaResponseFormat.Text: No changes to request (default behavior)ArgumentExceptionifResponseFormatis used with user-provided tools (mutual exclusivity)NotSupportedExceptionfor streaming requests (Bedrock limitation)Motivation and Context
Closes #3911
Users need consistent behavior when using
IChatClientacross different AI providers. Currently, the Bedrock implementation ignoresChatOptions.ResponseFormat, making it impossible to request structured responses through the standardized Microsoft.Extensions.AI interface. This prevents Bedrock from being a drop-in replacement for other providers in structured data workflows.Testing
Test coverage:
ResponseFormat_Json_WithSchema_CreatesSyntheticToolWithCorrectSchema: Validates synthetic tool creation with custom schemaResponseFormat_Json_ModelReturnsToolUse_ExtractsJsonCorrectly: Validates JSON extraction from tool use responsesTypes of changes
Checklist
License