|
20 | 20 | # litellm is imported inside the functions that use it; eager import is slow |
21 | 21 | # and fetches a remote model-cost map. |
22 | 22 |
|
| 23 | +_logger = logging.getLogger(__name__) |
| 24 | + |
23 | 25 | # Backward compatibility: support CHATGPT_API_KEY as alias for OPENAI_API_KEY |
24 | 26 | if not os.getenv("OPENAI_API_KEY") and os.getenv("CHATGPT_API_KEY"): |
25 | 27 | os.environ["OPENAI_API_KEY"] = os.getenv("CHATGPT_API_KEY") |
@@ -154,39 +156,41 @@ def get_json_content(response): |
154 | 156 | return json_content |
155 | 157 |
|
156 | 158 |
|
157 | | -def extract_json(content): |
| 159 | +def _decode_embedded_json(content): |
| 160 | + """Return the first JSON object or array embedded in a model response.""" |
| 161 | + decoder = json.JSONDecoder() |
| 162 | + starts = [index for index in (content.find("{"), content.find("[")) if index >= 0] |
| 163 | + if not starts: |
| 164 | + return None |
158 | 165 | try: |
159 | | - # First, try to extract JSON enclosed within ```json and ``` |
160 | | - start_idx = content.find("```json") |
161 | | - if start_idx != -1: |
162 | | - start_idx += 7 # Adjust index to start after the delimiter |
163 | | - end_idx = content.rfind("```") |
164 | | - json_content = content[start_idx:end_idx].strip() |
165 | | - else: |
166 | | - # If no delimiters, assume entire content could be JSON |
167 | | - json_content = content.strip() |
168 | | - |
169 | | - # Clean up common issues that might cause parsing errors |
170 | | - json_content = json_content.replace('None', 'null') # Replace Python None with JSON null |
171 | | - json_content = json_content.replace('\n', ' ').replace('\r', ' ') # Remove newlines |
172 | | - json_content = ' '.join(json_content.split()) # Normalize whitespace |
173 | | - |
174 | | - # Attempt to parse and return the JSON object |
175 | | - return json.loads(json_content) |
176 | | - except json.JSONDecodeError as e: |
177 | | - logging.error(f"Failed to extract JSON: {e}") |
178 | | - # Try to clean up the content further if initial parsing fails |
179 | | - try: |
180 | | - # Remove any trailing commas before closing brackets/braces |
181 | | - json_content = json_content.replace(',]', ']').replace(',}', '}') |
182 | | - return json.loads(json_content) |
183 | | - except: |
184 | | - logging.error("Failed to parse JSON even after cleanup") |
185 | | - return {} |
186 | | - except Exception as e: |
187 | | - logging.error(f"Unexpected error while extracting JSON: {e}") |
| 166 | + value, _ = decoder.raw_decode(content[min(starts):]) |
| 167 | + except json.JSONDecodeError: |
| 168 | + return None |
| 169 | + return value |
| 170 | + |
| 171 | + |
| 172 | +def extract_json(content): |
| 173 | + if not isinstance(content, str): |
| 174 | + _logger.error("Failed to extract JSON: response is not a string") |
188 | 175 | return {} |
189 | 176 |
|
| 177 | + # Prefer the fenced body, but also support providers that prepend or append prose. |
| 178 | + json_content = get_json_content(content) |
| 179 | + for candidate in (json_content, content): |
| 180 | + parsed = _decode_embedded_json(candidate) |
| 181 | + if parsed is not None: |
| 182 | + return parsed |
| 183 | + |
| 184 | + # Preserve the legacy repairs for models that emit Python's None or a trailing comma. |
| 185 | + repaired = json_content.replace('None', 'null') |
| 186 | + repaired = re.sub(r',\s*([}\]])', r'\1', repaired) |
| 187 | + parsed = _decode_embedded_json(repaired) |
| 188 | + if parsed is not None: |
| 189 | + return parsed |
| 190 | + |
| 191 | + _logger.error("Failed to parse JSON from model response") |
| 192 | + return {} |
| 193 | + |
190 | 194 | def write_node_id(data, node_id=0): |
191 | 195 | if isinstance(data, dict): |
192 | 196 | data['node_id'] = str(node_id).zfill(4) |
@@ -974,4 +978,3 @@ def print_tree(tree, indent=0): |
974 | 978 | def print_wrapped(text, width=100): |
975 | 979 | for line in text.splitlines(): |
976 | 980 | print(textwrap.fill(line, width=width)) |
977 | | - |
|
0 commit comments