TypeScript / Node.js SDK#
The puffinparse npm package is the same Rust core as the Python SDK and the CLI, loaded into Node.js
as a native addon (N-API, built with napi-rs). Every call returns a Promise;
responses are plain camelCase objects typed by the bundled index.d.ts. No provider logic lives in
JavaScript, so a model behaves identically from Python, Node, Rust and the CLI.
Node.js 18+. Source: js/ (wrapper and typings) and
crates/puffinparse-node (the addon).
Install#
Prebuilt binaries are not published to npm yet, so build the addon from a clone (needs a Rust toolchain):
git clone https://github.com/ajinkyashejul/puffinparse && cd puffinparse/js
npm install
npm run build # cargo build --release of crates/puffinparse-node -> puffinparse.<platform>.node
npm test # offline unit tests
Then depend on it by path (npm install ../puffinparse/js) or npm link. Keys come from the same
environment variables as every other surface (REDUCTO_API_KEY, LLAMA_API_KEY, EXTEND_API_KEY,
...; see Providers).
First call#
import { parse, ocr, extract } from 'puffinparse'
const doc = await parse('invoice.pdf', { model: 'llamaparse/cost_effective' })
console.log(doc.markdown, doc.costUsd, doc.pages[0].blocks[0].bbox)
const text = await ocr('scan.png', { model: 'mistral/ocr-latest' })
console.log(text.pages[0].lines.length)
const inv = await extract<{ total: number }>('invoice.pdf', {
model: 'reducto/extract',
schema: { type: 'object', properties: { total: { type: 'number' } } },
citations: true,
})
console.log(inv.data.total, inv.fields['/total']?.citations)
CommonJS works the same: const puffinparse = require('puffinparse').
Modes#
As everywhere in PuffinParse, the mode decides the response type, and a model that does not serve the
mode you asked for rejects with UnsupportedModelError before any network call.
| Mode | Call | Resolves to |
|---|---|---|
parse |
parse(doc, options?) |
ParseResponse — markdown + typed blocks with boxes |
ocr |
ocr(doc, options?) |
TextResponse — plain text + line/word boxes |
extract |
extract(doc, { schema, ... }) |
ExtractResponse<T> — your schema's object + citations |
Documents#
doc is one of:
| Form | Example |
|---|---|
| Path string | 'invoice.pdf' |
| URL string | 'https://example.com/invoice.pdf' (passed to the provider as a remote URL) |
URL |
new URL('https://...') or new URL('file:///tmp/a.pdf') |
| Bytes | Buffer, Uint8Array or ArrayBuffer — pass filename too (it sets the document type) |
| Object | { url }, { path } or { data, filename } |
Bytes are handed to the core as a Buffer, never base64-encoded through JSON.
Options#
The options mirror SPEC §4 and the Python keywords, in camelCase:
| Option | Default | Meaning |
|---|---|---|
model |
'reducto' |
"<provider>/<model>"; a bare provider picks its default model for the mode. |
fallbacks |
[] |
More models to try, in order, when model fails with a retryable error (a one-off ordered Router). |
filename |
— | Required with bytes. |
pages |
all | '1-3,7', 2 or [1, 2, 5], 1-based, forwarded best-effort. |
language |
— | Language hint when the provider supports one. |
output |
'markdown' |
parse only: preferred block content, 'markdown' or 'text'. |
outputFormat |
unified | parse / extract: 'reducto', 'extend' or 'llamaparse' returns that vendor's JSON shape, verbatim (compatibility). 'puffinparse' is the unified response. |
providerOptions |
— | Provider-specific options merged verbatim into the provider request. |
includeRaw |
false |
Attach the provider payload as response.raw. |
timeout |
300 |
Whole-call deadline in seconds (upload + polling + download), as in Python and the CLI. |
maxRetries |
2 |
Retries on 429 / 5xx / network errors with exponential backoff. |
apiKey, baseUrl |
env | Override the key or the provider base URL. |
metadata |
{} |
Echoed back in response.metadata. |
schema, instructions, citations |
— | extract only; schema is required and must be a JSON Schema object. |
Unknown option names are a TypeError that lists the accepted ones, so a snake_case typo such as
provider_options fails loudly instead of being ignored.
Responses#
The interfaces in index.d.ts follow SPEC §5 field for field, in camelCase.
Optional values are null (never missing), lists are always present, and three things are returned
exactly as received: data (your extraction), metadata (your keys plus puffinparse_* keys such as
puffinparse_fallback_index and puffinparse_derived_from) and raw. fields keeps its JSON-pointer keys.
interface ParseResponse {
id: string; provider: string; model: string; providerJobId: string | null
pages: Page[]; markdown: string; text: string
usage: { pages: number; credits: number | null; providerCostUsd: number | null }
costUsd: number | null; latencyMs: number; createdAt: string
metadata: Record<string, unknown>; raw: unknown
}
interface Page { pageNumber: number; width: number | null; height: number | null; markdown: string; text: string; blocks: Block[] }
interface Block { type: BlockType; content: string; text: string | null; bbox: BBox | null; confidence: number | null; pageNumber: number }
// TextResponse: same envelope + pages: TextPage[] ({ pageNumber, width, height, text, lines, words }) and text
// ExtractResponse<T>: same envelope + data: T and fields: Record<pointer, { confidence, citations }>
BBox is { x0, y0, x1, y1 }, normalised 0..1 with the origin top-left.
Router#
import { Router } from 'puffinparse'
const router = new Router({
models: ['reducto/standard', 'llamaparse/agentic', 'extend/parse_light'],
mode: 'parse', // default; every model must serve it
strategy: 'ordered', // or 'round_robin'
fallbackOn: ['provider', 'rate_limit', 'timeout', 'network'], // the default
})
const doc = await router.parse('contract.pdf')
router.stats() // { 'reducto/standard': { successes, failures, totalLatencyMs, totalCostUsd, totalPages, avgLatencyMs }, ... }
router.plan() // the order the next call would try
fallbackOn also accepts class names ('ProviderError') or the classes themselves. Calling a
method for another mode (router.ocr(...) on a parse router) rejects with InputError. Per-call
options are the module-level ones minus model and fallbacks. When a fallback served the call,
response.metadata.puffinparse_fallback_index says which.
Async jobs and webhooks#
parse() waits for the provider (polling job-queue providers for you). For long documents,
batches or webhook-driven pipelines, split the call in two and own the waiting yourself. Jobs are
parse mode only and need a provider with a job queue: reducto, extend, llamaparse.
import { submit, retrieve, handleWebhook, type Job } from 'puffinparse'
const job: Job = await submit('200-pages.pdf', {
model: 'reducto/standard',
webhookUrl: 'https://example.com/hooks/puffinparse', // optional, see below
})
await queue.put(JSON.stringify(job)) // a Job never holds an API key
// later, anywhere:
const result = await retrieve(JSON.parse(stored)) // the Job again while running, else ParseResponse
if ('jobId' in result) console.log('still running', result.jobId)
else console.log(result.markdown)
submit(doc, options) takes parse()'s options except fallbacks and outputFormat, plus
webhookUrl; timeout covers the upload and submission only. It resolves to a Job:
interface Job {
provider: string; model: string; jobId: string; submittedAt: string
output: 'markdown' | 'text'; includeRaw: boolean; baseUrl: string | null
providerState: Record<string, unknown> | null // non-secret options retrieve needs (Extend workspace_id)
metadata: Record<string, unknown>
}
retrieve(job, { apiKey?, baseUrl?, timeout = 120, maxRetries = 2, outputFormat? }) asks the
provider once. It resolves to the same Job object while the job is pending, or to the
ParseResponse (normalised exactly like parse(), latencyMs counted from submission; a
vendor shape with outputFormat) once it is done. A job the provider reports as failed rejects
with the typed PuffinParseError, jobId set, exactly like a failed parse(). The key is read from
the environment again unless you pass apiKey.
webhookUrl maps to Reducto async.webhook (direct mode) and LlamaParse webhook_url; Extend
has no per-job webhook (register an endpoint in its dashboard) and rejects it with InputError.
In your web handler, verify the provider's signature or your own secret first, then:
app.post('/hooks/puffinparse', async (req, res) => {
const result = await handleWebhook(req.body, { model: 'reducto' }) // Job | ParseResponse
res.sendStatus(204)
})
handleWebhook(payload, { model = 'reducto', apiKey?, baseUrl?, timeout?, maxRetries?,
outputFormat? }) accepts the parsed body, a JSON string or bytes. Bodies that carry the whole
result (a LlamaParse webhook_url push) are normalised directly; bodies that only name a finished
job (Reducto, Extend parse_run.*, LlamaCloud parse.* events) trigger one retrieve(); a
pending event resolves to its Job; a failure event rejects with the typed error. See SPEC §15
for every provider's body shape.
Errors#
Every provider or core failure rejects with a subclass of PuffinParseError, mapped from the core's
ErrorKind; argument mistakes are plain TypeErrors thrown before anything else runs.
| Class | kind |
When |
|---|---|---|
AuthenticationError |
authentication |
401/403, or no API key configured |
RateLimitError |
rate_limit |
429 after retries |
BadRequestError |
bad_request |
other 4xx, or an unknown outputFormat |
ProviderError |
provider |
5xx, malformed payload, failed job |
TimeoutError |
timeout |
the whole-call deadline passed |
UnsupportedModelError |
unsupported_model |
unknown model, or one that does not serve the mode |
InputError |
input |
unreadable file, bytes without filename, bad mode, router mode mismatch |
NetworkError |
network |
network / TLS / DNS failure after retries |
Each carries message (the provider's own message, verbatim), provider, statusCode, jobId
and retryable; toJSON() returns all of them.
import { parse, AuthenticationError, PuffinParseError } from 'puffinparse'
try {
await parse('a.pdf', { model: 'reducto/standard' })
} catch (e) {
if (e instanceof AuthenticationError) console.error('set REDUCTO_API_KEY')
else if (e instanceof PuffinParseError) console.error(e.kind, e.statusCode, e.message)
else throw e
}
Models, pricing and scoring#
import * as puffinparse from 'puffinparse'
puffinparse.listModels() // every "<provider>/<model>"
puffinparse.listModels('extract') // only models serving extract
puffinparse.resolveModel('reducto') // 'reducto/standard'
puffinparse.providers() // [{ name, displayName, envVar, baseUrl, docs, models }]
puffinparse.estimateCost('reducto/standard', 100) // USD, or null when unpriced
puffinparse.setPricing({ 'reducto/standard': 0.012 }) // per page, mode defaults to 'parse'
puffinparse.resetPricing()
puffinparse.outputFormats() // ['puffinparse', 'reducto', 'extend', 'llamaparse']
puffinparse.score(prediction, truth) // { charSimilarity, cer, wer, wordF1, ..., tableScore }
puffinparse.normalizeText(text) // the normalisation applied before scoring
puffinparse.initLogging('debug') // core tracing on stderr
Differences from the Python SDK#
- Async only: every mode returns a
Promise(there is no blocking variant);submit/retrieve/handleWebhookmatch Python'sasubmit/aretrieve/ahandle_webhook, andretrieve/handleWebhookalso takeoutputFormat. fallbackson a single call is a JavaScript convenience overRouter.- Success/failure callbacks are not mirrored; wrap the promise instead.
- Responses are plain objects, so the Python conveniences (
.tables,.num_pages,.field_info()) are one-liners overpages/fields.
See also#
- Python SDK — the same surface in Python.
- Specification — the unified request, response and error model.
js/README.md— building, testing and the release plan for prebuilt binaries.