diff --git a/.gitignore b/.gitignore index ae4aa20e..ae79b517 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ dist-deno dist-bundle *.mcpb oidc + +# local env / secrets — never commit API keys +.env +.env.local +.env.*.local diff --git a/README.md b/README.md index ebeab04d..1fc82f74 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,10 @@ [![NPM version]()](https://npmjs.org/package/landingai-ade) ![npm bundle size](https://img.shields.io/bundlephobia/minzip/landingai-ade) - **[Playground](https://va.landing.ai) · [Discord](https://discord.com/invite/RVcW3j9RgR) · [Blog](https://landing.ai/blog) · [Docs](https://docs.landing.ai)** - This library provides convenient access to the LandingAI ADE REST API from server-side TypeScript or JavaScript. The REST API documentation can be found on [docs.landing.ai](https://docs.landing.ai/). The full API of this library can be found in [api.md](api.md). @@ -49,7 +47,11 @@ const client = new LandingAIADE({ environment: 'eu', // defaults to 'production' }); -const response = await client.parse({ document: fs.createReadStream('path/to/file'), model: 'dpt-2-latest', saveTo: './output_folder' }); +const response = await client.parse({ + document: fs.createReadStream('path/to/file'), + model: 'dpt-2-latest', + saveTo: './output_folder', +}); // optional: saves as {input_file}_parse_output.json in the specified folder console.log(response.chunks); @@ -102,14 +104,14 @@ const schema = { properties: { name: { type: 'string', - description: "Person's name" + description: "Person's name", }, age: { type: 'number', - description: "Person's age" - } + description: "Person's age", + }, }, - required: ['name', 'age'] + required: ['name', 'age'], }; const client = new LandingAIADE({ @@ -118,14 +120,14 @@ const client = new LandingAIADE({ const response = await client.extract({ schema: JSON.stringify(schema), - markdown: fs.createReadStream('path/to/file.md') + markdown: fs.createReadStream('path/to/file.md'), }); ``` For advanced type-safe schemas with full TypeScript inference, see [Using Zod for Type-Safe Schemas](#using-zod-for-type-safe-schemas). - ### Split + Split parsed documents into separate sections based on classification rules and identifiers. ```js @@ -170,6 +172,100 @@ for (const split of splitResponse.splits) { } ``` +### V2 API (`client.v2`) + +`client.v2` is a new, **additive** sub-client for LandingAI's next-generation ADE gateway. It does not change anything about the V1 usage above — `client.parse`, `client.extract`, `client.parseJobs`, etc. keep working exactly as documented. Use `client.v2.*` for the newer parse/extract surface. + +The V2 gateway lives on its own host (`api.ade.[env].landing.ai`), separate from the V1 host (`api.va.[env].landing.ai`). Select the environment the same way as V1, via the `environment` argument or the `LANDINGAI_ADE_ENVIRONMENT` env var: + +```ts +import LandingAIADE from 'landingai-ade'; + +const client = new LandingAIADE({ + apikey: process.env['VISION_AGENT_API_KEY'], + // one of "production" (default), "eu", "staging", "dev" + // can also be set via the LANDINGAI_ADE_ENVIRONMENT env var instead of passing it here + environment: 'staging', +}); +``` + +#### V2 Parse + +Parse a document synchronously. Returns a `V2ParseResponse` (on a partial success the HTTP status is 206 and `metadata.failed_pages` lists the unparsed pages). A synchronous 504 surfaces as `V2SyncTimeoutError` — use `parseJobs` (below) for long-running documents. + +```ts +import fs from 'fs'; + +const response = await client.v2.parse({ + document: fs.createReadStream('path/to/file.pdf'), +}); +console.log(response.markdown); +``` + +#### V2 Extract + +Extract structured data from Markdown using a JSON schema. Provide exactly one of `markdown`, `markdown_ref` (from `client.v2.files.upload`), or `markdown_url`. + +```ts +const response = await client.v2.extract({ + schema: { type: 'object', properties: { title: { type: 'string' } } }, + markdown: 'some markdown', +}); +``` + +`schema` accepts a JSON-Schema object or a JSON-encoded string. For type-safe schemas, define them with Zod and pass `z.toJSONSchema(MySchema)` (see [Using Zod for Type-Safe Schemas](#using-zod-for-type-safe-schemas)). + +#### Async jobs + +`parseJobs` / `extractJobs` create jobs and return one unified `Job` shape regardless of the divergent upstream envelopes (the full envelope stays on `Job.raw`). `wait()` polls with backoff until the job is terminal: + + +```ts +const job = await client.v2.parseJobs.create({ + document: fs.createReadStream('large.pdf'), + service_tier: 'priority', +}); +const done = await client.v2.parseJobs.wait(job.job_id, { timeout: 600000, raiseOnFailure: true }); +console.log(done.status, done.result); + +// client.v2.extractJobs.{create,get,list,wait} mirror the same shape for extract jobs. +``` + +#### File staging + +`client.v2.files.upload` stages bytes on the ADE data plane and returns a `file_ref` you can pass as `markdown_ref` to extract: + + +```ts +const fileRef = await client.v2.files.upload({ file: fs.createReadStream('doc.md') }); +const result = await client.v2.extract({ schema: { type: 'object' }, markdown_ref: fileRef }); +``` + +`client.v2.parse` and `client.v2.extract` also accept `saveTo`, with the same auto-naming behavior as the V1 methods above. + +#### Workflows (`parse-extract`) + +`client.v2.workflow` runs a prebuilt pipeline (Phase 1: `parse-extract`) in one call — parse a document, then extract against a schema. Reference documents by `document_url`, or upload with `files.upload` and pass the returned ref as `document_ref`: + + +```ts +const result = await client.v2.workflow({ + inputs: { report: { document_url: 'https://example.com/report.pdf' } }, + steps: [ + { + name: 'parse-extract', + document: '$inputs.report', + schema: { type: 'object', properties: { revenue: { type: 'string' } } }, + }, + ], +}); +console.log(result.output['parse-extract']); + +// Async: client.v2.workflowJobs.{create,get,list,wait} mirror the jobs shape above. +``` + +To send a local file instead of a URL, pass it as `inputs..document` (the SDK stages it as a multipart part), or upload it first with `files.upload` and pass the returned ref as `document_ref`. + ### Request & Response types This library includes TypeScript definitions for all request params and response fields. You may import and use them like so: @@ -237,12 +333,14 @@ const InvoiceSchema = z.object({ name: z.string(), address: z.string().optional(), }), - items: z.array(z.object({ - description: z.string(), - quantity: z.number().int().positive(), - unitPrice: z.number().positive(), - total: z.number().positive(), - })), + items: z.array( + z.object({ + description: z.string(), + quantity: z.number().int().positive(), + unitPrice: z.number().positive(), + total: z.number().positive(), + }), + ), totalAmount: z.number().describe('Total amount due'), }); @@ -265,7 +363,7 @@ const result = await client.extract({ // 5. The extraction is now typed as Invoice const invoice: Invoice = result.extraction as Invoice; console.log(invoice.invoiceNumber); // TypeScript knows this is a string -console.log(invoice.totalAmount); // TypeScript knows this is a number +console.log(invoice.totalAmount); // TypeScript knows this is a number ``` Note: Zod is optional. You can also pass JSON Schema strings directly to the `extract` endpoint if you prefer. diff --git a/api.md b/api.md index 14a688d0..37bcca00 100644 --- a/api.md +++ b/api.md @@ -38,3 +38,39 @@ Methods: - client.parseJobs.create({ ...params }) -> ParseJobCreateResponse - client.parseJobs.list({ ...params }) -> ParseJobListResponse - client.parseJobs.get(jobID) -> ParseJobGetResponse + +# V2 + +The `client.v2` sub-client targets LandingAI's next-generation ADE gateway on its own host (`api.ade.[env].landing.ai`), separate from the V1 host (`api.va.[env].landing.ai`). It is **additive** — `client.v2.*` is a separate surface from the top-level `client.*` (V1) methods above, and using it does not change any V1 behavior. + +`client.v2.parseJobs` and `client.v2.extractJobs` both return a single, unified `Job` shape even though the underlying parse/extract job envelopes differ upstream — `Job.raw` retains the full original envelope as an escape hatch. + +Types: + +- Job +- JobError +- JobList +- JobStatus +- V2ParseResponse +- V2ExtractResult +- V2WorkflowResult +- V2FileUploadResponse + +Methods: + +- client.v2.parse({ ...params }) -> V2ParseResponse +- client.v2.extract({ ...params }) -> V2ExtractResult +- client.v2.parseJobs.create({ ...params }) -> Job +- client.v2.parseJobs.get(jobID) -> Job +- client.v2.parseJobs.list({ ...params }) -> JobList +- client.v2.parseJobs.wait(jobID, { ...options }) -> Job +- client.v2.extractJobs.create({ ...params }) -> Job +- client.v2.extractJobs.get(jobID) -> Job +- client.v2.extractJobs.list({ ...params }) -> JobList +- client.v2.extractJobs.wait(jobID, { ...options }) -> Job +- client.v2.files.upload({ file }) -> string +- client.v2.workflow({ ...params }) -> V2WorkflowResult +- client.v2.workflowJobs.create({ ...params }) -> Job +- client.v2.workflowJobs.get(jobID) -> Job +- client.v2.workflowJobs.list({ ...params }) -> JobList +- client.v2.workflowJobs.wait(jobID, { ...options }) -> Job diff --git a/examples/v2_smoke_test.ts b/examples/v2_smoke_test.ts new file mode 100644 index 00000000..df80e494 --- /dev/null +++ b/examples/v2_smoke_test.ts @@ -0,0 +1,319 @@ +/** + * Manual smoke test for the V2 (`client.v2`) endpoints against a live environment. + * + * Drives every V2 surface end-to-end so you can confirm auth, routing, and the + * response shapes against a real gateway. It is a manual/QA tool — NOT part of + * the automated test suite (which uses mocked transports). + * + * This is a *live* test that can hit real endpoints (and consume credits), so — + * unlike the client itself, which defaults to `production` — this script + * defaults to `staging` when neither `--environment` nor + * `LANDINGAI_ADE_ENVIRONMENT` is set. V2 lives on `api.ade..landing.ai`. + * + * Setup + * ----- + * Put your key in `.env.local` (auto-loaded), e.g.: + * VISION_AGENT_API_KEY= + * # optional: LANDINGAI_ADE_ENVIRONMENT=staging + * ...or export VISION_AGENT_API_KEY in your shell. + * + * Run + * --- + * yarn tsn examples/v2_smoke_test.ts # extract + files (no document needed) + * yarn tsn examples/v2_smoke_test.ts --document ./sample.pdf # + parse & workflow (sync & job) + * yarn tsn examples/v2_smoke_test.ts --document-url https://.../sample.pdf + * yarn tsn examples/v2_smoke_test.ts --only extract,extract_jobs # run a subset + * yarn tsn examples/v2_smoke_test.ts --environment dev + * + * Exit code is non-zero if any selected check failed, so it is CI-friendly too. + */ + +import * as fs from 'fs'; + +import LandingAIADE, { toFile, type ClientOptions } from 'landingai-ade'; + +const ALL_CHECKS = [ + 'files', + 'extract', + 'extract_jobs', + 'parse', + 'parse_jobs', + 'workflow', + 'workflow_jobs', +] as const; +type Check = (typeof ALL_CHECKS)[number]; + +/** A tiny self-contained markdown doc + schema so extract/files run without any file. */ +const SAMPLE_MARKDOWN = '# Acme Inc. — Q1 Report\n\nTotal revenue for the quarter was **$1,250,000**.\n'; + +const REVENUE_SCHEMA = { + type: 'object', + properties: { + revenue: { type: 'string', description: 'The total revenue figure, verbatim' }, + company: { type: 'string', description: 'The company name' }, + }, +}; + +/** + * Load `.env` then `.env.local` into process.env. Precedence (highest first): + * an existing shell env var > `.env.local` > `.env`; within a file the LAST + * assignment of a key wins (standard dotenv behavior). So if `.env.local` lists + * the same key twice (e.g. a dev line then a staging line), the last one wins. + */ +function loadDotEnv(): void { + const shellKeys = new Set(Object.keys(process.env)); + const fromFiles: Record = {}; + for (const file of ['.env', '.env.local']) { + let text: string; + try { + text = fs.readFileSync(file, 'utf8'); + } catch { + continue; + } + for (const rawLine of text.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq === -1) continue; + const key = line.slice(0, eq).trim(); + let value = line.slice(eq + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (key) fromFiles[key] = value; // last assignment wins (within and across files) + } + } + for (const [key, value] of Object.entries(fromFiles)) { + if (!shellKeys.has(key)) process.env[key] = value; // a real shell env var still wins + } +} + +interface Args { + environment?: string; + document?: string; + documentUrl?: string; + only?: string; + parseModel?: string; + extractModel?: string; + timeout: number; +} + +function parseArgs(argv: Array): Args { + const args: Args = { timeout: 600_000 }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + const next = (): string => { + const value = argv[++i]; + if (value === undefined) throw new Error(`Missing value for ${flag}`); + return value; + }; + switch (flag) { + case '--environment': + args.environment = next(); + break; + case '--document': + args.document = next(); + break; + case '--document-url': + args.documentUrl = next(); + break; + case '--only': + args.only = next(); + break; + case '--parse-model': + args.parseModel = next(); + break; + case '--extract-model': + args.extractModel = next(); + break; + case '--timeout': + args.timeout = Number(next()); + break; + default: + throw new Error(`Unknown argument: ${flag}`); + } + } + return args; +} + +/** Default this *live* smoke test to `staging`; return undefined to let the client read the env var. */ +function resolveEnvironment(args: Args): string | undefined { + if (args.environment) return args.environment; + if (process.env['LANDINGAI_ADE_ENVIRONMENT']) return undefined; + return 'staging'; +} + +function selectedChecks(only: string | undefined): Array { + if (!only) return [...ALL_CHECKS]; + const chosen = only + .split(',') + .map((c) => c.trim()) + .filter(Boolean); + const bad = chosen.filter((c) => !ALL_CHECKS.includes(c as Check)); + if (bad.length) throw new Error(`Unknown check(s): ${bad.join(', ')}. Valid: ${ALL_CHECKS.join(', ')}`); + return chosen as Array; +} + +function short(value: unknown, limit = 200): string { + let text: string; + try { + // JSON.stringify(undefined) is `undefined` (not a string), so fall back. + text = typeof value === 'string' ? value : JSON.stringify(value) ?? String(value); + } catch { + text = String(value); + } + return text.length <= limit ? text : text.slice(0, limit) + '…'; +} + +/** + * A *fresh* document source for one parse/workflow check, or null when the caller + * passed neither `--document` nor `--document-url`. The `--document` case returns a + * one-shot `fs.ReadStream` (readable only once), so call this once per check — a + * single shared source would be drained by the first check and leave every check + * after it uploading an empty body. + */ +function documentSource(args: Args): { document: fs.ReadStream } | { document_url: string } | null { + if (args.document) return { document: fs.createReadStream(args.document) }; + if (args.documentUrl) return { document_url: args.documentUrl }; + return null; +} + +async function main(): Promise { + loadDotEnv(); + const args = parseArgs(process.argv.slice(2)); + const checks = selectedChecks(args.only); + + if (!process.env['VISION_AGENT_API_KEY']) { + console.error('VISION_AGENT_API_KEY is not set (put it in .env.local or export it). Aborting.'); + return 1; + } + + const environment = resolveEnvironment(args); + const clientOptions: ClientOptions = {}; + if (environment) clientOptions.environment = environment as ClientOptions['environment']; + const client = new LandingAIADE(clientOptions); + console.log(`V1 base: ${client.baseURL} | V2 base: ${client.v2BaseURL}\n`); + + const results: Record = {}; + + const record = async (name: string, fn: () => Promise): Promise => { + console.log(`── ${name} `.padEnd(60, '─')); + try { + const out = await fn(); + results[name] = 'PASS'; + console.log(` PASS ${short(out)}\n`); + } catch (err) { + results[name] = 'FAIL'; + const e = err as Error; + console.log(` FAIL ${e?.constructor?.name ?? 'Error'}: ${e?.message ?? String(err)}`); + console.error(err); + console.log(); + } + }; + + const skip = (name: string, why: string): void => { + console.log(`── ${name} `.padEnd(60, '─') + `\n SKIP (${why})\n`); + results[name] = 'SKIP'; + }; + + let fileRef: string | undefined; + + if (checks.includes('files')) { + await record('files.upload', async () => { + fileRef = await client.v2.files.upload({ + file: await toFile(Buffer.from(SAMPLE_MARKDOWN), 'doc.md', { type: 'text/markdown' }), + }); + return `file_ref=${fileRef}`; + }); + } + + if (checks.includes('extract')) { + await record('v2.extract (sync)', async () => { + const res = await client.v2.extract({ + schema: REVENUE_SCHEMA, + markdown: SAMPLE_MARKDOWN, + ...(args.extractModel ? { model: args.extractModel } : {}), + }); + return `extraction=${short(res.extraction)} version=${res.metadata.version}`; + }); + } + + if (checks.includes('extract_jobs')) { + await record('v2.extractJobs (create+wait)', async () => { + const job = await client.v2.extractJobs.create({ schema: REVENUE_SCHEMA, markdown: SAMPLE_MARKDOWN }); + const done = await client.v2.extractJobs.wait(job.job_id, { timeout: args.timeout }); + return `job=${done.job_id} status=${done.status} result=${done.result ? 'set' : 'none'}`; + }); + } + + // Each document check pulls its OWN fresh source: `documentSource` hands back a + // one-shot `fs.ReadStream` for `--document`, so a shared source would be drained by + // the first check and leave the rest uploading an empty body. + const workflowStep = { + name: 'parse-extract' as const, + document: '$inputs.report', + schema: REVENUE_SCHEMA, + }; + + if (checks.includes('parse')) { + const src = documentSource(args); + if (!src) skip('v2.parse (sync)', 'no --document / --document-url'); + else { + await record('v2.parse (sync)', async () => { + const res = await client.v2.parse({ ...src, ...(args.parseModel ? { model: args.parseModel } : {}) }); + return short(res.markdown); + }); + } + } + + if (checks.includes('parse_jobs')) { + const src = documentSource(args); + if (!src) skip('v2.parseJobs (create+wait)', 'no --document / --document-url'); + else { + await record('v2.parseJobs (create+wait)', async () => { + const job = await client.v2.parseJobs.create({ ...src }); + const done = await client.v2.parseJobs.wait(job.job_id, { timeout: args.timeout }); + return `job=${done.job_id} status=${done.status}`; + }); + } + } + + if (checks.includes('workflow')) { + const src = documentSource(args); + if (!src) skip('v2.workflow (sync)', 'no --document / --document-url'); + else { + await record('v2.workflow (sync)', async () => { + const res = await client.v2.workflow({ inputs: { report: src }, steps: [workflowStep] }); + return `output keys=${short(Object.keys(res.output))}`; + }); + } + } + + if (checks.includes('workflow_jobs')) { + const src = documentSource(args); + if (!src) skip('v2.workflowJobs (create+wait)', 'no --document / --document-url'); + else { + await record('v2.workflowJobs (create+wait)', async () => { + const job = await client.v2.workflowJobs.create({ inputs: { report: src }, steps: [workflowStep] }); + const done = await client.v2.workflowJobs.wait(job.job_id, { timeout: args.timeout }); + return `job=${done.job_id} status=${done.status}`; + }); + } + } + + console.log('═'.repeat(60)); + for (const [name, status] of Object.entries(results)) { + console.log(` ${status.padEnd(5)} ${name}`); + } + const failed = Object.values(results).filter((s) => s === 'FAIL').length; + console.log('═'.repeat(60)); + console.log(`${failed} failed / ${Object.keys(results).length} run`); + return failed ? 1 : 0; +} + +main() + .then((code) => process.exit(code)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/src/client.ts b/src/client.ts index 8e353c81..1fc2367d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -40,6 +40,44 @@ import { ParseJobListResponse, ParseJobs, } from './resources/parse-jobs'; +import { V2 } from './resources/v2'; +import type { + FileUploadParams, + Job, + JobError, + JobList, + JobStatus, + PrebuiltWorkflowStep, + V2Billing, + V2Box, + V2ElementType, + V2ExtractJobCreateParams, + V2ExtractMetadata, + V2ExtractParams, + V2ExtractResult, + V2FileUploadResponse, + V2GroundingDocument, + V2GroundingElement, + V2GroundingEntry, + V2GroundingPage, + V2JobListParams, + V2ParseBilling, + V2ParseElement, + V2ParseJobCreateParams, + V2ParseMetadata, + V2ParsePage, + V2ParseParams, + V2ParseResponse, + V2ParseStructure, + V2Span, + V2WorkflowJobCreateParams, + V2WorkflowMetadata, + V2WorkflowParams, + V2WorkflowResult, + WaitOptions, + WorkflowDocumentInput, + WorkflowStepOptions, +} from './resources/v2'; import { type Fetch } from './internal/builtin-types'; import { HeadersLike, NullableHeaders, buildHeaders } from './internal/headers'; import { FinalRequestOptions, RequestOptions } from './internal/request-options'; @@ -57,9 +95,68 @@ import { isEmptyObj } from './internal/utils/values'; const environments = { production: 'https://api.va.landing.ai', eu: 'https://api.va.eu-west-1.landing.ai', + staging: 'https://api.va.staging.landing.ai', + dev: 'https://api.va.dev.landing.ai', }; type Environment = keyof typeof environments; +/** + * The V2 (ADE) surface lives on its own host (`api.ade.[env].landing.ai`), + * paired 1:1 with the V1 host by environment and selected via the same + * `environment` option / env var. + */ +const v2Environments = { + production: 'https://api.ade.landing.ai', + eu: 'https://api.ade.eu-west-1.landing.ai', + staging: 'https://api.ade.staging.landing.ai', + dev: 'https://api.ade.dev.landing.ai', +}; + +function stripTrailingSlash(url: string): string { + return url.replace(/\/+$/, ''); +} + +function knownEnvironment(value: string | undefined): Environment | undefined { + return value && value in environments ? (value as Environment) : undefined; +} + +/** + * Resolve the V2 (ADE) base URL, no trailing slash. Explicit constructor args + * beat ambient env vars. Precedence: + * explicit `v2BaseURL` > explicit `environment` > `LANDINGAI_ADE_V2_BASE_URL` env > + * `LANDINGAI_ADE_ENVIRONMENT` env (only when no explicit `baseURL`) > + * (if a V1 `baseURL` was set) follow it > production default. + */ +function resolveV2BaseURL(opts: { + explicitEnvironment: Environment | undefined; + envVarEnvironment: Environment | undefined; + v2BaseURL: string | null | undefined; + v1BaseURL: string; + v1BaseWasExplicit: boolean; +}): string { + if (opts.v2BaseURL) { + return stripTrailingSlash(opts.v2BaseURL); + } + // An explicit `environment` argument beats the ambient env-var override. + if (opts.explicitEnvironment) { + return v2Environments[opts.explicitEnvironment]; + } + const envOverride = readEnv('LANDINGAI_ADE_V2_BASE_URL'); + if (envOverride) { + return stripTrailingSlash(envOverride); + } + // An env-var-selected environment applies only when no explicit `baseURL` + // governs the client — an explicit `baseURL` must govern BOTH hosts, so V2 + // follows it rather than escaping to the env-var environment's gateway. + if (opts.envVarEnvironment && !opts.v1BaseWasExplicit) { + return v2Environments[opts.envVarEnvironment]; + } + if (opts.v1BaseWasExplicit) { + return stripTrailingSlash(opts.v1BaseURL); + } + return v2Environments.production; +} + /** * Extract base filename (without extension) from file or URL input. * @internal @@ -131,9 +228,13 @@ export interface ClientOptions { /** * Specifies the environment to use for the API. * - * Each environment maps to a different base URL: - * - `production` corresponds to `https://api.va.landing.ai` - * - `eu` corresponds to `https://api.va.eu-west-1.landing.ai` + * Selects both the V1 host and the paired V2 (ADE gateway) host: + * - `production`: `https://api.va.landing.ai` / `https://api.ade.landing.ai` + * - `eu`: `https://api.va.eu-west-1.landing.ai` / `https://api.ade.eu-west-1.landing.ai` + * - `staging`: `https://api.va.staging.landing.ai` / `https://api.ade.staging.landing.ai` + * - `dev`: `https://api.va.dev.landing.ai` / `https://api.ade.dev.landing.ai` + * + * Defaults to process.env['LANDINGAI_ADE_ENVIRONMENT'], then `production`. */ environment?: Environment | undefined; @@ -144,6 +245,15 @@ export interface ClientOptions { */ baseURL?: string | null | undefined; + /** + * Override the base URL for the V2 (ADE gateway) surface (`client.v2.*`). + * + * Defaults to process.env['LANDINGAI_ADE_V2_BASE_URL'], then the host paired + * with `environment`. If only `baseURL` is set (e.g. a mock server), V2 routes + * there too. + */ + v2BaseURL?: string | null | undefined; + /** * The maximum amount of time (in milliseconds) that the client should wait for a response * from the server before timing out a single request. @@ -213,6 +323,8 @@ export class LandingAIADE { apikey: string; baseURL: string; + /** Resolved base URL for the V2 (ADE gateway) surface (`client.v2.*`). */ + v2BaseURL: string; maxRetries: number; timeout: number; logger: Logger; @@ -248,11 +360,17 @@ export class LandingAIADE { ); } + // `environment` selects both the V1 and V2 hosts. Explicit option wins; + // otherwise the LANDINGAI_ADE_ENVIRONMENT env var (unless an explicit + // baseURL is set), then `production`. + const envVarEnvironment = knownEnvironment(readEnv('LANDINGAI_ADE_ENVIRONMENT')); + const environment = opts.environment ?? (baseURL ? undefined : envVarEnvironment) ?? 'production'; + const options: ClientOptions = { apikey, ...opts, baseURL, - environment: opts.environment ?? 'production', + environment, }; if (baseURL && opts.environment) { @@ -262,6 +380,13 @@ export class LandingAIADE { } this.baseURL = options.baseURL || environments[options.environment || 'production']; + this.v2BaseURL = resolveV2BaseURL({ + explicitEnvironment: opts.environment, + envVarEnvironment, + v2BaseURL: opts.v2BaseURL, + v1BaseURL: this.baseURL, + v1BaseWasExplicit: Boolean(baseURL), + }); this.timeout = options.timeout ?? LandingAIADE.DEFAULT_TIMEOUT /* 8 minutes */; this.logger = options.logger ?? console; const defaultLogLevel = 'warn'; @@ -967,13 +1092,18 @@ export class LandingAIADE { static InternalServerError = Errors.InternalServerError; static PermissionDeniedError = Errors.PermissionDeniedError; static UnprocessableEntityError = Errors.UnprocessableEntityError; + static V2SyncTimeoutError = Errors.V2SyncTimeoutError; + static JobWaitTimeoutError = Errors.JobWaitTimeoutError; + static JobFailedError = Errors.JobFailedError; static toFile = Uploads.toFile; parseJobs: API.ParseJobs = new API.ParseJobs(this); + v2: V2 = new V2(this); } LandingAIADE.ParseJobs = ParseJobs; +LandingAIADE.V2 = V2; export declare namespace LandingAIADE { export type RequestOptions = Opts.RequestOptions; @@ -1002,6 +1132,45 @@ export declare namespace LandingAIADE { type ParseJobListParams as ParseJobListParams, }; + export { + V2 as V2, + type Job as Job, + type JobError as JobError, + type JobList as JobList, + type JobStatus as JobStatus, + type V2ParseResponse as V2ParseResponse, + type V2ParseMetadata as V2ParseMetadata, + type V2ParseBilling as V2ParseBilling, + type V2ExtractResult as V2ExtractResult, + type V2ExtractMetadata as V2ExtractMetadata, + type V2FileUploadResponse as V2FileUploadResponse, + type V2ParseParams as V2ParseParams, + type V2ParseJobCreateParams as V2ParseJobCreateParams, + type V2ExtractParams as V2ExtractParams, + type V2ExtractJobCreateParams as V2ExtractJobCreateParams, + type V2JobListParams as V2JobListParams, + type V2Billing as V2Billing, + type V2ParseStructure as V2ParseStructure, + type V2ParsePage as V2ParsePage, + type V2ParseElement as V2ParseElement, + type V2GroundingDocument as V2GroundingDocument, + type V2GroundingPage as V2GroundingPage, + type V2GroundingElement as V2GroundingElement, + type V2GroundingEntry as V2GroundingEntry, + type V2ElementType as V2ElementType, + type V2Span as V2Span, + type V2Box as V2Box, + type V2WorkflowResult as V2WorkflowResult, + type V2WorkflowMetadata as V2WorkflowMetadata, + type V2WorkflowParams as V2WorkflowParams, + type V2WorkflowJobCreateParams as V2WorkflowJobCreateParams, + type WorkflowDocumentInput as WorkflowDocumentInput, + type PrebuiltWorkflowStep as PrebuiltWorkflowStep, + type WorkflowStepOptions as WorkflowStepOptions, + type FileUploadParams as FileUploadParams, + type WaitOptions as WaitOptions, + }; + export type ParseGroundingBox = API.ParseGroundingBox; export type ParseMetadata = API.ParseMetadata; } diff --git a/src/core/error.ts b/src/core/error.ts index 3b94ac9b..189d49fd 100644 --- a/src/core/error.ts +++ b/src/core/error.ts @@ -126,3 +126,17 @@ export class UnprocessableEntityError extends APIError<422, Headers> {} export class RateLimitError extends APIError<429, Headers> {} export class InternalServerError extends APIError {} + +/** + * A synchronous `client.v2.parse` / `client.v2.extract` call exceeded the server + * wait window (HTTP 504). The server cancels the work on timeout; use the async + * jobs route (`client.v2.parseJobs.create(...)` / `client.v2.extractJobs.create(...)`, + * then `.wait(...)`) for long-running documents. + */ +export class V2SyncTimeoutError extends LandingAIADEError {} + +/** `wait()` gave up before the job reached a terminal state. */ +export class JobWaitTimeoutError extends LandingAIADEError {} + +/** A job reached a terminal `failed`/`cancelled` state (when `raiseOnFailure` is set). */ +export class JobFailedError extends LandingAIADEError {} diff --git a/src/index.ts b/src/index.ts index 5aed998e..6278cea6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,4 +17,8 @@ export { InternalServerError, PermissionDeniedError, UnprocessableEntityError, + V2SyncTimeoutError, + JobWaitTimeoutError, + JobFailedError, } from './core/error'; +export { coerceSchema, type ExtractSchema } from './lib/schema'; diff --git a/src/lib/schema.ts b/src/lib/schema.ts new file mode 100644 index 00000000..348bf36b --- /dev/null +++ b/src/lib/schema.ts @@ -0,0 +1,35 @@ +import { LandingAIADEError } from '../core/error'; + +/** + * A JSON Schema accepted by `client.v2.extract` — either a JSON-Schema object or + * a JSON-encoded string that decodes to one. + * + * Note: unlike the Python SDK (which additionally accepts a pydantic model, + * since pydantic is a core dependency there), this SDK stays dependency-free and + * does not bundle a schema library. Pass a JSON Schema object directly, or + * convert your schema (e.g. via `zod-to-json-schema`) before calling. + */ +export type ExtractSchema = Record | string; + +/** + * Coerce an accepted `schema` value into a plain JSON-Schema object. The V2 + * extract endpoint takes `schema` as a JSON object in the request body. + */ +export function coerceSchema(schema: ExtractSchema): Record { + if (typeof schema === 'string') { + let parsed: unknown; + try { + parsed = JSON.parse(schema); + } catch (err) { + throw new LandingAIADEError(`schema is not valid JSON: ${(err as Error).message}`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new LandingAIADEError('schema JSON string must decode to an object'); + } + return parsed as Record; + } + if (typeof schema === 'object' && schema !== null && !Array.isArray(schema)) { + return schema; + } + throw new LandingAIADEError(`Unsupported schema type: ${typeof schema}`); +} diff --git a/src/resources/index.ts b/src/resources/index.ts index ad46e1c1..383f8764 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -21,3 +21,43 @@ export { type SectionParams, type SplitParams, } from './top-level'; +export { + V2, + DEFAULT_WAIT_TIMEOUT_MS, + isTerminalStatus, + type FileUploadParams, + type Job, + type JobError, + type JobList, + type JobStatus, + type WaitOptions, + type V2Billing, + type V2Box, + type V2ElementType, + type V2ExtractJobCreateParams, + type V2ExtractMetadata, + type V2ExtractParams, + type V2ExtractResult, + type V2FileUploadResponse, + type V2GroundingDocument, + type V2GroundingElement, + type V2GroundingEntry, + type V2GroundingPage, + type V2JobListParams, + type V2ParseBilling, + type V2ParseElement, + type V2ParseJobCreateParams, + type V2ParseMetadata, + type V2ParsePage, + type V2ParseParams, + type V2ParseResponse, + type V2ParseStructure, + type V2Span, + type V2WorkflowParams, + type V2WorkflowJobCreateParams, + type V2WorkflowMetadata, + type V2WorkflowResult, + type WorkflowDocumentInput, + type PrebuiltWorkflowStep, + type WorkflowStepOptions, +} from './v2'; diff --git a/src/resources/v2/_base.ts b/src/resources/v2/_base.ts new file mode 100644 index 00000000..d4bac637 --- /dev/null +++ b/src/resources/v2/_base.ts @@ -0,0 +1,133 @@ +import { APIResource } from '../../core/resource'; +import { APIError, JobFailedError, JobWaitTimeoutError, V2SyncTimeoutError } from '../../core/error'; +import { Job, JobList } from './types'; + +export const DEFAULT_POLL_INITIAL_MS = 1000; +export const DEFAULT_POLL_MAX_MS = 10000; +export const DEFAULT_POLL_FACTOR = 1.5; +/** Default `wait()` timeout, in milliseconds (10 minutes). */ +export const DEFAULT_WAIT_TIMEOUT_MS = 600000; + +/** + * Shared base for V2 sub-resources, which target the ADE gateway host rather + * than the V1 host. `buildURL` passes absolute URLs through untouched, so we + * build an absolute URL against the client's resolved `v2BaseURL` and inherit + * auth, retries, and the configured fetch. + */ +export abstract class V2Resource extends APIResource { + protected v2Url(path: string): string { + return `${this._client.v2BaseURL}${path}`; + } +} + +/** Drop `undefined`/`null` entries so unset params aren't serialized. */ +export function cleanQuery(query: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + out[key] = value; + } + } + return out; +} + +export function buildJobList(jobs: Array, env: Record): JobList { + return { + jobs, + has_more: typeof env['has_more'] === 'boolean' ? (env['has_more'] as boolean) : false, + org_id: typeof env['org_id'] === 'string' ? (env['org_id'] as string) : null, + page: typeof env['page'] === 'number' ? (env['page'] as number) : null, + page_size: typeof env['page_size'] === 'number' ? (env['page_size'] as number) : null, + }; +} + +export function jobsFromEnvelope(env: Record): Array> { + const jobs = env['jobs']; + if (!Array.isArray(jobs)) { + return []; + } + return jobs.filter((job): job is Record => typeof job === 'object' && job !== null); +} + +/** Translate a 504 from a synchronous parse/extract into a `V2SyncTimeoutError`. */ +export function throwIfSyncTimeout(err: unknown): void { + if (err instanceof APIError && err.status === 504) { + throw new V2SyncTimeoutError( + 'The synchronous request timed out (HTTP 504). The server cancels the work on timeout — ' + + 'use the async jobs route (`client.v2.parseJobs.create(...)` / `client.v2.extractJobs.create(...)`, ' + + 'then `.wait(...)`) for long-running documents.', + ); + } +} + +export interface WaitOptions { + /** Give up after this many milliseconds. Defaults to 600000 (10 minutes). */ + timeout?: number; + + /** Fixed poll interval in milliseconds. Omit to use exponential backoff. */ + pollInterval?: number; + + /** Throw `JobFailedError` if the job ends in a terminal `failed`/`cancelled` state. */ + raiseOnFailure?: boolean; +} + +/** + * Injectable clock so tests can drive polling without real time passing. + * Production callers use the default (wall clock + `setTimeout`). + */ +export interface PollClock { + now: () => number; + sleep: (ms: number) => Promise; +} + +const REAL_CLOCK: PollClock = { + now: () => Date.now(), + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), +}; + +function nextDelay(current: number, pollInterval: number | undefined): number { + if (pollInterval !== undefined) { + return pollInterval; + } + return Math.min(current * DEFAULT_POLL_FACTOR, DEFAULT_POLL_MAX_MS); +} + +/** + * Poll `getJob` with backoff until the job reaches a terminal state. Throws + * `JobWaitTimeoutError` on timeout and (when `raiseOnFailure`) `JobFailedError` + * if the job ended in a terminal `failed`/`cancelled` state. + */ +export async function pollUntilTerminal( + getJob: () => Promise, + options: WaitOptions, + clock: PollClock = REAL_CLOCK, +): Promise { + const timeout = options.timeout ?? DEFAULT_WAIT_TIMEOUT_MS; + const raiseOnFailure = options.raiseOnFailure ?? false; + const deadline = clock.now() + timeout; + let delay = options.pollInterval ?? DEFAULT_POLL_INITIAL_MS; + + for (;;) { + const job = await getJob(); + if (job.is_terminal) { + // Key off terminal status, not the presence of an error payload: a failed + // job may arrive with no error attached (must still throw), and a completed + // job may carry a residual/empty error object (must not throw). + if (raiseOnFailure && (job.status === 'failed' || job.status === 'cancelled')) { + throw new JobFailedError( + `Job ${job.job_id} ended ${job.status}: ${ + job.error?.message || job.error?.code || 'unknown error' + }`, + ); + } + return job; + } + if (clock.now() >= deadline) { + throw new JobWaitTimeoutError( + `Job ${job.job_id} did not finish within ${timeout}ms (last status: ${job.status}).`, + ); + } + await clock.sleep(Math.min(delay, Math.max(0, deadline - clock.now()))); + delay = nextDelay(delay, options.pollInterval); + } +} diff --git a/src/resources/v2/_normalize.ts b/src/resources/v2/_normalize.ts new file mode 100644 index 00000000..a1d4a96d --- /dev/null +++ b/src/resources/v2/_normalize.ts @@ -0,0 +1,132 @@ +// Normalize the divergent parse/extract/workflow job envelopes into one `Job`. +// +// The upstream envelopes differ in timestamp encoding (epoch seconds vs ISO +// strings), terminal payload field (`data`/`output_url` vs `result`), and +// failure representation (`failure_reason` string vs `error {code, message}`). + +import { + Job, + JobError, + JobStatus, + V2ExtractResult, + V2ParseResponse, + V2WorkflowResult, + isTerminalStatus, +} from './types'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Parse an epoch-seconds number or an ISO string into a Date; `null` on failure. */ +function toDate(value: unknown): Date | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value === 'number') { + // Epoch is in seconds (e.g. 1_700_000_000 -> 2023); 0 is a valid instant, + // not "missing", so it must round-trip to 1970-01-01T00:00:00Z. + const date = new Date(value * 1000); + return isNaN(date.getTime()) ? null : date; + } + if (typeof value === 'string') { + const date = new Date(value); + return isNaN(date.getTime()) ? null : date; + } + return null; +} + +function toProgress(value: unknown): number | null { + return typeof value === 'number' ? value : null; +} + +function toStatus(value: unknown): JobStatus { + // Unknown/renamed status from the gateway must not crash the normalizer; the + // original raw status is still available via `job.raw`. + if ( + value === 'pending' || + value === 'processing' || + value === 'completed' || + value === 'failed' || + value === 'cancelled' + ) { + return value; + } + return 'pending'; +} + +/** Extract job error: an `error {code, message}` object, or a `failure_reason` string (list envelope). */ +function isoError(raw: Record): JobError | null { + const err = raw['error']; + if (isRecord(err)) { + return { + code: typeof err['code'] === 'string' ? (err['code'] as string) : null, + message: typeof err['message'] === 'string' ? (err['message'] as string) : null, + }; + } + if (raw['failure_reason']) { + return { code: null, message: String(raw['failure_reason']) }; + } + return null; +} + +/** + * Shared skeleton for the ISO-timestamp envelopes (extract, workflow), which + * agree on everything except the concrete `result` payload type. + */ +function baseIsoJob(raw: Record): Job { + const status = toStatus(raw['status']); + return { + job_id: String(raw['job_id']), + status, + created_at: toDate(raw['created_at']), + completed_at: toDate(raw['completed_at']), + progress: toProgress(raw['progress']), + result: null, + error: isoError(raw), + is_terminal: isTerminalStatus(status), + raw, + }; +} + +export function normalizeParseJob(raw: Record): Job { + const status = toStatus(raw['status']); + const data = raw['data']; + const result = isRecord(data) ? (data as V2ParseResponse) : null; + + let error: JobError | null = null; + const reason = raw['failure_reason']; + if (reason) { + error = { code: null, message: String(reason) }; + } + + // Prefer `created_at`; fall back to `received_at`. Use `??` (not truthiness) + // so an epoch-zero `created_at` is preserved rather than falling through. + const created = raw['created_at'] ?? raw['received_at']; + + return { + job_id: String(raw['job_id']), + status, + created_at: toDate(created), + completed_at: null, // the parse envelope has no completed_at + progress: toProgress(raw['progress']), + result, + error, + is_terminal: isTerminalStatus(status), + raw, + }; +} + +export function normalizeExtractJob(raw: Record): Job { + const job = baseIsoJob(raw); + const payload = raw['result']; + job.result = isRecord(payload) ? (payload as unknown as V2ExtractResult) : null; + return job; +} + +export function normalizeWorkflowJob(raw: Record): Job { + const job = baseIsoJob(raw); + const payload = raw['result']; + job.result = isRecord(payload) ? (payload as unknown as V2WorkflowResult) : null; + return job; +} diff --git a/src/resources/v2/extract.ts b/src/resources/v2/extract.ts new file mode 100644 index 00000000..98a5c1aa --- /dev/null +++ b/src/resources/v2/extract.ts @@ -0,0 +1,117 @@ +import { LandingAIADEError } from '../../core/error'; +import { RequestOptions } from '../../internal/request-options'; +import { path } from '../../internal/utils/path'; +import { ExtractSchema, coerceSchema } from '../../lib/schema'; +import { + V2Resource, + WaitOptions, + buildJobList, + cleanQuery, + jobsFromEnvelope, + pollUntilTerminal, +} from './_base'; +import { normalizeExtractJob } from './_normalize'; +import { V2JobListParams } from './parse'; +import { Job, JobList } from './types'; + +export interface V2ExtractParams { + /** + * JSON schema for field extraction. Accepts a JSON-Schema object or a + * JSON-encoded string; it is coerced to a JSON object and sent as `schema`. + */ + schema: ExtractSchema; + + /** Markdown content to extract data from. */ + markdown?: string | null; + + /** A reference (e.g. from a prior parse or `files.upload`) to markdown content. */ + markdown_ref?: string | null; + + /** URL to the markdown file to extract data from. */ + markdown_url?: string | null; + + /** The version of the model to use for extraction. */ + model?: string | null; + + /** + * If `true`, reject schemas with unsupported fields (HTTP 422). If `false`, + * prune unsupported fields and continue. Sent as `options.strict`. + */ + strict?: boolean | null; + + /** An idempotency key for the request. */ + idempotency_key?: string | null; +} + +export interface V2ExtractJobCreateParams extends V2ExtractParams { + /** + * Async service tier. `priority` runs in the fast lane at the sync billing + * rate; absent → `standard`. + */ + service_tier?: 'standard' | 'priority' | null; +} + +export function buildExtractBody(params: V2ExtractJobCreateParams): Record { + const body: Record = { schema: coerceSchema(params.schema) }; + const entries: Array<[string, unknown]> = [ + ['markdown', params.markdown], + ['markdown_ref', params.markdown_ref], + ['markdown_url', params.markdown_url], + ['model', params.model], + ['idempotency_key', params.idempotency_key], + ['service_tier', params.service_tier], + ]; + for (const [key, value] of entries) { + if (value !== undefined && value !== null) { + body[key] = value; + } + } + if (params.strict !== undefined && params.strict !== null) { + body['options'] = { strict: Boolean(params.strict) }; + } + return body; +} + +export class ExtractJobs extends V2Resource { + /** + * Create an asynchronous extract job against `/v2/extract/jobs`. Returns a + * normalized `Job` immediately (typically `pending`). Poll with `.get(jobID)` + * or block until terminal with `.wait(jobID)`. + */ + async create(body: V2ExtractJobCreateParams, options?: RequestOptions): Promise { + const raw = await this._client.post>(this.v2Url('/v2/extract/jobs'), { + body: buildExtractBody(body), + ...options, + }); + return normalizeExtractJob(raw); + } + + /** Get the current status of an async extract job by `jobID`. */ + async get(jobID: string, options?: RequestOptions): Promise { + if (!jobID) { + throw new LandingAIADEError( + `Expected a non-empty value for 'jobID' but received ${JSON.stringify(jobID)}`, + ); + } + const raw = await this._client.get>( + this.v2Url(path`/v2/extract/jobs/${jobID}`), + options, + ); + return normalizeExtractJob(raw); + } + + /** List async extract jobs associated with your API key, newest first. */ + async list(query: V2JobListParams = {}, options?: RequestOptions): Promise { + const raw = await this._client.get>(this.v2Url('/v2/extract/jobs'), { + query: cleanQuery(query as Record), + ...options, + }); + const jobs = jobsFromEnvelope(raw).map(normalizeExtractJob); + return buildJobList(jobs, raw); + } + + /** Block, polling `.get(jobID)` with backoff, until the job is terminal. */ + wait(jobID: string, options: WaitOptions = {}): Promise { + return pollUntilTerminal(() => this.get(jobID), options); + } +} diff --git a/src/resources/v2/files.ts b/src/resources/v2/files.ts new file mode 100644 index 00000000..f61e9be3 --- /dev/null +++ b/src/resources/v2/files.ts @@ -0,0 +1,31 @@ +import { LandingAIADEError } from '../../core/error'; +import { type Uploadable } from '../../core/uploads'; +import { RequestOptions } from '../../internal/request-options'; +import { multipartFormRequestOptions } from '../../internal/uploads'; +import { V2Resource } from './_base'; +import { V2FileUploadResponse } from './types'; + +export interface FileUploadParams { + /** The file to stage. */ + file: Uploadable; +} + +export class Files extends V2Resource { + /** + * Stage a file's bytes on the ADE data plane and return a `file_ref` string, + * which can be passed as `markdown_ref` to `client.v2.extract` / + * `client.v2.extractJobs.create`. Served on the ADE host under `/v1/files`. + */ + async upload(body: FileUploadParams, options?: RequestOptions): Promise { + const response = await this._client.post( + this.v2Url('/v1/files'), + multipartFormRequestOptions({ body: { file: body.file }, ...options }, this._client), + ); + if (!response.file_ref) { + throw new LandingAIADEError( + `POST /v1/files did not return a file_ref (got: ${JSON.stringify(response)}).`, + ); + } + return response.file_ref; + } +} diff --git a/src/resources/v2/index.ts b/src/resources/v2/index.ts new file mode 100644 index 00000000..61e98fdf --- /dev/null +++ b/src/resources/v2/index.ts @@ -0,0 +1,39 @@ +export { V2 } from './v2'; +export { Files, type FileUploadParams } from './files'; +export { ParseJobs, type V2ParseParams, type V2ParseJobCreateParams, type V2JobListParams } from './parse'; +export { ExtractJobs, type V2ExtractParams, type V2ExtractJobCreateParams } from './extract'; +export { + WorkflowJobs, + type V2WorkflowParams, + type V2WorkflowJobCreateParams, + type WorkflowDocumentInput, + type PrebuiltWorkflowStep, + type WorkflowStepOptions, +} from './workflow'; +export { type WaitOptions, DEFAULT_WAIT_TIMEOUT_MS } from './_base'; +export { + type Job, + type JobError, + type JobList, + type JobStatus, + type V2Billing, + type V2Box, + type V2ElementType, + type V2ExtractMetadata, + type V2ExtractResult, + type V2FileUploadResponse, + type V2GroundingDocument, + type V2GroundingElement, + type V2GroundingEntry, + type V2GroundingPage, + type V2ParseBilling, + type V2ParseElement, + type V2ParseMetadata, + type V2ParsePage, + type V2ParseResponse, + type V2ParseStructure, + type V2Span, + type V2WorkflowMetadata, + type V2WorkflowResult, + isTerminalStatus, +} from './types'; diff --git a/src/resources/v2/parse.ts b/src/resources/v2/parse.ts new file mode 100644 index 00000000..7dea4b44 --- /dev/null +++ b/src/resources/v2/parse.ts @@ -0,0 +1,119 @@ +import { LandingAIADEError } from '../../core/error'; +import { type Uploadable } from '../../core/uploads'; +import { RequestOptions } from '../../internal/request-options'; +import { multipartFormRequestOptions } from '../../internal/uploads'; +import { path } from '../../internal/utils/path'; +import { + V2Resource, + WaitOptions, + buildJobList, + cleanQuery, + jobsFromEnvelope, + pollUntilTerminal, +} from './_base'; +import { normalizeParseJob } from './_normalize'; +import { Job, JobList } from './types'; + +export interface V2ParseParams { + /** A file to be parsed. Provide either this or `document_url`. */ + document?: Uploadable | null; + + /** URL to the file to be parsed. Provide either this or `document`. */ + document_url?: string | null; + + /** The version of the model to use for parsing. */ + model?: string | null; + + /** Additional parsing options. Sent to the server as a JSON-encoded form field. */ + options?: Record | string | null; + + /** + * Encrypted PDFs are not currently supported: providing a password returns a + * 422. Decrypt the file before uploading. + */ + password?: string | null; +} + +export interface V2ParseJobCreateParams extends V2ParseParams { + /** + * If zero data retention (ZDR) is enabled, a URL the parsed output should be + * saved to instead of being returned in the job result. + */ + output_save_url?: string | null; + + /** + * Async service tier. `priority` runs in the fast lane at the sync billing + * rate; absent → `standard`. + */ + service_tier?: 'standard' | 'priority' | null; +} + +export interface V2JobListParams { + page?: number; + + page_size?: number; + + status?: string | null; +} + +/** + * Build the multipart form body for parse. `options` is JSON-encoded per the + * contract; unset (`undefined`/`null`) fields are dropped so they aren't sent. + */ +export function buildParseForm(params: V2ParseJobCreateParams): Record { + const { options, ...rest } = params; + const body: Record = {}; + for (const [key, value] of Object.entries(rest)) { + if (value !== undefined && value !== null) { + body[key] = value; + } + } + if (options !== undefined && options !== null) { + body['options'] = typeof options === 'string' ? options : JSON.stringify(options); + } + return body; +} + +export class ParseJobs extends V2Resource { + /** + * Create an asynchronous parse job against `/v2/parse/jobs`. Returns a + * normalized `Job` immediately (typically `pending`). Poll with `.get(jobID)` + * or block until terminal with `.wait(jobID)`. + */ + async create(body: V2ParseJobCreateParams, options?: RequestOptions): Promise { + const raw = await this._client.post>( + this.v2Url('/v2/parse/jobs'), + multipartFormRequestOptions({ body: buildParseForm(body), ...options }, this._client), + ); + return normalizeParseJob(raw); + } + + /** Get the current status of an async parse job by `jobID`. */ + async get(jobID: string, options?: RequestOptions): Promise { + if (!jobID) { + throw new LandingAIADEError( + `Expected a non-empty value for 'jobID' but received ${JSON.stringify(jobID)}`, + ); + } + const raw = await this._client.get>( + this.v2Url(path`/v2/parse/jobs/${jobID}`), + options, + ); + return normalizeParseJob(raw); + } + + /** List async parse jobs associated with your API key, newest first. */ + async list(query: V2JobListParams = {}, options?: RequestOptions): Promise { + const raw = await this._client.get>(this.v2Url('/v2/parse/jobs'), { + query: cleanQuery(query as Record), + ...options, + }); + const jobs = jobsFromEnvelope(raw).map(normalizeParseJob); + return buildJobList(jobs, raw); + } + + /** Block, polling `.get(jobID)` with backoff, until the job is terminal. */ + wait(jobID: string, options: WaitOptions = {}): Promise { + return pollUntilTerminal(() => this.get(jobID), options); + } +} diff --git a/src/resources/v2/types.ts b/src/resources/v2/types.ts new file mode 100644 index 00000000..363f9aca --- /dev/null +++ b/src/resources/v2/types.ts @@ -0,0 +1,274 @@ +// V2 (ADE gateway) types. +// +// The response models below mirror the wire JSON (snake_case), matching the +// convention used by the rest of the SDK. The unified `Job` / `JobList` shapes +// are a hand-written, normalized ergonomic layer over the divergent parse and +// extract job envelopes and therefore use idiomatic camelCase; the full +// original envelope is always available on `Job.raw`. + +/** + * Common job status across parse, extract, and workflow jobs. Extract and + * workflow jobs never report `cancelled`, but the union is shared so callers + * only learn one enum. + */ +export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + +export interface JobError { + code: string | null; + + message: string | null; +} + +/** + * One normalized job shape across parse, extract, and workflow (the envelopes + * diverge upstream). `result` is a `V2ParseResponse` for parse jobs, a + * `V2ExtractResult` for extract jobs, and a `V2WorkflowResult` for workflow + * jobs, or `null` until completion. `raw` retains the full original envelope + * for any field not surfaced here (e.g. `org_id`, `output_url`, `version`). + */ +export interface Job { + job_id: string; + + status: JobStatus; + + created_at: Date | null; + + completed_at: Date | null; + + progress: number | null; + + result: V2ParseResponse | V2ExtractResult | V2WorkflowResult | null; + + error: JobError | null; + + /** `true` when `status` is `completed`, `failed`, or `cancelled`. */ + is_terminal: boolean; + + raw: Record; +} + +/** + * A page of normalized jobs plus the pagination envelope. `org_id` is populated + * for parse listings, `page`/`page_size` for extract/workflow listings. + */ +export interface JobList { + jobs: Array; + + has_more: boolean; + + org_id: string | null; + + page: number | null; + + page_size: number | null; +} + +/** Billing summary: the service tier the request ran in and the credits charged. */ +export interface V2Billing { + service_tier?: 'standard' | 'priority' | null; + + total_credits?: number | null; +} + +// ---- Parse ---- + +export interface V2ParseBilling { + service_tier?: 'standard' | 'priority' | null; + + total_credits?: number | null; +} + +export interface V2ParseMetadata { + req_id?: string | null; + + job_id?: string | null; + + model_version?: string | null; + + page_count?: number | null; + + markdown_chars?: number | null; + + /** 0-indexed pages that failed to parse. Populated on a 206 partial success. */ + failed_pages?: Array | null; + + duration_ms?: number | null; + + billing?: V2ParseBilling | null; +} + +/** `[start, end)` Unicode code-point offsets into the top-level `markdown`. */ +export type V2Span = [number, number]; + +/** `[left, top, right, bottom]` bounding box on the source page, in pixels. */ +export type V2Box = [number, number, number, number]; + +export type V2ElementType = + | 'text' + | 'table' + | 'table_cell' + | 'figure' + | 'marginalia' + | 'attestation' + | 'logo' + | 'card' + | 'scan_code'; + +/** A node in the `structure` tree (non-page element). */ +export interface V2ParseElement { + type: V2ElementType; + + id: string; + + span: V2Span; + + /** Cells of a `table` element; present only when `type` is `table`. */ + children?: Array | null; + + row?: number | null; + + col?: number | null; + + colspan?: number | null; + + rowspan?: number | null; +} + +export interface V2ParsePage { + type?: 'page'; + + page: number; + + span: V2Span; + + width?: number | null; + + height?: number | null; + + dpi?: number | null; + + status?: 'ok' | 'failed'; + + reason?: string | null; + + children?: Array; +} + +/** The document's hierarchical `structure`: pages and their elements. */ +export interface V2ParseStructure { + type?: 'document'; + + children?: Array; +} + +/** One fine-grained grounding segment (line-level or finer). */ +export interface V2GroundingEntry { + span: V2Span; + + box: V2Box; +} + +export interface V2GroundingElement { + type: V2ElementType; + + id: string; + + span: V2Span; + + box: V2Box; + + parts?: Array; + + children?: Array | null; +} + +export interface V2GroundingPage { + type?: 'page'; + + page: number; + + span: V2Span; + + children?: Array; +} + +/** The document's spatial `grounding` tree, mirroring `structure`. */ +export interface V2GroundingDocument { + type?: 'document'; + + children?: Array; +} + +/** V2 parse result: full `markdown`, hierarchical `structure`, spatial `grounding`, and `metadata`. */ +export interface V2ParseResponse { + markdown?: string | null; + + structure?: V2ParseStructure | null; + + grounding?: V2GroundingDocument | null; + + metadata?: V2ParseMetadata | null; +} + +// ---- Extract ---- + +export interface V2ExtractMetadata { + job_id: string; + + version: string; + + duration_ms: number; + + doc_id?: string | null; + + credit_usage?: number; + + billing?: V2Billing | null; +} + +export interface V2ExtractResult { + extraction: Record; + + extraction_metadata: Record; + + markdown: string; + + metadata: V2ExtractMetadata; +} + +// ---- Workflow ---- + +export interface V2WorkflowMetadata { + job_id: string; + + duration_ms: number; + + credit_usage?: number; + + billing?: V2Billing | null; +} + +/** Result of a `parse-extract` workflow: step results keyed by step name (or a caller projection). */ +export interface V2WorkflowResult { + output: Record; + + metadata: V2WorkflowMetadata; +} + +// ---- Files ---- + +/** + * `POST /v1/files` returns an open string map; `file_ref` is the key the SDK + * consumes to reference staged markdown/documents. + */ +export interface V2FileUploadResponse { + file_ref?: string | null; + + [key: string]: unknown; +} + +const TERMINAL_STATUSES: ReadonlySet = new Set(['completed', 'failed', 'cancelled']); + +export function isTerminalStatus(status: JobStatus): boolean { + return TERMINAL_STATUSES.has(status); +} diff --git a/src/resources/v2/v2.ts b/src/resources/v2/v2.ts new file mode 100644 index 00000000..24b68776 --- /dev/null +++ b/src/resources/v2/v2.ts @@ -0,0 +1,112 @@ +import { _getInputFilename, _saveResponse } from '../../client'; +import { RequestOptions } from '../../internal/request-options'; +import { multipartFormRequestOptions } from '../../internal/uploads'; +import { V2Resource, throwIfSyncTimeout } from './_base'; +import { Files } from './files'; +import { ExtractJobs, V2ExtractParams, buildExtractBody } from './extract'; +import { ParseJobs, V2ParseParams, buildParseForm } from './parse'; +import { WorkflowJobs, V2WorkflowParams, prepareWorkflowRequest } from './workflow'; +import { V2ExtractResult, V2ParseResponse, V2WorkflowResult } from './types'; + +/** + * Container for the additive V2 (ADE gateway) surface: `client.v2.*`. All + * requests route to the client's resolved `v2BaseURL` and share the V1 + * transport (auth, retries, fetch). Using it does not change any V1 behavior. + */ +export class V2 extends V2Resource { + files: Files = new Files(this._client); + parseJobs: ParseJobs = new ParseJobs(this._client); + extractJobs: ExtractJobs = new ExtractJobs(this._client); + workflowJobs: WorkflowJobs = new WorkflowJobs(this._client); + + /** + * Parse a document synchronously (`POST /v2/parse`). Resolves with a + * `V2ParseResponse` on both a full success (HTTP 200) and a partial success + * (HTTP 206, where `metadata.failed_pages` lists unparsed pages). Rejects with + * `V2SyncTimeoutError` on a 504; use `parseJobs` for long-running documents. + * + * Pass `saveTo` to also write the response to disk (a `.json` path writes + * there directly; otherwise it is treated as a directory with an auto-named + * file), mirroring the V1 `saveTo` behavior. + */ + async parse(body: V2ParseParams & { saveTo?: string }, options?: RequestOptions): Promise { + const { saveTo, ...rest } = body; + try { + // A 504 means the server cancelled the (long-running) work, so retrying + // re-runs a doomed request. Cap retries at 1 (below the client default) so + // a 504 costs at most 2 attempts, while a transient connection blip on this + // long sync call can still recover once. Caller can override via options. + const result = await this._client.post( + this.v2Url('/v2/parse'), + multipartFormRequestOptions({ body: buildParseForm(rest), maxRetries: 1, ...options }, this._client), + ); + if (saveTo) { + const filename = _getInputFilename(rest.document ?? null, rest.document_url ?? null); + _saveResponse(saveTo, filename, 'parse', result); + } + return result; + } catch (err) { + throwIfSyncTimeout(err); + throw err; + } + } + + /** + * Extract structured data from markdown synchronously (`POST /v2/extract`, + * JSON body). `schema` accepts a JSON-Schema object or a JSON-encoded string. + * Provide exactly one of `markdown`, `markdown_ref`, or `markdown_url`. + * Rejects with `V2SyncTimeoutError` on a 504; use `extractJobs` for + * long-running documents. + */ + async extract( + body: V2ExtractParams & { saveTo?: string }, + options?: RequestOptions, + ): Promise { + const { saveTo, ...rest } = body; + try { + const result = await this._client.post(this.v2Url('/v2/extract'), { + body: buildExtractBody(rest), + maxRetries: 1, // see parse(): cap sync retries so a 504 costs <= 2 attempts + ...options, + }); + if (saveTo) { + const filename = _getInputFilename(null, rest.markdown_url ?? null); + _saveResponse(saveTo, filename, 'extract', result); + } + return result; + } catch (err) { + throwIfSyncTimeout(err); + throw err; + } + } + + /** + * Run a workflow synchronously (`POST /v2/workflow`). Phase 1 supports a single + * `parse-extract` step. Reference documents by uploading via + * `client.v2.files.upload` and passing the returned ref as + * `inputs..document_ref`, or use `document_url`. Rejects with + * `V2SyncTimeoutError` on a 504; use `workflowJobs` for long-running documents. + */ + async workflow( + body: V2WorkflowParams & { saveTo?: string }, + options?: RequestOptions, + ): Promise { + const { saveTo, ...rest } = body; + try { + const { multipart, body: reqBody } = prepareWorkflowRequest(rest); + const result = await this._client.post( + this.v2Url('/v2/workflow'), + multipart ? + multipartFormRequestOptions({ body: reqBody, maxRetries: 1, ...options }, this._client) + : { body: reqBody, maxRetries: 1, ...options }, // see parse(): cap sync retries + ); + if (saveTo) { + _saveResponse(saveTo, _getInputFilename(null, null), 'workflow', result); + } + return result; + } catch (err) { + throwIfSyncTimeout(err); + throw err; + } + } +} diff --git a/src/resources/v2/workflow.ts b/src/resources/v2/workflow.ts new file mode 100644 index 00000000..d085136e --- /dev/null +++ b/src/resources/v2/workflow.ts @@ -0,0 +1,166 @@ +import { LandingAIADEError } from '../../core/error'; +import { type Uploadable } from '../../core/uploads'; +import { RequestOptions } from '../../internal/request-options'; +import { multipartFormRequestOptions } from '../../internal/uploads'; +import { path } from '../../internal/utils/path'; +import { + V2Resource, + WaitOptions, + buildJobList, + cleanQuery, + jobsFromEnvelope, + pollUntilTerminal, +} from './_base'; +import { normalizeWorkflowJob } from './_normalize'; +import { V2JobListParams } from './parse'; +import { Job, JobList } from './types'; + +/** + * One declared document input. Provide exactly one of `document` (a file — the + * SDK stages it as a multipart part and references it by name), `document_ref` + * (from `client.v2.files.upload`), or `document_url`. + */ +export interface WorkflowDocumentInput { + document?: Uploadable | null; + + document_ref?: string | null; + + document_url?: string | null; +} + +/** Parse + extract options for a `parse-extract` step. */ +export interface WorkflowStepOptions { + /** 0-indexed page indices to process (parse stage). `null` = all pages. */ + pages?: Array | null; + + /** Reject unsupported schema fields with a 422 instead of skipping them (extract stage). */ + strict?: boolean; +} + +/** A prebuilt pipeline step. `document` references an `inputs` entry as `"$inputs."`. */ +export interface PrebuiltWorkflowStep { + name: 'parse-extract'; + + document: string; + + schema: Record; + + options?: WorkflowStepOptions | null; +} + +export interface V2WorkflowParams { + /** Named document sources, referenced from steps as `"$inputs."`. */ + inputs: Record; + + /** Phase 1: a single prebuilt `parse-extract` step. */ + steps: Array; + + /** Optional projection map: response field name → `"$output....."`. */ + output?: Record | null; + + idempotency_key?: string | null; +} + +export interface V2WorkflowJobCreateParams extends V2WorkflowParams { + /** + * Async service tier. `priority` runs in the fast lane at the sync billing + * rate; absent → `standard`. + */ + service_tier?: 'standard' | 'priority' | null; +} + +export interface PreparedWorkflowRequest { + multipart: boolean; + body: Record; +} + +/** + * Build the request body for a workflow call. If any input carries a file + * (`document`), returns a multipart form — `inputs`/`steps`/`output` as + * JSON-encoded fields plus one binary part per file, with each file's input + * rewritten to reference its part name. Otherwise a plain JSON body. + */ +export function prepareWorkflowRequest(params: V2WorkflowJobCreateParams): PreparedWorkflowRequest { + const files: Array<[string, Uploadable]> = []; + const resolvedInputs: Record = {}; + + for (const [key, input] of Object.entries(params.inputs)) { + if (input.document != null) { + // A file: stage it as a named multipart part and reference it by name. + const partName = `document_${key}`; + files.push([partName, input.document]); + resolvedInputs[key] = { document: partName }; + } else { + const resolved: Record = {}; + if (input.document_ref != null) resolved['document_ref'] = input.document_ref; + if (input.document_url != null) resolved['document_url'] = input.document_url; + resolvedInputs[key] = resolved; + } + } + + if (files.length === 0) { + const body: Record = { inputs: resolvedInputs, steps: params.steps }; + if (params.output != null) body['output'] = params.output; + if (params.idempotency_key != null) body['idempotency_key'] = params.idempotency_key; + if (params.service_tier != null) body['service_tier'] = params.service_tier; + return { multipart: false, body }; + } + + // Multipart: JSON-encode the structured fields, append files as parts. + const form: Record = { + inputs: JSON.stringify(resolvedInputs), + steps: JSON.stringify(params.steps), + }; + if (params.output != null) form['output'] = JSON.stringify(params.output); + if (params.idempotency_key != null) form['idempotency_key'] = params.idempotency_key; + if (params.service_tier != null) form['service_tier'] = params.service_tier; + for (const [name, file] of files) form[name] = file; + return { multipart: true, body: form }; +} + +export class WorkflowJobs extends V2Resource { + /** + * Create an asynchronous workflow job against `/v2/workflow/jobs`. Returns a + * normalized `Job` immediately (typically `pending`). Poll with `.get(jobID)` + * or block until terminal with `.wait(jobID)`. + */ + async create(body: V2WorkflowJobCreateParams, options?: RequestOptions): Promise { + const { multipart, body: reqBody } = prepareWorkflowRequest(body); + const raw = await this._client.post>( + this.v2Url('/v2/workflow/jobs'), + multipart ? + multipartFormRequestOptions({ body: reqBody, ...options }, this._client) + : { body: reqBody, ...options }, + ); + return normalizeWorkflowJob(raw); + } + + /** Get the current status of an async workflow job by `jobID`. */ + async get(jobID: string, options?: RequestOptions): Promise { + if (!jobID) { + throw new LandingAIADEError( + `Expected a non-empty value for 'jobID' but received ${JSON.stringify(jobID)}`, + ); + } + const raw = await this._client.get>( + this.v2Url(path`/v2/workflow/jobs/${jobID}`), + options, + ); + return normalizeWorkflowJob(raw); + } + + /** List async workflow jobs associated with your API key, newest first. */ + async list(query: V2JobListParams = {}, options?: RequestOptions): Promise { + const raw = await this._client.get>(this.v2Url('/v2/workflow/jobs'), { + query: cleanQuery(query as Record), + ...options, + }); + const jobs = jobsFromEnvelope(raw).map(normalizeWorkflowJob); + return buildJobList(jobs, raw); + } + + /** Block, polling `.get(jobID)` with backoff, until the job is terminal. */ + wait(jobID: string, options: WaitOptions = {}): Promise { + return pollUntilTerminal(() => this.get(jobID), options); + } +} diff --git a/tests/api-resources/v2/v2.test.ts b/tests/api-resources/v2/v2.test.ts new file mode 100644 index 00000000..64adae9a --- /dev/null +++ b/tests/api-resources/v2/v2.test.ts @@ -0,0 +1,181 @@ +import LandingAIADE, { V2SyncTimeoutError, toFile } from 'landingai-ade'; +import type { Fetch } from 'landingai-ade/internal/builtin-types'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +/** A client backed by a stub fetch that records request URLs and returns `handler`'s response. */ +function stubClient(handler: (url: string) => Response): { client: LandingAIADE; calls: string[] } { + const calls: string[] = []; + const fetch: Fetch = async (input) => { + const url = String(input); + if (!url.startsWith('data:')) calls.push(url); // ignore the FormData-support probe + return handler(url); + }; + const client = new LandingAIADE({ apikey: 'k', environment: 'staging', maxRetries: 0, fetch }); + return { client, calls }; +} + +describe('client.v2 routing', () => { + test('files.upload routes to the V2 host and returns file_ref', async () => { + const { client, calls } = stubClient(() => jsonResponse({ file_ref: 'ref-1' })); + const ref = await client.v2.files.upload({ file: await toFile(Buffer.from('hi'), 'a.md') }); + expect(ref).toBe('ref-1'); + expect(calls.some((u) => u === 'https://api.ade.staging.landing.ai/v1/files')).toBe(true); + }); + + test('parse routes to the V2 host and returns a 206 partial result', async () => { + const { client, calls } = stubClient(() => + jsonResponse({ markdown: 'x', metadata: { failed_pages: [2] } }, 206), + ); + const res = await client.v2.parse({ document: await toFile(Buffer.from('%PDF'), 'a.pdf') }); + expect(res.metadata?.failed_pages).toEqual([2]); + expect(calls.some((u) => u === 'https://api.ade.staging.landing.ai/v2/parse')).toBe(true); + }); + + test('extract sends a JSON body to the V2 host', async () => { + const { client, calls } = stubClient(() => + jsonResponse({ + extraction: { a: 1 }, + extraction_metadata: {}, + markdown: '# doc', + metadata: { job_id: 'j', version: 'v', duration_ms: 1 }, + }), + ); + const res = await client.v2.extract({ schema: { type: 'object' }, markdown: 'hi' }); + expect(res.metadata.version).toBe('v'); + expect(calls.some((u) => u === 'https://api.ade.staging.landing.ai/v2/extract')).toBe(true); + }); + + test('a 504 on a sync call surfaces as V2SyncTimeoutError', async () => { + const { client } = stubClient(() => new Response('', { status: 504 })); + await expect(client.v2.extract({ schema: { type: 'object' }, markdown: 'x' })).rejects.toBeInstanceOf( + V2SyncTimeoutError, + ); + }); + + test('a sync 504 is capped at one retry (below the client default), then surfaces as V2SyncTimeoutError', async () => { + let calls = 0; + const fetch: Fetch = async (input) => { + if (!String(input).startsWith('data:')) calls++; + return new Response('', { status: 504 }); + }; + // No maxRetries override here — the sync method caps retries at 1 itself + // (client default is 2, which would otherwise mean 3 doomed attempts). + const client = new LandingAIADE({ apikey: 'k', environment: 'staging', fetch }); + await expect(client.v2.extract({ schema: { type: 'object' }, markdown: 'x' })).rejects.toBeInstanceOf( + V2SyncTimeoutError, + ); + expect(calls).toBe(2); // 1 initial attempt + 1 retry + }); + + test('parseJobs.create normalizes the create envelope', async () => { + const { client, calls } = stubClient(() => jsonResponse({ job_id: 'pj-1' }, 202)); + const job = await client.v2.parseJobs.create({ document: await toFile(Buffer.from('x'), 'a.pdf') }); + expect(job.job_id).toBe('pj-1'); + expect(job.status).toBe('pending'); + expect(calls.some((u) => u === 'https://api.ade.staging.landing.ai/v2/parse/jobs')).toBe(true); + }); + + test('extractJobs.get normalizes a completed job', async () => { + const { client, calls } = stubClient(() => + jsonResponse({ + job_id: 'ej-1', + status: 'completed', + created_at: '2026-01-02T03:04:05Z', + result: { + extraction: {}, + extraction_metadata: {}, + markdown: '', + metadata: { job_id: 'ej-1', version: 'v', duration_ms: 1 }, + }, + }), + ); + const job = await client.v2.extractJobs.get('ej-1'); + expect(job.status).toBe('completed'); + expect(job.is_terminal).toBe(true); + expect(calls.some((u) => u === 'https://api.ade.staging.landing.ai/v2/extract/jobs/ej-1')).toBe(true); + }); + + test('parseJobs.list builds a JobList with the pagination envelope', async () => { + const { client } = stubClient(() => + jsonResponse({ jobs: [{ job_id: 'a', status: 'pending' }], has_more: true, org_id: 'o' }), + ); + const list = await client.v2.parseJobs.list({ page: 0, page_size: 10 }); + expect(list.jobs[0]!.job_id).toBe('a'); + expect(list.has_more).toBe(true); + expect(list.org_id).toBe('o'); + }); + + test('extractJobs.create sends service_tier in the JSON body', async () => { + let sentBody: unknown; + const fetch: Fetch = async (_input, init) => { + sentBody = init?.body; + return jsonResponse({ job_id: 'ej-2' }, 202); + }; + const client = new LandingAIADE({ apikey: 'k', environment: 'staging', maxRetries: 0, fetch }); + const job = await client.v2.extractJobs.create({ + schema: { type: 'object' }, + markdown: 'hi', + service_tier: 'priority', + }); + expect(job.job_id).toBe('ej-2'); + expect(JSON.parse(String(sentBody))).toMatchObject({ service_tier: 'priority' }); + }); + + test('workflow (sync) routes to the V2 host and returns output + metadata', async () => { + const { client, calls } = stubClient(() => + jsonResponse({ + output: { 'parse-extract': { extract: { extraction: { revenue: '1M' } } } }, + metadata: { job_id: 'w', duration_ms: 5 }, + }), + ); + const res = await client.v2.workflow({ + inputs: { report: { document_url: 'https://example.com/r.pdf' } }, + steps: [{ name: 'parse-extract', document: '$inputs.report', schema: { type: 'object' } }], + }); + expect(res.metadata.job_id).toBe('w'); + expect(calls.some((u) => u === 'https://api.ade.staging.landing.ai/v2/workflow')).toBe(true); + }); + + test('workflowJobs.create sends service_tier and normalizes the job', async () => { + let sentBody: unknown; + const fetch: Fetch = async (_input, init) => { + sentBody = init?.body; + return jsonResponse({ job_id: 'wj-1' }, 202); + }; + const client = new LandingAIADE({ apikey: 'k', environment: 'staging', maxRetries: 0, fetch }); + const job = await client.v2.workflowJobs.create({ + inputs: { report: { document_ref: 'ref-1' } }, + steps: [{ name: 'parse-extract', document: '$inputs.report', schema: { type: 'object' } }], + service_tier: 'priority', + }); + expect(job.job_id).toBe('wj-1'); + expect(job.status).toBe('pending'); + expect(JSON.parse(String(sentBody))).toMatchObject({ service_tier: 'priority' }); + }); + + test('workflow with a file input sends multipart with the file part', async () => { + let sentBody: unknown; + const fetch: Fetch = async (input, init) => { + if (!String(input).startsWith('data:')) sentBody = init?.body; + return jsonResponse({ output: {}, metadata: { job_id: 'w2', duration_ms: 1 } }); + }; + const client = new LandingAIADE({ apikey: 'k', environment: 'staging', maxRetries: 0, fetch }); + await client.v2.workflow({ + inputs: { report: { document: await toFile(Buffer.from('%PDF'), 'r.pdf') } }, + steps: [{ name: 'parse-extract', document: '$inputs.report', schema: { type: 'object' } }], + }); + expect(sentBody).toBeInstanceOf(FormData); + const form = sentBody as FormData; + const part = form.get('document_report'); + expect(part).not.toBeNull(); + expect(typeof part).not.toBe('string'); // a binary File part, not a string field + expect(JSON.parse(String(form.get('inputs')))).toEqual({ report: { document: 'document_report' } }); + expect(typeof form.get('steps')).toBe('string'); + }); +}); diff --git a/tests/v2-environment.test.ts b/tests/v2-environment.test.ts new file mode 100644 index 00000000..e1bde285 --- /dev/null +++ b/tests/v2-environment.test.ts @@ -0,0 +1,90 @@ +import LandingAIADE from 'landingai-ade'; + +const APIKEY = 'My Apikey'; + +// Snapshot and restore the env vars the constructor reads, so these tests are +// deterministic regardless of the ambient environment. +const ENV_KEYS = ['LANDINGAI_ADE_BASE_URL', 'LANDINGAI_ADE_V2_BASE_URL', 'LANDINGAI_ADE_ENVIRONMENT']; +let saved: Record = {}; + +beforeEach(() => { + saved = {}; + for (const key of ENV_KEYS) { + saved[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } +}); + +describe('V2 environment / base URL resolution', () => { + test('default production pair', () => { + const client = new LandingAIADE({ apikey: APIKEY }); + expect(client.baseURL).toBe('https://api.va.landing.ai'); + expect(client.v2BaseURL).toBe('https://api.ade.landing.ai'); + }); + + test.each([ + ['production', 'https://api.va.landing.ai', 'https://api.ade.landing.ai'], + ['eu', 'https://api.va.eu-west-1.landing.ai', 'https://api.ade.eu-west-1.landing.ai'], + ['staging', 'https://api.va.staging.landing.ai', 'https://api.ade.staging.landing.ai'], + ['dev', 'https://api.va.dev.landing.ai', 'https://api.ade.dev.landing.ai'], + ] as const)('environment=%s pairs the V1 and V2 hosts', (environment, v1, v2) => { + const client = new LandingAIADE({ apikey: APIKEY, environment }); + expect(client.baseURL).toBe(v1); + expect(client.v2BaseURL).toBe(v2); + }); + + test('environment from LANDINGAI_ADE_ENVIRONMENT env var', () => { + process.env['LANDINGAI_ADE_ENVIRONMENT'] = 'staging'; + const client = new LandingAIADE({ apikey: APIKEY }); + expect(client.baseURL).toBe('https://api.va.staging.landing.ai'); + expect(client.v2BaseURL).toBe('https://api.ade.staging.landing.ai'); + }); + + test('explicit v2BaseURL wins', () => { + const client = new LandingAIADE({ apikey: APIKEY, v2BaseURL: 'https://mock.local/' }); + expect(client.v2BaseURL).toBe('https://mock.local'); + }); + + test('LANDINGAI_ADE_V2_BASE_URL env var', () => { + process.env['LANDINGAI_ADE_V2_BASE_URL'] = 'https://v2.mock.local'; + const client = new LandingAIADE({ apikey: APIKEY }); + expect(client.v2BaseURL).toBe('https://v2.mock.local'); + }); + + test('V2 follows baseURL when only baseURL is set', () => { + const client = new LandingAIADE({ apikey: APIKEY, baseURL: 'http://127.0.0.1:4010' }); + expect(client.baseURL).toBe('http://127.0.0.1:4010'); + expect(client.v2BaseURL).toBe('http://127.0.0.1:4010'); + }); + + test('explicit baseURL governs V2 even when LANDINGAI_ADE_ENVIRONMENT is set (no host split)', () => { + process.env['LANDINGAI_ADE_ENVIRONMENT'] = 'staging'; + const client = new LandingAIADE({ apikey: APIKEY, baseURL: 'http://127.0.0.1:4010' }); + expect(client.baseURL).toBe('http://127.0.0.1:4010'); + // Must follow the explicit baseURL, NOT escape to api.ade.staging.landing.ai. + expect(client.v2BaseURL).toBe('http://127.0.0.1:4010'); + }); + + test('explicit environment beats the LANDINGAI_ADE_V2_BASE_URL env var', () => { + process.env['LANDINGAI_ADE_V2_BASE_URL'] = 'https://api.ade.staging.landing.ai'; + const client = new LandingAIADE({ apikey: APIKEY, environment: 'production' }); + expect(client.v2BaseURL).toBe('https://api.ade.landing.ai'); + }); + + test('v2 sub-client exists', () => { + const client = new LandingAIADE({ apikey: APIKEY }); + expect(client.v2).toBeDefined(); + expect(typeof client.v2.parse).toBe('function'); + expect(typeof client.v2.extract).toBe('function'); + expect(client.v2.files).toBeDefined(); + expect(client.v2.parseJobs).toBeDefined(); + expect(client.v2.extractJobs).toBeDefined(); + }); +}); diff --git a/tests/v2-normalize.test.ts b/tests/v2-normalize.test.ts new file mode 100644 index 00000000..471e8be8 --- /dev/null +++ b/tests/v2-normalize.test.ts @@ -0,0 +1,124 @@ +import { + normalizeExtractJob, + normalizeParseJob, + normalizeWorkflowJob, +} from 'landingai-ade/resources/v2/_normalize'; + +describe('normalizeParseJob', () => { + test('epoch timestamps + inline data', () => { + const job = normalizeParseJob({ + job_id: 'p1', + status: 'completed', + received_at: 1_700_000_000, + created_at: 1_700_000_005, + progress: 1.0, + org_id: 'o1', + output_url: null, + data: { markdown: '# hi', metadata: { job_id: 'p1', page_count: 1 } }, + }); + expect(job.job_id).toBe('p1'); + expect(job.status).toBe('completed'); + expect(job.is_terminal).toBe(true); + expect(job.created_at?.getUTCFullYear()).toBe(2023); + expect((job.result as any)?.markdown).toBe('# hi'); + expect(job.error).toBeNull(); + expect(job.raw['org_id']).toBe('o1'); // envelope-only fields preserved + }); + + test('epoch-zero created_at is preserved (not treated as missing)', () => { + const job = normalizeParseJob({ job_id: 'p', status: 'pending', created_at: 0, received_at: 123 }); + expect(job.created_at).not.toBeNull(); + expect(job.created_at?.getTime()).toBe(0); // 1970-01-01, not the received_at fallback + }); + + test('failure_reason maps to error.message', () => { + const job = normalizeParseJob({ + job_id: 'p2', + status: 'failed', + failure_reason: 'bad pdf', + created_at: 1, + }); + expect(job.status).toBe('failed'); + expect(job.error?.message).toBe('bad pdf'); + expect(job.result).toBeNull(); + }); + + test('minimal create envelope defaults to pending', () => { + const job = normalizeParseJob({ job_id: 'parse-x' }); + expect(job.job_id).toBe('parse-x'); + expect(job.status).toBe('pending'); + expect(job.is_terminal).toBe(false); + expect(job.result).toBeNull(); + }); + + test('unknown status defaults to pending but preserves raw', () => { + const job = normalizeParseJob({ job_id: 'p', status: 'some_brand_new_status' }); + expect(job.status).toBe('pending'); + expect(job.raw['status']).toBe('some_brand_new_status'); + }); +}); + +describe('normalizeExtractJob', () => { + test('ISO timestamps + result', () => { + const job = normalizeExtractJob({ + job_id: 'e1', + status: 'completed', + created_at: '2026-01-02T03:04:05Z', + completed_at: '2026-01-02T03:04:09Z', + result: { + extraction: { revenue: '1M' }, + extraction_metadata: { revenue: { value: '1M', spans: [] } }, + markdown: '# doc', + metadata: { job_id: 'e1', version: 'extract-1', duration_ms: 10 }, + }, + }); + expect(job.status).toBe('completed'); + expect(job.created_at?.getUTCFullYear()).toBe(2026); + expect(job.completed_at).not.toBeNull(); + expect((job.result as any)?.metadata.version).toBe('extract-1'); + }); + + test('error object maps to code + message', () => { + const job = normalizeExtractJob({ + job_id: 'e2', + status: 'failed', + error: { code: 'internal_error', message: 'boom' }, + }); + expect(job.status).toBe('failed'); + expect(job.error?.code).toBe('internal_error'); + expect(job.error?.message).toBe('boom'); + }); + + test('list envelope failure_reason maps to error.message', () => { + const job = normalizeExtractJob({ job_id: 'e3', status: 'failed', failure_reason: 'nope' }); + expect(job.error?.message).toBe('nope'); + }); +}); + +describe('normalizeWorkflowJob', () => { + test('ISO timestamps + workflow result (output + metadata)', () => { + const job = normalizeWorkflowJob({ + job_id: 'w1', + status: 'completed', + created_at: '2026-01-02T03:04:05Z', + completed_at: '2026-01-02T03:04:20Z', + result: { + output: { 'parse-extract': { extract: { extraction: { revenue: '1M' } } } }, + metadata: { job_id: 'w1', duration_ms: 100 }, + }, + }); + expect(job.status).toBe('completed'); + expect(job.is_terminal).toBe(true); + expect(job.completed_at).not.toBeNull(); + expect((job.result as any)?.output['parse-extract'].extract.extraction.revenue).toBe('1M'); + }); + + test('error object maps to code + message', () => { + const job = normalizeWorkflowJob({ + job_id: 'w2', + status: 'failed', + error: { code: 'boom', message: 'x' }, + }); + expect(job.error?.code).toBe('boom'); + }); +}); diff --git a/tests/v2-schema.test.ts b/tests/v2-schema.test.ts new file mode 100644 index 00000000..39263a4e --- /dev/null +++ b/tests/v2-schema.test.ts @@ -0,0 +1,25 @@ +import { LandingAIADEError } from 'landingai-ade'; +import { coerceSchema } from 'landingai-ade/lib/schema'; + +describe('coerceSchema', () => { + test('passes a JSON-Schema object through', () => { + const schema = { type: 'object', properties: { name: { type: 'string' } } }; + expect(coerceSchema(schema)).toBe(schema); + }); + + test('parses a JSON string into an object', () => { + expect(coerceSchema('{"type":"object"}')).toEqual({ type: 'object' }); + }); + + test('rejects a JSON string that decodes to a non-object', () => { + expect(() => coerceSchema('[1,2,3]')).toThrow(LandingAIADEError); + }); + + test('rejects an invalid JSON string', () => { + expect(() => coerceSchema('not json')).toThrow(LandingAIADEError); + }); + + test('rejects an unsupported type', () => { + expect(() => coerceSchema(123 as any)).toThrow(LandingAIADEError); + }); +}); diff --git a/tests/v2-waiter.test.ts b/tests/v2-waiter.test.ts new file mode 100644 index 00000000..f31e8577 --- /dev/null +++ b/tests/v2-waiter.test.ts @@ -0,0 +1,79 @@ +import { JobFailedError, JobWaitTimeoutError } from 'landingai-ade'; +import { PollClock, pollUntilTerminal } from 'landingai-ade/resources/v2/_base'; +import { Job, JobStatus } from 'landingai-ade/resources/v2/types'; + +function makeJob(status: JobStatus, error: Job['error'] = null): Job { + return { + job_id: 'j', + status, + created_at: null, + completed_at: null, + progress: null, + result: null, + error, + is_terminal: status === 'completed' || status === 'failed' || status === 'cancelled', + raw: {}, + }; +} + +// Fake clock: `sleep` advances virtual time instead of waiting, so tests run +// instantly and deterministically. +function fakeClock(): PollClock { + let t = 0; + return { + now: () => t, + sleep: async (ms: number) => { + t += ms; + }, + }; +} + +describe('pollUntilTerminal', () => { + test('returns once the job is terminal', async () => { + const statuses: JobStatus[] = ['pending', 'processing', 'completed']; + let i = 0; + const job = await pollUntilTerminal(async () => makeJob(statuses[i++]!), {}, fakeClock()); + expect(job.status).toBe('completed'); + expect(i).toBe(3); + }); + + test('throws JobWaitTimeoutError when it never finishes', async () => { + await expect( + pollUntilTerminal(async () => makeJob('processing'), { timeout: 5000 }, fakeClock()), + ).rejects.toBeInstanceOf(JobWaitTimeoutError); + }); + + test('throws JobFailedError when raiseOnFailure and the job failed with an error', async () => { + await expect( + pollUntilTerminal( + async () => makeJob('failed', { code: 'x', message: 'boom' }), + { raiseOnFailure: true }, + fakeClock(), + ), + ).rejects.toBeInstanceOf(JobFailedError); + }); + + test('does not throw on failure when raiseOnFailure is false', async () => { + const job = await pollUntilTerminal( + async () => makeJob('failed', { code: 'x', message: 'boom' }), + { raiseOnFailure: false }, + fakeClock(), + ); + expect(job.status).toBe('failed'); + }); + + test('raiseOnFailure throws on a failed job even with no error payload', async () => { + await expect( + pollUntilTerminal(async () => makeJob('failed', null), { raiseOnFailure: true }, fakeClock()), + ).rejects.toBeInstanceOf(JobFailedError); + }); + + test('raiseOnFailure does NOT throw on a completed job carrying a residual error object', async () => { + const job = await pollUntilTerminal( + async () => makeJob('completed', { code: null, message: null }), + { raiseOnFailure: true }, + fakeClock(), + ); + expect(job.status).toBe('completed'); + }); +});