@@ -136,7 +136,9 @@ async def call(
136136 initial_backoff: Initial backoff time in seconds.
137137 max_backoff: Maximum backoff time in seconds.
138138 skip_validation: Return raw JSON without Pydantic validation.
139- strict_schema: Use strict JSON schema enforcement (not supported by Anthropic).
139+ strict_schema: Route structured output through a forced tool_use tool for
140+ native constrained decoding (issue #1002). When False, falls back to
141+ schema-in-prompt + JSON parse.
140142 return_usage: If True, return tuple (result, TokenUsage) instead of just result.
141143
142144 Returns:
@@ -167,14 +169,21 @@ async def call(
167169 else :
168170 anthropic_messages .append ({"role" : role , "content" : content })
169171
170- # Add JSON schema instruction if response_format is provided
172+ # Structured output: prefer Anthropic-native constrained decoding via a single
173+ # forced tool_use tool (strict_schema) over text-injecting the schema and
174+ # parsing the reply. Native constrained decoding guarantees schema-valid JSON,
175+ # eliminating the invalid-JSON retry storm (issue #1002). When strict_schema is
176+ # off we keep the text-inject + json.loads fallback for backward compatibility.
177+ schema = None
178+ use_forced_tool = False
179+ _tool_name = "structured_response"
171180 if response_format is not None and hasattr (response_format , "model_json_schema" ):
172181 schema = response_format .model_json_schema ()
173- schema_msg = f"\n \n You must respond with valid JSON matching this schema:\n { json .dumps (schema , indent = 2 , ensure_ascii = False )} "
174- if system_prompt :
175- system_prompt += schema_msg
182+ if strict_schema :
183+ use_forced_tool = True
176184 else :
177- system_prompt = schema_msg
185+ schema_msg = f"\n \n You must respond with valid JSON matching this schema:\n { json .dumps (schema , indent = 2 , ensure_ascii = False )} "
186+ system_prompt = (system_prompt + schema_msg ) if system_prompt else schema_msg
178187
179188 # Prepare parameters
180189 call_params : dict [str , Any ] = {
@@ -186,6 +195,14 @@ async def call(
186195 if system_prompt :
187196 call_params ["system" ] = system_prompt
188197
198+ if use_forced_tool :
199+ # Single tool whose input_schema IS the response schema; force the model to
200+ # emit it via tool_choice so the SDK does constrained decoding for us.
201+ call_params ["tools" ] = [
202+ {"name" : _tool_name , "description" : "Return the structured response." , "input_schema" : schema }
203+ ]
204+ call_params ["tool_choice" ] = {"type" : "tool" , "name" : _tool_name }
205+
189206 if self ._extra_body :
190207 call_params ["extra_body" ] = self ._extra_body
191208
@@ -195,32 +212,49 @@ async def call(
195212 try :
196213 response = await self ._client .messages .create (** call_params )
197214
198- # Anthropic response content is a list of blocks
199- content = ""
200- for block in response .content :
201- if block .type == "text" :
202- content += block .text
203-
204- if response_format is not None :
205- # Models may wrap JSON in markdown code blocks
206- clean_content = content
207- if "```json" in content :
208- clean_content = content .split ("```json" )[1 ].split ("```" )[0 ].strip ()
209- elif "```" in content :
210- clean_content = content .split ("```" )[1 ].split ("```" )[0 ].strip ()
211-
212- try :
213- json_data = json .loads (clean_content )
214- except json .JSONDecodeError :
215- # Fallback to parsing raw content if markdown stripping failed
216- json_data = json .loads (content )
217-
218- if skip_validation :
219- result = json_data
220- else :
221- result = response_format .model_validate (json_data )
215+ if use_forced_tool :
216+ # Forced tool_use → the validated args are already a dict; no parsing,
217+ # no markdown-strip, no JSON-decode retry possible.
218+ tool_input = None
219+ for block in response .content :
220+ if block .type == "tool_use" and block .name == _tool_name :
221+ tool_input = block .input or {}
222+ break
223+ if tool_input is None :
224+ # Model ignored the forced tool (rare, e.g. a gateway that drops
225+ # tool_choice). Fall back to text parse so we don't hard-fail; the
226+ # existing retry loop still covers genuine errors.
227+ content = "" .join (b .text for b in response .content if b .type == "text" )
228+ tool_input = json .loads (content )
229+ content = json .dumps (tool_input )
230+ result = tool_input if skip_validation else response_format .model_validate (tool_input )
222231 else :
223- result = content
232+ # Anthropic response content is a list of blocks
233+ content = ""
234+ for block in response .content :
235+ if block .type == "text" :
236+ content += block .text
237+
238+ if response_format is not None :
239+ # Models may wrap JSON in markdown code blocks
240+ clean_content = content
241+ if "```json" in content :
242+ clean_content = content .split ("```json" )[1 ].split ("```" )[0 ].strip ()
243+ elif "```" in content :
244+ clean_content = content .split ("```" )[1 ].split ("```" )[0 ].strip ()
245+
246+ try :
247+ json_data = json .loads (clean_content )
248+ except json .JSONDecodeError :
249+ # Fallback to parsing raw content if markdown stripping failed
250+ json_data = json .loads (content )
251+
252+ if skip_validation :
253+ result = json_data
254+ else :
255+ result = response_format .model_validate (json_data )
256+ else :
257+ result = content
224258
225259 # Record metrics and log slow calls
226260 duration = time .time () - start_time
0 commit comments