@@ -67,35 +67,68 @@ def __init__(self, message: str, *, retryable: bool = True):
6767 self .retryable = retryable
6868
6969
70+ def _is_json (text : str ) -> bool :
71+ """True if ``text`` parses as a JSON value."""
72+ try :
73+ json .loads (text )
74+ except (json .JSONDecodeError , ValueError ):
75+ return False
76+ return True
77+
78+
79+ def _outer_json_span (content : str ) -> str | None :
80+ """Return the outermost ``{...}`` / ``[...]`` span if it parses as JSON, else None.
81+
82+ Fallback for responses where fences are partial/absent or the model wrapped
83+ the JSON in surrounding prose. Only returned when it is valid JSON so callers
84+ never receive a worse candidate than the raw content.
85+ """
86+ starts = [i for i in (content .find ("{" ), content .find ("[" )) if i >= 0 ]
87+ ends = [i for i in (content .rfind ("}" ), content .rfind ("]" )) if i >= 0 ]
88+ if not starts or not ends :
89+ return None
90+ start , end = min (starts ), max (ends )
91+ if end <= start :
92+ return None
93+ candidate = content [start : end + 1 ].strip ()
94+ return candidate if _is_json (candidate ) else None
95+
96+
7097def _strip_code_fences (content : str ) -> str :
7198 """Strip markdown code fences from LLM response if present.
7299
73100 Many LLM providers (MiniMax, some Ollama models, Claude via proxies)
74101 wrap JSON responses in ```json ... ``` fences even when json_object
75- response format is requested. This strips the fences while preserving
76- the JSON content inside. Returns the original content unchanged if
77- no fences are detected.
102+ response format is requested. Fences are detected by line (a closing
103+ ``` must sit alone on its line) so triple-backticks *inside* JSON string
104+ values do not truncate the payload. When the stripped candidate is not
105+ valid JSON (partial fence, prose-wrapped output, truncated response), fall
106+ back to the outermost parseable JSON span. Returns the original content
107+ unchanged if no better candidate is found.
78108 """
79- if "```" not in content :
80- return content
81- lines = content .split ("\n " )
82- # Find first line that starts a code fence (``` optionally followed by language)
83- fence_start = None
84- for i , line in enumerate (lines ):
85- if line .startswith ("```" ):
86- fence_start = i
87- break
88- if fence_start is None :
89- return content
90- # Find matching closing fence (``` alone or with trailing whitespace)
91- fence_end = None
92- for j in range (fence_start + 1 , len (lines )):
93- if lines [j ].strip () == "```" :
94- fence_end = j
95- break
96- if fence_end is None :
97- return content
98- return "\n " .join (lines [fence_start + 1 : fence_end ]).strip ()
109+ candidate = content
110+ if "```" in content :
111+ lines = content .split ("\n " )
112+ # Find first line that starts a code fence (``` optionally followed by language)
113+ fence_start = next ((i for i , line in enumerate (lines ) if line .startswith ("```" )), None )
114+ if fence_start is not None :
115+ # Find matching closing fence (``` alone or with trailing whitespace)
116+ fence_end = next (
117+ (j for j in range (fence_start + 1 , len (lines )) if lines [j ].strip () == "```" ),
118+ None ,
119+ )
120+ if fence_end is not None :
121+ candidate = "\n " .join (lines [fence_start + 1 : fence_end ]).strip ()
122+
123+ if _is_json (candidate ):
124+ return candidate
125+
126+ # Fence stripping did not yield valid JSON — try to recover the outer JSON span.
127+ span = _outer_json_span (content )
128+ if span is not None :
129+ return span
130+
131+ return candidate
99132
100133
101134# Reasoning/thinking tags emitted by extended-thinking models. Some providers
0 commit comments