euredact 0.6.0 → 0.7.0

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.
Files changed (57) hide show
  1. package/NOTICE +11 -0
  2. package/README.md +403 -39
  3. package/dist/cjs/cache.js +2 -8
  4. package/dist/cjs/cloud/account.js +180 -0
  5. package/dist/cjs/cloud/batches.js +469 -0
  6. package/dist/cjs/cloud/client.js +124 -14
  7. package/dist/cjs/cloud/config.js +28 -2
  8. package/dist/cjs/cloud/errors.js +39 -2
  9. package/dist/cjs/cloud/index.js +16 -1
  10. package/dist/cjs/index.js +9 -1
  11. package/dist/cjs/platform.browser.js +98 -0
  12. package/dist/cjs/platform.js +140 -0
  13. package/dist/cjs/rules/bicGleif.js +57 -0
  14. package/dist/cjs/rules/bicRegistry.js +37 -11
  15. package/dist/cjs/rules/countries/index.js +63 -10
  16. package/dist/cjs/rules/cues.js +29 -9
  17. package/dist/cjs/rules/engine.js +14 -6
  18. package/dist/cjs/rules/suppressors.js +292 -22
  19. package/dist/cjs/rules/validators.js +50 -0
  20. package/dist/cjs/sdk.js +18 -7
  21. package/dist/cjs/types.js +4 -1
  22. package/dist/esm/cache.js +2 -8
  23. package/dist/esm/cloud/account.d.ts +155 -0
  24. package/dist/esm/cloud/account.js +175 -0
  25. package/dist/esm/cloud/batches.d.ts +166 -0
  26. package/dist/esm/cloud/batches.js +464 -0
  27. package/dist/esm/cloud/client.d.ts +83 -1
  28. package/dist/esm/cloud/client.js +119 -22
  29. package/dist/esm/cloud/config.d.ts +6 -0
  30. package/dist/esm/cloud/config.js +27 -2
  31. package/dist/esm/cloud/errors.d.ts +26 -1
  32. package/dist/esm/cloud/errors.js +35 -1
  33. package/dist/esm/cloud/index.d.ts +4 -2
  34. package/dist/esm/cloud/index.js +4 -2
  35. package/dist/esm/index.d.ts +2 -2
  36. package/dist/esm/index.js +1 -1
  37. package/dist/esm/platform.browser.d.ts +27 -0
  38. package/dist/esm/platform.browser.js +92 -0
  39. package/dist/esm/platform.d.ts +20 -0
  40. package/dist/esm/platform.js +101 -0
  41. package/dist/esm/rules/bicGleif.d.ts +4 -0
  42. package/dist/esm/rules/bicGleif.js +53 -0
  43. package/dist/esm/rules/bicRegistry.d.ts +25 -11
  44. package/dist/esm/rules/bicRegistry.js +36 -11
  45. package/dist/esm/rules/countries/index.js +63 -10
  46. package/dist/esm/rules/cues.d.ts +1 -1
  47. package/dist/esm/rules/cues.js +29 -9
  48. package/dist/esm/rules/engine.js +14 -6
  49. package/dist/esm/rules/suppressors.d.ts +1 -0
  50. package/dist/esm/rules/suppressors.js +291 -21
  51. package/dist/esm/rules/validators.d.ts +14 -0
  52. package/dist/esm/rules/validators.js +47 -0
  53. package/dist/esm/sdk.d.ts +51 -4
  54. package/dist/esm/sdk.js +18 -9
  55. package/dist/esm/types.d.ts +58 -2
  56. package/dist/esm/types.js +4 -1
  57. package/package.json +12 -4
