@saplingai/sapling-js 1.0.53 → 1.0.55

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/sapling/client.js CHANGED
@@ -26,6 +26,41 @@ const REPHRASE_TENSE_MAPPINGS = [
26
26
  ];
27
27
  // Languages statistics() scores readability for (plus 'auto').
28
28
  const STATISTICS_LANGS = ['en', 'de', 'es', 'fr', 'it', 'nl', 'pl', 'ru', 'ar'];
29
+ // Buckets apiEndpointUsage() accepts (request_validator.INTERVAL_VALS).
30
+ const REPORTING_INTERVALS = ['Daily', 'Weekly', 'Monthly'];
31
+
32
+ // Shared by the reporting methods: the request body with the window offsets,
33
+ // which the API documents as non-negative integers (days before today).
34
+ // Omitted offsets are left out so each endpoint applies its own default.
35
+ const reportingWindowBody = (apiKey, options, method) => {
36
+ const data = { key: apiKey };
37
+ for (const field of ['start_days_back', 'end_days_back']) {
38
+ if (options?.[field] !== undefined) {
39
+ if (!Number.isInteger(options[field]) || options[field] < 0) {
40
+ throw new TypeError(`${method}: ${field} must be a non-negative integer.`);
41
+ }
42
+ data[field] = options[field];
43
+ }
44
+ }
45
+ return data;
46
+ };
47
+
48
+ // Shared by chunkText() and chunkHtml(). The API reads both numbers with an
49
+ // integer type check (a numeric string like "25" is a 400), and max_length is
50
+ // required in practice: its default of 0 fails the positive-integer check.
51
+ const chunkBody = (apiKey, maxLength, options, method) => {
52
+ if (!Number.isInteger(maxLength) || maxLength < 1) {
53
+ throw new TypeError(`${method}: maxLength must be a positive integer.`);
54
+ }
55
+ const data = { key: apiKey, max_length: maxLength };
56
+ if (options?.step_size !== undefined) {
57
+ if (!Number.isInteger(options.step_size) || options.step_size < 0) {
58
+ throw new TypeError(`${method}: step_size must be a non-negative integer.`);
59
+ }
60
+ data.step_size = options.step_size;
61
+ }
62
+ return data;
63
+ };
29
64
 
30
65
  // Shared by styleguide() and saveStyleguideRuleset(). Shape/type checks only —
31
66
  // size limits (rule/description length) are server configuration and enforced
@@ -74,18 +109,138 @@ const validateStyleguideRules = (rules, method) => {
74
109
  }
75
110
  };
76
111
 
