Skip to main content

Trustworthy JSON: schema-validate your model's structured output

· 14 min read
Rafael Fernandes
NLP Engineer & Tech Writer at WiLine
Share:
Promptfoo++JSON

"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 24
    export WEC_API_KEY='sk-your-key'
tip

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:

Output
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%)

Step 1 — one ticket fails because the JSON is wrapped in markdown fences 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.

Step 2 — with the fence-stripping transform, both tickets pass Figure 2. The transform removes the code fences, so the previously-failing ticket parses.

transform needs an explicit return

A 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.

Step 3 — JSON mode passes on GLM-5.2 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.

Step 4 — with JSON mode, two models pass and two emit reasoning Figure 4. response_format set on all four — but the two reasoning-oriented models ignore it.

ModelResultTokens (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.

Step 5 — same 4/8 without response_format, proving it was a no-op Figure 5. Identical result without JSON mode — the flag wasn't doing anything.

note

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.

Step 6 — JSON extraction rescues most models (7/8) 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).

The Promptfoo report: Pass Rate bars per model and expandable per-case output 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.

note

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; }

The gate blocking on a failed eval 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.2 and Qwen2.5-3B reliably produce schema-valid JSON and are ~8× cheaper than the reasoning models. For structured output on WEC, start there.
  • Reasoning models (qwen3.5:9B, and gemma4 on the day we tested) narrate their thinking and cost far more — poor fits for strict JSON.
  • A transform is 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_format is 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`

The transform-return error 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.

The failing case expanded in the report — JSON followed by trailing reasoning 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:]')

The ByteString error from an invisible key character 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.