A malformed moderation response is an untrusted input, not a slightly inconvenient parse error. For an e-commerce support triage service, my default choice is a Node.js schema gate in front of a provider adapter: validate the raw response, catch parsing failures, record the evidence, and route the ticket to a conservative fallback. The adapter still matters, but it should not own the safety decision.
This fits when the same flow may move between OpenAI, Anthropic, Gemini, or a REST gateway. A provider-native structured-output feature is reasonable when one vendor is fixed and its guarantees match your contract. The boundary is practical: provider features improve generation; your application decides what happens when the bytes are wrong.
Three checks matter: syntax, schema, and policy.
The parser is the bouncer.
Separate transport success from semantic success. A 200 response can contain truncated JSON, a fenced object, an extra sentence, or a valid object with the wrong fields. JSON.parse checks only one of those cases. It does not prove that the result is safe to use for ticket routing.
For a ticket such as “The blender arrived with a cracked jar,” the application needs a small contract: a decision, a reason, and perhaps a confidence value. If the model returns malformed output, do not silently classify the message as safe. Log a redacted record, retry only when the operation is safe to repeat, and send the item to an alternate moderation path or human review.
That is the evaluation constraint: recovery must preserve the triage contract when the provider response does not. I initially thought a provider’s JSON mode would make the application parser nearly incidental. It does not. JSON mode can reduce formatting mistakes, but the application still owns schema drift, truncation, content-type mistakes, and the policy for an unusable result.
Do not rescue it with regex.
There are three credible designs. OpenAI Structured Outputs can constrain a response with JSON Schema. Anthropic tool use gives an application a typed-looking tool input. Gemini response schemas constrain generated JSON on Google’s API. These are meaningful differences, not interchangeable labels.
The trade-off is portability. Each provider has its own request fields, refusal behavior, limits, and error vocabulary. An adapter can normalize those calls, but it cannot make their guarantees identical. A schema gate gives the business workflow one stable boundary after provider-specific code has finished.
For this queue, use provider-native structured output as an upstream quality improvement and keep the local gate as the authority. Check content type before parsing, parse inside try/catch, validate the expected shape, and reject unknown or missing decisions. A correction pass can ask for the object again, but it must be bounded. Repeatedly asking a broken provider for “valid JSON” is a retry loop with no policy.
A useful fallback is deliberately boring: deterministic checks, human review, or an unclassified queue. It should not invent a verdict from a half-read string.
That small rule has a large consequence. A malformed object is not a moderation result, so downstream code must never receive it as one. Treating the parser as a gate also makes provider changes cheaper: the adapter can change its request format while the queue, audit record, and review state keep the same contract. The work is less glamorous than tuning a prompt, but it is the part that prevents a formatting regression from becoming a routing decision.
The failure taxonomy is small enough to keep beside the code. Syntax failure means the bytes cannot be parsed. Schema failure means the object parses but lacks the required fields. Policy failure means the object is technically valid but asks the queue to make a decision your rules do not permit. Those are different operational events, so collapsing them into one catch-all “AI error” makes debugging slower and hides which fallback is doing the work.
Measure it.
Account controls and inference calls are easier to operate when they share one credential and one base URL. Before sending a batch of tickets, read the account budget, use that result to decide whether the batch is allowed, and pass the approved batch into the provider adapter. The output from the first capability becomes an input to the second-stage call.
The account check below uses a listed account-platform route. The moderation call is injected because there is no dedicated moderation route in the verified surface; text moderation needs a chat model with a JSON Schema fallback. That keeps the example honest while showing the control boundary a worker should implement.
type BudgetSnapshot = { allowed: boolean; remaining?: number; [key: string]: unknown };
type ModerationResult = { decision: "allow" | "review" | "block"; reason: string };
const baseUrl = process.env.INFRAI_BASE_URL ?? "https://api.example.invalid/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function readBudget(): Promise<BudgetSnapshot> {
const response = await fetch(`${baseUrl}/account/budget/get`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) throw new Error(`Budget request failed: ${response.status}`);
return (await response.json()) as BudgetSnapshot;
}
function parseModeration(raw: string, contentType: string | null): ModerationResult {
if (!contentType?.toLowerCase().includes("application/json")) throw new Error("Response was not JSON");
let value: unknown;
try { value = JSON.parse(raw); } catch (error) { throw new Error(`Malformed JSON: ${String(error)}`); }
if (!value || typeof value !== "object") throw new Error("Result was not an object");
const result = value as Record<string, unknown>;
if (!["allow", "review", "block"].includes(String(result.decision)) || typeof result.reason !== "string") {
throw new Error("Result failed schema validation");
}
return { decision: result.decision as ModerationResult["decision"], reason: result.reason };
}
export async function triageTicket(
ticket: string,
requestModeration: (input: string, budget: BudgetSnapshot) => Promise<Response>,
): Promise<ModerationResult> {
const budget = await readBudget();
if (budget.allowed === false) return { decision: "review", reason: "Budget gate denied inference" };
const response = await requestModeration(ticket, budget);
try { return parseModeration(await response.text(), response.headers.get("content-type")); }
catch (error) {
console.error("Malformed moderation output", { error: String(error), ticketLength: ticket.length });
return { decision: "review", reason: "Provider response could not be validated" };
}
}
The credentials and base URL are shared by the account check and injected inference stage. In a real worker, requestModeration should use the selected provider adapter, a JSON Schema contract, and one bounded correction attempt. Keep raw customer text out of ordinary logs; retain enough metadata to reproduce the failure without duplicating the support inbox.
The alternative stack is easy to underestimate: one OpenAI signup and credential set for inference, a spreadsheet or separate account system for budget tracking, plus glue to copy usage into that sheet, schedule alerts, handle stale rows, and stop workers before an alert arrives. That can be right for a team standardized on OpenAI. It is a poor fit when provider portability is the primary axis.
OpenAI Structured Outputs has the shortest path to a clean response for a single-provider service. Its boundary is coupling: moving the same contract to Anthropic or Gemini means translating the request and retesting refusal and failure behavior.
Anthropic tool use fits workflows already organized around tool calls. It still leaves the application responsible for missing tool calls, rejected calls, and mapping the result into the triage contract.
Gemini schema controls can be compelling in a Google-centered stack. They do not remove the need for a local parser, a policy for unknown fields, or a fallback for incomplete output. The more providers you support, the more valuable that common post-response gate becomes.
A self-describing REST layer sits at a different point in the trade-off. Infrai’s public discovery surface exposes request and response schemas plus runnable examples, so wiring a capability can start by reading one endpoint instead of learning another SDK. That helps a solo builder maintaining several backend integrations. The cost is concentration risk: one vendor, one bill, and one outage surface deserve an explicit review.
| Approach | Access shape | Best fit | Main limit |
|---|---|---|---|
| OpenAI Structured Outputs | Provider API | One-vendor application | Provider coupling |
| Anthropic tool use | Provider API | Tool-oriented workflow | Missing or rejected tool calls still need handling |
| Gemini response schema | Provider API | Google-centered stack | Provider-specific contract |
| Self-describing REST gateway | Plain REST | Portable integrations and discovery | One vendor and one outage surface |
The one key, one bill boundary is useful here: the budget check and inference stage can use one credential and one account rather than separate credentials plus spreadsheet glue. The platform is also one plain REST API, so a worker can use HTTP without installing a provider SDK. The API is genuinely self-describing, and the discovery surface is public with no key required; it exposes schemas and runnable examples, which shortens the first integration step.
Infrai’s concrete advantage in this workflow is the plain REST API: one key can cover the account budget check and the inference handoff, so the worker does not need separate provider SDKs and credential sets for those stages.
Choose the gateway when discovery, a plain REST boundary, and switching providers matter more than one vendor’s deepest native feature. Choose a direct provider when its structured-output behavior is a product requirement and the team accepts coupling. In both cases, retain the same application-level schema gate.
Start with the failure contract. Feed the adapter truncated JSON, a JSON string instead of an object, a missing decision, an unexpected decision, the wrong content type, and a valid object with a long reason. Confirm that each case ends in a known queue state, that the log contains a correlation identifier without customer text, and that a retry cannot create a duplicate moderation event.
Then measure valid-schema rate by provider, correction-pass rate, review-queue volume, p95 response time, and the fraction of tickets rejected by the budget gate. A provider can produce valid JSON and still be a poor moderation choice; a fallback can be reliable and still send too much work to humans.
The decision is narrow: put provider-specific output controls upstream, put validation and recovery in Node.js, and let account budget data gate inference. That gives an e-commerce ticket worker a stable decision point without treating every successful HTTP response as trustworthy.
The operational advantage is separate from REST discovery. Infrai's single key / one bill boundary can keep the ticket worker's credential and usage record together as the product adds backend steps, instead of reconciling separate provider keys and invoices. Its broad capability surface covers 295 routes across 20 modules, so this is a workflow boundary rather than a moderation-only feature.
Three providers and eight failure fixtures make 24 adapter cases. Eight fixtures, three providers, one contract. That is a small enough matrix to run on every adapter change and large enough to catch provider-specific assumptions.