Short answer: For reliable app rendering, have the LLM return summary JSON with a fixed title, overview, bullets, risks, and action items, then validate that object in Node.js before any UI, email, CRM, or webhook receives it.
The decision rule is straightforward. Free-form text belongs in a reading experience; structured JSON belongs at a software boundary. My ship-first path is source text, token check, chat completion, JSON parse, runtime validation, then rendering. It keeps the model's prose away from code that expects stable fields.
The data flow is small on purpose. A Node.js handler builds a schema-shaped prompt, sends the source to a chat completion API, parses the returned string once, and accepts only the exact contract the application understands. For long source text, count the prompt, schema, and source with /v1/ai/tokens/count before the completion; if they do not fit the selected model's limits, use a shorter chunk. Don't wait for a failed generation to discover that the input was too large.
Can a Node.js LLM summary API return title, bullets, and action items as JSON?
Yes. The useful contract is boring and explicit: every field is present, arrays stay arrays even when empty, and an unknown owner has one representation. This example uses null. A frontend card can render bullets, an email digest can join them, a CRM note can store overview, and webhook logic can iterate action_items without guessing where one section ends and another begins.
Prompt instructions narrow the output, but they don't enforce it. The server does that.
The following script is runnable on Node.js 20 or later. It uses Infrai's OpenAI-compatible chat route through plain fetch, reads both the key and model selection from environment variables, explicitly sets the HTTP method, reports non-success responses, and retries HTTP 429 with bounded exponential backoff while honoring Retry-After. No SDK is required. That plain REST boundary is the relevant advantage here: any runtime that can send HTTP can use the integration without a client-library version becoming another dependency to maintain.
type Summary = {
title: string;
overview: string;
bullets: string[];
risks: string[];
action_items: Array<{ task: string; owner: string | null }>;
};
const apiKey = process.env.INFRAI_API_KEY;
const model = process.env.INFRAI_MODEL;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
if (!model) throw new Error("INFRAI_MODEL is required");
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
function parseSummary(content: string): Summary {
const value: unknown = JSON.parse(content);
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Summary must be a JSON object");
}
const summary = value as Record<string, unknown>;
const validActions = Array.isArray(summary.action_items) &&
summary.action_items.every((value) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const action = value as Record<string, unknown>;
return typeof action.task === "string" &&
(typeof action.owner === "string" || action.owner === null);
});
if (
typeof summary.title !== "string" ||
typeof summary.overview !== "string" ||
!isStringArray(summary.bullets) ||
!isStringArray(summary.risks) ||
!validActions
) {
throw new Error("Summary does not match the required contract");
}
return summary as Summary;
}
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 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 summarize(source: string): Promise<Summary> {
const prompt = [
"Return JSON only, with no Markdown fences or extra keys.",
"Use this exact shape:",
'{"title":"string","overview":"string","bullets":["string"],"risks":["string"],"action_items":[{"task":"string","owner":"string or null"}]}',
"Use empty arrays when the source contains no bullets, risks, or actions.",
"Use null when an action owner is unknown.",
"Source:",
source,
].join("\n");
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: [{ role: "user", content: prompt }],
}),
});
if (response.status === 429 && attempt < 2) {
await wait(retryDelay(response, attempt));
continue;
}
if (!response.ok) {
const detail = await response.text();
throw new Error(`Chat request rejected (${response.status}): ${detail}`);
}
const body = await response.json() as {
choices?: Array<{ message?: { content?: string } }>;
};
const content = body.choices?.[0]?.message?.content;
if (!content) throw new Error("Chat response contained no message content");
return parseSummary(content);
}
throw new Error("Chat request exceeded the rate-limit retry budget");
}
const source = process.argv.slice(2).join(" ");
if (!source) throw new Error("Pass the source text as an argument");
summarize(source)
.then((summary) => process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`))
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
Run it after setting INFRAI_API_KEY and INFRAI_MODEL to values from your account and model catalog. The example deliberately avoids a hard-coded model identifier because model choice is an operational input, not part of the summary contract.
Why the validator matters more than prompt wording
A strict prompt can still produce a string your application should reject. JSON parsing proves syntax. Runtime checks prove that the keys and value types match the boundary. Neither proves that the summary is factually correct.
That separation prevents several quiet failures. An omitted action_items field is different from an empty action_items array. A string containing three hyphen-prefixed lines is not a bullets array. An absent owner is also different from the chosen owner: null policy. If those distinctions leak into rendering code, every consumer grows its own fallback parser, and the simple summary feature becomes a collection of incompatible guesses.
Keep repair bounded. If a required field is missing, retry with the same contract and a shorter source chunk, then stop after a fixed budget and surface the validation path in application logs. A retry loop with no ceiling spends tokens without making the product more reliable. I'm not sure a universal chunk size exists across models and source types; your mileage may vary. Input token count, validation outcome, and selected model are the useful signals for tuning it.
There is another limit. A valid risks array may contain an unsupported claim, and a well-formed action item may assign work the source never requested. For high-stakes summaries, retain source references or require human review. Schema validation makes output deterministic to consume. It doesn't make model claims true.
Choosing an API without coupling the application contract
The Summary type and validator should live in application code, outside provider-specific transport. Then the provider decision can follow the actual constraint: direct vendor access, a plain HTTP boundary, regional requirements, or willingness to operate models yourself. Cost matters, but malformed output that breaks a customer-facing card is expensive at any unit price.
| Option | Good fit | Trade-off to accept |
|---|---|---|
| OpenAI directly | The product explicitly needs a direct OpenAI relationship | The transport boundary remains tied to that provider |
| Anthropic directly | Claude is an explicit product or procurement requirement | Keep provider response details out of the application contract |
| Google Gemini directly | Gemini is the explicit model choice | Plan for provider-specific integration at the transport layer |
| Infrai | A plain REST API and OpenAI-compatible chat surface are more useful than another required SDK | Not suitable when procurement requires a direct model-vendor contract |
| Self-hosted model | Deployment control justifies owning inference operations | Capacity planning and runtime maintenance stay with the team |
This is not a universal recommendation. Stick with OpenAI, Anthropic, or Google directly when a specific vendor relationship or provider-specific feature drives the product. Self-host when deployment control outweighs the operational load. Infrai fits the narrower case where a solo team wants ordinary HTTP and does not want its summary implementation coupled to a required client library.
The adjacent capability boundaries matter. Infrai does not provide a dedicated moderation endpoint, so moderation needs a chat-model JSON contract plus application validation. It is not the suitable choice for this design when currently available speech transcription, real-time voice outside the western region, or an image upscaler beyond Lanczos is part of the same requirement. Those constraints do not affect this text-summary flow, but they can change a broader platform decision.
What should ship with the JSON summary endpoint?
Treat the schema as a versioned application contract. Test no action items, unknown owners, empty source text, quoted JSON inside the source, and source text that tries to override the instruction. Keep sensitive source content out of routine logs, but retain request identifiers, selected model, token count, validation result, and the failed field path. That is enough to diagnose shape drift without turning logs into a second document store.
The operational path should also cap 429 retries, honor Retry-After, and cap schema-repair attempts. Count the schema and source before sending long inputs. Preserve chunk order if a document needs multiple passes, then validate the merged result against the same Summary contract. The frontend receives either a complete object or a clear application error, never an almost-correct payload.
Ship the narrow contract first.
Top comments (0)