diff --git a/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py b/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py index aade75602..1c43ad6bc 100644 --- a/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py +++ b/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py @@ -72,18 +72,37 @@ def _strip_code_fences(content: str) -> str: Many LLM providers (MiniMax, some Ollama models, Claude via proxies) wrap JSON responses in ```json ... ``` fences even when json_object - response format is requested. This strips the fences while preserving - the JSON content inside. Returns the original content unchanged if - no fences are detected. + response format is requested. This strips the fences only when the + candidate parses as JSON. Returns the original content unchanged if + no parseable JSON wrapper is detected. """ if "```" not in content: return content - try: - if "```json" in content: - return content.split("```json")[1].split("```")[0].strip() - return content.split("```")[1].split("```")[0].strip() - except (IndexError, ValueError): - return content + + fence_match = re.search(r"```(?:json)?\s*\n?(?P.*?)\n?```", content, flags=re.DOTALL) + if fence_match: + fenced_body = fence_match.group("body").strip() + if fenced_body.startswith(("{", "[")): + try: + json.loads(fenced_body) + return fenced_body + except json.JSONDecodeError: + pass + + starts = [index for index in (content.find("{"), content.find("[")) if index >= 0] + ends = [index for index in (content.rfind("}"), content.rfind("]")) if index >= 0] + if starts and ends: + start = min(starts) + end = max(ends) + if end > start: + candidate = content[start : end + 1].strip() + try: + json.loads(candidate) + return candidate + except json.JSONDecodeError: + pass + + return content # Reasoning/thinking tags emitted by extended-thinking models. Some providers diff --git a/hindsight-api-slim/tests/test_strip_code_fences.py b/hindsight-api-slim/tests/test_strip_code_fences.py index 4ef950257..3bee09799 100644 --- a/hindsight-api-slim/tests/test_strip_code_fences.py +++ b/hindsight-api-slim/tests/test_strip_code_fences.py @@ -1,5 +1,7 @@ """Tests for _strip_code_fences helper in OpenAI-compatible LLM provider.""" +import json + import pytest from hindsight_api.engine.providers.openai_compatible_llm import _strip_code_fences @@ -32,9 +34,8 @@ def test_fence_with_trailing_whitespace(self): def test_fence_with_leading_whitespace(self): """Content with leading whitespace before fence.""" content = ' ```json\n{"facts": []}\n```' - # The function checks for ``` in content, not startswith result = _strip_code_fences(content) - assert '{"facts": []}' in result + assert result == '{"facts": []}' def test_no_fences_no_change(self): """Content without any backticks passes through.""" @@ -57,8 +58,37 @@ def test_malformed_fence_returns_original(self): """Malformed fences (missing closing) return something parseable.""" content = '```json\n{"facts": []}' result = _strip_code_fences(content) - # Should attempt to strip and return best effort - assert isinstance(result, str) + assert result == '{"facts": []}' + + @pytest.mark.parametrize( + "content", + [ + "```\nthis is not json\n```", + "```json\n```", + ], + ) + def test_non_json_fence_returns_original(self, content): + """Non-JSON fenced content is not converted into a bad JSON candidate.""" + assert _strip_code_fences(content) == content + + def test_json_with_nested_backticks_in_string(self): + """Backticks inside a JSON string do not truncate the extracted JSON.""" + content = '```json\n{"facts": [{"what": "saw ```nested``` marker"}]}\n```' + + result = _strip_code_fences(content) + + assert json.loads(result) == {"facts": [{"what": "saw ```nested``` marker"}]} + + @pytest.mark.parametrize( + "content", + [ + 'Reasoning first.\n```json\n{"facts": []}\n```\nDone.', + 'Reasoning first.\n```json\n{"facts": []}\nDone.', + ], + ) + def test_extracts_parseable_json_from_surrounding_text(self, content): + """Provider prose around a JSON payload does not force a retry.""" + assert _strip_code_fences(content) == '{"facts": []}' def test_minimax_style_response(self): """Real-world MiniMax response format.""" @@ -85,8 +115,6 @@ def test_minimax_style_response(self): assert not result.startswith("```") assert not result.endswith("```") # Should be valid JSON - import json - parsed = json.loads(result) assert len(parsed["facts"]) == 1 assert parsed["facts"][0]["who"] == "Sebastian"