@anyformat/sdk 0.2.3 → 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 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 result = await af
18
+ const workflow = await af
11
19
  .workflow("Invoice")
12
20
  .parse()
13
21
  .extract([
@@ -18,60 +26,240 @@ const result = await af
18
26
  Schema.option("EUR", "Euro."),
19
27
  ]),
20
28
  ])
21
- .run(file)
22
- .wait();
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
- ## Quick parse: one call → markdown
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.
28
44
 
29
- `af.parse()` runs the platform's atomic `/parse` operation (a fast lite parse)
30
- and returns an opaque `jobId`. Poll `getParseMarkdown(jobId)` for the markdown —
31
- no workflow to author or manage.
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):
32
53
 
33
54
  ```ts
34
- const jobId = await af.parse(file); // File | Blob
35
- let markdown = await af.getParseMarkdown(jobId);
36
- while (markdown === null) {
37
- await new Promise((r) => setTimeout(r, 3000));
38
- markdown = await af.getParseMarkdown(jobId); // null until done
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:
140
+
141
+ ```ts
142
+ for await (const run of af.iterRuns(workflow.id)) console.log(run.id, run.status);
143
+
144
+ const all = [];
145
+ for await (const wf of af.iterWorkflows()) all.push(wf);
146
+ ```
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
+ }
39
166
  }
40
- console.log(markdown);
167
+
168
+ await af.updateWorkflow(workflow.id, detail); // PATCH echoes GET's shape
41
169
  ```
42
170
 
43
- ## Managing workflows
171
+ When authoring from scratch with a known identity, set it on the factory:
44
172
 
45
173
  ```ts
46
- const workflows = await af.listWorkflows({ pageSize: 50 }); // -> Workflow[]
47
- for (const wf of workflows) console.log(wf.id, wf.name);
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.
48
181
 
49
- await af.deleteWorkflow(workflows[0].id); // -> the deleted id
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);
50
196
  ```
51
197
 
52
198
  ## Smart lookup
53
199
 
54
200
  Resolve a field by matching the document against a reference file (CSV/catalog) instead of
55
- reading it off the page — flag the field with `{ lookup: true }` and pass the file via
56
- `lookupFiles`:
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:
57
206
 
58
207
  ```ts
59
- await af
208
+ const wf = await af
60
209
  .workflow("Invoice + vendor lookup")
61
210
  .parse()
62
211
  .extract(
63
212
  [
64
213
  Schema.string("vendor_name", "Vendor as printed on the invoice."),
65
- Schema.string("vendor_id", "Canonical vendor code from the catalog, joined on vendor_name.", { lookup: true }),
214
+ Schema.string("vendor_id", "Canonical vendor code from the catalog, joined on vendor_name.", { source: "smart_lookup" }),
66
215
  ],
67
216
  { lookupFiles: ["vendor_catalog.csv"], lookupSuggestion: "Join vendor_name → vendor_id." },
68
217
  )
69
- .run(file)
70
- .wait();
218
+ .create();
219
+ const result = await (await wf.run(file)).wait();
71
220
  ```
72
221
 
73
222
  Full runnable example: [`examples/smart-lookup.mjs`](examples/smart-lookup.mjs).
74
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
+
75
263
  ## How it's built
76
264
 
77
265
  The wire types and the low-level HTTP client are **generated** from the API's
@@ -82,15 +270,18 @@ ergonomic layer (`Schema`, `WorkflowBuilder`, `Result`, `Anyformat`) wraps it.
82
270
  ## Regenerating (and the drift guard)
83
271
 
84
272
  ```bash
85
- bun run gen # dump the v2 OpenAPI offline + regenerate src/sdk
273
+ bun run gen # dump both OpenAPI artifacts offline + regenerate src/sdk
86
274
  bun run gen:check # regenerate and fail if the committed output drifted
87
275
  ```
88
276
 
89
- `gen:check` runs in CI and as a pre-commit hook (scoped to changes under
90
- `anyformat/services/api` and `anyformat/workflow`) so the committed SDK can
91
- never fall behind the backend. The SDK's own `tsc` then fails to compile if a
92
- backend model change alters a type the hand-written layer relies on — drift is
93
- caught as a type error, not just a diff.
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.
94
285
 
95
286
  ## Scripts
96
287
 
@@ -99,7 +290,7 @@ toolchain). Then:
99
290
 
100
291
  | script | what |
101
292
  | --- | --- |
102
- | `bun run gen` | dump spec + regenerate `src/sdk` |
293
+ | `bun run gen` | dump specs + regenerate `src/sdk` |
103
294
  | `bun run gen:check` | regenerate + assert no drift |
104
295
  | `bun run build` | bundle to `dist/` (ESM + CJS + types) via tsup |
105
296
  | `bun run typecheck` | `tsc --noEmit` |
@@ -114,10 +305,9 @@ Cut on demand — a maintainer bumps the version in a PR, then pushes a `sdk-js-
114
305
  Every knob is an option on `.parse({...})` / `.extract(fields, {...})`. `parse` options are a
115
306
  `mode`-discriminated union, so a wrong-mode knob is a compile-time error.
116
307
 
117
- **`.parse({...})`** — `mode` (`"standard"`/`"agentic"`/`"lite"`), `promptHint`, `figureEnhancement`,
118
- `cache`; **agentic-only** `effort` (`"low"`/`"mid"`/`"accurate"`); **lite-only** `ocrEffort`
119
- (`"medium"`/`"high"`), `region` (`"eu"`/`"global"`), `skipRoutingReview`, `figures`,
120
- `textFormatting`, `routingEffort`.
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.
121
311
 
122
312
  **`.extract(fields, {...})`** — `mode` (`"standard"`/`"agentic"`/`"max"`/`"lite"`), `useImages`,
123
313
  `lookupSuggestion`, `lookupReasoningEffort` (`"minimal"`/`"low"`/`"medium"`/`"high"`), `lookupFiles`.