DEV Community

Cover image for Eliminating Broken LLM JSON in Production Workflows (Node.js, FastAPI & n8n Strict Schemas)
Trung Thanh Tran
Trung Thanh Tran

Posted on Edited on

Eliminating Broken LLM JSON in Production Workflows (Node.js, FastAPI & n8n Strict Schemas)

If you have ever wired an LLM into a CRM, billing webhook, or database ingestion workflow, you have probably met the 2:00 AM SyntaxError: Unexpected end of JSON input alert.

At 10 test runs on your laptop, telling a model "Respond ONLY with valid JSON, no markdown fences" feels like it works. At 10,000 automated runs per week inside an n8n, Make.com, Node.js, or FastAPI pipeline, prompt-only JSON fails in four predictable ways:

  1. Silent Key Drift: The model outputs urgencyScore on Tuesday and urgency_score on Wednesday, leaving downstream CRM fields null.
  2. Markdown Preamble Bleed: Under edge-case inputs, the model wraps the payload in ```json ... ``` or adds "Sure, here is the extracted payload:".
  3. Mid-String Token Truncation (finish_reason: "length"): A verbose customer email causes the completion to hit max_tokens halfway through a string ({"summary": "Customer requested an enterprise...), crashing JSON.parse().
  4. Safety Refusal Collisions: When an input triggers a content policy refusal, the assistant message body is plain prose while message.content is empty or non-JSON.

Many teams respond by stacking regex strippers and retry-repair prompts (jsonrepair) on top of broken outputs—doubling latency and token bills without fixing schema drift.

The real fix is architectural: Constrained Token Decoding (strict: true JSON Schemas) paired with finish_reason state guards and SHA-256 webhook idempotency. Here is how to engineer that stack cleanly across FastAPI (Python), Node.js / TypeScript, and n8n.


Prompt Mode vs. json_object vs. Strict json_schema

Before writing code, it helps to understand what happens inside the inference engine across the three JSON modes:

Mechanism How It Works at Decode Time Guarantees Valid JSON Syntax? Guarantees Exact Keys, Types & Enums? Failure Mode to Watch
1. System Prompt Only Purely probabilistic next-token prediction No (~94–97%) No Markdown fences, trailing commas, key hallucinations
2. Legacy JSON Mode (json_object) Lexer masks invalid JSON tokens globally Yes (unless truncated) No (keys/types can still drift) Missing required fields or wrong nested types
3. Structured Outputs (json_schema, strict: true) Compiles your JSON Schema into a context-free grammar (CFG) masking logits at every token step Yes (100%) Yes (100% match to schema) finish_reason == "length" or explicit refusal

With strict: true, the model literally cannot sample a token that violates your property names, enum values, or required types—because invalid tokens have their logit probabilities masked to -∞ during sampling.

The Three Strict-Grammar Rules Most Developers Miss

If your strict: true request returns a 400 Invalid Schema error from OpenAI (or compatible vLLM / structured-decoding endpoints), check these three constraints:

  1. Every object level (root and nested) must set "additionalProperties": false.
  2. Every property defined in "properties" must be listed in "required" (for optional values, use a union type like ["string", "null"] rather than omitting the key from required).
  3. Keep recursive nesting shallow and prefer explicit enum arrays over open-ended strings for routing keys.

Reference Implementation #1: FastAPI + Pydantic Strict Service (Python 3.11)

In Python, client.beta.chat.completions.parse() compiles Pydantic BaseModel classes into strict JSON schemas automatically—if you guard finish_reason and message.refusal before touching .parsed.

Here is a production-hardened FastAPI endpoint pattern (adapted from fastapi_openai_structured_rag_service.py):

import hashlib
from enum import Enum
from typing import Optional
from fastapi import FastAPI, HTTPException, Header
from openai import AsyncOpenAI
from pydantic import BaseModel, ConfigDict, Field

app = FastAPI(title="Deterministic Lead Triage Pipeline")
client = AsyncOpenAI()


class RouteTeam(str, Enum):
    REVOPS_ENTERPRISE = "revops_enterprise"
    SELF_SERVE_SUPPORT = "self_serve_support"
    BILLING_OPS = "billing_ops"
    SPAM_QUARANTINE = "spam_quarantine"


class NextAction(str, Enum):
    CREATE_HUBSPOT_DEAL = "create_hubspot_deal"
    OPEN_ZENDESK_TICKET = "open_zendesk_ticket"
    ARCHIVE_NO_ACTION = "archive_no_action"


class LeadTriageDecision(BaseModel):
    model_config = ConfigDict(extra="forbid")

    intent_summary: str = Field(..., description="Concise 1-sentence summary under 160 chars.")
    urgency_score: int = Field(..., ge=1, le=100)
    owner_team: RouteTeam
    next_action: NextAction
    extracted_company_domain: Optional[str] = Field(
        default=None, description="Normalized company domain or null if consumer email."
    )


class InboundWebhookPayload(BaseModel):
    source_event_id: str
    sender_email: str
    raw_message: str


# Replace with Redis SET NX EX in multi-worker deployments
PROCESSED_KEYS: set[str] = set()


@app.post("/v1/triage")
async def triage_inbound_lead(
    payload: InboundWebhookPayload,
    x_idempotency_key: Optional[str] = Header(default=None),
) -> dict:
    # 1. Deterministic Idempotency Guard (prevents duplicate HubSpot deals on webhook retries)
    idem_raw = x_idempotency_key or f"{payload.source_event_id}:{payload.sender_email}"
    idem_hash = hashlib.sha256(idem_raw.encode("utf-8")).hexdigest()[:24]
    if idem_hash in PROCESSED_KEYS:
        return {"status": "duplicate_ignored", "idempotency_key": idem_hash}

    # 2. Constrained Decoding Call with Strict Pydantic Schema
    completion = await client.beta.chat.completions.parse(
        model="gpt-4o-mini",
        temperature=0.0,
        max_completion_tokens=450,
        messages=[
            {
                "role": "system",
                "content": (
                    "Classify the inbound message into the strict triage schema. "
                    "Never invent domains not present in sender_email or message body."
                ),
            },
            {
                "role": "user",
                "content": f"From: {payload.sender_email}\n\nBody:\n{payload.raw_message[:6000]}",
            },
        ],
        response_format=LeadTriageDecision,
    )

    choice = completion.choices[0]

    # 3. Critical Production Guards: Truncation & Policy Refusals
    if choice.finish_reason == "length":
        raise HTTPException(
            status_code=502,
            detail="LLM output hit max_completion_tokens; refusing to emit truncated JSON.",
        )

    if choice.message.refusal:
        raise HTTPException(
            status_code=422,
            detail=f"Model safety refusal: {choice.message.refusal}",
        )

    parsed: LeadTriageDecision | None = choice.message.parsed
    if parsed is None:
        raise HTTPException(status_code=500, detail="Parsed schema payload missing.")

    PROCESSED_KEYS.add(idem_hash)
    return {
        "status": "processed",
        "idempotency_key": idem_hash,
        "finish_reason": choice.finish_reason,
        "decision": parsed.model_dump(),
    }
Enter fullscreen mode Exit fullscreen mode

Notice what happens when choice.finish_reason == "length" is checked before downstream side effects: instead of writing corrupted state or silently dropping fields, your webhook returns a clean 502 or routes the oversized payload to a chunking/summarization dead-letter queue.


Reference Implementation #2: Node.js / TypeScript with zodResponseFormat

If your backend or serverless worker runs on Node.js / TypeScript, pair zod with zodResponseFormat from the official openai/helpers/zod module so your compile-time TypeScript types and runtime LLM decoding grammar come from a single source of truth:

import OpenAI from "openai";
import { zodResponseFormat } from "openai/helpers/zod";
import { z } from "zod";

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

const InvoiceExtractionSchema = z.object({
  vendor_name: z.string(),
  invoice_number: z.string(),
  currency: z.enum(["USD", "EUR", "GBP", "CAD", "AUD"]),
  line_items: z.array(
    z.object({
      description: z.string(),
      quantity: z.number(),
      unit_price: z.number(),
      line_total: z.number(),
    })
  ),
  subtotal: z.number(),
  tax_amount: z.number(),
  grand_total: z.number(),
});

export type InvoiceExtraction = z.infer<typeof InvoiceExtractionSchema>;

export async function extractVerifiedInvoice(ocrText: string) {
  const completion = await openai.beta.chat.completions.parse({
    model: "gpt-4o-mini",
    temperature: 0,
    max_completion_tokens: 1200,
    messages: [
      {
        role: "system",
        content: "Extract structured invoice fields accurately from raw OCR text.",
      },
      { role: "user", content: ocrText.slice(0, 12000) },
    ],
    response_format: zodResponseFormat(InvoiceExtractionSchema, "invoice_extraction_v1"),
  });

  const choice = completion.choices[0];
  if (choice.finish_reason === "length") {
    throw new Error("Token budget exceeded (finish_reason=length); increase max_completion_tokens.");
  }
  if (choice.message.refusal || !choice.message.parsed) {
    throw new Error(`Extraction refused or empty: ${choice.message.refusal ?? "unknown"}`);
  }

  const data = choice.message.parsed;

  // Deterministic Post-LLM Arithmetic Invariant Check!
  const computedSum = data.line_items.reduce((acc, item) => acc + item.line_total, 0);
  const mathDelta = Math.abs(computedSum + data.tax_amount - data.grand_total);

  return {
    ...data,
    needs_human_audit: mathDelta > 0.05,
    computed_delta: Number(mathDelta.toFixed(2)),
  };
}
Enter fullscreen mode Exit fullscreen mode

Pro-Tip: Separate Structure from Arithmetic Verification

Strict JSON schemas guarantee structural validity (grand_total is a number), not mathematical truth (2 + 2 == 4 on smudged OCR text). Adding a 3-line deterministic invariant (mathDelta > 0.05 -> needs_human_audit: true) gives accounting workflows 100% trust without manual spot-checking.


Wiring Strict Schemas Directly Inside n8n (Without Fragile AI-Agent Loops)

In n8n, a common pitfall is dropping the high-level AI Agent + Structured Output Parser sub-node onto high-volume webhooks. Under the hood, several visual parser nodes still rely on prompt injection + auto-fixing retry calls rather than native API-level response_format.type = "json_schema".

For mission-critical n8n or Make.com workflows, call the Chat Completions endpoint directly via an HTTP Request node with explicit json_schema enforcement:

  1. Node 1 (Webhook): Receive inbound lead/document payload.
  2. Node 2 (Crypto -> SHA-256): Hash {{$json.event_id}} and check against Redis / Data Store / Supabase to drop duplicate retries immediately.
  3. Node 3 (HTTP Request -> POST https://api.openai.com/v1/chat/completions): Pass the exact JSON body below:
{
  "model": "gpt-4o-mini",
  "temperature": 0,
  "max_completion_tokens": 500,
  "messages": [
    {
      "role": "system",
      "content": "Extract B2B routing fields strictly according to the schema."
    },
    {
      "role": "user",
      "content": "{{ JSON.stringify($json.body).slice(0, 6000) }}"
    }
  ],
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "n8n_crm_router_v1",
      "strict": true,
      "schema": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "intent": {
            "type": "string",
            "enum": ["demo_request", "support_issue", "partnership", "noise"]
          },
          "priority": { "type": "integer" },
          "crm_note": { "type": "string" }
        },
        "required": ["intent", "priority", "crm_note"]
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. Node 4 (IF Guard Node): Assert {{ $json.choices[0].finish_reason === "stop" }} before JSON.parse($json.choices[0].message.content) routes into HubSpot, Pipedrive, Slack, or Google Sheets. Any non-stop completion branches cleanly into your error-alert channel with full input context preserved.

Production Checklist Before You Ship

Before promoting any LLM automation from staging to production, verify all five invariants:

  • [ ] "strict": true and "additionalProperties": false are set on every nested object in your schema.
  • [ ] temperature: 0 is set for deterministic classification and extraction tasks.
  • [ ] finish_reason === "length" and message.refusal are explicitly handled before JSON.parse().
  • [ ] Input text length is sliced/bounded (slice(0, N)) so a single 5 MB email thread cannot blow out context limits.
  • [ ] Downstream CRM/database writes are guarded by an idempotency hash (event_id + schema_version).

You can explore the complete Python FastAPI + Hybrid RAG + Strict Schema reference code in my open-source repository: ai-automation-and-rag-toolkit.


I'm Trung Thanh Tran, an AI Automation, Python & n8n Workflow Engineer helping teams turn brittle LLM prototypes into deterministic production pipelines.

  • Want 3 ready-to-import n8n & Make.com JSON workflow blueprints + 6 production Python CLI tools (including a Workflow Blueprint Compiler & API Triage Pack)? Check out the Python, AI & n8n Scraper Toolkit Pro on Gumroad.
  • Need help debugging broken n8n/Make scenarios, FastAPI microservices, or RAG pipelines? Connect with me on Upwork or Fiverr.

Top comments (0)