@anyformat/sdk 0.2.2 → 1.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +269 -20
- package/dist/index.cjs +650 -114
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1776 -367
- package/dist/index.d.ts +1776 -367
- package/dist/index.js +634 -107
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
# @anyformat/sdk
|
|
2
2
|
|
|
3
|
-
TypeScript SDK for the AnyFormat API. Mirrors the Python SDK's fluent flow.
|
|
3
|
+
TypeScript SDK for the AnyFormat API (v3). Mirrors the Python SDK's fluent flow.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @anyformat/sdk@rc
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
> **Release candidate.** v3 support ships as `1.0.0-rc.1` on the npm `rc`
|
|
10
|
+
> dist-tag. Plain `npm install @anyformat/sdk` keeps resolving to the stable
|
|
11
|
+
> 0.2.x (v2) line until 1.0.0 final ships after the RC bake period.
|
|
4
12
|
|
|
5
13
|
```ts
|
|
6
14
|
import { Anyformat, Schema } from "@anyformat/sdk";
|
|
7
15
|
|
|
8
16
|
const af = new Anyformat({ apiKey: process.env.ANYFORMAT_API_KEY! });
|
|
9
17
|
|
|
10
|
-
const
|
|
18
|
+
const workflow = await af
|
|
11
19
|
.workflow("Invoice")
|
|
12
20
|
.parse()
|
|
13
21
|
.extract([
|
|
@@ -18,21 +26,240 @@ const result = await af
|
|
|
18
26
|
Schema.option("EUR", "Euro."),
|
|
19
27
|
]),
|
|
20
28
|
])
|
|
21
|
-
.
|
|
22
|
-
|
|
29
|
+
.create();
|
|
30
|
+
|
|
31
|
+
const run = await workflow.run(new File([bytes], "invoice.pdf"));
|
|
32
|
+
const result = await run.wait();
|
|
23
33
|
|
|
24
34
|
result.field("vendor")?.value; // -> "Acme Corp"
|
|
25
35
|
```
|
|
26
36
|
|
|
27
|
-
|
|
37
|
+
`workflow.run(files)` uploads 1..100 files as one **document packet** (the unit
|
|
38
|
+
runs execute on) and enqueues a **run**. `run.wait()` polls
|
|
39
|
+
`GET /v3/runs/{run_id}/` until the status is terminal: `processed` resolves
|
|
40
|
+
with the results inline; `error` throws `ExtractionFailedError` and `cancelled`
|
|
41
|
+
throws `ExtractionCancelledError` (both subclass `RunFailedError`). A transient
|
|
42
|
+
429/5xx *during* polling is retried with backoff (honouring `Retry-After`), so a
|
|
43
|
+
short spike doesn't abort a long wait.
|
|
44
|
+
|
|
45
|
+
`result.field("line_items")` returns the array of row records for a
|
|
46
|
+
list/object field (and the scalar `ExtractedField` for a scalar field); only a
|
|
47
|
+
genuinely missing field returns `undefined`.
|
|
48
|
+
|
|
49
|
+
## Upload now, run later
|
|
50
|
+
|
|
51
|
+
Uploading spends no credits — trigger extraction when you're ready, and re-run
|
|
52
|
+
the same packet after editing the workflow (each trigger creates a new run):
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const upload = await workflow.upload([fileA, fileB]); // one packet, two files
|
|
56
|
+
console.log(upload.documentPacketId, upload.files);
|
|
57
|
+
|
|
58
|
+
const run = await upload.run();
|
|
59
|
+
const result = await run.wait();
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Or import server-side from HTTPS URLs:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const upload = await workflow.uploadFromUrls(["https://example.com/invoices/april.pdf"]);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Packets stay addressable later:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
const packet = await af.getDocumentPacket(upload.documentPacketId);
|
|
72
|
+
packet.status; // not_started | queued | in_progress | processed | error | cancelled
|
|
73
|
+
packet.latest_run_id; // null until the packet has been run
|
|
74
|
+
|
|
75
|
+
await af.runDocumentPacket(packet.id); // re-run on the latest workflow version
|
|
76
|
+
await af.deleteDocumentPacket(packet.id); // irreversible
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Dataset uploads (ground truth)
|
|
80
|
+
|
|
81
|
+
Upload a document into a workflow's **dataset** as one
|
|
82
|
+
document packet — 1..100 files treated as one document, plus optional `groundTruth`
|
|
83
|
+
(the expected extraction). Atomic: any rejected file or a ground-truth failure
|
|
84
|
+
fails the whole call and stores nothing. Filenames are unique within a
|
|
85
|
+
workflow, so by default a colliding name fails with `409`; pass
|
|
86
|
+
`onConflict: "rename"` to auto-rename instead (a rename is never silent):
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
const entry = await workflow.uploadToDataset(new File([bytes], "invoice.pdf"), {
|
|
90
|
+
groundTruth: { vendor: "ACME", total: "42.00", line_items: [{ sku: "A1", qty: "2" }] },
|
|
91
|
+
});
|
|
92
|
+
console.log(entry.documentPacketId, entry.groundTruthSaved); // "069d…", true
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`groundTruth` keys are the workflow schema's field identifiers (`persistent_id`);
|
|
96
|
+
a scalar maps to `string | null`, a table/object field to an array of row
|
|
97
|
+
objects. Bulk ingestion loops one atomic call per document — there is no batch
|
|
98
|
+
endpoint, so each call is bounded and independently retryable:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
await workflow.uploadDocumentsToDataset([
|
|
102
|
+
{ files: new File([a], "a.pdf"), groundTruth: { vendor: "ACME" } },
|
|
103
|
+
{ files: [new File([b1], "b1.pdf"), new File([b2], "b2.pdf")] }, // one 2-file document
|
|
104
|
+
]);
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Both are also available off the client: `af.uploadToDataset(workflowId, files, opts)`
|
|
108
|
+
and `af.uploadDocumentsToDataset(workflowId, documents)`.
|
|
109
|
+
|
|
110
|
+
## Idempotency keys
|
|
111
|
+
|
|
112
|
+
Upload and run triggers accept an `Idempotency-Key` (Stripe convention) —
|
|
113
|
+
retrying with the same key replays the original packet/run instead of
|
|
114
|
+
creating (and billing) a duplicate:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
const run = await workflow.run(file, { idempotencyKey: "invoice-2026-07-0042" });
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Pagination
|
|
121
|
+
|
|
122
|
+
Every list is keyset-paginated: pass `cursor` from the previous page's
|
|
123
|
+
`nextCursor` until it is `null`. There are no totals or page numbers.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
let cursor: string | undefined;
|
|
127
|
+
do {
|
|
128
|
+
const page = await af.listRuns(workflow.id, { limit: 100, cursor });
|
|
129
|
+
for (const run of page.items) console.log(run.id, run.status);
|
|
130
|
+
cursor = page.nextCursor ?? undefined;
|
|
131
|
+
} while (cursor);
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`listWorkflows`, `listDocumentPackets(workflowId)` and `listRuns(workflowId)`
|
|
135
|
+
all follow this shape.
|
|
136
|
+
|
|
137
|
+
To walk **every** row without cursor bookkeeping, use the auto-paging async
|
|
138
|
+
iterators — they follow `nextCursor` for you and fetch each page lazily as you
|
|
139
|
+
consume it:
|
|
28
140
|
|
|
29
141
|
```ts
|
|
30
|
-
const
|
|
31
|
-
for (const wf of workflows) console.log(wf.id, wf.name);
|
|
142
|
+
for await (const run of af.iterRuns(workflow.id)) console.log(run.id, run.status);
|
|
32
143
|
|
|
33
|
-
|
|
144
|
+
const all = [];
|
|
145
|
+
for await (const wf of af.iterWorkflows()) all.push(wf);
|
|
34
146
|
```
|
|
35
147
|
|
|
148
|
+
`iterWorkflows`, `iterDocumentPackets(workflowId)` and `iterRuns(workflowId)`
|
|
149
|
+
mirror their `list*` counterparts.
|
|
150
|
+
|
|
151
|
+
## Editing a workflow — the `persistent_id` round-trip
|
|
152
|
+
|
|
153
|
+
`GET /v3/workflows/{id}/` returns the full typed graph; PATCH accepts the same
|
|
154
|
+
shape back. Every extraction field carries a server-assigned `persistent_id` —
|
|
155
|
+
echo it unchanged (including across renames) and the field keeps its identity,
|
|
156
|
+
so analytics, ground truth and quality metrics stay attached:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
const detail = await af.getWorkflow(workflow.id);
|
|
160
|
+
|
|
161
|
+
for (const node of detail.nodes) {
|
|
162
|
+
if (node.type !== "extract") continue;
|
|
163
|
+
for (const field of node.extraction_schema.fields) {
|
|
164
|
+
if (field.name === "total") field.name = "grand_total"; // rename, persistent_id untouched
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await af.updateWorkflow(workflow.id, detail); // PATCH echoes GET's shape
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
When authoring from scratch with a known identity, set it on the factory:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
Schema.float("grand_total", "Grand total amount.", {
|
|
175
|
+
persistentId: "0686bb97-8c30-70f0-8000-97669e00aaaa",
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Omit `persistentId` for new fields — an omitted id falls back to name-matching
|
|
180
|
+
against the prior version.
|
|
181
|
+
|
|
182
|
+
## Quick parse: one call → markdown
|
|
183
|
+
|
|
184
|
+
`af.parse()` runs the platform's atomic parse operation (a fast lite parse)
|
|
185
|
+
and returns a run id. Poll `getParseMarkdown(runId)` for the markdown —
|
|
186
|
+
no workflow to author or manage.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
const runId = await af.parse(file); // File | Blob
|
|
190
|
+
let markdown = await af.getParseMarkdown(runId);
|
|
191
|
+
while (markdown === null) {
|
|
192
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
193
|
+
markdown = await af.getParseMarkdown(runId); // null until processed
|
|
194
|
+
}
|
|
195
|
+
console.log(markdown);
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Smart lookup
|
|
199
|
+
|
|
200
|
+
Resolve a field by matching the document against a reference file (CSV/catalog) instead of
|
|
201
|
+
reading it off the page — flag the field with `{ source: "smart_lookup" }` and pass the file
|
|
202
|
+
via `lookupFiles`. A smart-lookup field is overwritten by the lookup; use
|
|
203
|
+
`{ source: "lookup_if_missing" }` to extract it from the document too and let the lookup
|
|
204
|
+
fill only the slots extraction
|
|
205
|
+
left empty:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
const wf = await af
|
|
209
|
+
.workflow("Invoice + vendor lookup")
|
|
210
|
+
.parse()
|
|
211
|
+
.extract(
|
|
212
|
+
[
|
|
213
|
+
Schema.string("vendor_name", "Vendor as printed on the invoice."),
|
|
214
|
+
Schema.string("vendor_id", "Canonical vendor code from the catalog, joined on vendor_name.", { source: "smart_lookup" }),
|
|
215
|
+
],
|
|
216
|
+
{ lookupFiles: ["vendor_catalog.csv"], lookupSuggestion: "Join vendor_name → vendor_id." },
|
|
217
|
+
)
|
|
218
|
+
.create();
|
|
219
|
+
const result = await (await wf.run(file)).wait();
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Full runnable example: [`examples/smart-lookup.mjs`](examples/smart-lookup.mjs).
|
|
223
|
+
|
|
224
|
+
## Errors
|
|
225
|
+
|
|
226
|
+
API failures throw a typed subclass of `APIError` carrying the v3 error
|
|
227
|
+
envelope: `status`, `errorCode`, `retryable`, `requestId`, `detail`,
|
|
228
|
+
and the raw `body`. `err.message` is the envelope's `error` summary alone —
|
|
229
|
+
`detail` is developer-facing and stays on `err.detail`, never in `message`
|
|
230
|
+
(ADR platform/0053 §2). Dynamic values (e.g. the missing scope on a scope
|
|
231
|
+
denial) are named in the error message, not a separate structured field.
|
|
232
|
+
|
|
233
|
+
Most classes are chosen by HTTP status:
|
|
234
|
+
|
|
235
|
+
| status | class | `error_code` | retry? |
|
|
236
|
+
| --- | --- | --- | --- |
|
|
237
|
+
| 400 | `BadRequestError` | `VALIDATION_ERROR` / `TOPOLOGY_INVALID` | no |
|
|
238
|
+
| 401 | `UnauthorizedError` | `AUTH_FAILED` | no |
|
|
239
|
+
| 402 | `PaymentRequiredError` | `PAYMENT_REQUIRED` | no — top up credit first |
|
|
240
|
+
| 403 | `ForbiddenError` | `ACCESS_DENIED` | no |
|
|
241
|
+
| 404 | `NotFoundError` | `NOT_FOUND` | no |
|
|
242
|
+
| 422 | `APIError` | `VALIDATION_ERROR` | no |
|
|
243
|
+
| 429 | `RateLimitedError` | `RATE_LIMITED` | yes, after `err.retryAfter` seconds (`Retry-After`) |
|
|
244
|
+
| 5xx | `ServerError` | `INTERNAL_ERROR` / `GATEWAY_TIMEOUT` | yes |
|
|
245
|
+
|
|
246
|
+
A few `error_code` values name a condition a caller would plausibly handle on
|
|
247
|
+
its own, so they get a more specific class than their status alone. Each still
|
|
248
|
+
subclasses its status class, so an `instanceof ForbiddenError` catch keeps
|
|
249
|
+
working:
|
|
250
|
+
|
|
251
|
+
| `error_code` | class | status |
|
|
252
|
+
| --- | --- | --- |
|
|
253
|
+
| `insufficient_api_key_scope` | `InsufficientScopeError` | 403 (`ForbiddenError`) |
|
|
254
|
+
| `terms_acceptance_required` | `TermsAcceptanceRequiredError` | 403 (`ForbiddenError`) |
|
|
255
|
+
| `WORKFLOW_NAME_CONFLICT` | `WorkflowNameConflictError` | 409 (`APIError`) |
|
|
256
|
+
|
|
257
|
+
`Run.wait()` additionally throws `ExtractionFailedError` (status `error`) or
|
|
258
|
+
`ExtractionCancelledError` (status `cancelled`) — both subclass `RunFailedError`,
|
|
259
|
+
so `catch (e) { if (e instanceof RunFailedError) … }` still catches either — and
|
|
260
|
+
`SDKTimeoutError` when the wait budget elapses. It transparently retries a
|
|
261
|
+
transient 429/5xx encountered while polling rather than surfacing it.
|
|
262
|
+
|
|
36
263
|
## How it's built
|
|
37
264
|
|
|
38
265
|
The wire types and the low-level HTTP client are **generated** from the API's
|
|
@@ -43,22 +270,44 @@ ergonomic layer (`Schema`, `WorkflowBuilder`, `Result`, `Anyformat`) wraps it.
|
|
|
43
270
|
## Regenerating (and the drift guard)
|
|
44
271
|
|
|
45
272
|
```bash
|
|
46
|
-
|
|
47
|
-
|
|
273
|
+
bun run gen # dump both OpenAPI artifacts offline + regenerate src/sdk
|
|
274
|
+
bun run gen:check # regenerate and fail if the committed output drifted
|
|
48
275
|
```
|
|
49
276
|
|
|
50
|
-
|
|
51
|
-
`
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
277
|
+
Two committed artifacts: `openapi/v3.json` feeds the generated client;
|
|
278
|
+
`openapi/v2.json` stays as the frozen v2 surface guard (the dump script's
|
|
279
|
+
`--surface v2` output must remain byte-identical). `gen:check` runs in CI and
|
|
280
|
+
as a pre-commit hook (scoped to changes under `anyformat/services/api` and
|
|
281
|
+
`anyformat/workflow`) so the committed SDK can never fall behind the backend.
|
|
282
|
+
The SDK's own `tsc` then fails to compile if a backend model change alters a
|
|
283
|
+
type the hand-written layer relies on — drift is caught as a type error, not
|
|
284
|
+
just a diff.
|
|
55
285
|
|
|
56
286
|
## Scripts
|
|
57
287
|
|
|
288
|
+
Install deps with `bun install --frozen-lockfile` (matches CI + the frontend
|
|
289
|
+
toolchain). Then:
|
|
290
|
+
|
|
58
291
|
| script | what |
|
|
59
292
|
| --- | --- |
|
|
60
|
-
| `
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
63
|
-
| `
|
|
64
|
-
| `
|
|
293
|
+
| `bun run gen` | dump specs + regenerate `src/sdk` |
|
|
294
|
+
| `bun run gen:check` | regenerate + assert no drift |
|
|
295
|
+
| `bun run build` | bundle to `dist/` (ESM + CJS + types) via tsup |
|
|
296
|
+
| `bun run typecheck` | `tsc --noEmit` |
|
|
297
|
+
| `bun run test` | vitest |
|
|
298
|
+
|
|
299
|
+
## Releasing
|
|
300
|
+
|
|
301
|
+
Cut on demand — a maintainer bumps the version in a PR, then pushes a `sdk-js-vX.Y.Z` tag on the release commit. Full flow + one-time setup: [`../RELEASING.md`](../RELEASING.md). Break-glass: `./publish.sh [--build-only|--dry-run]`.
|
|
302
|
+
|
|
303
|
+
## Node configuration knobs
|
|
304
|
+
|
|
305
|
+
Every knob is an option on `.parse({...})` / `.extract(fields, {...})`. `parse` options are a
|
|
306
|
+
`mode`-discriminated union, so a wrong-mode knob is a compile-time error.
|
|
307
|
+
|
|
308
|
+
**`.parse({...})`** — `mode` (`"standard"`/`"agentic"`/`"lite"`/`"flash"`), `promptHint`, `figureEnhancement`,
|
|
309
|
+
`cache`; **agentic-only** `effort` (`"low"`/`"mid"`/`"accurate"`); **lite**: no knobs of its own — one
|
|
310
|
+
OCR-only recipe that never reads `promptHint`; **flash-only** `scannedPages` (`"skip"`/`"fail"`) — no model call, no OCR.
|
|
311
|
+
|
|
312
|
+
**`.extract(fields, {...})`** — `mode` (`"standard"`/`"agentic"`/`"max"`/`"lite"`), `useImages`,
|
|
313
|
+
`lookupSuggestion`, `lookupReasoningEffort` (`"minimal"`/`"low"`/`"medium"`/`"high"`), `lookupFiles`.
|