@@ -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