A benchmarking harness for OCR pipelines built on vision LLMs.
Run the same documents through many models and prompt versions, then compare
accuracy, speed, cost, and throughput side by side.
Picking a vision model for document extraction usually comes down to guesswork: you try one on a couple of images, it looks fine, and you ship it. But the model that reads a clean café receipt perfectly may drop line items on a dense grocery receipt, and the model that's 4% more accurate may cost 50× more per page.
LensBench makes that trade-off measurable. Define your prompts, upload a handful of documents with the JSON you expect to get back, and run the full cross-product of prompt × model × document. You get a per-field accuracy score for every run, plus the cost and elapsed time it took to get there.
Note
Every number in these screenshots is synthetic, generated by
scripts/seed-demo-data.js so the UI can be
demonstrated without burning API credits. They are not real measurements —
benchmark the models yourself before drawing conclusions.
- Prompt library — write and version OCR instructions. Prompts are immutable; saving under an existing name creates the next version, and results reference the prompt that produced them.
- Ground-truth datasets — upload document images and pair each with the exact JSON you expect.
- Batch runner — queue the full cross-product of prompts × models × documents, with N iterations each. Requests go out in batches of 5.
- Field-level accuracy — scoring walks the ground-truth JSON path by path, so you see which fields a model got wrong, not just a single percentage. Read How accuracy is scored before trusting the number.
- Confidence scores — models are asked to return
{ value, confidence }per field. Confidence is surfaced in the per-run detail view in Results. - Cost & token tracking — token counts come from the API response and are priced from a per-model table.
- Any OpenRouter model — nine vision models are preconfigured; add any other OpenRouter model ID from the UI without touching code.
- Leaderboard & comparison — rank models by accuracy, speed, cost, or throughput, and diff their raw output against ground truth field by field.
- Local-only storage — prompts, results, and custom models in
localStorage; images in IndexedDB. No backend, no server, no analytics.
Version your extraction instructions, so you can prove a prompt change actually helped.
Each document is stored with the JSON you expect back. That expected JSON is what accuracy is scored against.
Pick prompts, models, and documents, set your iteration count, and the runner queues every combination — here 3 prompts × 6 models × 3 receipts × 3 iterations = 162 runs.
Every successful attempt, with its accuracy, elapsed time, and cost. Filter by model, prompt, document, or time window.
The trade-off plot. The shaded quadrant marks models scoring above the mean accuracy and below the mean cost of the models currently selected — switch the x-axis to elapsed time when speed is what matters.
Narrow to one document to see exactly where each model diverged from ground truth. Here one model returned the date as 14/02/2026 instead of ISO 2026-02-14, dropped a line item entirely, and reported a line total where the unit price was expected.
Aggregate ranking across all runs, re-sortable by the metric you actually care about.
Prerequisites: Node.js 20.19+ or 22.12+ (required by @vitejs/plugin-react 5), and an OpenRouter account.
git clone https://github.com/trippuroskie/LensBench.git
cd LensBench
npm install
cp .env.example .env.local
# then edit .env.local and set your key from https://openrouter.ai/keys
npm run devOpen http://localhost:3000.
To explore the interface with the synthetic dataset used in the screenshots above, skip the key entirely:
npm run dev- Open the app, then open your browser's devtools console
- Paste the contents of
scripts/seed-demo-data.jsand press Enter - Reload
To clear it again: localStorage.clear(); indexedDB.deleteDatabase('LensBenchDB'); location.reload();
- Prompts → New Prompt. Ask for the fields you need. A JSON envelope requesting
{ value, confidence }per field is prepended automatically (seeSYSTEM_PROMPT_PREFIXinconstants.tsx). - Receipts → Upload Receipt. Add an image, then edit its ground-truth JSON to the values you expect.
- New Benchmark → select prompts, models, and documents, set iterations, and launch.
- Results / Compare / Leaderboard → read the outcome.
Start with one cheap model and one document to sanity-check your prompt before
queueing a large matrix — a full run is prompts × models × documents × iterations billable API calls.
Important
Your API key is exposed to the browser. Run LensBench locally only.
vite.config.ts inlines OPENROUTER_API_KEY into the client bundle at build
time, and the browser calls the OpenRouter API directly. That is fine for a
local tool, but it means:
- Never deploy a build of this app to a public URL. The key would be readable in the JavaScript bundle by anyone who loads the page.
- The dev server is bound to
localhoston purpose. The bundle it serves has the key inlined, so binding all interfaces would hand the key to anyone on your network. If you need LAN access anyway, opt in explicitly withnpm run dev -- --hostand understand what you're exposing. - Never commit
.env.local. It is gitignored, along with.envand every.env.*variant except.env.example. - Use a key with a spend limit. OpenRouter lets you cap credits per key — do that, and rotate the key if a build artifact ever leaves your machine.
npm run buildwrites the key intodist/. Treatdist/as a secret; it is gitignored.
To make this deployable you would need to proxy model calls through a small server-side endpoint that holds the key and never returns it to the client. Contributions in that direction are welcome.
utils/evaluator.ts flattens the ground-truth JSON into leaf paths
(items[0].price, total, …) and, for each one, looks for the corresponding
value in the model's output — first by exact path, then by searching for the
leaf key anywhere in the response, so a model that nests its output differently
isn't punished for shape alone. Values are unwrapped from
{ value, confidence }, then compared as case-insensitive trimmed strings.
Accuracy is matched leaf paths / total leaf paths.
This is a deliberately strict, deliberately simple metric. Know its limits before you trust it:
- No fuzzy matching.
"Blue Harbor Coffee"vs"BLUE HARBOR COFFEE"matches (case and surrounding whitespace are normalized), but"Blue Harbor Cafe"scores zero for that field. - No numeric tolerance.
20.26vs20.3is a miss. Comparison is string-based afterString(...), so a numeric20.3also misses the string"20.30". - Every leaf path is weighted equally. A wrong
totalcosts the same as a wrongqtyon one line item. - Omission and hallucination score the same. A model that skips a field and one that invents a wrong value both simply fail to match.
- The leaf-key fallback takes the first match. For repeated keys like
items[N].price, if the exact path is missing, the field is scored against the firstpricefound anywhere — which can credit a value from the wrong line item. - Ground-truth
nullcan never match, because the comparison requires a non-null value. Since the system prompt tells models to returnnullfor unreadable fields, datasets withnullin ground truth cannot reach 100%. - Unparseable output scores 0 for the whole run, not per field. If a model returns malformed JSON, the entire run is a zero.
If you need different semantics, calculateAccuracy is a single pure function —
swap it out.
Worth knowing before you rely on the numbers:
- Failed runs are dropped, not recorded. If a request errors, times out (60s), or the provider rejects it, the attempt is logged to the browser console and vanishes — it does not appear in history as a failure or a zero. Only a 402/quota error stops the batch. Check the console if a run count looks short.
- "Inference time" is client-side wall-clock around the whole HTTP request, so it includes network latency and provider queueing, not just model inference.
- Custom models added from the UI use placeholder pricing (
$0.10/Min,$0.40/Mout) because there is no pricing field in the dialog. Their cost figures are therefore not meaningful until you add the model toMODEL_CONFIGSwith real prices. - Cancelling stops the queue, not in-flight requests. Up to 5 already-dispatched requests will finish, still bill you, and still record results after the UI returns to the config screen.
- JSON mode is always requested.
response_format: { type: "json_object" }is sent unconditionally, so models or providers that don't support it will fail (and be dropped, per above). - The "refine prompt" action in Results makes an extra billable call, hardcoded to
google/gemini-2.0-flash-001. - Two views are unreachable.
ViewStateincludesmodelsandmatrix;CostAccuracyMatrixis wired intoApp.tsxbut has no sidebar entry, andmodelshas neither. - Two known TypeScript errors in
Compare.tsxandCostAccuracyMatrix.tsx— Recharts 3's types reject thefillprop onReferenceArea, though it works at runtime.npm run buildis unaffected (Vite does not typecheck).
index.html Entry document; loads Tailwind, Font Awesome, fonts from CDNs
index.tsx React entry point
App.tsx Root state, batch-run orchestration, persistence
types.ts ViewState, BenchmarkResult, Receipt, Prompt, CustomModel
constants.tsx MODEL_CONFIGS (names + pricing), SYSTEM_PROMPT_PREFIX
vite.config.ts Dev server, path alias, and the env `define` that inlines the key
tsconfig.json TypeScript config
metadata.json PWA manifest
services/openrouter.ts OpenRouter client: runOCR(), refinePrompt()
utils/evaluator.ts calculateAccuracy() and JSON path helpers
components/
Sidebar.tsx Navigation
Dashboard.tsx Aggregate metrics and trends
PromptManager.tsx Prompt versioning
ReceiptManager.tsx Image upload and ground-truth editing
BenchmarkRunner.tsx Run configuration
ResultsHistory.tsx Per-run history, confidence detail, prompt refinement
Compare.tsx Accuracy-vs-cost plot, field-by-field diff
Leaderboard.tsx Aggregate model ranking
CostAccuracyMatrix.tsx Cost/accuracy matrix (no sidebar entry — unreachable)
scripts/
seed-demo-data.js Synthetic demo dataset for the screenshots above
See AGENTS.md for conventions, including how to add a model or a view.
Your data stays in your browser. Prompts, results, and custom model configs go
to localStorage; document images and their base64 data go to IndexedDB. There
is no backend and no analytics.
Two things do leave your machine:
- Document images, sent to OpenRouter, which routes them to the model provider you selected — along with an
HTTP-Referer: http://localhost:3000andX-Title: LensBenchattribution header. Don't benchmark documents containing information you aren't willing to send to a third party, and check the retention policy of the providers you route to. - Static assets.
index.htmlloads Tailwind, Font Awesome, and Google Fonts from public CDNs on every page load, so those CDNs see your IP and user agent. Vendor them locally if that matters to you.
Any OpenRouter model works without a code change — New Benchmark → Add
Model and paste the model ID. Custom models are saved to localStorage. Note
that they get placeholder pricing (see Known limitations).
To add a model to the built-in list with correct pricing, add a config to
MODEL_CONFIGS in constants.tsx. By convention you also add the ID to
ModelIdValues in types.ts and reference it from there — ModelId is just
string, so this is for readability rather than type safety. Prices are stored
per-token (per-million divided by 1,000,000), so keep them current with
OpenRouter's pricing.
Issues and pull requests are welcome — see CONTRIBUTING.md. The most valuable change would be a server-side proxy for the API key.