112
+ // Shared by extract() and fileExtract(). Shape/type checks only — size limits
113
+ // (field name/description length) are server configuration and enforced there
114
+ // with a clear 400.
115
+ const validateExtractFields = (fields, method) => {
116
+ const isValidField = (field) => {
117
+ if (typeof field === 'string') {
118
+ return field.trim().length > 0;
119
+ }
120
+ if (field == null || typeof field !== 'object' || Array.isArray(field)) {
121
+ return false;
122
+ }
123
+ if (typeof field.name !== 'string' || field.name.trim().length === 0) {
124
+ return false;
125
+ }
126
+ if (field.type !== undefined && !EXTRACT_FIELD_TYPES.includes(field.type)) {
127
+ return false;
128
+ }
129
+ if (field.description !== undefined && typeof field.description !== 'string') {
130
+ return false;
131
+ }
132
+ if (field.required !== undefined && typeof field.required !== 'boolean') {
133
+ return false;
134
+ }
135
+ // Reject unknown keys so a typo like `{ name, typ: 'number' }` fails here
136
+ // rather than being forwarded to the API as a malformed field (the MCP
137
+ // schema is `.strict()` for the same reason).
138
+ const allowed = new Set(['name', 'type', 'description', 'required']);
139
+ if (Object.keys(field).some((key) => !allowed.has(key))) {
140
+ return false;
141
+ }
142
+ return true;
143
+ };
144
+
145
+ if (
146
+ !Array.isArray(fields)
147
+ || fields.length < 1
148
+ || fields.length > 20
149
+ // Array.from() materializes holes in sparse arrays as `undefined` (which
150
+ // isValidField rejects); a bare `.every()` would skip them and let a hole
151
+ // through to be serialized as `null`.
152
+ || !Array.from(fields).every(isValidField)
153
+ ) {
154
+ throw new TypeError(`${method}: fields must be an array of 1 to 20 non-empty strings or `
155
+ + '{name, type?, description?, required?} objects (string name, type one of '
156
+ + `${EXTRACT_FIELD_TYPES.join(', ')}, optional string description, optional boolean required).`);
157
+ }
158
+
159
+ const names = fields.map((field) => (typeof field === 'string' ? field : field.name).toLowerCase());
160
+ if (names.length !== new Set(names).size) {
161
+ throw new TypeError(`${method}: field names must be unique (case-insensitive).`);
162
+ }
163
+ };
164
+
165
+ // Multipart body for the document-upload endpoints (/api/v1/file-aidetect,
166
+ // /api/v1/file-extract, /api/v1/file-summarize): the document in a `file` part and the options as a
167
+ // `jsonParams` form field. `file` is a Blob/File (browser, or Node 18+), or a
168
+ // Buffer/Uint8Array/ArrayBuffer read from disk. The API picks the format by
169
+ // file extension, so a name is required: a File's own name, or
170
+ // `options.filename`. Which extensions are accepted is server configuration
171
+ // (a clear 400 lists them), so it is not mirrored here.
172
+ const buildUploadForm = (file, filename, jsonParams, method) => {
173
+ // Global FormData/Blob arrived in Node 18; fail with a clear message rather
174
+ // than a ReferenceError on older runtimes without a polyfill.
175
+ if (typeof FormData === 'undefined' || typeof Blob === 'undefined') {
176
+ throw new Error(`${method}: document upload needs global FormData and Blob `
177
+ + '(Node 18+ or a browser).');
178
+ }
179
+ let blob;
180
+ if (file instanceof Blob) {
181
+ blob = file;
182
+ } else if (file instanceof ArrayBuffer || ArrayBuffer.isView(file)) {
183
+ blob = new Blob([file]);
184
+ } else {
185
+ throw new TypeError(`${method}: file must be a Blob, File, Buffer, Uint8Array or ArrayBuffer.`);
186
+ }
187
+ const name = filename !== undefined ? filename : file.name;
188
+ if (typeof name !== 'string' || name.trim().length === 0) {
189
+ throw new TypeError(`${method}: a filename is required (pass options.filename, e.g. `
190
+ + "'report.pdf'); the API chooses the document format by its extension.");
191
+ }
192
+ const form = new FormData();
193
+ form.append('file', blob, name);
194
+ form.append('jsonParams', JSON.stringify(jsonParams));
195
+ return form;
196
+ };
197
+
198
+ // Shared by pdfToText() and docxToText(): the key (and the client-level
199
+ // return_usage flag, which axiosPromise only injects into plain-object bodies)
200
+ // ride the jsonParams part.
201
+ const fileToTextForm = (client, file, options, method) => {
202
+ const params = { key: client.apiKey };
203
+ if (client.returnUsage) {
204
+ params.return_usage = true;
205
+ }
206
+ return buildUploadForm(file, options?.filename, params, method);
207
+ };
208
+
77
209
  export class Client {
78
210
  constructor(apiKey, options = {}) {
79
211
  this.apiKey = apiKey;
80
212
  this.timeout = options.timeout || 120000;
81
- this.hostname = options.hostname || 'https://api.sapling.ai';
82
- this.pathname = options.pathname || '/api/v1/';
213
+ this.hostname = (options.hostname || 'https://api.sapling.ai').replace(/\/+$/, '');
214
+ const path = (options.pathname || '/api/v1/').replace(/^\/+|\/+$/g, '');
215
+ this.pathname = path ? `/${path}/` : '/';
83
216
  this.urlEndpoint = `${this.hostname}${this.pathname}`;
84
217
  this.defaultSessionId = uuidv4();
218
+ // `!== undefined` (not `!= null`) so an explicit `null` is rejected as a
219
+ // bad type rather than silently treated as "omitted", matching the
220
+ // per-method option handling.
221
+ if (options.returnUsage !== undefined) {
222
+ if (typeof options.returnUsage !== 'boolean') {
223
+ throw new TypeError('Client: returnUsage must be a boolean.');
224
+ }
225
+ this.returnUsage = options.returnUsage;
226
+ } else {
227
+ this.returnUsage = false;
228
+ }
85
229
  axios.defaults.timeout = this.timeout;
86
230
  }
87
231
 
88
232
  axiosPromise(url, method, data) {
233
+ // Client-wide opt-in usage reporting: `return_usage` is accepted by every
234
+ // API-key endpoint (attached centrally server-side), so it is injected
235
+ // centrally here rather than wired into each method. Only `true` is sent —
236
+ // false is the server default and is omitted, and a `return_usage` a
237
+ // future method sets itself wins over the client-level flag. The
238
+ // `[object Object]` guard restricts the shallow copy to plain objects so a
239
+ // future non-plain body (Buffer, FormData, URLSearchParams, ...) is left
240
+ // untouched rather than spread into a stripped-down copy.
241
+ if (this.returnUsage && Object.prototype.toString.call(data) === '[object Object]' && !('return_usage' in data)) {
242
+ data = { ...data, return_usage: true };
243
+ }
89
244
  return new Promise((resolve, reject) => {
90
245
  axios({
91
246
  url,
@@ -204,8 +359,10 @@ export class Client {
204
359
  data.variety = options.variety;
205
360
  }
206
361
 
362
+ // The API reads this option as camelCase `userData` (and echoes it back
363
+ // as `user_data`); a snake_case key is silently ignored.
207
364
  if (options.userData != null) {
208
- data.user_data = options.userData;
365
+ data.userData = options.userData;
209
366
  }
210
367
 
211
368
  if (userDetails) {
@@ -218,6 +375,18 @@ export class Client {
218
375
  }
219
376
 
220
377
 
378
+ /**
379
+ * Autocomplete: predict how a partially written sentence continues.
380
+ *
381
+ * Returns `output` (the continuation), `query` (the part of the sent query
382
+ * it continues: only the text after the last run of three spaces) and
383
+ * `hash`. A 200 with `[]` means no completion is available. The query is
384
+ * capped at 200 characters, and the API key needs autocomplete enabled.
385
+ *
386
+ * @param {string} query - The end of what the user has typed.
387
+ * @param {string} [sessionId] - Defaults to the client's session id.
388
+ * @returns {Promise<AxiosResponse>}
389
+ */
221
390
  complete(query, sessionId) {
222
391
  const url = `${this.urlEndpoint}complete`;
223
392
  sessionId = sessionId || this.defaultSessionId;
@@ -230,6 +399,18 @@ export class Client {
230
399
  }
231
400
 
232
401
 
402
+ /**
403
+ * Record that the user accepted a completion from complete().
404
+ *
405
+ * Pass the `hash`, `query` and `output` that complete() returned, unchanged:
406
+ * the API recomputes the hash and answers 400 when they don't match.
407
+ *
408
+ * @param {string} completeUUID - The `hash` from complete().
409
+ * @param {string} query - The `query` from complete().
410
+ * @param {string} completion - The `output` from complete().
411
+ * @param {string} [sessionId] - Defaults to the client's session id.
412
+ * @returns {Promise<AxiosResponse>}
413
+ */
233
414
  acceptComplete(completeUUID, query, completion, sessionId) {
234
415
  const url = `${this.urlEndpoint}complete/${completeUUID}/accept`;
235
416
  sessionId = sessionId || this.defaultSessionId;
@@ -245,6 +426,17 @@ export class Client {
245
426
  return this.axiosPromise(url, 'post', data);
246
427
  }
247
428
 
429
+ /**
430
+ * Tone classification: overall and per-sentence emotional tone.
431
+ *
432
+ * Returns `overall` (up to 3 [probability, tone name, emoji] triples, most
433
+ * likely first, probabilities summing to 1), `results` (one list of triples
434
+ * per sentence) and `sents` (the sentences). A text the API cannot split
435
+ * into sentences gets a 200 with `[]`.
436
+ *
437
+ * @param {string} text - The text to analyze, at most 8000 characters.
438
+ * @returns {Promise<AxiosResponse>}
439
+ */
248
440
  tone(text) {
249
441
  const url = `${this.urlEndpoint}tone`;
250
442
 
@@ -466,7 +658,9 @@ export class Client {
466
658
  dictionaryEntries() {
467
659
  const url = `${this.urlEndpoint}dictionary`;
468
660
  // Not axiosPromise: this is a GET and the key rides the query string,
469
- // like styleguideRulesets().
661
+ // like styleguideRulesets(). The client-level return_usage opt-in is
662
+ // deliberately not forwarded: the response is a bare JSON array, which
663
+ // the API cannot attach `usage` to.
470
664
  return axios({
471
665
  url,
472
666
  method: 'get',
@@ -526,7 +720,217 @@ export class Client {
526
720
  }
527
721
  const url = `${this.urlEndpoint}dictionary/${encodeURIComponent(entryId)}`;
528
722
  // The key rides the query string, like deleteStyleguideRuleset(): some
529
- // HTTP stacks drop DELETE bodies.
723
+ // HTTP stacks drop DELETE bodies. return_usage is deliberately not
724
+ // forwarded: the 204 has no body to attach `usage` to.
725
+ return axios({
726
+ url,
727
+ method: 'delete',
728
+ params: { key: this.apiKey },
729
+ timeout: this.timeout,
730
+ });
731
+ }
732
+
733
+ /**
734
+ * List the custom mappings of this API key's account: rules that make the
735
+ * grammar check suggest a fixed replacement wherever `entry` matches
736
+ * (brand names, preferred terms, banned phrasings).
737
+ *
738
+ * Returns `custom_mappings`: `{id, entry, mapping, description,
739
+ * case_sensitive, created_at, updated_at}` entries sorted by entry
740
+ * (timestamps are RFC 1123 strings).
741
+ *
742
+ * @returns {Promise<AxiosResponse>}
743
+ */
744
+ customMappings() {
745
+ const url = `${this.urlEndpoint}custom_mapping`;
746
+ // Not axiosPromise (that injects into the POST body): this is a GET, so
747
+ // the key and the client-level return_usage opt-in ride the query
748
+ // string, like styleguideRulesets(). The response is a JSON object, so
749
+ // the API can attach `usage` to it.
750
+ const params = { key: this.apiKey };
751
+ if (this.returnUsage) {
752
+ params.return_usage = true;
753
+ }
754
+ return axios({
755
+ url,
756
+ method: 'get',
757
+ params,
758
+ timeout: this.timeout,
759
+ });
760
+ }
761
+
762
+ /**
763
+ * Add a custom mapping to this API key's account: wherever `entry`
764
+ * matches, the grammar check suggests `mapping` instead.
765
+ *
766
+ * `entry` supports two pattern forms: `^` / `$` anchor a match to the
767
+ * start / end of a sentence (e.g. `^the` to enforce capitalization), and
768
+ * `#` matches a number (e.g. `Gate #`); a `#` in `mapping` substitutes the
769
+ * matched number back in.
770
+ *
771
+ * Returns the created `{id, entry, mapping, description, case_sensitive,
772
+ * created_at, updated_at}` entry. The API answers 400 for a duplicate
773
+ * entry ("Entry already exists."), an entry equal to its mapping, an entry
774
+ * with more than two digits, and a case-insensitive rule whose entry and
775
+ * mapping differ only in case (e.g. iphone -> iPhone; mark it case
776
+ * sensitive).
777
+ *
778
+ * @param {string} entry - The text to match (surrounding whitespace is
779
+ * stripped server-side).
780
+ * @param {string} mapping - The replacement to suggest.
781
+ * @param {Object} [options]
782
+ * @param {string} [options.description] - Shown to the end user with the
783
+ * suggestion. A mapping with a description cannot be ignored.
784
+ * @param {boolean} [options.case_sensitive] - Whether matching is case
785
+ * sensitive. Defaults to true; when false, lower and title case both
786
+ * match.
787
+ * @returns {Promise<AxiosResponse>}
788
+ */
789
+ addCustomMapping(entry, mapping, options = {}) {
790
+ const url = `${this.urlEndpoint}custom_mapping`;
791
+
792
+ if (typeof entry !== 'string' || entry.trim().length === 0) {
793
+ throw new TypeError('addCustomMapping: entry must be a non-empty string.');
794
+ }
795
+ if (typeof mapping !== 'string') {
796
+ throw new TypeError('addCustomMapping: mapping must be a string.');
797
+ }
798
+
799
+ let data = {
800
+ entry,
801
+ mapping,
802
+ key: this.apiKey,
803
+ }
804
+
805
+ if (options?.description !== undefined) {
806
+ if (typeof options.description !== 'string') {
807
+ throw new TypeError('addCustomMapping: description must be a string.');
808
+ }
809
+ data.description = options.description;
810
+ }
811
+ if (options?.case_sensitive !== undefined) {
812
+ if (typeof options.case_sensitive !== 'boolean') {
813
+ throw new TypeError('addCustomMapping: case_sensitive must be a boolean.');
814
+ }
815
+ data.case_sensitive = options.case_sensitive;
816
+ }
817
+
818
+ return this.axiosPromise(url, 'post', data);
819
+ }
820
+
821
+ /**
822
+ * Delete a custom mapping by the `id` customMappings() or
823
+ * addCustomMapping() returned. Answers 204 with no body; an id that is not
824
+ * this account's gets a 400 (not a 404).
825
+ *
826
+ * @param {string} mappingId - The mapping's id (a UUID).
827
+ * @returns {Promise<AxiosResponse>}
828
+ */
829
+ deleteCustomMapping(mappingId) {
830
+ if (typeof mappingId !== 'string' || mappingId.trim().length === 0) {
831
+ throw new TypeError('deleteCustomMapping: mappingId must be a non-empty string.');
832
+ }
833
+ const url = `${this.urlEndpoint}custom_mapping/${encodeURIComponent(mappingId)}`;
834
+ // Key in the query string, like deleteDictionaryEntry(): some HTTP
835
+ // stacks drop DELETE bodies, and the 204 has no body to attach `usage`
836
+ // to, so return_usage is not forwarded.
837
+ return axios({
838
+ url,
839
+ method: 'delete',
840
+ params: { key: this.apiKey },
841
+ timeout: this.timeout,
842
+ });
843
+ }
844
+
845
+ /**
846
+ * List the custom filters of this API key's account: rules that suppress
847
+ * grammar-check suggestions rewriting `input` to `output`.
848
+ *
849
+ * Returns `custom_filters`: `{id, input, output, case_sensitive,
850
+ * created_at, updated_at}` entries sorted by input. An empty `input` or
851
+ * `output` is a wildcard.
852
+ *
853
+ * @returns {Promise<AxiosResponse>}
854
+ */
855
+ customFilters() {
856
+ const url = `${this.urlEndpoint}custom_filter`;
857
+ // GET: key and return_usage ride the query string, like customMappings().
858
+ const params = { key: this.apiKey };
859
+ if (this.returnUsage) {
860
+ params.return_usage = true;
861
+ }
862
+ return axios({
863
+ url,
864
+ method: 'get',
865
+ params,
866
+ timeout: this.timeout,
867
+ });
868
+ }
869
+
870
+ /**
871
+ * Add a custom filter to this API key's account, so the grammar check
872
+ * stops suggesting edits that rewrite `input` to `output`. Pass an empty
873
+ * string for either side as a wildcard: `('Patient', '')` suppresses every
874
+ * edit of "Patient"; `('', 'The patient')` every edit that produces "The
875
+ * patient".
876
+ *
877
+ * Returns the created `{id, input, output, case_sensitive, created_at,
878
+ * updated_at}` filter. The API answers 400 when both sides are blank
879
+ * after trimming, when they are equal, for a duplicate filter ("Filter
880
+ * already exists."), an input with more than two digits, and a
881
+ * case-insensitive filter whose sides differ only in case.
882
+ *
883
+ * @param {string} input - The original text of the edits to suppress, or
884
+ * '' for any (surrounding whitespace is stripped server-side).
885
+ * @param {string} output - The replacement text of the edits to suppress,
886
+ * or '' for any.
887
+ * @param {Object} [options]
888
+ * @param {boolean} [options.case_sensitive] - Whether matching is case
889
+ * sensitive. Defaults to true.
890
+ * @returns {Promise<AxiosResponse>}
891
+ */
892
+ addCustomFilter(input, output, options = {}) {
893
+ const url = `${this.urlEndpoint}custom_filter`;
894
+
895
+ if (typeof input !== 'string' || typeof output !== 'string') {
896
+ throw new TypeError('addCustomFilter: input and output must be strings.');
897
+ }
898
+ if (input.trim().length === 0 && output.trim().length === 0) {
899
+ throw new TypeError('addCustomFilter: input and output cannot both be blank.');
900
+ }
901
+
902
+ // The API's field names are `inp` / `out`; the response says
903
+ // `input` / `output`.
904
+ let data = {
905
+ inp: input,
906
+ out: output,
907
+ key: this.apiKey,
908
+ }
909
+
910
+ if (options?.case_sensitive !== undefined) {
911
+ if (typeof options.case_sensitive !== 'boolean') {
912
+ throw new TypeError('addCustomFilter: case_sensitive must be a boolean.');
913
+ }
914
+ data.case_sensitive = options.case_sensitive;
915
+ }
916
+
917
+ return this.axiosPromise(url, 'post', data);
918
+ }
919
+
920
+ /**
921
+ * Delete a custom filter by the `id` customFilters() or addCustomFilter()
922
+ * returned. Answers 204 with no body; an id that is malformed or not this
923
+ * account's gets a 400 (not a 404).
924
+ *
925
+ * @param {string} filterId - The filter's id (a UUID).
926
+ * @returns {Promise<AxiosResponse>}
927
+ */
928
+ deleteCustomFilter(filterId) {
929
+ if (typeof filterId !== 'string' || filterId.trim().length === 0) {
930
+ throw new TypeError('deleteCustomFilter: filterId must be a non-empty string.');
931
+ }
932
+ const url = `${this.urlEndpoint}custom_filter/${encodeURIComponent(filterId)}`;
933
+ // Key in the query string and no return_usage, like deleteCustomMapping().
530
934
  return axios({
531
935
  url,
532
936
  method: 'delete',
@@ -590,53 +994,7 @@ export class Client {
590
994
  throw new TypeError('extract: text must be a non-empty string.');
591
995
  }
592
996
 
593
- const isValidField = (field) => {
594
- if (typeof field === 'string') {
595
- return field.trim().length > 0;
596
- }
597
- if (field == null || typeof field !== 'object' || Array.isArray(field)) {
598
- return false;
599
- }
600
- if (typeof field.name !== 'string' || field.name.trim().length === 0) {
601
- return false;
602
- }
603
- if (field.type !== undefined && !EXTRACT_FIELD_TYPES.includes(field.type)) {
604
- return false;
605
- }
606
- if (field.description !== undefined && typeof field.description !== 'string') {
607
- return false;
608
- }
609
- if (field.required !== undefined && typeof field.required !== 'boolean') {
610
- return false;
611
- }
612
- // Reject unknown keys so a typo like `{ name, typ: 'number' }` fails here
613
- // rather than being forwarded to the API as a malformed field (the MCP
614
- // schema is `.strict()` for the same reason).
615
- const allowed = new Set(['name', 'type', 'description', 'required']);
616
- if (Object.keys(field).some((key) => !allowed.has(key))) {
617
- return false;
618
- }
619
- return true;
620
- };
621
-
622
- if (
623
- !Array.isArray(fields)
624
- || fields.length < 1
625
- || fields.length > 20
626
- // Array.from() materializes holes in sparse arrays as `undefined` (which
627
- // isValidField rejects); a bare `.every()` would skip them and let a hole
628
- // through to be serialized as `null`.
629
- || !Array.from(fields).every(isValidField)
630
- ) {
631
- throw new TypeError('extract: fields must be an array of 1 to 20 non-empty strings or '
632
- + '{name, type?, description?, required?} objects (string name, type one of '
633
- + `${EXTRACT_FIELD_TYPES.join(', ')}, optional string description, optional boolean required).`);
634
- }
635
-
636
- const names = fields.map((field) => (typeof field === 'string' ? field : field.name).toLowerCase());
637
- if (names.length !== new Set(names).size) {
638
- throw new TypeError('extract: field names must be unique (case-insensitive).');
639
- }
997
+ validateExtractFields(fields, 'extract');
640
998
 
641
999
  let data = {
642
1000
  ...(isBatch ? { texts: text } : { text }),
@@ -657,6 +1015,48 @@ export class Client {
657
1015
  return this.axiosPromise(url, 'post', data);
658
1016
  }
659
1017
 
1018
+ /**
1019
+ * Extract caller-named fields from an uploaded document
1020
+ * (POST /api/v1/file-extract): the same extraction as extract(), with the text
1021
+ * read server-side from a .pdf, .docx, .pptx, .odt, .txt, .md, .html or .htm
1022
+ * file of up to 30MB.
1023
+ *
1024
+ * Only the first 10,000 characters of the document's text are read; the
1025
+ * response's `document` block (`file_type`, `characters`, `characters_used`,
1026
+ * `truncated`) says how much was used, and `characters_used` is what is billed.
1027
+ *
1028
+ * @param {Blob|File|Buffer|Uint8Array|ArrayBuffer} file - The document.
1029
+ * @param {Array<string|object>} fields - Same as extract().
1030
+ * @param {object} [options]
1031
+ * @param {string} [options.filename] - Required unless `file` is a File: the
1032
+ * format is chosen by its extension (e.g. 'invoice.pdf').
1033
+ * @param {string} [options.context] - Same as extract().
1034
+ */
1035
+ fileExtract(file, fields, options = {}) {
1036
+ const url = `${this.urlEndpoint}file-extract`;
1037
+
1038
+ validateExtractFields(fields, 'fileExtract');
1039
+
1040
+ let params = {
1041
+ key: this.apiKey,
1042
+ fields,
1043
+ };
1044
+ if (options?.context !== undefined) {
1045
+ if (typeof options.context !== 'string') {
1046
+ throw new TypeError('fileExtract: context must be a string.');
1047
+ }
1048
+ params.context = options.context;
1049
+ }
1050
+ // axiosPromise only injects return_usage into plain-object bodies, so the
1051
+ // client-level flag rides the jsonParams part here.
1052
+ if (this.returnUsage) {
1053
+ params.return_usage = true;
1054
+ }
1055
+
1056
+ const form = buildUploadForm(file, options?.filename, params, 'fileExtract');
1057
+ return this.axiosPromise(url, 'post', form);
1058
+ }
1059
+
660
1060
  /**
661
1061
  * Translate text into a target language with an LLM.
662
1062
  *
@@ -806,6 +1206,44 @@ export class Client {
806
1206
  return this.axiosPromise(url, 'post', data);
807
1207
  }
808
1208
 
1209
+ /**
1210
+ * Summarize an uploaded document (POST /api/v1/file-summarize): the same
1211
+ * summary as summarize(), with the text read server-side from a .pdf, .docx,
1212
+ * .pptx, .odt, .txt, .md, .html or .htm file of up to 30MB.
1213
+ *
1214
+ * Only the first 20,000 characters of the document's text are read; the
1215
+ * response's `document` block (`file_type`, `characters`, `characters_used`,
1216
+ * `truncated`) says how much was used, and `characters_used` is what is billed.
1217
+ *
1218
+ * @param {Blob|File|Buffer|Uint8Array|ArrayBuffer} file - The document.
1219
+ * @param {object} [options]
1220
+ * @param {string} [options.filename] - Required unless `file` is a File: the
1221
+ * format is chosen by its extension (e.g. 'report.pdf').
1222
+ * @param {string} [options.length] - Same as summarize().
1223
+ */
1224
+ fileSummarize(file, options = {}) {
1225
+ const url = `${this.urlEndpoint}file-summarize`;
1226
+
1227
+ let params = {
1228
+ key: this.apiKey,
1229
+ };
1230
+ if (options?.length !== undefined) {
1231
+ if (!SUMMARIZE_LENGTHS.includes(options.length)) {
1232
+ throw new TypeError('fileSummarize: length must be one of '
1233
+ + `${SUMMARIZE_LENGTHS.join(', ')}.`);
1234
+ }
1235
+ params.length = options.length;
1236
+ }
1237
+ // axiosPromise only injects return_usage into plain-object bodies, so the
1238
+ // client-level flag rides the jsonParams part here.
1239
+ if (this.returnUsage) {
1240
+ params.return_usage = true;
1241
+ }
1242
+
1243
+ const form = buildUploadForm(file, options?.filename, params, 'fileSummarize');
1244
+ return this.axiosPromise(url, 'post', form);
1245
+ }
1246
+
809
1247
  langdetect(text, options = {}) {
810
1248
  const url = `${this.urlEndpoint}langdetect`;
811
1249
 
@@ -1176,9 +1614,122 @@ export class Client {
1176
1614
  }
1177
1615
  data.version = options.version;
1178
1616
  }
1617
+ // The supported codes are server config (the language routes), so only
1618
+ // the type is checked here; an unsupported code gets the API's 400.
1619
+ if (options.lang !== undefined) {
1620
+ if (typeof options.lang !== 'string') {
1621
+ throw new TypeError('aidetect: lang must be a string.');
1622
+ }
1623
+ data.lang = options.lang;
1624
+ }
1625
+ if (options.zero_retention !== undefined) {
1626
+ if (typeof options.zero_retention !== 'boolean') {
1627
+ throw new TypeError('aidetect: zero_retention must be a boolean.');
1628
+ }
1629
+ data.zero_retention = options.zero_retention;
1630
+ }
1631
+ // Single text only, and not with zero_retention or a public key: the
1632
+ // API 400s those before scoring, so only the type is checked here.
1633
+ if (options.create_report !== undefined) {
1634
+ if (typeof options.create_report !== 'boolean') {
1635
+ throw new TypeError('aidetect: create_report must be a boolean.');
1636
+ }
1637
+ data.create_report = options.create_report;
1638
+ }
1179
1639
 
1180
1640
  return this.axiosPromise(url, 'post', data);
1181
1641
  }
1642
+ /**
1643
+ * Score an uploaded document for AI-generated text (POST /api/v1/file-aidetect).
1644
+ *
1645
+ * The API reads the text of a .pdf, .docx, .pptx, .odt, .txt, .md, .html or
1646
+ * .htm file of up to 30MB and scores its first 200,000 characters like
1647
+ * aidetect() with sentence scores and score_string enabled. The language is
1648
+ * detected and routed automatically (no lang/version option). Billed like
1649
+ * aidetect().
1650
+ *
1651
+ * @param {Blob|File|Buffer|Uint8Array|ArrayBuffer} file - The document.
1652
+ * @param {object} [options]
1653
+ * @param {string} [options.filename] - Required unless `file` is a File: the
1654
+ * format is chosen by its extension (e.g. 'essay.docx').
1655
+ * @param {boolean} [options.zero_retention] - As for aidetect(): keep no
1656
+ * copy of the extracted text.
1657
+ * @param {boolean} [options.create_report] - As for aidetect(): save the
1658
+ * result as a shareable report named after the file and return `report`.
1659
+ */
1660
+ fileAidetect(file, options = {}) {
1661
+ const url = `${this.urlEndpoint}file-aidetect`;
1662
+
1663
+ let params = { key: this.apiKey };
1664
+ if (this.returnUsage) {
1665
+ params.return_usage = true;
1666
+ }
1667
+ if (options?.zero_retention !== undefined) {
1668
+ if (typeof options.zero_retention !== 'boolean') {
1669
+ throw new TypeError('fileAidetect: zero_retention must be a boolean.');
1670
+ }
1671
+ params.zero_retention = options.zero_retention;
1672
+ }
1673
+ if (options?.create_report !== undefined) {
1674
+ if (typeof options.create_report !== 'boolean') {
1675
+ throw new TypeError('fileAidetect: create_report must be a boolean.');
1676
+ }
1677
+ params.create_report = options.create_report;
1678
+ }
1679
+
1680
+ const form = buildUploadForm(file, options?.filename, params, 'fileAidetect');
1681
+ return this.axiosPromise(url, 'post', form);
1682
+ }
1683
+
1684
+ /**
1685
+ * Score a public web page for AI-generated text (POST /api/v1/url-aidetect).
1686
+ *
1687
+ * The API fetches the URL and scores the page's main readable text
1688
+ * (navigation, headers, footers, forms and hidden elements dropped; the
1689
+ * largest <article>, else <main>, preferred) like aidetect(). A linked
1690
+ * .pdf, .docx, .pptx, .odt, .txt or .md document is read like
1691
+ * fileAidetect(). The response adds `source` ({url, title, content_type,
1692
+ * characters, truncated}). Billed like aidetect() on the characters scored.
1693
+ *
1694
+ * @param {string} url - http(s) URL; a bare host/path gets https://.
1695
+ * @param {object} [options] - sent_scores, score_string, version, lang,
1696
+ * zero_retention, create_report: as for aidetect() (a report is named
1697
+ * after the final URL).
1698
+ */
1699
+ urlAidetect(url, options = {}) {
1700
+ const endpoint = `${this.urlEndpoint}url-aidetect`;
1701
+
1702
+ // Scheme, length and address checks are the server's (it 400s with a
1703
+ // clear message); only the type is checked here.
1704
+ if (typeof url !== 'string' || url.trim().length === 0) {
1705
+ throw new TypeError('urlAidetect: url must be a non-empty string.');
1706
+ }
1707
+
1708
+ let data = {
1709
+ url,
1710
+ key: this.apiKey,
1711
+ }
1712
+
1713
+ for (const name of ['sent_scores', 'score_string', 'zero_retention', 'create_report']) {
1714
+ if (options?.[name] !== undefined) {
1715
+ if (typeof options[name] !== 'boolean') {
1716
+ throw new TypeError(`urlAidetect: ${name} must be a boolean.`);
1717
+ }
1718
+ data[name] = options[name];
1719
+ }
1720
+ }
1721
+ for (const name of ['version', 'lang']) {
1722
+ if (options?.[name] !== undefined) {
1723
+ if (typeof options[name] !== 'string') {
1724
+ throw new TypeError(`urlAidetect: ${name} must be a string.`);
1725
+ }
1726
+ data[name] = options[name];
1727
+ }
1728
+ }
1729
+
1730
+ return this.axiosPromise(endpoint, 'post', data);
1731
+ }
1732
+
1182
1733
 
1183
1734
  /**
1184
1735
  * Check a text for compliance with style rules you supply (house style,
@@ -1267,11 +1818,17 @@ export class Client {
1267
1818
  */
1268
1819
  styleguideRulesets() {
1269
1820
  const url = `${this.urlEndpoint}styleguide/rulesets`;
1270
- // Not axiosPromise: this is a GET and the key rides the query string.
1821
+ // Not axiosPromise (that injects into the POST body): this is a GET, so
1822
+ // the key — and the client-level return_usage opt-in — ride the query
1823
+ // string, keeping usage reporting consistent with the sibling POST/DELETE.
1824
+ const params = { key: this.apiKey };
1825
+ if (this.returnUsage) {
1826
+ params.return_usage = true;
1827
+ }
1271
1828
  return axios({
1272
1829
  url,
1273
1830
  method: 'get',
1274
- params: { key: this.apiKey },
1831
+ params,
1275
1832
  timeout: this.timeout,
1276
1833
  });
1277
1834
  }
@@ -1321,11 +1878,17 @@ export class Client {
1321
1878
  throw new TypeError('deleteStyleguideRuleset: name must be a non-empty string.');
1322
1879
  }
1323
1880
  // The name rides the query string: some HTTP stacks drop DELETE bodies,
1324
- // and the API accepts the query form for exactly that reason.
1881
+ // and the API accepts the query form for exactly that reason. The
1882
+ // client-level return_usage opt-in rides it too (axiosPromise only
1883
+ // injects into POST bodies), keeping usage reporting consistent.
1884
+ const params = { key: this.apiKey, name };
1885
+ if (this.returnUsage) {
1886
+ params.return_usage = true;
1887
+ }
1325
1888
  return axios({
1326
1889
  url,
1327
1890
  method: 'delete',
1328
- params: { key: this.apiKey, name },
1891
+ params,
1329
1892
  timeout: this.timeout,
1330
1893
  });
1331
1894
  }
@@ -1538,6 +2101,157 @@ export class Client {
1538
2101
  return this.axiosPromise(url, 'post', data);
1539
2102
  }
1540
2103
 
2104
+ /**
2105
+ * Measure how closely a resume overlaps with a job description
2106
+ * (POST /api/v1/jd-match): the share of the posting's keywords the resume
2107
+ * uses, the passages the two share word for word, and a low / moderate / high
2108
+ * level. Deterministic and unbilled; both texts count against the rate limit.
2109
+ *
2110
+ * `score` is a heuristic over those two signals, not a probability, and says
2111
+ * nothing about who wrote the resume. High overlap can simply mean a good fit,
2112
+ * so pair it with aidetect() and don't use it alone to decide an application.
2113
+ *
2114
+ * @param {string} text - The resume. The API caps it at 50,000 characters and
2115
+ * compares it as sent, so `copied_spans` offsets index this exact string, in
2116
+ * Unicode code points.
2117
+ * @param {string} jobDescription - The posting (sent as the API's
2118
+ * `job_description`). The API caps it at 20,000 characters and answers 400
2119
+ * when it has fewer than 5 distinct keywords.
2120
+ * @returns {Promise<AxiosResponse>}
2121
+ */
2122
+ jdMatch(text, jobDescription) {
2123
+ // Length, empty-text and keyword-count checks stay on the server, which
2124
+ // answers each with a clear 400.
2125
+ if (typeof text !== 'string') {
2126
+ throw new TypeError('jdMatch: text must be a string.');
2127
+ }
2128
+ if (typeof jobDescription !== 'string') {
2129
+ throw new TypeError('jdMatch: jobDescription must be a string.');
2130
+ }
2131
+ const data = { key: this.apiKey, text, job_description: jobDescription };
2132
+ return this.axiosPromise(`${this.urlEndpoint}jd-match`, 'post', data);
2133
+ }
2134
+
2135
+ /**
2136
+ * Split text into chunks of at most `maxLength` characters, e.g. to fit an
2137
+ * endpoint's length limit or an LLM context window. Each split prefers, in
2138
+ * order, a page break, a paragraph break, a line break, a tab, ending
2139
+ * punctuation (`.`, `?`, `!`) and then any whitespace, and falls back to a
2140
+ * hard cut at `maxLength`. Chunks don't overlap and keep their delimiters,
2141
+ * so joining them gives back the original text. Unbilled.
2142
+ *
2143
+ * Returns `chunks`, an array of strings in document order.
2144
+ *
2145
+ * @param {string} text - The text to chunk.
2146
+ * @param {number} maxLength - Maximum chunk length in characters (a
2147
+ * positive integer; sent as the API's `max_length`).
2148
+ * @param {Object} [options]
2149
+ * @param {number} [options.step_size] - Window, in characters, searched
2150
+ * backwards from each `maxLength` cut for a split point. Larger windows
2151
+ * find preferred split points more often but vary chunk sizes more.
2152
+ * Defaults to 10% of the text length.
2153
+ * @returns {Promise<AxiosResponse>}
2154
+ */
2155
+ chunkText(text, maxLength, options = {}) {
2156
+ if (typeof text !== 'string') {
2157
+ throw new TypeError('chunkText: text must be a string.');
2158
+ }
2159
+ const data = chunkBody(this.apiKey, maxLength, options, 'chunkText');
2160
+ data.text = text;
2161
+ return this.axiosPromise(`${this.urlEndpoint}ingest/chunk_text`, 'post', data);
2162
+ }
2163
+
2164
+ /**
2165
+ * Extract the text of an HTML document and split it like chunkText(). Tags
2166
+ * are discarded, so the chunks are plain text. Unbilled.
2167
+ *
2168
+ * @param {string} html - The HTML to extract text from and chunk.
2169
+ * @param {number} maxLength - Maximum chunk length in characters.
2170
+ * @param {Object} [options]
2171
+ * @param {number} [options.step_size] - See chunkText().
2172
+ * @returns {Promise<AxiosResponse>}
2173
+ */
2174
+ chunkHtml(html, maxLength, options = {}) {
2175
+ if (typeof html !== 'string') {
2176
+ throw new TypeError('chunkHtml: html must be a string.');
2177
+ }
2178
+ const data = chunkBody(this.apiKey, maxLength, options, 'chunkHtml');
2179
+ data.html = html;
2180
+ return this.axiosPromise(`${this.urlEndpoint}ingest/chunk_html`, 'post', data);
2181
+ }
2182
+
2183
+ /**
2184
+ * Extract the plain text of an uploaded PDF (POST /api/v1/ingest/pdf_to_text).
2185
+ * Resolves to `{text}`. The API requires a `.pdf` filename; a password-protected,
2186
+ * empty or malformed file gets a 400 whose body is `{error}` (not `{msg}`).
2187
+ * Unbilled.
2188
+ *
2189
+ * @param {Blob|File|Buffer|Uint8Array|ArrayBuffer} file - The PDF.
2190
+ * @param {object} [options]
2191
+ * @param {string} [options.filename] - Required unless `file` is a File,
2192
+ * e.g. 'report.pdf'.
2193
+ * @returns {Promise<AxiosResponse>}
2194
+ */
2195
+ pdfToText(file, options = {}) {
2196
+ const form = fileToTextForm(this, file, options, 'pdfToText');
2197
+ return this.axiosPromise(`${this.urlEndpoint}ingest/pdf_to_text`, 'post', form);
2198
+ }
2199
+
2200
+ /**
2201
+ * Extract the plain text of an uploaded Word document
2202
+ * (POST /api/v1/ingest/docx_to_text). Resolves to `{text}`, with blocks
2203
+ * (paragraphs, tables) separated by blank lines. The API requires a `.docx`
2204
+ * filename; a corrupt or macro-enabled file gets a 400 whose body is
2205
+ * `{error}` (not `{msg}`). Unbilled.
2206
+ *
2207
+ * @param {Blob|File|Buffer|Uint8Array|ArrayBuffer} file - The DOCX.
2208
+ * @param {object} [options]
2209
+ * @param {string} [options.filename] - Required unless `file` is a File,
2210
+ * e.g. 'notes.docx'.
2211
+ * @returns {Promise<AxiosResponse>}
2212
+ */
2213
+ docxToText(file, options = {}) {
2214
+ const form = fileToTextForm(this, file, options, 'docxToText');
2215
+ return this.axiosPromise(`${this.urlEndpoint}ingest/docx_to_text`, 'post', form);
2216
+ }
2217
+
2218
+ /**
2219
+ * Release and build identifiers of the deployment answering the request:
2220
+ * `version` (for example `'2026.6'`) and `build`, which is `'unknown'` when the
2221
+ * image carries no build number. Unbilled, and needs no API key and sends
2222
+ * none, so it can check which release a hostname is serving.
2223
+ *
2224
+ * @returns {Promise<AxiosResponse>}
2225
+ */
2226
+ version() {
2227
+ const url = `${this.urlEndpoint}version`;
2228
+ // Not axiosPromise, and no key: the route is public, so neither the API key
2229
+ // nor the client-level return_usage opt-in has any reason to leave the client.
2230
+ return axios({
2231
+ url,
2232
+ method: 'get',
2233
+ timeout: this.timeout,
2234
+ });
2235
+ }
2236
+
2237
+ /**
2238
+ * Whether the API process is up: answers 200 with `{ msg: 'alive' }`. Like
2239
+ * version(), it's unbilled, needs no API key and sends none. It doesn't
2240
+ * check the services behind the API, so it can succeed during an outage of
2241
+ * one of them.
2242
+ *
2243
+ * @returns {Promise<AxiosResponse>}
2244
+ */
2245
+ liveness() {
2246
+ const url = `${this.urlEndpoint}liveness`;
2247
+ // Not axiosPromise, and no key: see version().
2248
+ return axios({
2249
+ url,
2250
+ method: 'get',
2251
+ timeout: this.timeout,
2252
+ });
2253
+ }
2254
+
1541
2255
  /**
1542
2256
  * Check whether an account-wide block (expired key/subscription,
1543
2257
  * depleted credits, billing limit, trial daily allowance, monthly quota)
@@ -1565,7 +2279,10 @@ export class Client {
1565
2279
  accountStatus() {
1566
2280
  const url = `${this.urlEndpoint}account/status`;
1567
2281
  // Not axiosPromise: this is a GET and the key rides the query string,
1568
- // like styleguideRulesets().
2282
+ // like styleguideRulesets(). The client-level return_usage opt-in is
2283
+ // deliberately not forwarded: the status check is unbilled and the
2284
+ // response already has its own month-to-date `usage` object, which the
2285
+ // API leaves unchanged.
1569
2286
  return axios({
1570
2287
  url,
1571
2288
  method: 'get',
@@ -1573,4 +2290,174 @@ export class Client {
1573
2290
  timeout: this.timeout,
1574
2291
  });
1575
2292
  }
2293
+
2294
+ /**
2295
+ * Usage and cost report for the account that owns this API key (or, for a
2296
+ * team-scoped key, the team): characters and list-price cost over a window,
2297
+ * broken down by key, endpoint, day and month. Keys are identified by
2298
+ * masked ids (`****<last4>-<hash8>`), never raw. Unbilled.
2299
+ *
2300
+ * Returns `start_date`, `end_date`, `total_characters`, `total_cost_usd`,
2301
+ * `currency`, `by_api_key`, `by_endpoint`, `daily_usage`, `monthly_usage`,
2302
+ * `last_updated` and, for keys that are not team-scoped,
2303
+ * `credit_balance_data` (the same object as accountStatus()'s `credits`).
2304
+ * The API answers 400 when start_days_back < end_days_back and 403 for a
2305
+ * window over a year; the window is clamped to the account's age.
2306
+ *
2307
+ * @param {Object} [options]
2308
+ * @param {number} [options.start_days_back] - Days before today the window
2309
+ * starts. Defaults to the first of the current month.
2310
+ * @param {number} [options.end_days_back] - Days before today the window
2311
+ * ends (inclusive). Defaults to 0, today.
2312
+ * @returns {Promise<AxiosResponse>}
2313
+ */
2314
+ apiUsage(options = {}) {
2315
+ const url = `${this.urlEndpoint}reporting/api_usage`;
2316
+ const data = reportingWindowBody(this.apiKey, options, 'apiUsage');
2317
+ return this.axiosPromise(url, 'post', data);
2318
+ }
2319
+
2320
+ /**
2321
+ * Characters per endpoint over a window, bucketed by day, week or month.
2322
+ * `data` maps an endpoint name (e.g. `EDIT`) to `{<days back>: characters}`,
2323
+ * where each key is the bucket's start as days before today (a string, as
2324
+ * in all JSON object keys). `last_updated` is an ISO timestamp or null.
2325
+ * Unbilled.
2326
+ *
2327
+ * @param {Object} [options]
2328
+ * @param {number} [options.start_days_back] - Days before today the window
2329
+ * starts. Defaults to 6.
2330
+ * @param {number} [options.end_days_back] - Days before today the window
2331
+ * ends. Defaults to 0.
2332
+ * @param {'Daily'|'Weekly'|'Monthly'} [options.interval] - Bucket size.
2333
+ * Defaults to 'Daily'.
2334
+ * @returns {Promise<AxiosResponse>}
2335
+ */
2336
+ apiEndpointUsage(options = {}) {
2337
+ const url = `${this.urlEndpoint}reporting/api_endpoint_usage`;
2338
+ const data = reportingWindowBody(this.apiKey, options, 'apiEndpointUsage');
2339
+ if (options?.interval !== undefined) {
2340
+ if (!REPORTING_INTERVALS.includes(options.interval)) {
2341
+ throw new TypeError(
2342
+ `apiEndpointUsage: interval must be one of ${REPORTING_INTERVALS.join(', ')}.`);
2343
+ }
2344
+ data.interval = options.interval;
2345
+ }
2346
+ return this.axiosPromise(url, 'post', data);
2347
+ }
2348
+
2349
+ /**
2350
+ * Monthly quota and characters used over a window. Returns `quota` (the
2351
+ * monthly character limit, -1 for none), `usage` (characters in the
2352
+ * window), `accepts`/`ignores` (suggestions accepted or ignored through
2353
+ * acceptEdit()/rejectEdit() since the first of the current month, whatever
2354
+ * the window) and `last_updated` (Unix epoch seconds). Unbilled.
2355
+ *
2356
+ * The API reads a start_days_back of 0 as 1 (its default), so the shortest
2357
+ * window it reports is yesterday and today.
2358
+ *
2359
+ * @param {Object} [options]
2360
+ * @param {number} [options.start_days_back] - Days before today the window
2361
+ * starts. Defaults to 1.
2362
+ * @param {number} [options.end_days_back] - Days before today the window
2363
+ * ends. Defaults to 0.
2364
+ * @returns {Promise<AxiosResponse>}
2365
+ */
2366
+ apiQuotaUsage(options = {}) {
2367
+ const url = `${this.urlEndpoint}reporting/api_quota_usage`;
2368
+ const data = reportingWindowBody(this.apiKey, options, 'apiQuotaUsage');
2369
+ // Not axiosPromise: the client-level return_usage opt-in is deliberately
2370
+ // not forwarded, like accountStatus(). The response's own `usage` is an
2371
+ // integer, which the API leaves unchanged, so the flag would do nothing.
2372
+ return axios({
2373
+ url,
2374
+ method: 'post',
2375
+ data,
2376
+ timeout: this.timeout,
2377
+ });
2378
+ }
2379
+
2380
+ /**
2381
+ * Suggestion activity and active users over a window: `edits_shown`,
2382
+ * `edits_accepted`, `edits_ignored` and `active_users` ({logged_in,
2383
+ * anonymous}). Accepts and ignores are only counted when your integration
2384
+ * reports them with acceptEdit()/rejectEdit(). Unbilled.
2385
+ *
2386
+ * @param {Object} [options]
2387
+ * @param {number} [options.start_days_back] - Days before today the window
2388
+ * starts. Defaults to 6.
2389
+ * @param {number} [options.end_days_back] - Days before today the window
2390
+ * ends. Defaults to 0.
2391
+ * @returns {Promise<AxiosResponse>}
2392
+ */
2393
+ apiUserActivity(options = {}) {
2394
+ const url = `${this.urlEndpoint}reporting/api_user_activity`;
2395
+ const data = reportingWindowBody(this.apiKey, options, 'apiUserActivity');
2396
+ return this.axiosPromise(url, 'post', data);
2397
+ }
2398
+
2399
+ /**
2400
+ * Inclusive-language check: finds non-inclusive or biased wording
2401
+ * (gendered defaults, ableist idioms, ageist framing, outdated terms for
2402
+ * groups, classist or appearance-based language). Each issue carries the
2403
+ * verbatim passage, start/end offsets into the exact text submitted (null
2404
+ * when the passage can't be located), a category, a severity
2405
+ * (low / medium / high), a short note and a neutral replacement; the
2406
+ * response's `inclusive` is true when nothing was flagged. Wording is
2407
+ * flagged, not topics: text that discusses or quotes a term to critique
2408
+ * it is not an issue.
2409
+ *
2410
+ * @param {string|string[]} text - The text to check, or an array of 1–10
2411
+ * texts for the batch form (sent as the API's `texts` parameter; combined
2412
+ * length up to 10,000 characters, same categories applied to every item).
2413
+ * A batch response is `{results: [...]}` with one single-response-shaped
2414
+ * entry per input, in order.
2415
+ * @param {Object} [options]
2416
+ * @param {string[]} [options.categories] - Categories to check (gender,
2417
+ * race_ethnicity, disability, age, lgbtq, religion, socioeconomic,
2418
+ * appearance). Default: all of them.
2419
+ */
2420
+ inclusive(text, options = {}) {
2421
+ const url = `${this.urlEndpoint}inclusive`;
2422
+
2423
+ // Shape/type checks only; the set of valid categories is server
2424
+ // configuration and enforced there with a clear 400 (see ner()).
2425
+ const isBatch = Array.isArray(text);
2426
+ if (isBatch) {
2427
+ // for...of (not text.every) so holes in a sparse array are visited as
2428
+ // undefined and rejected, rather than skipped.
2429
+ let ok = text.length >= 1 && text.length <= 10;
2430
+ if (ok) {
2431
+ for (const item of text) {
2432
+ if (typeof item !== 'string' || item.trim().length === 0) {
2433
+ ok = false;
2434
+ break;
2435
+ }
2436
+ }
2437
+ }
2438
+ if (!ok) {
2439
+ throw new TypeError('inclusive: texts must be an array of 1 to 10 non-empty strings.');
2440
+ }
2441
+ } else if (typeof text !== 'string' || text.trim().length === 0) {
2442
+ throw new TypeError('inclusive: text must be a non-empty string.');
2443
+ }
2444
+
2445
+ let data = {
2446
+ ...(isBatch ? { texts: text } : { text }),
2447
+ key: this.apiKey,
2448
+ }
2449
+
2450
+ // `!== undefined` so an explicit `null` is rejected rather than treated
2451
+ // as omitted; an empty list is rejected too (the API and MCP schema both
2452
+ // require at least one category), matching ner()'s types.
2453
+ if (options?.categories !== undefined) {
2454
+ if (!Array.isArray(options.categories) || options.categories.length === 0
2455
+ || !Array.from(options.categories).every((category) => typeof category === 'string' && category.trim().length > 0)) {
2456
+ throw new TypeError('inclusive: categories must be a non-empty array of non-empty strings.');
2457
+ }
2458
+ data.categories = options.categories;
2459
+ }
2460
+
2461
+ return this.axiosPromise(url, 'post', data);
2462
+ }
1576
2463
  }