Skip to content

Commit 686e062

Browse files
committed
Address PR #4113 feedback: use DocumentMarshaller, clarify exception handling, restore docs
1 parent a98118b commit 686e062

File tree

1 file changed

+21
-75
lines changed

1 file changed

+21
-75
lines changed

extensions/src/AWSSDK.Extensions.Bedrock.MEAI/BedrockChatClient.cs

Lines changed: 21 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,6 @@ internal sealed partial class BedrockChatClient : IChatClient
4141
private const string ResponseFormatToolName = "generate_response";
4242
/// <summary>The description used for the synthetic tool that enforces response format.</summary>
4343
private const string ResponseFormatToolDescription = "Generate response in specified format";
44-
/// <summary>Maximum nesting depth for Document to JSON conversion to prevent stack overflow.</summary>
45-
private const int MaxDocumentNestingDepth = 100;
4644

4745
/// <summary>The wrapped <see cref="IAmazonBedrockRuntime"/> instance.</summary>
4846
private readonly IAmazonBedrockRuntime _runtime;
@@ -51,7 +49,11 @@ internal sealed partial class BedrockChatClient : IChatClient
5149
/// <summary>Metadata describing the chat client.</summary>
5250
private readonly ChatClientMetadata _metadata;
5351

54-
/// <summary>Initializes a new instance of the <see cref="BedrockChatClient"/> class.</summary>
52+
/// <summary>
53+
/// Initializes a new instance of the <see cref="BedrockChatClient"/> class.
54+
/// </summary>
55+
/// <param name="runtime">The <see cref="IAmazonBedrockRuntime"/> instance to wrap.</param>
56+
/// <param name="defaultModelId">Model ID to use as the default when no model ID is specified in a request.</param>
5557
public BedrockChatClient(IAmazonBedrockRuntime runtime, string? defaultModelId)
5658
{
5759
Debug.Assert(runtime is not null);
@@ -68,6 +70,12 @@ public void Dispose()
6870
}
6971

7072
/// <inheritdoc />
73+
/// <remarks>
74+
/// When <see cref="ChatOptions.ResponseFormat"/> is specified, the model must support
75+
/// the ToolChoice feature. Models without this support will throw <see cref="NotSupportedException"/>.
76+
/// If the model fails to return the expected structured output, <see cref="InvalidOperationException"/>
77+
/// is thrown.
78+
/// </remarks>
7179
public async Task<ChatResponse> GetResponseAsync(
7280
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
7381
{
@@ -84,32 +92,24 @@ public async Task<ChatResponse> GetResponseAsync(
8492
request.InferenceConfig = CreateInferenceConfiguration(request.InferenceConfig, options);
8593
request.AdditionalModelRequestFields = CreateAdditionalModelRequestFields(request.AdditionalModelRequestFields, options);
8694

87-
// Execute the request with proper error handling for ResponseFormat scenarios
8895
ConverseResponse response;
8996
try
9097
{
9198
response = await _runtime.ConverseAsync(request, cancellationToken).ConfigureAwait(false);
9299
}
93100
catch (AmazonBedrockRuntimeException ex) when (options?.ResponseFormat is ChatResponseFormatJson)
94101
{
95-
// Check if this is a ToolChoice validation error (model doesn't support it)
96-
bool isToolChoiceNotSupported =
97-
ex.ErrorCode == "ValidationException" &&
98-
(ex.Message.IndexOf("toolChoice", StringComparison.OrdinalIgnoreCase) >= 0 ||
99-
ex.Message.IndexOf("tool_choice", StringComparison.OrdinalIgnoreCase) >= 0 ||
100-
ex.Message.IndexOf("ToolChoice", StringComparison.OrdinalIgnoreCase) >= 0);
101-
102-
if (isToolChoiceNotSupported)
102+
// Detect unsupported model: ValidationException mentioning "toolChoice"
103+
if (ex.ErrorCode == "ValidationException" &&
104+
ex.Message.IndexOf("toolchoice", StringComparison.OrdinalIgnoreCase) >= 0)
103105
{
104-
// Provide a more helpful error message when ToolChoice fails due to model limitations
105106
throw new NotSupportedException(
106107
$"The model '{request.ModelId}' does not support ResponseFormat. " +
107108
$"ResponseFormat requires ToolChoice support, which is only available in Claude 3+ and Mistral Large models. " +
108109
$"See: https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference-supported-models-features.html",
109110
ex);
110111
}
111112

112-
// Re-throw other exceptions as-is
113113
throw;
114114
}
115115

@@ -147,15 +147,14 @@ public async Task<ChatResponse> GetResponseAsync(
147147
}
148148
else
149149
{
150-
// User requested structured output but didn't get it - this is a contract violation
150+
// Model succeeded but did not return expected structured output
151151
var errorMessage = string.Format(
152152
"ResponseFormat was specified but model did not return expected tool use. ModelId: {0}, StopReason: {1}",
153153
request.ModelId,
154154
response.StopReason?.Value ?? "unknown");
155155

156156
DefaultLogger.Error(new InvalidOperationException(errorMessage), errorMessage);
157157

158-
// Always throw when ResponseFormat was requested but not fulfilled
159158
throw new InvalidOperationException(
160159
$"Model '{request.ModelId}' did not return structured output as requested. " +
161160
$"This may indicate the model refused to follow the tool use instruction, " +
@@ -1030,73 +1029,20 @@ private static Document ToDocument(JsonElement json)
10301029
return null;
10311030
}
10321031

1033-
/// <summary>Converts a <see cref="Document"/> to a JSON string.</summary>
1032+
/// <summary>
1033+
/// Converts a <see cref="Document"/> to a JSON string using the SDK's standard DocumentMarshaller.
1034+
/// Note: Document is a struct (value type), so circular references are structurally impossible.
1035+
/// </summary>
10341036
private static string DocumentToJsonString(Document document)
10351037
{
10361038
using var stream = new MemoryStream();
10371039
using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false }))
10381040
{
1039-
WriteDocumentAsJson(writer, document);
1040-
} // Explicit scope to ensure writer is flushed before reading buffer
1041-
1041+
Amazon.Runtime.Documents.Internal.Transform.DocumentMarshaller.Instance.Write(writer, document);
1042+
}
10421043
return Encoding.UTF8.GetString(stream.ToArray());
10431044
}
10441045

