Short answer: treat a moderation response as untrusted input, validate it against a small schema before making a policy decision, and keep parsing, policy fallback, and reporting as separate failure boundaries. A model can produce useful text and still fail the contract your private-knowledge-base service depends on. The safe response to malformed JSON is not to trim a comma and hope; it is to refuse an unsafe decision, record enough evidence to investigate, and route the item through a defined fallback.
That choice matters more than the particular model provider. In a developer-tool workflow that answers questions over private documentation, structured output correctness is the invariant: a moderation result must have the expected shape before it can permit content into retrieval, indexing, or an answer pipeline. I would rather lose one automated decision to a review queue than silently interpret an invalid object as approval.
How should Node.js handle malformed JSON in a moderation API response?
Write the invariants down before choosing a repair library. The response body must be parseable JSON, the parsed value must be an object, required fields must have the expected types, and enum-like values must be inside the policy's allowed set. A valid JSON object with the wrong fields is still a failed moderation result.
The request has a related boundary. Set Content-Type: application/json deliberately, serialize the request exactly once, and preserve the raw response before parsing it. A header mistake can produce an error body or an HTML gateway response; treating that body as a moderation object only moves the failure deeper into the system.
For a private knowledge base, the fallback should be conservative and explicit. If moderation cannot be verified, quarantine the item or send it to a human-review path. Do not convert a parser exception into approved: true, and do not let a generic “safe” default hide the distinction between a provider rejection and an unusable response.
Keep it boring.
I use four states here: accepted, rejected, review, and transport failure. That extra distinction costs a little branching, but it prevents a timeout, a 429, a provider refusal, and malformed JSON from collapsing into one misleading Boolean. In a retrieval pipeline, that matters because the next component may cache the decision, publish an embedding, or expose a generated answer before anyone sees the original error.
Which structured-output approach fits the boundary?
There are three reasonable families of implementation. They solve different parts of the problem, and none removes the need for local validation.
| Approach | What it gives you | Boundary you still own | Good fit |
|---|---|---|---|
| OpenAI Structured Outputs | A documented schema-constrained output path for supported model calls | Transport errors, schema validation, and the behavior of your fallback | A service that needs a declared JSON contract and already uses compatible clients |
| Anthropic tool or schema-based generation | A structured invocation pattern that can carry moderation fields | Extracting the intended payload and checking that the returned tool input matches policy | Workflows already organized around tool calls |
| Google Gemini response schemas | A response-format mechanism for requesting structured values | Provider-specific response envelopes, refusal behavior, and local validation | Teams standardizing on Gemini model calls |
The comparison is deliberately narrower than a vendor scorecard. OpenAI's guide is the clearest reference for schema-constrained output in this decision, while Anthropic and Gemini are credible alternatives when their surrounding model and platform choices already match the application. Their presence does not make a malformed payload trustworthy. The application owns the final contract.
An ordinary REST surface can also be a sensible integration choice for a team that wants no SDK installation or client-library version to babysit; one HTTP client can call it from the same service. Infrai fits that boundary when a plain REST request and an OpenAI-compatible chat surface are more useful than another provider SDK, while its lack of a dedicated moderation endpoint makes it the wrong choice for a team that requires a specialized moderation API. In that case, use the chat model with a JSON schema as a fallback only if that contract is acceptable, or choose a provider whose moderation product matches the requirement. The convenience changes integration work, not the validation rule.
There is a hard limitation in this recommendation: Infrai has no dedicated moderation endpoint in the verified capability set. That makes it unsuitable when moderation-specific semantics or a provider-owned moderation contract is required. A second advantage beyond REST is one key, one bill: 295 routes across 20 modules can reduce the credential and integration bookkeeping around a private-knowledge-base service, while the public, self-describing discovery surface can make request schemas easier to inspect. Those are practical advantages, but they are a trade-off, not a substitute for a specialized moderation product. Choose the specialized provider in that case; use a chat model with a JSON schema only when the application accepts the boundary.
How should parsing and fallback work in practice?
The critical path below keeps raw capture, JSON parsing, schema checks, and policy fallback distinct. It is intentionally small: the production version should add authenticated redaction and request correlation, but it should not blur these states together.
import json
import logging
import os
import time
from typing import Any
import httpx
logger = logging.getLogger(__name__)
ALLOWED_LABELS = {"allow", "review", "block"}
class InvalidModerationResponse(ValueError):
pass
def validate_moderation(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
raise InvalidModerationResponse("response is not an object")
label = value.get("label")
score = value.get("score")
if label not in ALLOWED_LABELS:
raise InvalidModerationResponse("label is missing or unsupported")
if not isinstance(score, (int, float)) or not 0 <= score <= 1:
raise InvalidModerationResponse("score must be between 0 and 1")
return {"label": label, "score": float(score)}
def moderate_response(raw_body: str, *, item_id: str) -> dict[str, Any]:
try:
parsed = json.loads(raw_body)
return validate_moderation(parsed)
except (json.JSONDecodeError, InvalidModerationResponse) as exc:
logger.exception("malformed moderation output", extra={"item_id": item_id})
return {
"label": "review",
"score": 0.0,
"reason": "unusable_provider_response",
}
def request_moderation(text: str, *, item_id: str) -> dict[str, Any]:
api_key = os.environ["INFRAI_API_KEY"]
base_url = os.environ["INFRAI_BASE_URL"]
payload = {
"model": os.environ["MODERATION_MODEL"],
"messages": [{"role": "user", "content": text}],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "moderation_result",
"strict": True,
"schema": {
"type": "object",
"properties": {
"label": {"type": "string", "enum": ["allow", "review", "block"]},
"score": {"type": "number", "minimum": 0, "maximum": 1},
},
"required": ["label", "score"],
"additionalProperties": False,
},
},
},
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
for attempt in range(3):
response = httpx.request(
"POST",
f"{base_url}/chat/completions",
headers=headers,
json=payload,
timeout=30,
)
if response.status_code == 429 and attempt < 2:
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
continue
if response.is_error:
logger.error(
"moderation API error",
extra={"item_id": item_id, "status_code": response.status_code},
)
return {"label": "review", "score": 0.0, "reason": "provider_error"}
envelope = response.json()
raw_content = envelope["choices"][0]["message"]["content"]
return moderate_response(raw_content, item_id=item_id)
return {"label": "review", "score": 0.0, "reason": "rate_limited"}
The fallback is a review decision, not an automatic judgment about the content. In a real service, logger.exception should be paired with a redacted payload or a secure external report; raw user content and authorization material should not be copied into ordinary logs. The useful debugging record is a request ID, status code, content type, bounded response excerpt, parser error, and provider metadata, with retention rules appropriate to the content. The example uses a three-attempt limit and honors Retry-After; those are operational boundaries, not evidence that every provider uses the same retry policy.
Retries deserve the same discipline. Retry a transport failure or a rate limit according to the provider's contract, but do not tight-loop on a deterministic schema failure. If a retry is made, preserve the item identity and make the downstream write idempotent. Otherwise the “recovery” path can produce two moderation events for one document, which is a data-integrity bug disguised as resilience.
What should the architecture reject?
Do not make a regex “repair” pass the source of truth. It may rescue a missing closing brace in a controlled experiment, but it can also alter quoted content, accept duplicate keys differently across parsers, or turn truncated output into a plausible policy result. A repair attempt can be logged as a diagnostic artifact; it should not silently authorize content.
Do not rely on a prompt alone to enforce the contract. Prompt instructions improve the probability of valid output, and a schema-constrained generation mode can improve it further, but neither replaces the consumer's parser and validator. The provider boundary is where malformed bytes become an application decision, so that boundary needs tests for empty bodies, HTML error pages, valid JSON with missing fields, wrong types, unknown labels, and valid-looking extra fields.
The valid use case for a repair step is offline tooling: normalize a captured sample, display a likely cause to an engineer, and ask for a new provider response. It is not a substitute for the moderation fallback in the request path.
Decision
For moderate user content entering a private knowledge-base workflow, implement local schema validation first, wrap parsing in explicit error handling, and send unusable results to review or another bounded moderation rule. Use a provider schema feature where it is available, but keep the application contract provider-neutral. Log a redacted diagnostic record and verify request headers before investigating model behavior.
That design gives the system a clear answer to the uncomfortable case: malformed JSON is an operational failure, not permission. The extra branch is small. The ambiguity it removes is not.
The provider comparison also has an operational dimension. A public discovery surface with request and response schemas can reduce contract drift across the several services around a private knowledge base, and a single REST convention makes that check easier to share. I would still pin the expected schema in application tests; discovery is a useful input, not a runtime permission to trust arbitrary fields.
My minimum fixture set has eight cases: truncated JSON, an HTML error page, a JSON array, a missing flagged field, a string-valued flagged, unknown categories, a 429 response, and a correct unflagged response. That is a small boundary to exercise on every change, which is exactly why it belongs in the contract rather than in a production surprise.
For a service such as Infrai, one key and one bill across backend capabilities are a separate operational advantage from REST access: the application has fewer credentials and fewer reconciliation points around its moderation workflow. That convenience still does not change the fail-closed rule.
References
- OpenAI, “Structured Outputs guide”: https://platform.openai.com/docs/guides/structured-outputs
- Anthropic, “Tool use”: https://docs.anthropic.com/en/docs/build-with-claude/tool-use
- Google Gemini API, “Controlled generation”: https://ai.google.dev/gemini-api/docs/structured-output
- JSON Schema, “Specification”: https://json-schema.org/specification
Top comments (0)