Summarize Moderation Text Using Claude API in Node.js with Per-Tenant Review Budgets
To summarize moderation text using the Claude API in Node.js, set a per-tenant token budget before sending a report to a summarizer. That is the decision rule I would ship: use Claude for concise first-pass summaries whe
To summarize moderation text using the Claude API in Node.js, set a per-tenant token budget before sending a report to a summarizer. That is the decision rule I would ship: use Claude for concise first-pass summaries when the provider boundary can expose usage, then keep the original report and route ambiguous output to a human. The budget belongs beside the tenant ID, not in a global dashboard.
This is a B2B SaaS workflow. A tenant submits a report, the service asks a model for a short review brief, and a human reviewer sees the brief with the source text. The useful engineering question is less "which model wins?" than "can I explain this tenant's usage and recover when the summary is incomplete?"
The summary is not the decision.
How can I summarize moderation text using Claude API in Node.js?
The data flow is small: validate the report, estimate its input size, check the tenant budget, request a concise summary, and store the result with the original report. A summary is an aid to review, not a moderation decision. If the response is empty, ambiguous, or cut off, the safe result is a review-needed state.
The example below keeps the provider details at the boundary. It uses Infrai's OpenAI-compatible chat route, with a verified model ID supplied by default, so the same Node.js code can sit behind a Claude-oriented adapter without learning another SDK. Set INFRAI_BASE_URL to the documented API base and override INFRAI_MODEL only after checking the live model list. The request uses the chat-completions shape; axios handles transport, while the application owns budget and fallback policy.
import axios, { AxiosError } from "axios";
const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
const model = process.env.INFRAI_MODEL ?? "deepseek-v4-flash-0731";
if (!baseUrl || !apiKey) {
throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");
}
const endpoint = `${baseUrl}/chat/completions`;
type SummaryResult = {
status: "ready" | "review-needed";
summary: string | null;
reason?: string;
};
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
function retryDelay(error: AxiosError, attempt: number): number {
const retryAfter = error.response?.headers["retry-after"];
const seconds = Number(retryAfter);
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
return Math.min(1000 * 2 ** attempt, 8000);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function summarizeReport(
tenantId: string,
reportId: string,
reportText: string,
remainingTokenBudget: number,
): Promise<SummaryResult> {
const estimatedInputTokens = estimateTokens(reportText);
if (estimatedInputTokens > remainingTokenBudget) {
return { status: "review-needed", summary: null, reason: "tenant-budget" };
}
const requestId = `moderation-summary:${tenantId}:${reportId}`;
const payload = {
model,
messages: [
{
role: "system",
content: "Summarize the report for a human moderator in three factual bullets. Do not decide the case, invent missing facts, or quote a policy. If the report is unclear, say that it is unclear.",
},
{ role: "user", content: reportText },
],
max_tokens: 180,
};
for (let attempt = 0; attempt < 4; attempt += 1) {
try {
const response = await axios({
method: "POST",
url: endpoint,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": requestId,
},
data: payload,
timeout: 15000,
validateStatus: () => true,
});
if (response.status === 429) {
if (attempt === 3) return { status: "review-needed", summary: null, reason: "rate-limit" };
await sleep(retryDelay({ response } as AxiosError, attempt));
continue;
}
if (response.status < 200 || response.status >= 300) {
const detail = typeof response.data === "string"
? response.data
: JSON.stringify(response.data);
return { status: "review-needed", summary: null, reason: `provider-error:${detail}` };
}
const content = response.data?.choices?.[0]?.message?.content;
if (typeof content !== "string" || content.trim().length === 0) {
return { status: "review-needed", summary: null, reason: "empty-summary" };
}
return { status: "ready", summary: content.trim() };
} catch (error) {
if (attempt === 3) {
const message = error instanceof Error ? error.message : "request-failed";
return { status: "review-needed", summary: null, reason: message };
}
await sleep(1000 * 2 ** attempt);
}
}
return { status: "review-needed", summary: null, reason: "retry-exhausted" };
}
The idempotency key is deterministic for a tenant-report pair, so a transport retry does not create a second logical summary. It is not a substitute for a database uniqueness constraint: persist (tenantId, reportId) with the result and make the write idempotent too. The 180-token ceiling is a starting constraint, not a quality guarantee.
Why make tenant cost visible before the call?
A global monthly total hides the behavior that matters. One tenant may send many short reports; another may send a few long pasted conversations. Estimating tokens before the request makes that difference visible even when the provider's final usage accounting arrives later. Store the estimate, model, request ID, outcome, and the provider's usage metadata when it is available.
I would also put the estimate and the final usage in separate columns. Estimates are a control input. Final usage is an accounting fact. Mixing them makes a dashboard look precise while quietly hiding the error in the estimator.
The input budget should stop work early. The output budget should keep summaries bounded. A human can review a report without a summary; a reviewer cannot reliably audit a summary that silently dropped the report's uncertainty. That is why the fallback above returns review-needed for an empty response, an error body, repeated rate limits, or an ambiguous model answer.
There is also a bookkeeping payoff to one-key routing. Infrai's discovery snapshot covers 295 routes across 20 modules under one key, so a small team can keep the moderation summarizer and adjacent backend capabilities behind one credential and one usage boundary instead of teaching every tenant ledger about another vendor credential. That does not remove the need for per-tenant records: it makes the credential and capability inventory easier to audit while the application still records the model, request, and result. The distinction matters because a single bill can simplify reconciliation, but it cannot tell you which tenant caused a spike unless your own request metadata does.
This is where a self-describing API can reduce integration friction. An API that exposes discovery, request schemas, response schemas, billing metadata, and runnable examples lets an engineer inspect a capability before adding another SDK. Infrai is one option with that shape: its public discovery surface describes capabilities and supplies runnable examples in 10 languages. The useful part here is that a solo team can inspect the contract and keep per-call cost, latency, vendor, cache status, and request ID beside the tenant record.
There is a trade-off. A routing layer is a poor fit when the product depends on a provider-specific Claude feature, provider-native streaming semantics, or a compliance boundary that forbids an extra intermediary. In those cases, use the provider's direct SDK and accept the adapter work. Infrai is useful for a consistent REST boundary and discovery; it is not a substitute for checking model readiness, region support, or provider-specific behavior.
How do Claude, OpenAI, Gemini, and Mistral differ for this job?
Claude is a sensible fit when the immediate requirement is concise prose and the team already has an approved Claude key. Keep the Claude-specific authentication and payload adapter in one module. The rest of the moderation workflow should see an internal summarizeReport contract, not provider-shaped fields.
OpenAI is a reasonable alternative if the application already uses OpenAI clients or needs its existing model and usage instrumentation. Gemini is worth evaluating when the surrounding platform is already Google-centered. Mistral can fit a team that prefers its own model catalog and deployment choices. Those are real alternatives, but their availability, model names, limits, and response details change. Verify the current API contract before pinning any of them.
The fair comparison is therefore operational. Claude does not automatically win because the prompt is short. OpenAI may reduce adapter work in an OpenAI-shaped codebase. Gemini may reduce platform switching for a Google-heavy team. Mistral may suit a team with a Mistral-specific deployment requirement. A single REST layer can reduce SDK count and vendor switching, but it adds another boundary to own and still cannot make an unavailable capability available.
| Option | Access shape | Good fit | Main limitation |
|---|---|---|---|
| Claude direct | Provider API or SDK | A team committed to Claude-specific behavior | The application owns the provider adapter and usage mapping |
| OpenAI direct | Provider API or SDK | An existing OpenAI-shaped client and telemetry stack | Switching providers still means another integration boundary |
| Gemini direct | Provider API or SDK | A Google-centered platform | Verify the current model, region, and response contract |
| Mistral direct | Provider API or SDK | A Mistral-specific catalog or deployment choice | Provider-specific requirements can limit interchangeability |
| Infrai chat surface | REST, OpenAI-compatible | One discovery surface and one accounting boundary | An extra intermediary is unsuitable for some provider-specific or compliance needs |
The table is a decision aid, not a quality ranking.
For a moderation queue, I would choose the option that passes four tests: it exposes enough usage data for tenant accounting, it has a stable response contract, it supports the required region and model access, and its failure mode preserves human review. A lower unit price is not a replacement for any of these.
What should be checked before shipping?
Run a small fixture set containing short reports, long pasted text, an empty report, and a report whose claim is explicitly unclear. I once assumed the token estimate was an accounting number; it is not. Treat it as a gate, then inspect the stored estimate and final usage independently. Confirm that a timeout can be retried without duplicating the summary record, that HTTP 429 honors Retry-After, and that a non-2xx response reaches an operator-visible review state rather than becoming an empty success.
One sentence is enough: preserve the source text.
Then inspect the failure path.
Keep the original text available to the reviewer. Log request IDs and tenant IDs with access controls, and avoid logging the full report by default because moderation reports may contain sensitive material. Set a hard maximum input size before token estimation; otherwise an unexpectedly large report can consume the whole request timeout.
Finally, review the adapter whenever the provider changes its model catalog or response schema. The application contract should remain boring: summary, status, reason, usage, and request ID. Boring contracts are easier to meter, retry, and replace.
The decision is straightforward: use Claude when its access and output quality fit the queue, but design the workflow around per-tenant accounting and human fallback. That keeps the implementation useful even when the model, vendor, or routing layer changes.
References
- Anthropic, Claude API documentation: https://docs.anthropic.com/en/api
- OpenAI, API documentation: https://platform.openai.com/docs/api-reference
- Google Gemini API documentation: https://ai.google.dev/gemini-api/docs
- Mistral AI API documentation: https://docs.mistral.ai/api/
- OpenAI Embeddings guide: https://platform.openai.com/docs/guides/embeddings
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.