← Files PDF ParserARCHIVED FILE

scripts/llamaparse.mjs

7.59 KB · Oct 3, 2026 · 06:36 UTC

↓ Download file

#!/usr/bin/env node

import {constants as fsConstants} from 'node:fs';
import {access, chmod, lstat, mkdir, open, readFile, rename, stat, unlink} from 'node:fs/promises';
import path from 'node:path';
import {fileURLToPath} from 'node:url';

const OFFICIAL_BASE_URL = 'https://api.cloud.llamaindex.ai';
const MAX_PDF_BYTES = 250 * 1024 * 1024;
const DEFAULT_EXPANDS = ['text', 'markdown', 'items', 'images_content_metadata'];
const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED']);
const ALLOWED_TIERS = new Set(['agentic', 'agentic_plus', 'cost_effective', 'fast']);

function usage(message) {
  if (message) console.error(message);
  console.error('Usage: llamaparse.mjs <input.pdf> --out <private-result.json> [--tier <tier>] [--poll-ms <milliseconds>] [--timeout-seconds <seconds>]');
  process.exitCode = 2;
}

function parseInteger(value, name, minimum, maximum) {
  const parsed = Number(value);
  if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
    throw new Error(`${name} must be an integer from ${minimum} to ${maximum}`);
  }
  return parsed;
}

function parseArgs(argv) {
  const options = {
    input: null,
    out: null,
    tier: 'agentic',
    pollMs: 2000,
    timeoutSeconds: 1800,
  };

  for (let index = 0; index < argv.length; index += 1) {
    const arg = argv[index];
    if (arg === '--out') options.out = argv[++index];
    else if (arg === '--tier') options.tier = argv[++index];
    else if (arg === '--poll-ms') options.pollMs = parseInteger(argv[++index], '--poll-ms', 250, 60000);
    else if (arg === '--timeout-seconds') options.timeoutSeconds = parseInteger(argv[++index], '--timeout-seconds', 10, 7200);
    else if (arg === '--api-key' || arg.startsWith('--api-key=')) {
      throw new Error('API keys are accepted only through LLAMA_CLOUD_API_KEY; command-line keys are forbidden');
    } else if (arg.startsWith('-')) throw new Error(`unknown option: ${arg}`);
    else if (!options.input) options.input = arg;
    else throw new Error(`unexpected argument: ${arg}`);
  }

  if (!options.input || !options.out) throw new Error('both <input.pdf> and --out are required');
  if (!ALLOWED_TIERS.has(options.tier)) throw new Error(`unsupported tier: ${options.tier}`);
  return options;
}

function baseUrl() {
  if (process.env.NODE_ENV === 'test' && process.env.PDF_PARSER_TEST_BASE_URL) {
    return process.env.PDF_PARSER_TEST_BASE_URL.replace(/\/$/, '');
  }
  return OFFICIAL_BASE_URL;
}

