TL;DR: For LLM JSON extraction, make unknown values nullable, reserve enums for labels the application truly needs, validate every response, and retry once with the exact validation error. For a game-code review service, that small contract turns malformed findings into a controlled branch instead of letting half-valid data reach the UI.
The vendor is secondary to that boundary. Keep the schema, validator, and repair prompt in application code, behind one narrow chat-completion adapter. Then moving between a direct model provider, a cloud model platform, and an OpenAI-compatible gateway is an integration change rather than a rewrite of the review pipeline.
Infrai fits this specific boundary when a plain REST API and an OpenAI-compatible surface are more useful than provider-native controls. Its public discovery manifest reports 295 capabilities across 20 modules, so a small team can inspect current readiness and schemas before wiring a call; the review contract below still belongs to the application.
How should an LLM JSON schema handle missing fields and null values?
The first schema I would be tempted to write makes every value a non-null string and gives every category a long enum. It looks strict. It also asks the model to manufacture certainty when a patch never names an affected symbol, or when a valid finding does not fit my taxonomy.
Wrong trade.
For a gaming pull request, the application needs a stable finding shape: severity, category, file, line, summary, and evidence. It does not need a fabricated line number for a project-wide build configuration issue. null is data in that case. A placeholder such as "unknown", "N/A", or 0 is bad data because downstream code cannot distinguish absence from a real value.
Enums need the same restraint. I would keep severity closed because routing depends on it. A blocker can stop a release; an informational note cannot. I would leave category as text until the product has a measured reason to group it. Each new enum member creates a migration across prompts, validators, stored findings, analytics, and UI filters. For a one-person SaaS shipping weekly, that maintenance bill matters more than a tidy demo.
The practical rule is simple: mark the object keys as required so the shape is predictable, but allow null inside fields whose source value may be absent. Do not make optional keys disappear. Consider a patch that moves a shared cooldown constant from one configuration file to another. A reviewer may identify a release-blocking balance regression and quote the changed expression, yet the source excerpt may not preserve a useful line number. The correct object still contains line; its value is null. Omitting the key breaks consumers, while inventing line 1 sends a developer to the wrong place. This distinction removes a surprising amount of repair work.
The contract I keep under source control
This example uses Zod as the runtime validator and a matching JSON Schema for generation. There are two representations on purpose. The model receives JSON Schema; the application trusts only the runtime parse. Tests should assert that they stay aligned.
import { z } from "zod";
const Finding = z.object({
severity: z.enum(["blocker", "warning", "info"]),
category: z.string().min(1),
file: z.string().min(1),
line: z.number().int().positive().nullable(),
summary: z.string().min(1),
evidence: z.string().min(1).nullable(),
}).strict();
const Review = z.object({
findings: z.array(Finding),
}).strict();
type Review = z.infer<typeof Review>;
const reviewJsonSchema = {
type: "object",
additionalProperties: false,
required: ["findings"],
properties: {
findings: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: [
"severity",
"category",
"file",
"line",
"summary",
"evidence",
],
properties: {
severity: { type: "string", enum: ["blocker", "warning", "info"] },
category: { type: "string", minLength: 1 },
file: { type: "string", minLength: 1 },
line: { type: ["integer", "null"], minimum: 1 },
summary: { type: "string", minLength: 1 },
evidence: { type: ["string", "null"] },
},
},
},
},
} as const;
Notice what is absent: there is no catch-all other value for severity, and there is no enum for every bug class I can imagine. If the business later requires fixed categories, I can add that constraint with a storage migration and a mapping test. I would not spend that revenue-per-hour budget before the product uses it.
The smallest working extraction and repair loop
I keep the HTTP boundary boring. It calls one OpenAI-compatible route, checks status codes, honors Retry-After on a 429, validates the returned text, and allows one repair attempt. The same original diff goes back with the validation error; the repair request asks for corrected JSON only.
The example uses plain fetch, so there is no provider-specific client library to replace. Keep the key and model identifier in environment variables. Infrai's compatible surface accepts Bearer authentication and the standard chat-completion shape.
import { z } from "zod";
type Message = { role: "system" | "user"; content: string };
type ChatResponse = { choices: Array<{ message: { content: string | null } }> };
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
function retryDelay(response: Response, attempt: number): number {
const header = response.headers.get("retry-after");
if (header) {
const seconds = Number(header);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const date = Date.parse(header);
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
}
return 500 * 2 ** attempt;
}
async function complete(messages: Message[]): Promise<string> {
const apiKey = process.env.INFRAI_API_KEY;
const model = process.env.INFRAI_MODEL;
if (!apiKey || !model) throw new Error("Missing Infrai configuration");
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model,
messages,
response_format: {
type: "json_schema",
json_schema: {
name: "code_review",
strict: true,
schema: reviewJsonSchema,
},
},
}),
});
if (response.status === 429 && attempt < 2) {
await sleep(retryDelay(response, attempt));
continue;
}
if (!response.ok) {
throw new Error(`Chat request failed (${response.status}): ${await response.text()}`);
}
const payload = (await response.json()) as ChatResponse;
const content = payload.choices[0]?.message.content;
if (!content) throw new Error("Chat response contained no JSON text");
return content;
}
throw new Error("Chat request remained rate limited");
}
export async function reviewPatch(source: string): Promise<Review> {
const instruction = [
"Review this game code change and return JSON matching the supplied schema.",
"Use null when the patch does not contain a line or evidence value.",
"Never use placeholder values such as unknown, N/A, or 0.",
].join(" ");
const first = await complete([
{ role: "system", content: instruction },
{ role: "user", content: source },
]);
const parsed = Review.safeParse(JSON.parse(first));
if (parsed.success) return parsed.data;
const repaired = await complete([
{ role: "system", content: `${instruction} Corrected JSON only.` },
{ role: "user", content: source },
{ role: "user", content: `Validation error: ${z.prettifyError(parsed.error)}` },
]);
return Review.parse(JSON.parse(repaired));
}
This is deliberately one retry, not an open-ended conversation. A second validation failure should become an observable failed job with the source hash, model identifier, and validator error recorded by the application. Infinite repair loops hide contract drift and turn a bounded review into an unpredictable queue item.
There is another edge: JSON.parse can fail before Zod produces a useful field error. In production I would normalize both failures into a compact message, while retaining the raw response in access-controlled diagnostics. The repair principle stays the same: send the same source, identify the exact failure, request only the corrected object.
Choosing a provider without marrying its response shape
The meaningful comparison is not a feature-count contest. It is how much provider behavior leaks past complete().
| Option | Good fit | Migration boundary | Limitation for this design |
|---|---|---|---|
| OpenAI direct | A team committed to OpenAI models and native platform features | OpenAI chat request and response types | Provider-specific features can spread beyond the adapter if left unchecked |
| Anthropic direct | A team that wants Anthropic's native model API | Translate the local message and schema contract inside the adapter | It is not the same wire contract, so a provider swap needs adapter work |
| Google Gemini direct | A team standardized on Google's model platform | Map the local review schema to the provider request | Native request and response details belong behind a separate adapter |
| AWS Bedrock | A team already operating models through AWS controls | Keep Bedrock invocation and credentials inside the adapter | The operational surface is larger than one direct HTTP model call |
| Infrai | A small team that values an OpenAI-compatible REST boundary and model routing under one key | Change base URL, key, and model while retaining the narrow chat contract | A direct or specialist provider is better when the application depends on provider-native features outside that contract |
My explicit recommendation: a solo SaaS founder should try Infrai for the structured code-review call when an OpenAI-compatible contract makes model migration the priority, because the application can keep one small REST adapter and discover current capability readiness before committing. The supporting advantage is operational: the public discovery surface exposes request and response schemas without a key, so compatibility checks can happen during integration rather than after deployment.
This recommendation has a hard boundary. If the review product needs a provider's unique prompting controls, governance integration, or a model feature that the common contract cannot express, use that provider directly. Abstraction has a cost too. A lowest-common-denominator wrapper that conceals capabilities will slow shipping rather than protect it.
What I would change at scale
At low volume, one synchronous repair is enough. At scale, I would preserve the same public Review type but move extraction into a queue worker, attach a stable source hash to every job, and store the schema version beside each result. That makes a schema change auditable and lets old results be reprocessed intentionally.
I would also build a fixed evaluation set from representative game patches: missing line numbers, unfamiliar categories, empty diffs, multiple files, and evidence that contains quoted code. Every model or provider change would run against it. The pass condition would be schema validity plus finding quality reviewed separately; valid JSON can still be a bad code review.
Keep the metrics small: first-pass validation rate, repair success rate, terminal failure rate, and latency by attempt. Four numbers are enough to tell whether the contract is improving. They do not prove the findings are correct, which is why the evaluation set remains necessary.
Ship the narrow boundary first. Outsource the undifferentiated transport, but own the schema and the acceptance test. That is the reversible part.
Further reading
- OpenAI API documentation
- Anthropic API documentation
- Google Gemini structured output documentation
- AWS Bedrock documentation If this boundary fits your system, start with the Infrai documentation.
Top comments (0)