1045-
/// <summary>Recursively writes a <see cref="Document"/> as JSON.</summary>
1046-
private static void WriteDocumentAsJson(Utf8JsonWriter writer, Document document, int depth = 0)
1047-
{
1048-
// Check depth to prevent stack overflow from deeply nested or circular structures
1049-
if (depth > MaxDocumentNestingDepth)
1050-
{
1051-
throw new InvalidOperationException(
1052-
$"Document nesting depth exceeds maximum of {MaxDocumentNestingDepth}. " +
1053-
$"This may indicate a circular reference or excessively nested data structure.");
1054-
}
1055-
1056-
if (document.IsBool())
1057-
{
1058-
writer.WriteBooleanValue(document.AsBool());
1059-
}
1060-
else if (document.IsInt())
1061-
{
1062-
writer.WriteNumberValue(document.AsInt());
1063-
}
1064-
else if (document.IsLong())
1065-
{
1066-
writer.WriteNumberValue(document.AsLong());
1067-
}
1068-
else if (document.IsDouble())
1069-
{
1070-
writer.WriteNumberValue(document.AsDouble());
1071-
}
1072-
else if (document.IsString())
1073-
{
1074-
writer.WriteStringValue(document.AsString());
1075-
}
1076-
else if (document.IsDictionary())
1077-
{
1078-
writer.WriteStartObject();
1079-
foreach (var kvp in document.AsDictionary())
1080-
{
1081-
writer.WritePropertyName(kvp.Key);
1082-
WriteDocumentAsJson(writer, kvp.Value, depth + 1);
1083-
}
1084-
writer.WriteEndObject();
1085-
}
1086-
else if (document.IsList())
1087-
{
1088-
writer.WriteStartArray();
1089-
foreach (var item in document.AsList())
1090-
{
1091-
WriteDocumentAsJson(writer, item, depth + 1);
1092-
}
1093-
writer.WriteEndArray();
1094-
}
1095-
else
1096-
{
1097-
writer.WriteNullValue();
1098-
}
1099-
}
11001046

11011047
/// <summary>Creates an <see cref="InferenceConfiguration"/> from the specified options.</summary>
11021048
private static InferenceConfiguration CreateInferenceConfiguration(InferenceConfiguration config, ChatOptions? options)

0 commit comments

Comments
 (0)