@@ -0,0 +1,464 @@
1
+ /**
2
+ * [CLOUD EXTENSION] Batches: cloud mode, with hours between masking and mapping
3
+ * (rules-engine#84; the gateway side is `docs/BATCHES.md` in euredact-inference).
4
+ *
5
+ * Cloud mode masks a document here, sends only the masked text and maps the
6
+ * service's answer back onto the original, all in one call. A batch returns up
7
+ * to 24 h later, so `create()` writes one **local batch file** per batch: each
8
+ * original, its local detections and the SHA-256 of the masked text that was
9
+ * sent. None of it is uploaded. `results()` maps the service's spans back from
10
+ * that file, checking the hash before placing a span, then wipes the originals
11
+ * and leaves a text-free receipt.
12
+ *
13
+ * The file format is shared with the Python SDK, offsets counted in code points,
14
+ * so a batch created by one can be resolved by the other.
15
+ *
16
+ * In Node the file is `~/.euredact/batches/<batchId>.json` (`0600` in a `0700`
17
+ * directory); `batchDir` moves it. A page has no filesystem, so there `store`
18
+ * is required: any object with async `read`, `write`, `delete` and `list`.
19
+ * `cipher` encrypts the file at rest with a key the caller holds.
20
+ */
21
+ import { EuRedact, applyReplacements, maskForCloud, ontoOriginal } from "../sdk.js";
22
+ import { defaultBatchStore, gzip, sha256Hex } from "../platform.js";
23
+ import { cloudErrorFor, codePointOffsets, isInteger, toCloudInfo, toResult } from "./client.js";
24
+ import { getConfig, requireSecureBaseUrl } from "./config.js";
25
+ import { CloudError, NotConfiguredError } from "./errors.js";
26
+ /** The gateway's limits (docs/BATCHES.md §2-§3), checked before upload. */
27
+ export const MAX_DOCUMENTS = 5000;
28
+ export const MAX_CUSTOM_ID = 64;
29
+ /** The gateway's customId rule (euredact-inference gateway/batches.py), whole string. */
30
+ export const CUSTOM_ID = /^[A-Za-z0-9_.:\-]{1,64}$/;
31
+ export const MAX_BODY_BYTES = 128 * 1024 * 1024;
32
+ /**
33
+ * Per document, counted by the model's tokenizer on the gateway. The SDK has no
34
+ * copy of it and no character count decides it, so it is **not** checked before
35
+ * upload: a document over it comes back as an error with code `too_long`.
36
+ */
37
+ export const MAX_DOCUMENT_TOKENS = 5000;
38
+ export const FILE_FORMAT = "euredact-batch/1";
39
+ export class BatchError extends CloudError {
40
+ constructor(message) {
41
+ super(message);
42
+ this.name = "BatchError";
43
+ }
44
+ }
45
+ const encoder = new TextEncoder();
46
+ const decoder = new TextDecoder();
47
+ function now() {
48
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
49
+ }
50
+ /** UTF-16 offset -> code point offset, for writing the shared file format. */
51
+ function utf16ToCodePoints(text) {
52
+ if (!/[\uD800-\uDBFF]/.test(text))
53
+ return unit => unit;
54
+ const map = new Map();
55
+ let cp = 0;
56
+ let unit = 0;
57
+ for (const ch of text) {
58
+ map.set(unit, cp);
59
+ unit += ch.length;
60
+ cp += 1;
61
+ }
62
+ map.set(unit, cp);
63
+ return u => map.get(u) ?? u;
64
+ }
65
+ function toStored(d, cp) {
66
+ return {
67
+ type: String(d.entityType), start: cp(d.start), end: cp(d.end), text: d.text,
68
+ source: String(d.source), country: d.country ?? null, confidence: d.confidence ?? "high",
69
+ country_confidence: d.countryConfidence ?? 0, out_of_scope: d.outOfScope ?? false,
70
+ };
71
+ }
72
+ function fromStored(raw, unit) {
73
+ return {
74
+ entityType: raw.type, start: unit(raw.start), end: unit(raw.end),
75
+ text: raw.text, source: raw.source, country: raw.country,
76
+ confidence: raw.confidence, countryConfidence: raw.country_confidence,
77
+ outOfScope: raw.out_of_scope,
78
+ };
79
+ }
80
+ // ── the client ────────────────────────────────────────────────────────────
81
+ export class Batches {
82
+ config;
83
+ store;
84
+ cipher;
85
+ fetchImpl;
86
+ sdk;
87
+ constructor(options = {}) {
88
+ const config = options.config ?? getConfig();
89
+ if (config === null)
90
+ throw new NotConfiguredError();
91
+ requireSecureBaseUrl(config.baseUrl);
92
+ this.config = config;
93
+ if (options.batchDir !== undefined && options.store !== undefined) {
94
+ throw new Error("pass batchDir or store, not both");
95
+ }
96
+ const store = options.store ?? defaultBatchStore(options.batchDir);
97
+ if (store === null) {
98
+ throw new Error("no filesystem here: pass a store ({ read, write, delete, list }) to Batches");
99
+ }
100
+ this.store = store;
101
+ this.cipher = options.cipher ?? null;
102
+ this.fetchImpl = options.fetchImpl;
103
+ this.sdk = options.sdk ?? null;
104
+ }
105
+ // -- plumbing ---------------------------------------------------------
106
+ engine() {
107
+ if (this.sdk === null)
108
+ this.sdk = new EuRedact();
109
+ return this.sdk;
110
+ }
111
+ async request(method, path, body, headers = {}) {
112
+ const impl = this.fetchImpl ?? globalThis.fetch;
113
+ if (!impl)
114
+ throw new CloudError("no fetch implementation available");
115
+ try {
116
+ return await impl(`${this.config.baseUrl}${path}`, {
117
+ method,
118
+ body: body,
119
+ headers: {
120
+ Authorization: `Bearer ${this.config.apiKey}`,
121
+ "Idempotency-Key": headers["Idempotency-Key"] ?? crypto.randomUUID(),
122
+ ...this.config.headers,
123
+ ...headers,
124
+ },
125
+ });
126
+ }
127
+ catch (err) {
128
+ throw new CloudError(`batch request failed: ${err.message}`);
129
+ }
130
+ }
131
+ async json(resp) {
132
+ try {
133
+ return (await resp.json());
134
+ }
135
+ catch {
136
+ return {};
137
+ }
138
+ }
139
+ raiseFor(status, payload) {
140
+ throw cloudErrorFor(status, payload);
141
+ }
142
+ async load(batchId) {
143
+ let data = await this.store.read(batchId);
144
+ if (data === null)
145
+ return null;
146
+ if (this.cipher)
147
+ data = await this.cipher.decrypt(data);
148
+ const state = JSON.parse(decoder.decode(data));
149
+ if (state.format !== FILE_FORMAT) {
150
+ throw new BatchError(`local batch file ${batchId}: unknown format ${JSON.stringify(state.format)}`);
151
+ }
152
+ return state;
153
+ }
154
+ async save(state) {
155
+ let data = encoder.encode(JSON.stringify(state));
156
+ if (this.cipher)
157
+ data = await this.cipher.encrypt(data);
158
+ await this.store.write(state.batch_id, data);
159
+ }
160
+ static batch(raw) {
161
+ const str = (k) => (typeof raw[k] === "string" ? raw[k] : null);
162
+ return {
163
+ id: str("id") ?? "", status: str("status") ?? "",
164
+ createdAt: str("created_at"), expiresAt: str("expires_at"), endedAt: str("ended_at"),
165
+ resultsExpireAt: str("results_expire_at"),
166
+ counts: { ...(raw.counts ?? {}) },
167
+ documents: isInteger(raw.documents) ? raw.documents : null,
168
+ tokens: Object.fromEntries(Object.entries(raw.tokens && typeof raw.tokens === "object" ? raw.tokens : {}).filter((e) => isInteger(e[1]))),
169
+ creditsCharged: isInteger(raw.credits_charged) ? raw.credits_charged : null,
170
+ billingRate: typeof raw.billing_rate === "number" ? raw.billing_rate : null,
171
+ raw,
172
+ };
173
+ }
174
+ // -- create -----------------------------------------------------------
175
+ /**
176
+ * Mask every document here, upload only the masked text, keep the rest in
177
+ * the local batch file. The document count, `customId`s, countries and body
178
+ * size are checked before anything is uploaded or written. The 5,000-token
179
+ * limit per document is **not**: it is counted by the model's tokenizer on
180
+ * the gateway, which the SDK does not have, so a document over it comes back
181
+ * from `results()` as an error with code `too_long` that says so.
182
+ */
183
+ async create(documents, options = {}) {
184
+ if (documents.length === 0)
185
+ throw new BatchError("a batch needs at least one document");
186
+ if (documents.length > MAX_DOCUMENTS) {
187
+ throw new BatchError(`${documents.length.toLocaleString("en-US")} documents; a batch holds at most ` +
188
+ `${MAX_DOCUMENTS.toLocaleString("en-US")}`);
189
+ }
190
+ // Every document is checked before any is masked: masking 5,000 documents
191
+ // and then learning that the gateway refuses the batch for one bad customId
192
+ // wastes the whole pass (rules-engine#88).
193
+ const seen = new Set();
194
+ const checked = [];
195
+ documents.forEach((doc, index) => {
196
+ const number = index + 1;
197
+ const customId = doc.customId;
198
+ if (typeof customId !== "string" || !CUSTOM_ID.test(customId)) {
199
+ throw new BatchError(`document ${number}: customId must be 1-${MAX_CUSTOM_ID} characters of A-Z a-z 0-9 _ . : -`);
200
+ }
201
+ if (seen.has(customId)) {
202
+ throw new BatchError(`document ${number}: duplicate customId ${JSON.stringify(customId)}`);
203
+ }
204
+ seen.add(customId);
205
+ if (typeof doc.text !== "string")
206
+ throw new BatchError(`document ${number}: text must be a string`);
207
+ const countries = (doc.countries ?? []).map(c => c.toUpperCase());
208
+ if (countries.length !== 1) {
209
+ throw new BatchError(`document ${number}: exactly one country is needed, as in cloud mode`);
210
+ }
211
+ checked.push({ customId, text: doc.text, countries, language: doc.language ?? "" });
212
+ });
213
+ const lines = [];
214
+ const entries = {};
215
+ const engine = this.engine();
216
+ for (const { customId, text, countries, language } of checked) {
217
+ // The same local pass as redactAsync(..., { mode: "cloud" }): dates on, no
218
+ // allowlist, no tokens. What is sent is what cloud mode would send.
219
+ const local = engine.redact(text, { countries, detectDates: true, cache: false });
220
+ const [masked] = maskForCloud(text, local.detections);
221
+ const line = { custom_id: customId, text: masked, countries };
222
+ if (language)
223
+ line.language = language;
224
+ lines.push(JSON.stringify(line));
225
+ const cp = utf16ToCodePoints(text);
226
+ entries[customId] = {
227
+ text: text,
228
+ masked_sha256: sha256Hex([masked]),
229
+ countries,
230
+ language,
231
+ detections: local.detections.map(d => toStored(d, cp)),
232
+ detection_mode: local.detectionMode,
233
+ inferred_countries: local.inferredCountries,
234
+ };
235
+ }
236
+ const body = encoder.encode(lines.join("\n") + "\n");
237
+ if (body.length > MAX_BODY_BYTES) {
238
+ throw new BatchError(`the masked batch is ${body.length.toLocaleString("en-US")} bytes; the limit is ` +
239
+ `${MAX_BODY_BYTES.toLocaleString("en-US")}`);
240
+ }
241
+ const compressed = gzip(body);
242
+ const headers = {
243
+ "Content-Type": "application/x-ndjson",
244
+ "Idempotency-Key": options.idempotencyKey ?? crypto.randomUUID(),
245
+ };
246
+ if (compressed !== null)
247
+ headers["Content-Encoding"] = "gzip";
248
+ const resp = await this.request("POST", "/v1/batches", compressed ?? body, headers);
249
+ const payload = await this.json(resp);
250
+ if (resp.status !== 200 && resp.status !== 201)
251
+ this.raiseFor(resp.status, payload);
252
+ const batch = Batches.batch(payload);
253
+ if (!batch.id)
254
+ throw new BatchError("the gateway accepted the batch but returned no id");
255
+ await this.save({
256
+ format: FILE_FORMAT,
257
+ batch_id: batch.id,
258
+ status: "pending",
259
+ created_at: batch.createdAt ?? now(),
260
+ expires_at: batch.expiresAt,
261
+ results_expire_at: null,
262
+ resolved_at: null,
263
+ engine: "euredact-ts",
264
+ engine_version: null,
265
+ counts: { documents: Object.keys(entries).length },
266
+ entries,
267
+ });
268
+ return batch;
269
+ }
270
+ // -- retrieve, cancel, pending, purge, sweep ------------------------------
271
+ /** The gateway's view: status, counts and timestamps. */
272
+ async retrieve(batchId) {
273
+ await this.sweep();
274
+ const resp = await this.request("GET", `/v1/batches/${encodeURIComponent(batchId)}`);
275
+ const payload = await this.json(resp);
276
+ if (resp.status !== 200)
277
+ this.raiseFor(resp.status, payload);
278
+ const batch = Batches.batch(payload);
279
+ const state = await this.load(batchId);
280
+ if (state !== null && batch.resultsExpireAt && state.results_expire_at !== batch.resultsExpireAt) {
281
+ state.results_expire_at = batch.resultsExpireAt;
282
+ await this.save(state);
283
+ }
284
+ return batch;
285
+ }
286
+ /**
287
+ * This account's batches, newest first (1-100, the gateway's bound). The
288
+ * gateway's view only: `pending()` lists the ones with a local file here.
289
+ */
290
+ async list(limit = 20) {
291
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
292
+ throw new RangeError("limit must be between 1 and 100");
293
+ }
294
+ const resp = await this.request("GET", `/v1/batches?limit=${limit}`);
295
+ const payload = await this.json(resp);
296
+ if (resp.status !== 200)
297
+ this.raiseFor(resp.status, payload);
298
+ const raw = Array.isArray(payload.batches) ? payload.batches : [];
299
+ return raw.filter((b) => !!b && typeof b === "object" && !Array.isArray(b))
300
+ .map(b => Batches.batch(b));
301
+ }
302
+ /** Cancel queued documents; the local file stays pending until the finished
303
+ * ones are mapped by `results()`. */
304
+ async cancel(batchId) {
305
+ const resp = await this.request("POST", `/v1/batches/${encodeURIComponent(batchId)}/cancel`);
306
+ const payload = await this.json(resp);
307
+ if (resp.status !== 200 && resp.status !== 202)
308
+ this.raiseFor(resp.status, payload);
309
+ return Batches.batch(payload);
310
+ }
311
+ /** Batch ids whose local file still waits to be mapped. Reads files only. */
312
+ async pending() {
313
+ await this.sweep();
314
+ const out = [];
315
+ for (const id of await this.store.list()) {
316
+ const state = await this.load(id);
317
+ if (state !== null && state.status === "pending")
318
+ out.push(id);
319
+ }
320
+ return out;
321
+ }
322
+ /** Delete the local file, whatever its status. */
323
+ async purge(batchId) {
324
+ await this.store.delete(batchId);
325
+ }
326
+ /**
327
+ * Delete receipts and expire pending files past `results_expire_at`. Runs at
328
+ * the start of the calls that look in the store, so nothing has to be
329
+ * scheduled. A pending file past that time can never be mapped -- the gateway
330
+ * has swept the results -- so its originals are wiped.
331
+ */
332
+ async sweep(at = new Date()) {
333
+ for (const id of await this.store.list()) {
334
+ let state;
335
+ try {
336
+ state = await this.load(id);
337
+ }
338
+ catch {
339
+ continue;
340
+ }
341
+ if (state === null || !state.results_expire_at)
342
+ continue;
343
+ if (at < new Date(state.results_expire_at))
344
+ continue;
345
+ if (state.status === "pending")
346
+ await this.wipe(state, "expired");
347
+ else
348
+ await this.store.delete(id);
349
+ }
350
+ }
351
+ async wipe(state, status) {
352
+ state.entries = {};
353
+ state.status = status;
354
+ state.resolved_at = now();
355
+ await this.save(state);
356
+ }
357
+ // -- results ----------------------------------------------------------
358
+ /**
359
+ * Map an ended batch back onto the originals in its local file. Each
360
+ * document's masked text is rebuilt from the file and must hash to both the
361
+ * stored SHA-256 and the gateway's `masked_sha256` before a span is placed.
362
+ * Anything that cannot be mapped is a per-document error, never the masked
363
+ * text passed off as a result.
364
+ */
365
+ async results(batchId) {
366
+ const state = await this.load(batchId);
367
+ if (state !== null && (state.status === "resolved" || state.status === "expired")) {
368
+ return {
369
+ batchId, documents: {}, batch: null,
370
+ status: state.status === "resolved" ? "already_resolved" : "expired",
371
+ };
372
+ }
373
+ const batch = await this.retrieve(batchId);
374
+ if (batch.status !== "ended")
375
+ return { batchId, status: "not_ended", documents: {}, batch };
376
+ const resp = await this.request("GET", `/v1/batches/${encodeURIComponent(batchId)}/results`);
377
+ if (resp.status === 410) {
378
+ if (state !== null)
379
+ await this.wipe(state, "expired");
380
+ return { batchId, status: "expired", documents: {}, batch };
381
+ }
382
+ if (resp.status === 409)
383
+ return { batchId, status: "not_ended", documents: {}, batch };
384
+ if (resp.status !== 200)
385
+ this.raiseFor(resp.status, await this.json(resp));
386
+ const entries = state?.entries ?? {};
387
+ const documents = {};
388
+ for (const line of (await resp.text()).split("\n")) {
389
+ if (!line.trim())
390
+ continue;
391
+ const row = JSON.parse(line);
392
+ const customId = row.custom_id ?? "";
393
+ documents[customId] = this.outcome(customId, row.result ?? {}, entries[customId], state !== null);
394
+ }
395
+ if (state !== null) {
396
+ const counts = {
397
+ documents: Object.keys(documents).length,
398
+ mapped: Object.values(documents).filter(o => o.ok).length,
399
+ };
400
+ for (const o of Object.values(documents)) {
401
+ if (o.error)
402
+ counts[o.error] = (counts[o.error] ?? 0) + 1;
403
+ }
404
+ state.counts = counts;
405
+ await this.wipe(state, "resolved");
406
+ }
407
+ return { batchId, status: "resolved", documents, batch };
408
+ }
409
+ outcome(customId, result, entry, fileExists) {
410
+ const fail = (error, message) => ({ customId, ok: false, result: null, error, message });
411
+ if (result.type !== "succeeded") {
412
+ const err = result.error ?? {};
413
+ const code = err.code ?? result.type ?? "errored";
414
+ let message = err.message ?? "";
415
+ if (code === "too_long") {
416
+ message = `${message}. The ${MAX_DOCUMENT_TOKENS.toLocaleString("en-US")}-token limit is ` +
417
+ "counted by the model's tokenizer on the gateway; the SDK cannot check it before " +
418
+ "upload. Split the document and submit the parts.";
419
+ message = message.replace(/^\. /, "");
420
+ }
421
+ return fail(code, message);
422
+ }
423
+ if (entry === undefined) {
424
+ return fail("missing_local_entry", fileExists
425
+ ? "no entry for this document in the local batch file"
426
+ : "no local batch file on this machine");
427
+ }
428
+ const text = entry.text;
429
+ const unit = codePointOffsets(text);
430
+ const local = entry.detections.map(d => fromStored(d, unit));
431
+ const [masked, labels] = maskForCloud(text, local);
432
+ if (sha256Hex([masked]) !== entry.masked_sha256 || result.masked_sha256 !== entry.masked_sha256) {
433
+ return fail("local_mismatch", "the local batch file does not match what was sent");
434
+ }
435
+ // The gateway's offsets count code points of the masked text; toResult
436
+ // converts them to UTF-16 units, as the cloud client does for redactAsync.
437
+ const remote = toResult({ entities: result.entities }, masked);
438
+ let placed;
439
+ try {
440
+ placed = ontoOriginal(remote.detections, masked, text, labels, CloudError);
441
+ }
442
+ catch (err) {
443
+ return fail("local_mismatch", err.message);
444
+ }
445
+ const detections = [...local, ...placed].sort((a, b) => a.start - b.start || b.end - a.end);
446
+ return {
447
+ customId, ok: true, error: null, message: "",
448
+ result: {
449
+ redactedText: applyReplacements(text, detections, det => `[${det.entityType}]`),
450
+ detections,
451
+ source: "cloud",
452
+ degraded: false,
453
+ inferredCountries: entry.inferred_countries,
454
+ evidence: [],
455
+ detectionMode: entry.detection_mode,
456
+ tokens: {},
457
+ exempted: [],
458
+ // A batch line carries the model and anything unplaced; it has no job
459
+ // id and no usage of its own (the batch carries those).
460
+ cloud: toCloudInfo({ model_version: result.model_version, unlocated: result.unlocated }),
461
+ },
462
+ };
463
+ }
464
+ }
@@ -25,7 +25,33 @@
25
25
  * own `fetch`.
