@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 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,21 +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
- ## Managing workflows
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 workflows = await af.listWorkflows({ pageSize: 50 }); // -> Workflow[]
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
- await af.deleteWorkflow(workflows[0].id); // -> the deleted id
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
- npm run gen # dump the v2 OpenAPI offline + regenerate src/sdk
47
- npm run gen:check # regenerate and fail if the committed output drifted
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
- `gen:check` runs in CI and as a pre-commit hook (scoped to changes under
51
- `anyformat/services/api` and `anyformat/workflow`) so the committed SDK can
52
- never fall behind the backend. The SDK's own `tsc` then fails to compile if a
53
- backend model change alters a type the hand-written layer relies on — drift is
54
- 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.
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
- | `npm run gen` | dump spec + regenerate `src/sdk` |
61
- | `npm run gen:check` | regenerate + assert no drift |
62
- | `npm run build` | bundle to `dist/` (ESM + CJS + types) via tsup |
63
- | `npm run typecheck` | `tsc --noEmit` |
64
- | `npm test` | vitest |
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`.