function redact(value, secret) {
  let text = String(value ?? '');
  if (secret) text = text.split(secret).join('[REDACTED]');
  return text.replace(/Bearer\s+[^\s"']+/gi, 'Bearer [REDACTED]').slice(0, 1000);
}

async function readJsonResponse(response) {
  const raw = await response.text();
  let json;
  try {
    json = JSON.parse(raw);
  } catch {
    throw new Error(`LlamaParse returned non-JSON HTTP ${response.status}; response body withheld`);
  }
  if (!response.ok) {
    throw new Error(`LlamaParse HTTP ${response.status}; response body withheld`);
  }
  return {json, raw};
}

async function requestJson(url, apiKey, options = {}) {
  const response = await fetch(url, {
    ...options,
    signal: options.signal ?? AbortSignal.timeout(300000),
    headers: {
      Accept: 'application/json',
      Authorization: `Bearer ${apiKey}`,
      ...(options.headers ?? {}),
    },
  });
  return readJsonResponse(response);
}

async function validateInput(inputPath) {
  const absolute = path.resolve(inputPath);
  const details = await stat(absolute);
  if (!details.isFile()) throw new Error('input must be a regular file');
  if (details.size === 0 || details.size > MAX_PDF_BYTES) {
    throw new Error(`input PDF must be between 1 byte and ${MAX_PDF_BYTES} bytes`);
  }
  const handle = await open(absolute, 'r');
  try {
    const header = Buffer.alloc(5);
    const {bytesRead} = await handle.read(header, 0, 5, 0);
    if (bytesRead !== 5 || header.toString('ascii') !== '%PDF-') throw new Error('input is not a PDF by file signature');
  } finally {
    await handle.close();
  }
  return {absolute, details};
}

async function validateOutput(outputPath, inputPath) {
  const absolute = path.resolve(outputPath);
  if (absolute === inputPath) throw new Error('output must not overwrite the source PDF');
  try {
    const details = await lstat(absolute);
    if (details.isSymbolicLink()) throw new Error('output path must not be a symbolic link');
    throw new Error('output already exists; choose a new path so prior parse evidence is preserved');
  } catch (error) {
    if (error.code !== 'ENOENT') throw error;
  }
  await mkdir(path.dirname(absolute), {recursive: true, mode: 0o700});
  await access(path.dirname(absolute), fsConstants.W_OK);
  return absolute;
}

async function writePrivateAtomic(outputPath, raw) {
  const tempPath = `${outputPath}.tmp-${process.pid}-${Date.now()}`;
  const handle = await open(tempPath, 'wx', 0o600);
  try {
    await handle.writeFile(raw, 'utf8');
  } finally {
    await handle.close();
  }
  try {
    await rename(tempPath, outputPath);
    await chmod(outputPath, 0o600);
  } catch (error) {
    await unlink(tempPath).catch(() => {});
    throw error;
  }
}

function sleep(milliseconds) {
  return new Promise(resolve => setTimeout(resolve, milliseconds));
}

async function run(argv) {
  const options = parseArgs(argv);
  const apiKey = process.env.LLAMA_CLOUD_API_KEY;
  if (!apiKey || !apiKey.trim()) {
    throw new Error('LLAMA_CLOUD_API_KEY is required and must belong to the person running this command');
  }

  const input = await validateInput(options.input);
  const output = await validateOutput(options.out, input.absolute);
  const bytes = await readFile(input.absolute);
  const form = new FormData();
  form.append('purpose', 'parse');
  form.append('file', new Blob([bytes], {type: 'application/pdf'}), path.basename(input.absolute));

  const root = baseUrl();
  const upload = await requestJson(`${root}/api/v1/beta/files`, apiKey, {
    method: 'POST',
    body: form,
  });
  if (!upload.json.id || typeof upload.json.id !== 'string') throw new Error('upload response did not include a file id');

  const created = await requestJson(`${root}/api/v2/parse`, apiKey, {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({file_id: upload.json.id, tier: options.tier, version: 'latest'}),
  });
  if (!created.json.id || typeof created.json.id !== 'string') throw new Error('parse response did not include a job id');

  const resultUrl = new URL(`${root}/api/v2/parse/${encodeURIComponent(created.json.id)}`);
  for (const expand of DEFAULT_EXPANDS) resultUrl.searchParams.append('expand', expand);
  const deadline = Date.now() + options.timeoutSeconds * 1000;

  while (Date.now() <= deadline) {
    const result = await requestJson(resultUrl, apiKey);
    const status = result.json?.job?.status;
    if (!status || typeof status !== 'string') throw new Error('parse result did not include job.status');
    if (TERMINAL_STATUSES.has(status)) {
      if (status !== 'COMPLETED') throw new Error(`parse job ended with status ${status}`);
      await writePrivateAtomic(output, result.raw);
      console.log(`Parse complete. Private raw JSON written to ${output}`);
      return;
    }
    await sleep(options.pollMs);
  }
  throw new Error(`parse job exceeded ${options.timeoutSeconds} seconds`);
}

const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (invokedDirectly) {
  run(process.argv.slice(2)).catch(error => {
    console.error(`Error: ${redact(error.message, process.env.LLAMA_CLOUD_API_KEY)}`);
    process.exitCode = process.exitCode || 1;
  });
}

export {parseArgs, redact, run};

SHA-256: 942acd6e3487806d2c774aff61cdcaa117029c47bf8ed30914bb7029575733b3