26
26
  */
27
27
  import { type CloudConfig } from "./config.js";
28
- import { type RedactResult } from "../types.js";
28
+ import { CloudError } from "./errors.js";
29
+ import { type CloudInfo, type RedactResult, type Usage } from "../types.js";
30
+ /** @internal Shared with cloud/account.ts. */
31
+ export declare const RETRY_STATUS: Set<number>;
32
+ /** One span as the service reports it. */
33
+ interface WireSpan {
34
+ start: number;
35
+ end: number;
36
+ text: string;
37
+ type: string;
38
+ source: string;
39
+ match?: string;
40
+ confidence?: string;
41
+ }
42
+ interface WireResult {
43
+ job_id?: string;
44
+ status?: string;
45
+ redacted_text?: string;
46
+ entities?: WireSpan[];
47
+ unlocated?: unknown;
48
+ model_version?: string | null;
49
+ stats?: Record<string, unknown>;
50
+ usage?: unknown;
51
+ error?: string;
52
+ detail?: Record<string, unknown>;
53
+ location?: string;
54
+ }
29
55
  export interface CloudRedactOptions {
30
56
  country: string;
31
57
  language?: string;
@@ -34,6 +60,59 @@ export interface CloudRedactOptions {
34
60
  /** Injectable for tests. Defaults to the platform `fetch`. */
35
61
  fetchImpl?: typeof fetch;
36
62
  }
63
+ /** @internal */
64
+ export declare function requireFetch(supplied?: typeof fetch): typeof fetch;
65
+ /** @internal */
66
+ export declare function sleep(ms: number): Promise<void>;
67
+ /** @internal */
68
+ export declare function retryAfterMs(headers: Headers | undefined): number | null;
69
+ /**
70
+ * Full jitter. A synchronised retry storm from many clients is how a
71
+ * recovering service is knocked back over.
72
+ */
73
+ /** @internal */
74
+ export declare function backoffMs(attempt: number, retryAfter: number | null): number;
75
+ /** @internal */
76
+ export declare function uuid(): string;
77
+ /**
78
+ * Map the service's offsets, which count code points, onto this SDK's, which
79
+ * count UTF-16 units.
80
+ *
81
+ * The two agree until the first character outside the BMP — an emoji, say —
82
+ * after which every service offset is short by one unit per such character.
83
+ * Returns the identity when the text has none, which is the common case and
84
+ * costs one regex test.
85
+ */
86
+ /** @internal Shared with cloud/batches.ts. */
87
+ export declare function codePointOffsets(text: string): (cp: number) => number;
88
+ /** @internal */
89
+ export declare function isInteger(value: unknown): value is number;
90
+ /**
91
+ * The `usage` block, or undefined when absent or malformed. Shared rules with
92
+ * the Python SDK (`conformance/cloud_usage.json`): `tokens` and `credits` are
93
+ * integers, `billing_rate` a number, `factors` a list whose entries carry a
94
+ * string `code`. Anything else there drops the whole block rather than
95
+ * reporting a cost nobody can trust; unknown keys are ignored.
96
+ * @internal Exported for the conformance test.
97
+ */
98
+ export declare function toUsage(raw: unknown): Usage | undefined;
99
+ /**
100
+ * The service's account of a request beside its spans. Shared rules with the
101
+ * Python SDK (`conformance/cloud_result.json`): a field of the wrong type reads
102
+ * as absent, and an unlocated entry without string `text` is skipped.
103
+ * @internal
104
+ */
105
+ export declare function toCloudInfo(payload: Record<string, unknown>): CloudInfo;
106
+ /**
107
+ * A 429 from the gateway's daily quota rather than the edge's rate limit: the
108
+ * gateway answers in JSON with `detail.used` and `detail.limit`; nginx in HTML.
109
+ * @internal
110
+ */
111
+ export declare function isQuota(payload: Record<string, unknown>): boolean;
112
+ /** The error for a non-success answer. @internal Shared with batches and account. */
113
+ export declare function cloudErrorFor(status: number, payload: Record<string, unknown>): CloudError;
114
+ /** @internal Shared with cloud/batches.ts. */
115
+ export declare function toResult(payload: WireResult, text: string): RedactResult;
37
116
  export declare class CloudClient {
38
117
  readonly config: CloudConfig;
39
118
  constructor(config?: CloudConfig | null);
@@ -43,3 +122,6 @@ export declare class CloudClient {
43
122
  private poll;
44
123
  private withTimeout;
45
124
  }
125
+ /** @internal */
126
+ export declare function readJson(response: Response): Promise<WireResult>;
127
+ export {};