fix: strip markdown code fences from all LLM providers, not just local (#646)

LLM providers like MiniMax wrap JSON responses in markdown code fences
(```json ... ```), causing JSON parse failures and 5-11 retries per
extraction. The existing fence stripping logic was gated to only
"lmstudio" and "ollama" providers (and for Ollama, unreachable due to
the _call_ollama_native redirect).

Changes:
- Extract _strip_code_fences() helper function
- Apply fence stripping to all providers in call() (not just local)
- Add fence stripping safety net to _call_ollama_native()
- Add 10 tests covering bare JSON, fenced JSON, malformed fences,
  and real-world MiniMax response format

Fixes vectorize-io/hindsight#645

Co-authored-by: feniix <feniix@desktop>
This commit is contained in:
Sebastian B Otaegui 2026-03-22 17:29:16 -03:00 committed by GitHub
parent caa53ee370
commit 2f2db2a6e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 145 additions and 33 deletions

View file

@ -40,6 +40,25 @@ logger = logging.getLogger(__name__)
DEFAULT_LLM_SEED = 4242 DEFAULT_LLM_SEED = 4242
def _strip_code_fences(content: str) -> str:
"""Strip markdown code fences from LLM response if present.
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.
"""
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
class OpenAICompatibleLLM(LLMInterface): class OpenAICompatibleLLM(LLMInterface):
""" """
LLM provider for OpenAI-compatible APIs. LLM provider for OpenAI-compatible APIs.
@ -322,20 +341,14 @@ class OpenAICompatibleLLM(LLMInterface):
if len(content) < original_len: if len(content) < original_len:
logger.debug(f"Stripped {original_len - len(content)} chars of reasoning tokens") logger.debug(f"Stripped {original_len - len(content)} chars of reasoning tokens")
# For local models, they may wrap JSON in markdown code blocks # Strip markdown code fences if present — any provider may
if self.provider in ("lmstudio", "ollama"): # produce these (confirmed with MiniMax, some Ollama models,
clean_content = content # Claude via proxies). No-op when content is already bare JSON.
if "```json" in content: clean_content = _strip_code_fences(content)
clean_content = content.split("```json")[1].split("```")[0].strip() try:
elif "```" in content: json_data = json.loads(clean_content)
clean_content = content.split("```")[1].split("```")[0].strip() except json.JSONDecodeError:
try: # Fallback to parsing raw content in case stripping was wrong
json_data = json.loads(clean_content)
except json.JSONDecodeError:
# Fallback to parsing raw content
json_data = json.loads(content)
else:
# Log raw LLM response for debugging JSON parse issues
try: try:
json_data = json.loads(content) json_data = json.loads(content)
except json.JSONDecodeError as json_err: except json.JSONDecodeError as json_err:
@ -730,26 +743,33 @@ class OpenAICompatibleLLM(LLMInterface):
result = response.json() result = response.json()
content = result.get("message", {}).get("content", "") content = result.get("message", {}).get("content", "")
# Parse JSON response # Strip markdown code fences if present (safety net —
# Ollama with schema enforcement usually returns bare JSON,
# but some models may still wrap in fences)
clean_content = _strip_code_fences(content)
try: try:
json_data = json.loads(content) json_data = json.loads(clean_content)
except json.JSONDecodeError as json_err: except json.JSONDecodeError:
content_preview = content[:500] if content else "<empty>" # Fallback to raw content
if content and len(content) > 700: try:
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}" json_data = json.loads(content)
logger.warning( except json.JSONDecodeError as json_err:
f"Ollama JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n" content_preview = content[:500] if content else "<empty>"
f" Model: ollama/{self.model}\n" if content and len(content) > 700:
f" Content length: {len(content) if content else 0} chars\n" content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
f" Content preview: {content_preview!r}" logger.warning(
) f"Ollama JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
if attempt < max_retries: f" Model: ollama/{self.model}\n"
backoff = min(initial_backoff * (2**attempt), max_backoff) f" Content length: {len(content) if content else 0} chars\n"
await asyncio.sleep(backoff) f" Content preview: {content_preview!r}"
last_exception = json_err )
continue if attempt < max_retries:
else: backoff = min(initial_backoff * (2**attempt), max_backoff)
raise await asyncio.sleep(backoff)
last_exception = json_err
continue
else:
raise
# Extract token usage from Ollama response # Extract token usage from Ollama response
duration = time.time() - start_time duration = time.time() - start_time

View file

@ -0,0 +1,92 @@
"""Tests for _strip_code_fences helper in OpenAI-compatible LLM provider."""
import pytest
from hindsight_api.engine.providers.openai_compatible_llm import _strip_code_fences
class TestStripCodeFences:
"""Test markdown code fence stripping from LLM responses."""
def test_bare_json_unchanged(self):
"""Bare JSON passes through unchanged."""
content = '{"facts": [{"what": "test"}]}'
assert _strip_code_fences(content) == content
def test_json_fence_stripped(self):
"""```json ... ``` fences are stripped."""
content = '```json\n{"facts": [{"what": "test"}]}\n```'
assert _strip_code_fences(content) == '{"facts": [{"what": "test"}]}'
def test_plain_fence_stripped(self):
"""``` ... ``` fences without language tag are stripped."""
content = '```\n{"facts": [{"what": "test"}]}\n```'
assert _strip_code_fences(content) == '{"facts": [{"what": "test"}]}'
def test_fence_with_trailing_whitespace(self):
"""Fences with extra whitespace are handled."""
content = '```json\n{"facts": []}\n```\n'
result = _strip_code_fences(content)
assert result == '{"facts": []}'
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
def test_no_fences_no_change(self):
"""Content without any backticks passes through."""
content = "Just some text without fences"
assert _strip_code_fences(content) == content
def test_empty_string(self):
"""Empty string passes through."""
assert _strip_code_fences("") == ""
def test_multiline_json(self):
"""Multi-line JSON inside fences is preserved."""
content = '```json\n{\n "facts": [\n {"what": "line1"},\n {"what": "line2"}\n ]\n}\n```'
result = _strip_code_fences(content)
assert '"line1"' in result
assert '"line2"' in result
assert "```" not in result
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)
def test_minimax_style_response(self):
"""Real-world MiniMax response format."""
content = (
"```json\n"
"{\n"
' "facts": [\n'
" {\n"
' "what": "Sebastian switched the Hindsight extraction LLM",\n'
' "when": "2026-03-21",\n'
' "where": "N/A",\n'
' "who": "Sebastian",\n'
' "why": "MiniMax wraps JSON in code fences",\n'
' "fact_kind": "event",\n'
' "fact_type": "world",\n'
' "entities": [{"text": "Sebastian"}, {"text": "Hindsight"}],\n'
' "labels": {"source_type": "stated", "domain": ["infrastructure"]}\n'
" }\n"
" ]\n"
"}\n"
"```"
)
result = _strip_code_fences(content)
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"