How to Benchmark a Unified LLM API: Node.js Invoice Extraction Under Latency Limits A developer detailed a benchmarking approach for unified LLM APIs in Node.js invoice extraction, emphasizing field-level quality and latency budgets over provider feature grids. The method uses a small acceptance corpus with labels, controlled comparisons, and separate schema and expected-value validation to ensure the gateway meets production needs. Short answer: choose a unified LLM API for a Node.js invoice-extraction backend only after it passes the same field-level quality suite and latency budget as direct model access; one key and a tidy interface are useful, but they aren't acceptance criteria. The deciding constraint is the gap between a plausible invoice and a usable support record. A response can look perfect while swapping invoice number with purchase order , dropping the currency, or turning an absent due date into a confident guess. Customer-support staff then inherit a fast mistake. I would rather make the gateway earn its place with a small repeatable test than compare provider feature grids. This build log targets a simple backend that may send work to OpenAI, Claude, or Gemini through one credential. It doesn't rank those model families. The useful question is narrower: can the complete path meet the quality-versus-latency budget for the supplier documents the team actually receives in US and EU operations? Measure that path first. Start with an acceptance corpus, not a clever router. Twenty documents can expose basic contract mistakes, though they can't establish production accuracy. Keep the original text or OCR output, a reviewed expected record, and a few labels that describe why the document is hard. Useful labels for this job include multi-page, missing field, ambiguous date, comma decimal, and repeated totals. The labels are your debugging index — without them, an aggregate score hides the exact invoices that fail. Don't let the model grade itself. For exact fields such as invoice number, currency code, and supplier ID, compare normalized values. For money, parse the value and currency separately before comparison. For dates, define which source date wins when an invoice contains issue, delivery, and due dates. Free-form supplier names may need an explicit alias table owned by the application. Every normalization rule belongs in version control because changing one changes the benchmark. Use two budgets. The quality budget is a set of per-field thresholds plus a hard rule for critical omissions. The latency budget should include a percentile, not merely an average, because a handful of slow extractions can back up a support queue while the mean still looks respectable. Pick both limits from the workflow: how much correction the team accepts, and how long the caller can wait. I'm not sure a generic public benchmark can answer either question; resolving that uncertainty requires representative invoices and timings from the intended deployment path. Keep the comparison controlled. Send identical input, schema instructions, sampling settings, and retry policy through each candidate path. Run enough warm and cold requests to see whether connection setup matters. Record the model alias returned by your own adapter, the gateway request ID, total duration, schema validity, and field results. A one-key gateway may simplify credential handling, but it still sits inside this measured path. There is one easy trap here: “valid JSON” is not the same as “valid extraction.” These are distinct checks. A syntactically correct object with an invented due date passes the first and fails the second, so the evaluator needs both schema-level validation and expected-value assertions. The application should own a narrow contract that doesn't leak a provider SDK into invoice logic. Put provider-specific request mapping behind an adapter process, then point this evaluator at that process with LLM GATEWAY URL . The URL is configuration rather than a hard-coded route, and MODEL ALIAS is an application-controlled name rather than a guessed vendor model ID. The fixture below is deliberately tiny. It proves the harness, not the model. Add reviewed fixtures before treating any score as evidence. type Invoice = { invoice number: string | null; supplier name: string | null; currency: string | null; total: number | null; due date: string | null; }; type Fixture = { id: string; input: string; expected: Invoice; }; const fixtures: Fixture = { id: "missing-due-date", input: "SUPPLIER: Northwind Parts", "INVOICE: NW-1042", "CURRENCY: USD", "TOTAL: 1840.50", "DUE DATE: not stated", .join "\n" , expected: { invoice number: "NW-1042", supplier name: "Northwind Parts", currency: "USD", total: 1840.5, due date: null, }, }, { id: "comma-decimal", input: "SUPPLIER: Contoso Komponenten", "RECHNUNG: CK-7781", "CURRENCY: EUR", "TOTAL: 2.410,75", "DUE DATE: 2026-09-30", .join "\n" , expected: { invoice number: "CK-7781", supplier name: "Contoso Komponenten", currency: "EUR", total: 2410.75, due date: "2026-09-30", }, }, ; Now make the runner strict. It rejects extra prose, checks every expected key, times the complete HTTP call, and prints newline-delimited JSON so a CI job can preserve each observation. The adapter contract is intentionally boring. Good. Boring contracts are easy to replace. js import { performance } from "node:perf hooks"; type Invoice = { invoice number: string | null; supplier name: string | null; currency: string | null; total: number | null; due date: string | null; }; type Fixture = { id: string; input: string; expected: Invoice }; type AdapterResponse = { request id: string; model alias: string; output: unknown; }; const endpoint = process.env.LLM GATEWAY URL; const apiKey = process.env.LLM GATEWAY KEY; const modelAlias = process.env.MODEL ALIAS; if endpoint || apiKey || modelAlias { throw new Error "Set LLM GATEWAY URL, LLM GATEWAY KEY, and MODEL ALIAS" ; } const keys: keyof Invoice = "invoice number", "supplier name", "currency", "total", "due date", ; function isInvoice value: unknown : value is Invoice { if typeof value == "object" || value === null || Array.isArray value return false; const record = value as Record