Summarize Large Documents via LLM API Batch Ledger in Node.js A developer outlined a Node.js pattern for summarizing large document sets through an LLM API in which both asynchronous batch jobs and streamed chat completions write to a shared per-tenant usage ledger. The design records tenant ID, document identity, prompt version, input size, mode and job state before any work is submitted, so every chunk, retry and partial result stays attributable to one tenant. The writeup recommends chunking oversized inputs before submission and treating browser disconnects as a distinct outcome rather than evidence that no model work occurred. Short answer: choose the transport from the tenant workflow, then make both transports write to the same usage ledger. A large import or a many-document refresh belongs in an asynchronous batch job. A catalog editor waiting for one result needs a streamed chat completion. The important design decision is not “batch versus streaming” in isolation. It is how every document, chunk, retry, and partial result remains attributable to one tenant. Before: a web request calls an LLM and the resulting text disappears into an application log. After: the application records tenantId , document identity, prompt version, input size, mode, and job state before it sends work downstream. The model transport can change. The accounting contract does not. That contract is the useful boundary for a developer-tools product that enriches a catalog from messy descriptions. It lets an operator answer a plain question later: which tenant caused this work, and what happened to its output? Start with the ledger, not the endpoint. A batch refresh and an editor request should produce different workflow states, but they should share the same fields: | Field | Batch refresh | Editor request | |---|---|---| | Tenant identity | Required on the job and every document result | Required on the request and usage event | | Work identity | Stable job ID plus document ID | Stable request ID plus document ID | | Mode | batch | stream | | Prompt version | Stored with the input snapshot | Stored with the request | | Input accounting | Document count and chunk count | Token count before the request | | Terminal evidence | Status, results, cancellation, reconciliation | Stream completion, error, or client disconnect | The sequence matters. Create the local record, count tokens, snapshot the source text, and only then submit work. If the input is larger than the selected model context, chunk it first, summarize each chunk, and aggregate the chunk summaries. Streaming changes how output arrives. It does not make an oversized input fit. There is a small but important distinction here. A provider's response says what the provider accepted. Your ledger says what your product promised to a tenant. Keep those states separate. Yes, if the application owns the state machine. Think of it as a short diagram in words: source snapshot - token check - transport selection - provider state - result reconciliation - tenant usage event For batch work, submit through /v1/ai/batch/submit , poll status, and retrieve results. If an operator stops the refresh, represent cancellation as a state transition. A retry must carry the same logical submission identity so it cannot create a second tenant job. Result writes need the same idempotency rule. For an editor, /v1/chat/completions with stream: true sends progressive output. Record the request before opening the stream. Close the usage event only when the stream completes or a real error is recorded. A browser disconnect is its own outcome; it is not evidence that no model work occurred. Here is the decision code I would keep close to the catalog workflow. It does not hide the reason for the choice in a generic summarize helper. I would make one trade-off explicit: the sample favors an editor's visible progress, while the batch branch favors reconciliation. export async function summarizeForEditor tenantId: string, documentId: string, text: string, : Promise