@@ -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, " +
@@ -1032,73 +1031,20 @@ private static Document ToDocument(JsonElement json)
10321031 return null ;
10331032 }
10341033
1035- /// <summary>Converts a <see cref="Document"/> to a JSON string.</summary>
1034+ /// <summary>
1035+ /// Converts a <see cref="Document"/> to a JSON string using the SDK's standard DocumentMarshaller.
1036+ /// Note: Document is a struct (value type), so circular references are structurally impossible.
1037+ /// </summary>
10361038 private static string DocumentToJsonString ( Document document )
10371039 {
10381040 using var stream = new MemoryStream ( ) ;
10391041 using ( var writer = new Utf8JsonWriter ( stream , new JsonWriterOptions { Indented = false } ) )
10401042 {
1041- WriteDocumentAsJson ( writer , document ) ;
1042- } // Explicit scope to ensure writer is flushed before reading buffer
1043-
1043+ Amazon . Runtime . Documents . Internal . Transform . DocumentMarshaller . Instance . Write ( writer , document ) ;
1044+ }
10441045 return Encoding . UTF8 . GetString ( stream . ToArray ( ) ) ;
10451046 }
10461047
1047- /// <summary>Recursively writes a <see cref="Document"/> as JSON.</summary>
1048- private static void WriteDocumentAsJson ( Utf8JsonWriter writer , Document document , int depth = 0 )
1049- {
1050- // Check depth to prevent stack overflow from deeply nested or circular structures
1051- if ( depth > MaxDocumentNestingDepth )
1052- {
1053- throw new InvalidOperationException (
1054- $ "Document nesting depth exceeds maximum of { MaxDocumentNestingDepth } . " +
1055- $ "This may indicate a circular reference or excessively nested data structure.") ;
1056- }
1057-
1058- if ( document . IsBool ( ) )
1059- {
1060- writer . WriteBooleanValue ( document . AsBool ( ) ) ;
1061- }
1062- else if ( document . IsInt ( ) )
1063- {
1064- writer . WriteNumberValue ( document . AsInt ( ) ) ;
1065- }
1066- else if ( document . IsLong ( ) )
1067- {
1068- writer . WriteNumberValue ( document . AsLong ( ) ) ;
1069- }
1070- else if ( document . IsDouble ( ) )
1071- {
1072- writer . WriteNumberValue ( document . AsDouble ( ) ) ;
1073- }
1074- else if ( document . IsString ( ) )
1075- {
1076- writer . WriteStringValue ( document . AsString ( ) ) ;
1077- }
1078- else if ( document . IsDictionary ( ) )
1079- {
1080- writer . WriteStartObject ( ) ;
1081- foreach ( var kvp in document . AsDictionary ( ) )
1082- {
1083- writer . WritePropertyName ( kvp . Key ) ;
1084- WriteDocumentAsJson ( writer , kvp . Value , depth + 1 ) ;
1085- }
1086- writer . WriteEndObject ( ) ;
1087- }
1088- else if ( document . IsList ( ) )
1089- {
1090- writer . WriteStartArray ( ) ;
1091- foreach ( var item in document . AsList ( ) )
1092- {
1093- WriteDocumentAsJson ( writer , item , depth + 1 ) ;
1094- }
1095- writer . WriteEndArray ( ) ;
1096- }
1097- else
1098- {
1099- writer . WriteNullValue ( ) ;
1100- }
1101- }
11021048
11031049 /// <summary>Creates an <see cref="InferenceConfiguration"/> from the specified options.</summary>
11041050 private static InferenceConfiguration CreateInferenceConfiguration ( InferenceConfiguration config , ChatOptions ? options )
0 commit comments