I spent an afternoon building a tiny structured-extraction pipeline on MonkeyCode's free server, using a free model to turn messy support notes into clean JSON records. The model answered, the log output looked flawless, and then json.loads() exploded on the very first character of the response. The error message pointed at a byte I could not see, and that invisible byte turned a short task into a long debugging session.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The Symptom That Made No Sense
Here is what the log showed me after the model finished:
{
"customer": "Acme Corp",
"issue": "billing dispute",
"priority": 2
}
And here is what Python said when my pipeline tried to parse it:
JSONDecodeError: Expecting value: line 1 column 1 (char 0)
Line 1, column 1, character 0 — that is the opening brace, which was right there in the log. I stared at the screen and asked myself the question every developer knows too well: how do you debug a string that looks perfect but refuses to be parsed?
First Suspect: The Transport
My first instinct was to blame the HTTP layer, because that is where most of my recent failures have lived. I added a single line to dump the raw response before any processing:
print(repr(raw_response[:80]))
The output changed everything:
'\ufeff{\n "customer": "Acme Corp",\n "issue": "billing dispute",\n "priority": 2\n}'
There it was: a UTF-8 byte order mark, \ufeff, sitting at the start of the string. The response had arrived with a BOM, the log formatter rendered it as invisible whitespace, and Python's json.loads() refuses to skip it. The JSON was valid; the bytes were not what my parser expected.
The Second Layer: Markdown Fences
I fixed the BOM and ran the pipeline again, and this time the error moved to a different invisible enemy. The model had wrapped its answer in a markdown code block:
```json
{
"customer": "Acme Corp",
"issue": "billing dispute",
"priority": 2
}
```
Same error, same misleading line and column, completely different root cause. The model was generating valid JSON inside a fenced block, and my naive response.strip() call did nothing about the backticks. Two separate layers were conspiring to produce one identical, useless error message.
Root Cause: I Trusted the Pretty Printer
The real bug was not the BOM and not the fences; it was my assumption that what I saw in the logs was exactly what the parser received. The logging layer had pretty-printed the response, hiding invisible characters and markup, so every visible clue pointed away from the truth. I had also skipped validation, which meant the first time I learned about the format mismatch was inside a production code path instead of a test.
The Fix: Parse Like You Don't Trust Anyone
The fix is a small extraction pipeline that handles both failure modes before the JSON parser ever runs:
import json
import re
def extract_json(raw: str) -> dict:
# Strip a UTF-8 BOM if the server or model added one.
if raw.startswith("\ufeff"):
raw = raw.lstrip("\ufeff")
# Pull JSON out of markdown fences when the model wraps it.
fence = re.search(r"```
(?:json)?\s*(.*?)
```", raw, re.DOTALL)
if fence:
raw = fence.group(1).strip()
else:
# Fallback: grab the outermost JSON object if the model added prose.
start, end = raw.find("{"), raw.rfind("}")
if start != -1 and end > start:
raw = raw[start:end + 1]
return json.loads(raw)
That handles the failures I hit, but it is not a magic bullet. After parsing, I validate the result against the schema I actually need, because a dict that parses is not the same as a dict that is correct:
def validate(record: dict) -> None:
assert record["customer"], "missing customer"
assert isinstance(record["priority"], int), "priority must be int"
A Reproducible Test Plan
The most useful thing I did was turn every failure mode into a fixture before writing the fix. Here is the exact test script I now run on every change:
FIXTURES = [
("clean", '{"customer": "Acme Corp", "priority": 2}'),
("bom", '\ufeff{"customer": "Acme Corp", "priority": 2}'),
("fenced", '```
json\n{"customer": "Acme Corp", "priority": 2}\n
```'),
("fenced_bom", '\ufeff```
json\n{"customer": "Acme Corp", "priority": 2}\n
```'),
("prose", 'Here is the extracted result:\n{"customer": "Acme Corp", "priority": 2}'),
]
for name, raw in FIXTURES:
record = extract_json(raw)
validate(record)
print(f"PASS {name}")
Each fixture represents a real behavior I have observed: a clean response, a BOM, a fenced block, both at once, and a model that adds a sentence before the JSON. When a new failure appears in production, I add its raw output to this list first, watch the test fail, and only then change the extraction code.
The Reusable Debugging Workflow
This incident taught me a workflow that applies far beyond JSON parsing:
- Reproduce with the raw bytes, never with the pretty-printed log.
repr()or a hexdump shows what actually arrived. - Isolate the layers. Ask whether the problem lives in the transport, the model output, or the parser before changing any code.
- Add a golden fixture for every real failure before you fix it. A test that fails first is the only honest proof that your fix works.
- Fix the pipeline, not the symptom. One
lstripfor the BOM and one regex for the fences is more robust than patching each response by hand.
Limitations and Who Should Skip This
This approach is for text models that return JSON as plain text, and it has real limits. If you need a guaranteed structure, use a provider's structured-output mode or function calling instead of scraping fences out of prose. The fallback that grabs the first { and the last } is a heuristic, so it can misfire when prose contains braces; keep it narrow and always validate the parsed result against your schema. Free server and free model tiers are perfectly adequate for this kind of small pipeline, but I am not going to promise quotas, latency, or uptime — test with your own workload before you rely on it. And if you never consume model output programmatically, none of this matters to you, which is a perfectly good place to be.
If you want to reproduce this failure yourself, MonkeyCode's free server and free model tier are enough to run the exact test plan above — and the debugging workflow transfers to any provider you use afterward.
Top comments (0)