{"slug": "summarize-moderation-text-using-claude-api-in-node-js-with-per-tenant-review", "title": "Summarize Moderation Text Using Claude API in Node.js with Per-Tenant Review Budgets", "summary": "A developer outlined a Node.js pattern for summarizing moderation reports via an OpenAI-compatible chat-completions endpoint, enforcing per-tenant token budgets before each request and falling back to a 'review-needed' state when summaries are empty, ambiguous, or truncated. The approach keeps provider details at the boundary, uses idempotency keys per tenant and report, and retries rate-limited calls with exponential backoff. The summary is treated as an aid to human review rather than a moderation decision.", "body_md": "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.\n\nThis 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?\"\n\nThe summary is not the decision.\n\nThe 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.\n\nThe 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.\n\n``` python\nimport axios, { AxiosError } from \"axios\";\n\nconst baseUrl = process.env.INFRAI_BASE_URL;\nconst apiKey = process.env.INFRAI_API_KEY;\nconst model = process.env.INFRAI_MODEL ?? \"deepseek-v4-flash-0731\";\n\nif (!baseUrl || !apiKey) {\n  throw new Error(\"INFRAI_BASE_URL and INFRAI_API_KEY are required\");\n}\n\nconst endpoint = `${baseUrl}/chat/completions`;\n\ntype SummaryResult = {\n  status: \"ready\" | \"review-needed\";\n  summary: string | null;\n  reason?: string;\n};\n\nfunction estimateTokens(text: string): number {\n  return Math.ceil(text.length / 4);\n}\n\nfunction retryDelay(error: AxiosError, attempt: number): number {\n  const retryAfter = error.response?.headers[\"retry-after\"];\n  const seconds = Number(retryAfter);\n  if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;\n  return Math.min(1000 * 2 ** attempt, 8000);\n}\n\nfunction sleep(ms: number): Promise<void> {\n  return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nexport async function summarizeReport(\n  tenantId: string,\n  reportId: string,\n  reportText: string,\n  remainingTokenBudget: number,\n): Promise<SummaryResult> {\n  const estimatedInputTokens = estimateTokens(reportText);\n  if (estimatedInputTokens > remainingTokenBudget) {\n    return { status: \"review-needed\", summary: null, reason: \"tenant-budget\" };\n  }\n\n  const requestId = `moderation-summary:${tenantId}:${reportId}`;\n  const payload = {\n    model,\n    messages: [\n      {\n        role: \"system\",\n        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.\",\n      },\n      { role: \"user\", content: reportText },\n    ],\n    max_tokens: 180,\n  };\n\n  for (let attempt = 0; attempt < 4; attempt += 1) {\n    try {\n      const response = await axios({\n        method: \"POST\",\n        url: endpoint,\n        headers: {\n          Authorization: `Bearer ${apiKey}`,\n          \"Content-Type\": \"application/json\",\n          \"Idempotency-Key\": requestId,\n        },\n        data: payload,\n        timeout: 15000,\n        validateStatus: () => true,\n      });\n\n      if (response.status === 429) {\n        if (attempt === 3) return { status: \"review-needed\", summary: null, reason: \"rate-limit\" };\n        await sleep(retryDelay({ response } as AxiosError, attempt));\n        continue;\n      }\n\n      if (response.status < 200 || response.status >= 300) {\n        const detail = typeof response.data === \"string\"\n          ? response.data\n          : JSON.stringify(response.data);\n        return { status: \"review-needed\", summary: null, reason: `provider-error:${detail}` };\n      }\n\n      const content = response.data?.choices?.[0]?.message?.content;\n      if (typeof content !== \"string\" || content.trim().length === 0) {\n        return { status: \"review-needed\", summary: null, reason: \"empty-summary\" };\n      }\n\n      return { status: \"ready\", summary: content.trim() };\n    } catch (error) {\n      if (attempt === 3) {\n        const message = error instanceof Error ? error.message : \"request-failed\";\n        return { status: \"review-needed\", summary: null, reason: message };\n      }\n      await sleep(1000 * 2 ** attempt);\n    }\n  }\n\n  return { status: \"review-needed\", summary: null, reason: \"retry-exhausted\" };\n}\n```\n\nThe 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.\n\nA 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.\n\nI 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.\n\nThe 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.\n\nThere 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.\n\nThis 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.\n\nThere 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.\n\nClaude 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.\n\nOpenAI 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.\n\nThe 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.\n\n| Option | Access shape | Good fit | Main limitation | \n|---|---|---|---|\n| Claude direct | Provider API or SDK | A team committed to Claude-specific behavior | The application owns the provider adapter and usage mapping | \n| OpenAI direct | Provider API or SDK | An existing OpenAI-shaped client and telemetry stack | Switching providers still means another integration boundary | \n| Gemini direct | Provider API or SDK | A Google-centered platform | Verify the current model, region, and response contract | \n| Mistral direct | Provider API or SDK | A Mistral-specific catalog or deployment choice | Provider-specific requirements can limit interchangeability | \n| 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 | \n\nThe table is a decision aid, not a quality ranking.\n\nFor 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.\n\nRun 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.\n\nOne sentence is enough: preserve the source text.\n\nThen inspect the failure path.\n\nKeep 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.\n\nFinally, 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.\n\nThe 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.", "url": "https://wpnews.pro/news/summarize-moderation-text-using-claude-api-in-node-js-with-per-tenant-review", "canonical_source": "https://dev.to/dorianreed2186/summarize-moderation-text-using-claude-api-in-nodejs-with-per-tenant-review-budgets-173l", "published_at": "2026-10-27 03:59:52+00:00", "updated_at": "2026-10-27 04:13:49.872879+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "large-language-models", "ai-agents"], "entities": ["Claude", "Infrai", "Node.js", "axios"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/summarize-moderation-text-using-claude-api-in-node-js-with-per-tenant-review", "markdown": "https://wpnews.pro/news/summarize-moderation-text-using-claude-api-in-node-js-with-per-tenant-review.md", "text": "https://wpnews.pro/news/summarize-moderation-text-using-claude-api-in-node-js-with-per-tenant-review.txt", "jsonld": "https://wpnews.pro/news/summarize-moderation-text-using-claude-api-in-node-js-with-per-tenant-review.jsonld"}}