Trustworthy JSON: schema-validate your model's structured output
"Return only JSON" is one of the most common instructions in production LLM apps — and one
of the least reliable. Models wrap JSON in markdown fences, add a friendly sentence, or (if
they're reasoning models) narrate their entire thought process around it. Any of those
breaks a strict JSON.parse, and your pipeline falls over.
In this guide we build a Promptfoo eval that makes WEC
Inference API models classify support tickets into schema-validated JSON, then harden
it against real-world messiness — and use it to pick a model you can actually trust. This is
part 2 of the evals series (see part 1
for first-run setup). Everything here was run live against https://inference.wiline.com.
How the eval works
Every test runs through the same pipeline. The detail that matters most: the transform
runs on the model's output before the assertion sees it — that's what lets us recover
messy JSON before validating it.
Prerequisites
-
A WEC Inference API key — see Inference → API Keys.
-
The model names used here (
GLM-5.2,gemma4,Qwen2.5-3B-Instruct,qwen3.5:9B) come from the Models Hub — swap in whatever you've enabled. -
Node.js 22+ and your key exported (Promptfoo runs via
npx):nvm use 24export WEC_API_KEY='sk-your-key'
If your first run errors with Cannot convert argument to a ByteString, your pasted key has
an invisible character — see Troubleshooting at the bottom.
Step 1 — A strict schema, and a real failure
We'll do something closer to a real workload than "capital of France": classify a support
ticket into category, priority, and a short summary. The key upgrade over a basic
check is the JSON Schema — it enforces enums, so a model that invents a category fails.
cat > promptfooconfig.yaml <<'EOF'
description: "WEC Inference API — structured output (JSON schema)"
providers:
- id: openai:chat:zai-org/GLM-5.2
config:
apiBaseUrl: https://inference.wiline.com/v1
apiKeyEnvar: WEC_API_KEY
temperature: 0
prompts:
- |
Classify this support ticket. Return ONLY JSON with keys:
"category" (one of: billing, technical, account, other),
"priority" (one of: low, medium, high),
"summary" (string, max 12 words).
Ticket: {{ticket}}
defaultTest:
assert:
- type: is-json
value:
type: object
required: [category, priority, summary]
properties:
category: { type: string, enum: [billing, technical, account, other] }
priority: { type: string, enum: [low, medium, high] }
summary: { type: string }
tests:
- vars: { ticket: "I was charged twice for my instance this month." }
assert: [{ type: icontains, value: billing }]
- vars: { ticket: "My VM won't boot after the latest snapshot restore." }
assert: [{ type: icontains, value: technical }]
EOF
npx -y promptfoo@latest eval -c promptfooconfig.yaml
The result — 50%, and the failure is instructive:
ticket [zai-org/GLM-5.2]
I was charged twice for my instance… [FAIL] ```json
{ "category": "billing", "priority": "high", … }
```
My VM won't boot after the latest… [PASS] { "category": "technical", … }
✓ 1 passed (50%) ✗ 1 failed (50%)
Figure 1. Same model, same prompt, temperature 0 — one response is clean, the other is
wrapped in ```json fences, so is-json can't parse it.
The content is correct; the format isn't. is-json validates both "is it parseable JSON"
and "does it match the schema" — and fenced output fails the first test.
Step 2 — Recover fenced output with a transform
A transform runs on the model's output before assertions. Here we strip the fences.
(This block uses four backticks because the config itself contains triple backticks.)
cat > promptfooconfig.yaml <<'EOF'
description: "WEC Inference API — fence-stripping transform"
providers:
- id: openai:chat:zai-org/GLM-5.2
config:
apiBaseUrl: https://inference.wiline.com/v1
apiKeyEnvar: WEC_API_KEY
temperature: 0
prompts:
- |
Classify this support ticket. Return ONLY JSON with keys:
"category" (one of: billing, technical, account, other),
"priority" (one of: low, medium, high),
"summary" (string, max 12 words).
Ticket: {{ticket}}
defaultTest:
options:
transform: |
return output.replace(/```json\n?|\n?```/g, '').trim();
assert:
- type: is-json
value:
type: object
required: [category, priority, summary]
properties:
category: { type: string, enum: [billing, technical, account, other] }
priority: { type: string, enum: [low, medium, high] }
summary: { type: string }
tests:
- vars: { ticket: "I was charged twice for my instance this month." }
assert: [{ type: icontains, value: billing }]
- vars: { ticket: "My VM won't boot after the latest snapshot restore." }
assert: [{ type: icontains, value: technical }]
EOF
npx -y promptfoo@latest eval -c promptfooconfig.yaml --no-cache
Now both pass — 2/2. The fences are stripped before is-json sees the output.
Figure 2. The transform removes the code fences, so the previously-failing ticket parses.
transform needs an explicit returnA multi-line transform is a function body, not an expression — omit return and you get
Transform function did not return a value. See Troubleshooting.
Step 3 — The cleaner fix: JSON mode
Rather than clean up after the model, ask the API to only emit valid JSON via
response_format. No transform this time:
cat > promptfooconfig.yaml <<'EOF'
description: "WEC Inference API — JSON mode (response_format)"
providers:
- id: openai:chat:zai-org/GLM-5.2
config:
apiBaseUrl: https://inference.wiline.com/v1
apiKeyEnvar: WEC_API_KEY
temperature: 0
response_format: { type: json_object }
prompts:
- |
Classify this support ticket. Return ONLY JSON with keys:
"category" (one of: billing, technical, account, other),
"priority" (one of: low, medium, high),
"summary" (string, max 12 words).
Ticket: {{ticket}}
defaultTest:
assert:
- type: is-json
value:
type: object
required: [category, priority, summary]
properties:
category: { type: string, enum: [billing, technical, account, other] }
priority: { type: string, enum: [low, medium, high] }
summary: { type: string }
tests:
- vars: { ticket: "I was charged twice for my instance this month." }
assert: [{ type: icontains, value: billing }]
- vars: { ticket: "My VM won't boot after the latest snapshot restore." }
assert: [{ type: icontains, value: technical }]
EOF
npx -y promptfoo@latest eval -c promptfooconfig.yaml --no-cache
2/2 PASS with no transform — looks like JSON mode works.
Figure 3. With response_format: json_object, GLM-5.2 returns clean JSON, no transform needed.
But before trusting response_format, we need to test it across models — because a single
green result can be luck, not enforcement.
Step 4 — Reliability matrix across WEC models
Run the JSON-mode config against all four chat models. This is the payoff — a WEC-specific reliability comparison you can't get from a generic leaderboard.
cat > promptfooconfig.yaml <<'EOF'
description: "WEC Inference API — JSON-mode reliability across models"
providers:
- id: openai:chat:zai-org/GLM-5.2
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0, response_format: { type: json_object } }
- id: openai:chat:gemma4
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0, response_format: { type: json_object } }
- id: openai:chat:Qwen2.5-3B-Instruct
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0, response_format: { type: json_object } }
- id: openai:chat:qwen3.5:9B
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0, response_format: { type: json_object } }
prompts:
- |
Classify this support ticket. Return ONLY JSON with keys:
"category" (one of: billing, technical, account, other),
"priority" (one of: low, medium, high),
"summary" (string, max 12 words).
Ticket: {{ticket}}
defaultTest:
assert:
- type: is-json
value:
type: object
required: [category, priority, summary]
properties:
category: { type: string, enum: [billing, technical, account, other] }
priority: { type: string, enum: [low, medium, high] }
summary: { type: string }
tests:
- vars: { ticket: "I was charged twice for my instance this month." }
assert: [{ type: icontains, value: billing }]
- vars: { ticket: "My VM won't boot after the latest snapshot restore." }
assert: [{ type: icontains, value: technical }]
EOF
npx -y promptfoo@latest eval -c promptfooconfig.yaml --no-cache
4/8. GLM-5.2 and Qwen2.5-3B pass cleanly; gemma4 and qwen3.5:9B dump their reasoning
("Thinking Process…") instead of JSON — despite response_format being set.
Figure 4. response_format set on all four — but the two reasoning-oriented models ignore it.
| Model | Result | Tokens (2 calls) |
|---|---|---|
zai-org/GLM-5.2 | ✅✅ | 202 |
Qwen2.5-3B-Instruct | ✅✅ | 243 |
gemma4 | ❌❌ (reasoning) | 251 |
qwen3.5:9B | ❌❌ (reasoning) | 1,757 |
Step 5 — Verify: is response_format doing anything?
Don't assume the reasoning was caused by JSON mode. Re-run the same matrix without
response_format (using the fence-strip transform instead) and compare:
cat > promptfooconfig.yaml <<'EOF'
description: "WEC Inference API — matrix without response_format"
providers:
- id: openai:chat:zai-org/GLM-5.2
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
- id: openai:chat:gemma4
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
- id: openai:chat:Qwen2.5-3B-Instruct
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
- id: openai:chat:qwen3.5:9B
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
prompts:
- |
Classify this support ticket. Return ONLY JSON with keys:
"category" (one of: billing, technical, account, other),
"priority" (one of: low, medium, high),
"summary" (string, max 12 words).
Ticket: {{ticket}}
defaultTest:
options:
transform: |
return output.replace(/```json\n?|\n?```/g, '').trim();
assert:
- type: is-json
value:
type: object
required: [category, priority, summary]
properties:
category: { type: string, enum: [billing, technical, account, other] }
priority: { type: string, enum: [low, medium, high] }
summary: { type: string }
tests:
- vars: { ticket: "I was charged twice for my instance this month." }
assert: [{ type: icontains, value: billing }]
- vars: { ticket: "My VM won't boot after the latest snapshot restore." }
assert: [{ type: icontains, value: technical }]
EOF
npx -y promptfoo@latest eval -c promptfooconfig.yaml --no-cache
Still 4/8 — the exact same models fail. So response_format changed nothing: on WEC
today it's effectively a no-op (the backend isn't enforcing JSON mode). The reasoning
behavior belongs to the models, not to the flag.
Figure 5. Identical result without JSON mode — the flag wasn't doing anything.
Lesson worth its own callout: don't attribute a result to a change until you've tested the change in isolation. One extra run turned a wrong conclusion into the right one.
Step 6 — A more robust transform: extract the JSON
The reasoning models do emit JSON — it's just buried in prose. Instead of stripping fences,
extract the first { … } block from anywhere in the output:
cat > promptfooconfig.yaml <<'EOF'
description: "WEC Inference API — JSON extraction transform"
providers:
- id: openai:chat:zai-org/GLM-5.2
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
- id: openai:chat:gemma4
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
- id: openai:chat:Qwen2.5-3B-Instruct
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
- id: openai:chat:qwen3.5:9B
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
prompts:
- |
Classify this support ticket. Return ONLY JSON with keys:
"category" (one of: billing, technical, account, other),
"priority" (one of: low, medium, high),
"summary" (string, max 12 words).
Ticket: {{ticket}}
defaultTest:
options:
transform: |
const m = output.match(/\{[\s\S]*\}/);
return m ? m[0] : output;
assert:
- type: is-json
value:
type: object
required: [category, priority, summary]
properties:
category: { type: string, enum: [billing, technical, account, other] }
priority: { type: string, enum: [low, medium, high] }
summary: { type: string }
tests:
- vars: { ticket: "I was charged twice for my instance this month." }
assert: [{ type: icontains, value: billing }]
- vars: { ticket: "My VM won't boot after the latest snapshot restore." }
assert: [{ type: icontains, value: technical }]
EOF
npx -y promptfoo@latest eval -c promptfooconfig.yaml --no-cache
7/8 — a big jump. gemma4 and Qwen2.5 fully recover; qwen3.5:9B recovers one of two. The
one remaining failure is telling: that response emitted JSON and then kept reasoning (with
more braces), so the greedy { … } match over-captured and produced invalid JSON.
Figure 6. Extracting the JSON block recovers the chatty models — but it's a heuristic, not
a guarantee (note the one over-capture failure).
Step 7 — See it in the browser
The terminal table is fine for a quick read, but the web report is where a matrix like this comes alive:
npx -y promptfoo@latest view
The dashboard at the top gives you the model-reliability picture at a glance — the Pass
Rate (%) bars are the ones to watch here: three models tall, qwen3.5:9B sitting at 50%.
Below, each provider is a column you can expand to the full response (that's how we pulled
the over-capture failure in Figure 10 — the terminal truncates it).
Figure 7. The report's dashboard — the Pass Rate bars turn the model matrix into a
one-glance ranking, and any cell expands to the full output.
The report's other charts (the prompt-vs-prompt scatter) shine when you're A/B-testing prompts, not models — a good subject for a later post. For a single-prompt, multi-model run like this one, the Pass Rate bars are the chart that matters.
Step 8 — Gate it in CI
Promptfoo exits non-zero when tests fail, so a one-liner is a gate (put it in a script or
Makefile, never your interactive shell):
npx -y promptfoo@latest eval -c promptfooconfig.yaml \
&& echo "✅ passed" \
|| { echo "❌ eval failed — blocking"; exit 1; }
Figure 8. A real failure blocks: 7/8 passed, but qwen3.5:9B's over-capture (from Step 6)
trips the gate — the non-zero exit code is the signal CI uses to block a PR.
On GitLab:
eval:
image: node:22
script:
- npx -y promptfoo@latest eval -c promptfooconfig.yaml
# WEC_API_KEY set as a masked CI/CD variable — never committed.
What we learned
- Model choice dominates.
GLM-5.2andQwen2.5-3Breliably produce schema-valid JSON and are ~8× cheaper than the reasoning models. For structured output on WEC, start there. - Reasoning models (
qwen3.5:9B, andgemma4on the day we tested) narrate their thinking and cost far more — poor fits for strict JSON. - A
transformis your safety net — fence-stripping recovers the common case; JSON extraction recovers most chatty output — but extraction is a heuristic that can over-capture. response_formatis a no-op on WEC today — verified by isolating it. Don't rely on it; validate instead.- The eval is the point. Every one of these surprises was caught automatically, before it reached production.
Troubleshooting
"Transform function did not return a value"
A multi-line transform is compiled as a function body, so it needs an explicit
return. output.replace(...) on its own returns nothing.
options:
transform: |
return output.replace(/.../g, '').trim(); # note the `return`
Figure 9. Without return, Promptfoo errors instead of transforming.
A model returns reasoning around the JSON
If is-json fails with "Expected output to be valid JSON" and the response contains a
"Thinking Process…" / "Final Review…" narration, you're using a reasoning model. Sometimes it
emits only reasoning; sometimes — as below — it emits valid JSON and then keeps talking,
so even the Step 6 extraction over-captures and the parse fails. Options: switch to
GLM-5.2 / Qwen2.5-3B, or accept that the extraction transform is best-effort, not a guarantee.
Figure 10. The failing case expanded in the report: qwen3.5:9B returned valid JSON and
then kept reasoning ("7. Final Review…"), so the extraction over-captured and the assertion
reports "Expected output to be valid JSON."
"Cannot convert argument to a ByteString"
Your pasted API key contains an invisible Unicode character. Strip it:
export WEC_API_KEY=$(printf '%s' "$WEC_API_KEY" | LC_ALL=C tr -cd '[:print:]')
Figure 11. A stray character in the key trips the HTTP header before any request is sent.
What's next
- DeepEval regression suites — turn these checks into a versioned, CI-gated regression run.
- Tracing — see why a response went wrong, not just that it failed.
Have a structured-output case that still slips through? That's the next test to add.
