@itsoltech/basal-client 0.3.0 → 0.5.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.
- package/README.md +129 -1
- package/dist/cjs/batches.cjs +312 -0
- package/dist/cjs/batches.d.cts +214 -0
- package/dist/cjs/errors.cjs +97 -0
- package/dist/cjs/errors.d.cts +59 -0
- package/dist/cjs/index.cjs +131 -55
- package/dist/cjs/index.d.cts +23 -15
- package/dist/cjs/resources.cjs +206 -0
- package/dist/cjs/resources.d.cts +48 -0
- package/dist/esm/batches.d.ts +214 -0
- package/dist/esm/batches.js +300 -0
- package/dist/esm/errors.d.ts +59 -0
- package/dist/esm/errors.js +85 -0
- package/dist/esm/index.d.ts +23 -15
- package/dist/esm/index.js +114 -40
- package/dist/esm/resources.d.ts +48 -0
- package/dist/esm/resources.js +201 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @itsoltech/basal-client
|
|
2
2
|
|
|
3
|
-
TypeScript/JavaScript SDK for the native Basal API (`POST /v1/basal`). ESM and CommonJS, with type declarations.
|
|
3
|
+
TypeScript/JavaScript SDK for the native Basal API (`POST /v1/basal`) and the Batch API. ESM and CommonJS, with type declarations.
|
|
4
4
|
Node.js 22+ or modern browsers with `fetch` and `AbortController`. No runtime dependencies or GPU requirement.
|
|
5
5
|
|
|
6
6
|
This package is versioned independently. Release tag: `sdk-typescript-vX.Y.Z`.
|
|
@@ -75,6 +75,134 @@ validated before the promise resolves. Typed evidence defaults to an empty array
|
|
|
75
75
|
retains the native JSON without those defaults. The one-argument raw `decide(request, signal)` remains available.
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
## Batch API
|
|
79
|
+
|
|
80
|
+
Resource helpers (`create`, `wait`, `results`) require SDK 0.5.0+.
|
|
81
|
+
|
|
82
|
+
Requires a Basal server with the Batch API enabled ([docs/BATCHES.md](https://github.com/itsoltech/basal-rs/blob/main/docs/BATCHES.md)).
|
|
83
|
+
Every Batch and Files route needs `apiKey` set to a batch token `<key_id>.<secret>`, also on loopback; the owner
|
|
84
|
+
of every file and batch comes from that token. Records are native `/v1/basal` requests; the SDK only transfers files and controls batches.
|
|
85
|
+
`client.files` and `client.batches` follow the OpenAI resource names and workflow (upload, create, retrieve, download).
|
|
86
|
+
That compatibility is in naming and workflow only: records are native Basal requests, not OpenAI chat payloads.
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
import { BasalBatchSubmissionError, BasalClient, batchInput, buildRequest, choice, parseBatchResults } from "@itsoltech/basal-client";
|
|
90
|
+
|
|
91
|
+
const client = new BasalClient({ baseUrl: "https://basal.example.com", apiKey: "<key_id>.<secret>" });
|
|
92
|
+
const body = buildRequest("Klient prosi o zwrot.", { department: choice("Dział?", { returns: "Zwroty", support: "Wsparcie" }) }, {});
|
|
93
|
+
|
|
94
|
+
// Simplified: records in, terminal batch and records out.
|
|
95
|
+
const batch = await client.batches.create({ requests: [{ custom_id: "ticket-1", body }], metadata: { source: "tickets" } });
|
|
96
|
+
const done = await client.batches.wait(batch.id); // completed, failed, expired or cancelled
|
|
97
|
+
for await (const line of client.batches.results(done.id)) { // output file, then error file
|
|
98
|
+
console.log(line.custom_id, line.response?.status_code ?? line.error?.code);
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
// Same steps as separate calls: upload, create, retrieve, download.
|
|
104
|
+
const file = await client.files.create({ file: batchInput([{ custom_id: "ticket-1", body }]), filename: "tickets.jsonl" });
|
|
105
|
+
const created = await client.batches.create({ input_file_id: file.id }, { idempotencyKey: "tickets-2026-10-20" });
|
|
106
|
+
const current = await client.batches.retrieve(created.id);
|
|
107
|
+
const lines = current.output_file_id ? parseBatchResults(await client.files.content(current.output_file_id)) : [];
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
| Resource method | Same as |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `files.create`, `files.retrieve`, `files.list`, `files.delete`, `files.content` | `uploadFile`, `getFile`, `listFiles`, `deleteFile`, `downloadFile` |
|
|
113
|
+
| `batches.create({input_file_id, ...})`, `batches.retrieve`, `batches.list`, `batches.cancel` | `createBatch`, `getBatch`, `listBatches`, `cancelBatch` |
|
|
114
|
+
| `batches.create({requests, ...}, {idempotencyKey?, uploadTimeoutMs?, signal?})` | validate, `uploadFile`, `createBatch` |
|
|
115
|
+
| `batches.wait(id, {timeoutMs?, pollIntervalMs?, signal?})` | `getBatch` until a terminal status |
|
|
116
|
+
| `batches.results(id, {maxBytes?, timeoutMs?, signal?})` → async iterator | `getBatch`, `downloadFile`, line parsing |
|
|
117
|
+
|
|
118
|
+
`batches.create` takes exactly one of `input_file_id` and `requests` (a type error otherwise, and a `TypeError` at runtime).
|
|
119
|
+
`requests` is an iterable of `{custom_id, body, method?, url?}`; the SDK checks it before uploading anything: at least one
|
|
120
|
+
record, no other fields, unique `custom_id` of 1–256 UTF-8 bytes, `method` POST, `url` /v1/basal, an object `body`, at most
|
|
121
|
+
`MAX_BATCH_REQUESTS` (50 000) records and `MAX_BATCH_INPUT_BYTES` (200 000 000) bytes of JSONL. These are the server
|
|
122
|
+
defaults; a server configured lower refuses the upload or fails the batch. The JSONL is built in memory, so for a short
|
|
123
|
+
time memory holds up to about twice its size; `body` contents are checked by the server. Unknown top-level parameters
|
|
124
|
+
are refused locally in this form. The upload uses the client `timeoutMs` unless `uploadTimeoutMs` is set.
|
|
125
|
+
Without `idempotencyKey` the SDK generates one for the call. If the upload succeeds and creating the batch then fails
|
|
126
|
+
(HTTP, transport or abort), `BasalBatchSubmissionError` gives `fileId`, `idempotencyKey` and the original error in
|
|
127
|
+
`cause`. The file is not deleted and nothing is retried. The create may have succeeded on the server, so resume
|
|
128
|
+
with the file instead of uploading again:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
try { await client.batches.create({ requests, metadata }); }
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (!(error instanceof BasalBatchSubmissionError)) throw error;
|
|
134
|
+
await client.batches.create({ input_file_id: error.fileId, metadata }, { idempotencyKey: error.idempotencyKey });
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`batches.wait` retrieves immediately, then every `pollIntervalMs` (default 5000) until `completed`, `failed`, `expired`
|
|
139
|
+
or `cancelled`, and returns that batch. Check `status`, `errors` (validation errors of a `failed` batch) and
|
|
140
|
+
`request_counts`. Other and unknown statuses keep it waiting. `timeoutMs` (default 600 000) bounds the whole wait, including an
|
|
141
|
+
in-flight retrieve, which is aborted. It then raises `BasalBatchTimeoutError` with `batchId` and `lastBatch` (null if no
|
|
142
|
+
retrieve finished). The batch is not cancelled. HTTP and transport errors propagate without retries. Aborting
|
|
143
|
+
`signal` rejects with `BasalTransportError` (the reason in `cause`). Timers and listeners are removed when the wait ends;
|
|
144
|
+
nothing keeps running in the background.
|
|
145
|
+
|
|
146
|
+
`batches.results` does not wait: a batch that is not terminal raises `BasalBatchNotReadyError` (`batchId`, `status`).
|
|
147
|
+
It yields the records of `output_file_id`, then `error_file_id` (once if they are equal). Match records by `custom_id`, not by order. An
|
|
148
|
+
expired or cancelled batch yields its partial results; a completed batch can yield failed records (`response.status_code`
|
|
149
|
+
other than 200, or `error`). A batch without result files yields nothing. Each file is downloaded whole into memory,
|
|
150
|
+
bounded by `maxBytes` (default `maxResponseBytes`, 8 MiB); raise it for large results. `timeoutMs` is the deadline of each
|
|
151
|
+
download. The downloaded bytes are then decoded and checked one line at a time. A line that is not UTF-8 (including a BOM), not JSON, or not a
|
|
152
|
+
result envelope (`id`, `custom_id`, `response` or `error`) raises `BasalBatchResultError` (`fileId`, 1-based `line`, a
|
|
153
|
+
`BasalProtocolError`) after the earlier records were yielded; no record is skipped. Invalid arguments throw when
|
|
154
|
+
`results` is called; nothing is sent until iteration starts.
|
|
155
|
+
|
|
156
|
+
The flat methods below remain available with unchanged behavior. They never poll.
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
import { BasalClient, batchInput, buildRequest, choice, parseBatchResults } from "@itsoltech/basal-client";
|
|
160
|
+
|
|
161
|
+
const client = new BasalClient({ baseUrl: "https://basal.example.com", apiKey: "<key_id>.<secret>" });
|
|
162
|
+
const body = buildRequest("Klient prosi o zwrot.", { department: choice("Dział?", { returns: "Zwroty", support: "Wsparcie" }) }, {});
|
|
163
|
+
const file = await client.uploadFile({ file: batchInput([{ custom_id: "ticket-1", body }]), filename: "tickets.jsonl" });
|
|
164
|
+
const batch = await client.createBatch({ input_file_id: file.id, metadata: { source: "tickets" } }, { idempotencyKey: "tickets-2026-10-20" });
|
|
165
|
+
// Later:
|
|
166
|
+
const current = await client.getBatch(batch.id);
|
|
167
|
+
if (current.status === "completed" && current.output_file_id) {
|
|
168
|
+
const lines = parseBatchResults(await client.downloadFile(current.output_file_id, { maxBytes: 256 * 1024 * 1024 }));
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
| Method | Route |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `uploadFile({file, filename, expires_after?}, {signal?, timeoutMs?})` | `POST /v1/files` |
|
|
175
|
+
| `listFiles({after?, limit?, purpose?, signal?})`, `getFile(id)`, `deleteFile(id)` | `/v1/files[/{id}]` |
|
|
176
|
+
| `downloadFile(id, {signal?, timeoutMs?, maxBytes?})` → `Uint8Array` | `GET /v1/files/{id}/content` |
|
|
177
|
+
| `createBatch({input_file_id, metadata?, output_expires_after?}, {idempotencyKey?, signal?})` | `POST /v1/batches` |
|
|
178
|
+
| `listBatches({after?, limit?, signal?})`, `getBatch(id)`, `cancelBatch(id)` | `/v1/batches[/{id}[/cancel]]` |
|
|
179
|
+
|
|
180
|
+
Uploads accept `Uint8Array`, `ArrayBuffer`, `Blob` (a browser `File`, or Node's `fs.openAsBlob`) or a
|
|
181
|
+
`ReadableStream<Uint8Array>`. They never fetch URLs or read paths. The form sends `expires_after[...]`
|
|
182
|
+
and `purpose=batch` before the file part, as the server requires. Bytes and Blobs are sent with a
|
|
183
|
+
Content-Length; a stream is sent chunked and needs streaming request bodies (`duplex: "half"`): Node.js,
|
|
184
|
+
or Chromium over HTTP/2. Otherwise pass a Blob. The SDK takes ownership of a stream: it is
|
|
185
|
+
consumed and cancelled on abort, timeout or an early server answer (e.g. 413). The filename (1–255 UTF-8 bytes)
|
|
186
|
+
must not contain control characters or backslashes; it is stored by the server as given.
|
|
187
|
+
|
|
188
|
+
`downloadFile` returns the raw bytes and never parses JSON. It buffers the body in memory and is bounded by
|
|
189
|
+
`maxResponseBytes` (8 MiB by default). For larger result files, raise `maxBytes` for that call, and
|
|
190
|
+
`timeoutMs` if needed, instead of raising the client-wide limit. `parseBatchResults` turns the bytes into result lines; their
|
|
191
|
+
`response.body` is the native answer (`DecisionResponse` for status 200, the native error body otherwise).
|
|
192
|
+
|
|
193
|
+
`createBatch` fills `endpoint: "/v1/basal"` and `completion_window: "24h"` (the only accepted values).
|
|
194
|
+
`metadata` keeps insertion order, except integer-like keys, which JavaScript enumerates first.
|
|
195
|
+
`idempotencyKey` (1–128 visible ASCII characters) makes a retried create return the same batch while its
|
|
196
|
+
metadata is retained. The SDK does not retry on its own. Resource ids and cursors are URL-encoded;
|
|
197
|
+
the server answers malformed ids like missing resources (404). `cancelBatch` returns the batch in
|
|
198
|
+
`cancelling`; cancellation ends asynchronously.
|
|
199
|
+
|
|
200
|
+
`BasalBatchTimeoutError.lastBatch` and `BasalBatchNotReadyError.batch` are non-enumerable, like `BasalHTTPError.body`,
|
|
201
|
+
so default inspection does not log batch metadata. Errors use the same classes as the online API: `BasalHTTPError` with the
|
|
202
|
+
`{"error": {message, type, param, code}}` body and `retryAfter` (429/503), and
|
|
203
|
+
`BasalResponseTooLargeError` above the byte cap. Resource types preserve unknown fields at runtime, and
|
|
204
|
+
status/code enumerations accept future string values.
|
|
205
|
+
|
|
78
206
|
[Full documentation and publication setup](https://github.com/itsoltech/basal-rs/blob/main/docs/SDK.md).
|
|
79
207
|
|
|
80
208
|
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TERMINAL_BATCH_STATUSES = exports.MAX_BATCH_INPUT_BYTES = exports.MAX_BATCH_REQUESTS = void 0;
|
|
4
|
+
exports.resourcePath = resourcePath;
|
|
5
|
+
exports.listPath = listPath;
|
|
6
|
+
exports.idempotencyHeader = idempotencyHeader;
|
|
7
|
+
exports.createBody = createBody;
|
|
8
|
+
exports.uploadForm = uploadForm;
|
|
9
|
+
exports.batchInput = batchInput;
|
|
10
|
+
exports.parseBatchResults = parseBatchResults;
|
|
11
|
+
exports.serializeBatchRequests = serializeBatchRequests;
|
|
12
|
+
exports.readBatchResultLines = readBatchResultLines;
|
|
13
|
+
const errors_cjs_1 = require("./errors.cjs");
|
|
14
|
+
/** Client-side bounds of `batches.create({requests})`, equal to the server defaults; a server may be configured lower. */
|
|
15
|
+
exports.MAX_BATCH_REQUESTS = 50_000;
|
|
16
|
+
exports.MAX_BATCH_INPUT_BYTES = 200_000_000;
|
|
17
|
+
exports.TERMINAL_BATCH_STATUSES = Object.freeze(["completed", "failed", "expired", "cancelled"]);
|
|
18
|
+
const encoder = new TextEncoder();
|
|
19
|
+
/** Path segment of a resource id. `.` and `..` would be resolved as dot segments by URL parsing. */
|
|
20
|
+
function resourcePath(prefix, id, suffix = "") {
|
|
21
|
+
if (typeof id !== "string" || !id || id === "." || id === "..")
|
|
22
|
+
throw new TypeError("resource id must be a nonempty string");
|
|
23
|
+
return `${prefix}/${encodeURIComponent(id)}${suffix}`;
|
|
24
|
+
}
|
|
25
|
+
function listPath(path, options) {
|
|
26
|
+
const query = new URLSearchParams();
|
|
27
|
+
if (options.after !== undefined) {
|
|
28
|
+
if (typeof options.after !== "string" || !options.after)
|
|
29
|
+
throw new TypeError("after must be a nonempty id");
|
|
30
|
+
query.set("after", options.after);
|
|
31
|
+
}
|
|
32
|
+
if (options.limit !== undefined) {
|
|
33
|
+
if (!Number.isSafeInteger(options.limit) || options.limit < 1)
|
|
34
|
+
throw new TypeError("limit must be a positive integer");
|
|
35
|
+
query.set("limit", String(options.limit));
|
|
36
|
+
}
|
|
37
|
+
if (options.purpose !== undefined) {
|
|
38
|
+
if (typeof options.purpose !== "string" || !options.purpose)
|
|
39
|
+
throw new TypeError("purpose must be a nonempty string");
|
|
40
|
+
query.set("purpose", options.purpose);
|
|
41
|
+
}
|
|
42
|
+
const encoded = query.toString();
|
|
43
|
+
return encoded ? `${path}?${encoded}` : path;
|
|
44
|
+
}
|
|
45
|
+
function idempotencyHeader(key) {
|
|
46
|
+
if (key === undefined)
|
|
47
|
+
return {};
|
|
48
|
+
if (typeof key !== "string" || !/^[!-~]{1,128}$/.test(key)) {
|
|
49
|
+
throw new TypeError("idempotencyKey must have 1-128 visible ASCII characters");
|
|
50
|
+
}
|
|
51
|
+
return { "Idempotency-Key": key };
|
|
52
|
+
}
|
|
53
|
+
function createBody(request) {
|
|
54
|
+
if (!request || typeof request !== "object" || typeof request.input_file_id !== "string" || !request.input_file_id) {
|
|
55
|
+
throw new TypeError("input_file_id must be a nonempty string");
|
|
56
|
+
}
|
|
57
|
+
// Extra caller fields are forwarded; the server refuses fields it does not know.
|
|
58
|
+
return { endpoint: "/v1/basal", completion_window: "24h", ...request };
|
|
59
|
+
}
|
|
60
|
+
function expiresFields(expires) {
|
|
61
|
+
if (expires === undefined)
|
|
62
|
+
return [];
|
|
63
|
+
if (!expires || expires.anchor !== "created_at" || !Number.isSafeInteger(expires.seconds) || expires.seconds < 0) {
|
|
64
|
+
throw new TypeError("expires_after needs anchor created_at and whole seconds");
|
|
65
|
+
}
|
|
66
|
+
return [["expires_after[anchor]", expires.anchor], ["expires_after[seconds]", String(expires.seconds)]];
|
|
67
|
+
}
|
|
68
|
+
/** Multipart form of an upload: `expires_after[...]`, then `purpose`, then the file part last (the server reserves the
|
|
69
|
+
* upload session at the file part, so expiry fields must precede it).
|
|
70
|
+
*/
|
|
71
|
+
function uploadForm(request) {
|
|
72
|
+
if (!request || typeof request !== "object")
|
|
73
|
+
throw new TypeError("upload request must be an object");
|
|
74
|
+
const { file, filename } = request;
|
|
75
|
+
if (request.purpose !== undefined && request.purpose !== "batch")
|
|
76
|
+
throw new TypeError("purpose must be batch");
|
|
77
|
+
// Control characters (CR/LF) would split the part header; a backslash is ambiguous in a quoted filename.
|
|
78
|
+
if (typeof filename !== "string" || !filename || encoder.encode(filename).length > 255 || /[\p{Cc}\\]/u.test(filename)) {
|
|
79
|
+
throw new TypeError("filename must have 1-255 UTF-8 bytes without control characters or backslashes");
|
|
80
|
+
}
|
|
81
|
+
const random = new Uint8Array(16);
|
|
82
|
+
globalThis.crypto.getRandomValues(random);
|
|
83
|
+
const boundary = `basal-${Array.from(random, b => b.toString(16).padStart(2, "0")).join("")}`;
|
|
84
|
+
let head = "";
|
|
85
|
+
for (const [name, value] of [...expiresFields(request.expires_after), ["purpose", "batch"]]) {
|
|
86
|
+
head += `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
|
|
87
|
+
}
|
|
88
|
+
head += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename.replaceAll('"', '\\"')}"\r\n`
|
|
89
|
+
+ "Content-Type: application/jsonl\r\n\r\n";
|
|
90
|
+
const prefix = encoder.encode(head);
|
|
91
|
+
const suffix = encoder.encode(`\r\n--${boundary}--\r\n`);
|
|
92
|
+
const contentType = `multipart/form-data; boundary=${boundary}`;
|
|
93
|
+
if (file instanceof ReadableStream) {
|
|
94
|
+
if (file.locked)
|
|
95
|
+
throw new TypeError("upload stream is locked");
|
|
96
|
+
return { contentType, body: signal => framedStream(prefix, file, suffix, signal) };
|
|
97
|
+
}
|
|
98
|
+
let content;
|
|
99
|
+
if (file instanceof Blob || file instanceof ArrayBuffer)
|
|
100
|
+
content = file;
|
|
101
|
+
else if (file instanceof Uint8Array)
|
|
102
|
+
content = file.buffer instanceof ArrayBuffer ? file : new Uint8Array(file);
|
|
103
|
+
else
|
|
104
|
+
throw new TypeError("file must be a Uint8Array, ArrayBuffer, Blob or ReadableStream");
|
|
105
|
+
return { contentType, body: new Blob([prefix, content, suffix]) };
|
|
106
|
+
}
|
|
107
|
+
/** Streams the multipart prefix, the caller's chunks and the suffix without buffering the file.
|
|
108
|
+
* Fetch implementations may keep pulling a request body after abort, so `signal` cancels the source here.
|
|
109
|
+
*/
|
|
110
|
+
function framedStream(prefix, source, suffix, signal) {
|
|
111
|
+
let reader;
|
|
112
|
+
let stage = "prefix";
|
|
113
|
+
const stop = (reason) => {
|
|
114
|
+
signal.removeEventListener("abort", onAbort);
|
|
115
|
+
if (stage === "finished" || stage === "stopped")
|
|
116
|
+
return Promise.resolve();
|
|
117
|
+
stage = "stopped";
|
|
118
|
+
return (reader ? reader.cancel(reason) : source.cancel(reason)).catch(() => { });
|
|
119
|
+
};
|
|
120
|
+
const onAbort = () => { void stop(signal.reason); };
|
|
121
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
122
|
+
if (signal.aborted)
|
|
123
|
+
onAbort();
|
|
124
|
+
return new ReadableStream({
|
|
125
|
+
async pull(controller) {
|
|
126
|
+
if (stage === "stopped")
|
|
127
|
+
throw signal.reason ?? new Error("upload stopped");
|
|
128
|
+
if (stage === "prefix") {
|
|
129
|
+
stage = "file";
|
|
130
|
+
controller.enqueue(prefix);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (stage === "finished") {
|
|
134
|
+
controller.close();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
reader ??= source.getReader();
|
|
138
|
+
const { done, value } = await reader.read();
|
|
139
|
+
// A read cancelled by `stop` ends as done; it must not be framed as a complete file.
|
|
140
|
+
if (stage !== "file")
|
|
141
|
+
throw signal.reason ?? new Error("upload stopped");
|
|
142
|
+
if (!done) {
|
|
143
|
+
if (!(value instanceof Uint8Array)) {
|
|
144
|
+
const error = new TypeError("upload stream chunks must be Uint8Array");
|
|
145
|
+
await stop(error);
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
controller.enqueue(value);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
reader.releaseLock();
|
|
152
|
+
reader = undefined;
|
|
153
|
+
stage = "finished";
|
|
154
|
+
signal.removeEventListener("abort", onAbort);
|
|
155
|
+
controller.enqueue(suffix);
|
|
156
|
+
},
|
|
157
|
+
cancel(reason) { return stop(reason); },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/** JSONL input file of native records (`method` POST and `url` /v1/basal are filled in). Never sends anything. */
|
|
161
|
+
function batchInput(lines) {
|
|
162
|
+
const out = [];
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
if (!line || typeof line.custom_id !== "string" || !line.custom_id || !line.body || typeof line.body !== "object") {
|
|
165
|
+
throw new TypeError("each batch line needs a custom_id and a native request body");
|
|
166
|
+
}
|
|
167
|
+
out.push(JSON.stringify({ custom_id: line.custom_id, method: line.method ?? "POST", url: line.url ?? "/v1/basal", body: line.body }));
|
|
168
|
+
}
|
|
169
|
+
if (!out.length)
|
|
170
|
+
throw new TypeError("a batch input needs at least one line");
|
|
171
|
+
return encoder.encode(out.join("\n") + "\n");
|
|
172
|
+
}
|
|
173
|
+
/** Parse downloaded result bytes (output or error file) into lines. Never fetches anything. */
|
|
174
|
+
function parseBatchResults(content) {
|
|
175
|
+
let text;
|
|
176
|
+
try {
|
|
177
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
178
|
+
}
|
|
179
|
+
catch (cause) {
|
|
180
|
+
throw new errors_cjs_1.BasalProtocolError("Batch result file is not UTF-8", { cause });
|
|
181
|
+
}
|
|
182
|
+
const lines = text.split("\n");
|
|
183
|
+
if (lines[lines.length - 1] === "")
|
|
184
|
+
lines.pop();
|
|
185
|
+
return lines.map((line, index) => {
|
|
186
|
+
let value;
|
|
187
|
+
try {
|
|
188
|
+
value = JSON.parse(line);
|
|
189
|
+
}
|
|
190
|
+
catch (cause) {
|
|
191
|
+
throw new errors_cjs_1.BasalProtocolError(`Batch result line ${index + 1} is not JSON`, { cause });
|
|
192
|
+
}
|
|
193
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
194
|
+
throw new errors_cjs_1.BasalProtocolError(`Batch result line ${index + 1} is not a JSON object`);
|
|
195
|
+
}
|
|
196
|
+
return value;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const LINE_KEYS = new Set(["custom_id", "method", "url", "body"]);
|
|
200
|
+
/** Validated JSONL of `batches.create({requests})`: nonempty, supported envelope, unique ids and the bounds above.
|
|
201
|
+
* The result is held in memory (at most `MAX_BATCH_INPUT_BYTES`). Never sends anything.
|
|
202
|
+
*/
|
|
203
|
+
function serializeBatchRequests(requests) {
|
|
204
|
+
if (!requests || typeof requests !== "object" || typeof requests[Symbol.iterator] !== "function") {
|
|
205
|
+
throw new TypeError("requests must be an iterable of batch records");
|
|
206
|
+
}
|
|
207
|
+
const parts = [];
|
|
208
|
+
const seen = new Set();
|
|
209
|
+
let bytes = 0;
|
|
210
|
+
for (const line of requests) {
|
|
211
|
+
const at = `requests[${parts.length}]`;
|
|
212
|
+
if (parts.length >= exports.MAX_BATCH_REQUESTS)
|
|
213
|
+
throw new TypeError(`requests must have at most ${exports.MAX_BATCH_REQUESTS} records`);
|
|
214
|
+
if (!line || typeof line !== "object" || Array.isArray(line))
|
|
215
|
+
throw new TypeError(`${at} must be an object`);
|
|
216
|
+
for (const key of Object.keys(line)) {
|
|
217
|
+
if (!LINE_KEYS.has(key))
|
|
218
|
+
throw new TypeError(`${at} has unsupported field ${JSON.stringify(key)}`);
|
|
219
|
+
}
|
|
220
|
+
const { custom_id: id, body } = line;
|
|
221
|
+
// Lone surrogates have no UTF-8 form; the server compares ids byte for byte.
|
|
222
|
+
if (typeof id !== "string" || !id || /\p{Cs}/u.test(id) || encoder.encode(id).length > 256) {
|
|
223
|
+
throw new TypeError(`${at}.custom_id must be a string of 1-256 UTF-8 bytes`);
|
|
224
|
+
}
|
|
225
|
+
if (seen.has(id))
|
|
226
|
+
throw new TypeError(`${at}.custom_id duplicates an earlier record`);
|
|
227
|
+
seen.add(id);
|
|
228
|
+
if (line.method !== undefined && line.method !== "POST")
|
|
229
|
+
throw new TypeError(`${at}.method must be POST`);
|
|
230
|
+
if (line.url !== undefined && line.url !== "/v1/basal")
|
|
231
|
+
throw new TypeError(`${at}.url must be /v1/basal`);
|
|
232
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
233
|
+
throw new TypeError(`${at}.body must be a native request object`);
|
|
234
|
+
if (body.stream === true)
|
|
235
|
+
throw new TypeError(`${at}.body.stream must not be true`);
|
|
236
|
+
let json;
|
|
237
|
+
try {
|
|
238
|
+
json = JSON.stringify({ custom_id: id, method: "POST", url: "/v1/basal", body });
|
|
239
|
+
}
|
|
240
|
+
catch (cause) {
|
|
241
|
+
throw new TypeError(`${at}.body is not serializable as JSON`, { cause });
|
|
242
|
+
}
|
|
243
|
+
const encoded = encoder.encode(json + "\n");
|
|
244
|
+
bytes += encoded.byteLength;
|
|
245
|
+
if (bytes > exports.MAX_BATCH_INPUT_BYTES)
|
|
246
|
+
throw new TypeError(`requests exceed ${exports.MAX_BATCH_INPUT_BYTES} bytes of JSONL`);
|
|
247
|
+
parts.push(encoded);
|
|
248
|
+
}
|
|
249
|
+
if (!parts.length)
|
|
250
|
+
throw new TypeError("requests must contain at least one record");
|
|
251
|
+
return new Blob(parts, { type: "application/jsonl" });
|
|
252
|
+
}
|
|
253
|
+
/** Result lines of one downloaded file, decoded and checked one line at a time (no list of all lines). A final LF is
|
|
254
|
+
* optional; any other empty line, invalid UTF-8 (a BOM included), JSON or envelope raises `BasalBatchResultError`.
|
|
255
|
+
* Unknown fields are preserved.
|
|
256
|
+
*/
|
|
257
|
+
function* readBatchResultLines(content, fileId) {
|
|
258
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
259
|
+
let start = 0;
|
|
260
|
+
for (let number = 1; start < content.byteLength; number++) {
|
|
261
|
+
// UTF-8 never encodes another code point with byte 0x0A, so splitting bytes at LF is exact.
|
|
262
|
+
let end = content.indexOf(0x0a, start);
|
|
263
|
+
if (end < 0)
|
|
264
|
+
end = content.byteLength;
|
|
265
|
+
const bytes = content.subarray(start, end);
|
|
266
|
+
start = end + 1;
|
|
267
|
+
let text;
|
|
268
|
+
try {
|
|
269
|
+
text = decoder.decode(bytes);
|
|
270
|
+
}
|
|
271
|
+
catch (cause) {
|
|
272
|
+
throw new errors_cjs_1.BasalBatchResultError(fileId, number, "is not UTF-8", { cause });
|
|
273
|
+
}
|
|
274
|
+
let value;
|
|
275
|
+
try {
|
|
276
|
+
value = JSON.parse(text);
|
|
277
|
+
}
|
|
278
|
+
catch (cause) {
|
|
279
|
+
throw new errors_cjs_1.BasalBatchResultError(fileId, number, "is not JSON", { cause });
|
|
280
|
+
}
|
|
281
|
+
const problem = envelopeProblem(value);
|
|
282
|
+
if (problem)
|
|
283
|
+
throw new errors_cjs_1.BasalBatchResultError(fileId, number, problem);
|
|
284
|
+
yield value;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function isObject(value) {
|
|
288
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
289
|
+
}
|
|
290
|
+
function envelopeProblem(value) {
|
|
291
|
+
if (!isObject(value))
|
|
292
|
+
return "is not a JSON object";
|
|
293
|
+
if (typeof value.id !== "string" || !value.id)
|
|
294
|
+
return "has no string id";
|
|
295
|
+
if (typeof value.custom_id !== "string" || !value.custom_id)
|
|
296
|
+
return "has no string custom_id";
|
|
297
|
+
const { response, error } = value;
|
|
298
|
+
if (response === undefined || error === undefined)
|
|
299
|
+
return "lacks response or error";
|
|
300
|
+
if (response !== null && (!isObject(response) || !Number.isSafeInteger(response.status_code)
|
|
301
|
+
|| response.status_code < 100 || response.status_code > 599
|
|
302
|
+
|| typeof response.request_id !== "string" || !response.request_id || !isObject(response.body)))
|
|
303
|
+
return "has an invalid response";
|
|
304
|
+
if (error !== null && (!isObject(error) || typeof error.code !== "string" || !error.code || typeof error.message !== "string")) {
|
|
305
|
+
return "has an invalid error";
|
|
306
|
+
}
|
|
307
|
+
if (response === null && error === null)
|
|
308
|
+
return "has neither response nor error";
|
|
309
|
+
if (response !== null && error !== null)
|
|
310
|
+
return "has both response and error";
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { DecisionRequest, JsonValue } from "./index.cjs";
|
|
2
|
+
/** Batch and Files wire types (`contracts/batches/openapi.json`). Times are Unix seconds.
|
|
3
|
+
* Unknown response fields are preserved at runtime; `(string & {})` keeps enums open to future server values.
|
|
4
|
+
*/
|
|
5
|
+
export type FilePurpose = "batch" | "batch_output";
|
|
6
|
+
export interface FileObject {
|
|
7
|
+
id: string;
|
|
8
|
+
object: "file";
|
|
9
|
+
bytes: number;
|
|
10
|
+
created_at: number;
|
|
11
|
+
expires_at: number | null;
|
|
12
|
+
/** As uploaded, or `batch_<id>_output.jsonl` / `batch_<id>_error.jsonl` for results; never a server path. */
|
|
13
|
+
filename: string;
|
|
14
|
+
purpose: FilePurpose | (string & {});
|
|
15
|
+
/** Always `processed` (OpenAI compatibility). */
|
|
16
|
+
status: "processed" | (string & {});
|
|
17
|
+
status_details: string | null;
|
|
18
|
+
}
|
|
19
|
+
export interface DeletedFile {
|
|
20
|
+
id: string;
|
|
21
|
+
object: "file";
|
|
22
|
+
deleted: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface ListPage<T> {
|
|
25
|
+
object: "list";
|
|
26
|
+
data: T[];
|
|
27
|
+
first_id: string | null;
|
|
28
|
+
last_id: string | null;
|
|
29
|
+
has_more: boolean;
|
|
30
|
+
}
|
|
31
|
+
export type FileList = ListPage<FileObject>;
|
|
32
|
+
export type BatchList = ListPage<Batch>;
|
|
33
|
+
/** At most 16 pairs, keys of 1–64 and values of up to 512 characters, in insertion order (checked by the server).
|
|
34
|
+
* JavaScript enumerates integer-like keys first; use textual keys when order matters.
|
|
35
|
+
*/
|
|
36
|
+
export type Metadata = Record<string, string>;
|
|
37
|
+
export interface ExpiresAfter {
|
|
38
|
+
anchor: "created_at";
|
|
39
|
+
seconds: number;
|
|
40
|
+
}
|
|
41
|
+
export type BatchStatus = "validating" | "failed" | "in_progress" | "finalizing" | "completed" | "expired" | "cancelling" | "cancelled";
|
|
42
|
+
export type BatchErrorCode = "empty_file" | "empty_line" | "invalid_json_line" | "line_too_large" | "too_many_records" | "invalid_custom_id" | "duplicate_custom_id" | "invalid_method" | "invalid_url" | "stream_unsupported" | "invalid_body" | "model_not_found" | "mixed_models" | "budget_exceeded" | "host_budget_exceeded" | "unbounded_feature" | "input_changed" | "batch_expired" | "errors_truncated";
|
|
43
|
+
export interface RequestCounts {
|
|
44
|
+
total: number;
|
|
45
|
+
completed: number;
|
|
46
|
+
failed: number;
|
|
47
|
+
}
|
|
48
|
+
export interface BatchErrorEntry {
|
|
49
|
+
code: BatchErrorCode | (string & {});
|
|
50
|
+
message: string;
|
|
51
|
+
param: string | null;
|
|
52
|
+
line: number | null;
|
|
53
|
+
}
|
|
54
|
+
export interface BatchErrors {
|
|
55
|
+
object: "list";
|
|
56
|
+
data: BatchErrorEntry[];
|
|
57
|
+
}
|
|
58
|
+
export interface Batch {
|
|
59
|
+
id: string;
|
|
60
|
+
object: "batch";
|
|
61
|
+
endpoint: "/v1/basal" | (string & {});
|
|
62
|
+
model: string | null;
|
|
63
|
+
errors: BatchErrors | null;
|
|
64
|
+
input_file_id: string;
|
|
65
|
+
completion_window: "24h" | (string & {});
|
|
66
|
+
status: BatchStatus | (string & {});
|
|
67
|
+
output_file_id: string | null;
|
|
68
|
+
error_file_id: string | null;
|
|
69
|
+
created_at: number;
|
|
70
|
+
in_progress_at: number | null;
|
|
71
|
+
/** End of the 24h inference window; export may finish later. */
|
|
72
|
+
expires_at: number;
|
|
73
|
+
finalizing_at: number | null;
|
|
74
|
+
completed_at: number | null;
|
|
75
|
+
failed_at: number | null;
|
|
76
|
+
expired_at: number | null;
|
|
77
|
+
cancelling_at: number | null;
|
|
78
|
+
cancelled_at: number | null;
|
|
79
|
+
request_counts: RequestCounts;
|
|
80
|
+
metadata: Metadata | null;
|
|
81
|
+
}
|
|
82
|
+
/** `POST /v1/batches` body. `endpoint` and `completion_window` default to their only accepted values. */
|
|
83
|
+
export interface CreateBatchRequest {
|
|
84
|
+
input_file_id: string;
|
|
85
|
+
endpoint?: "/v1/basal";
|
|
86
|
+
completion_window?: "24h";
|
|
87
|
+
metadata?: Metadata | null;
|
|
88
|
+
/** Lifetime of each result file, anchored at its creation. */
|
|
89
|
+
output_expires_after?: ExpiresAfter | null;
|
|
90
|
+
}
|
|
91
|
+
/** One line of an input file; `body` is the native `/v1/basal` request (see `buildRequest`). */
|
|
92
|
+
export interface BatchInputLine {
|
|
93
|
+
custom_id: string;
|
|
94
|
+
method?: "POST";
|
|
95
|
+
url?: "/v1/basal";
|
|
96
|
+
body: DecisionRequest;
|
|
97
|
+
}
|
|
98
|
+
/** One line of a result file. `response.body` is the native answer: a `DecisionResponse` for status 200, otherwise
|
|
99
|
+
* the native error body. `error` is set when the record has no response (e.g. `batch_expired`, `result_too_large`).
|
|
100
|
+
*/
|
|
101
|
+
export interface BatchResultLine {
|
|
102
|
+
id: string;
|
|
103
|
+
custom_id: string;
|
|
104
|
+
response: {
|
|
105
|
+
status_code: number;
|
|
106
|
+
request_id: string;
|
|
107
|
+
body: {
|
|
108
|
+
[key: string]: JsonValue;
|
|
109
|
+
};
|
|
110
|
+
} | null;
|
|
111
|
+
error: {
|
|
112
|
+
code: string;
|
|
113
|
+
message: string;
|
|
114
|
+
} | null;
|
|
115
|
+
}
|
|
116
|
+
/** Common options of Batch and Files calls. */
|
|
117
|
+
export interface RequestOptions {
|
|
118
|
+
signal?: AbortSignal;
|
|
119
|
+
}
|
|
120
|
+
/** Upload and download options; `timeoutMs` replaces the client deadline for this call only. */
|
|
121
|
+
export interface TransferOptions extends RequestOptions {
|
|
122
|
+
timeoutMs?: number;
|
|
123
|
+
}
|
|
124
|
+
export interface DownloadOptions extends TransferOptions {
|
|
125
|
+
/** Byte cap of this download; defaults to the client's `maxResponseBytes`. The body is buffered in memory. */
|
|
126
|
+
maxBytes?: number;
|
|
127
|
+
}
|
|
128
|
+
export interface CreateBatchOptions extends RequestOptions {
|
|
129
|
+
/** 1–128 visible ASCII characters; a replay with the same request data returns the existing batch. */
|
|
130
|
+
idempotencyKey?: string;
|
|
131
|
+
}
|
|
132
|
+
export interface ListOptions extends RequestOptions {
|
|
133
|
+
after?: string;
|
|
134
|
+
limit?: number;
|
|
135
|
+
}
|
|
136
|
+
export interface ListFilesOptions extends ListOptions {
|
|
137
|
+
purpose?: FilePurpose;
|
|
138
|
+
}
|
|
139
|
+
/** Caller-owned upload content. The SDK never fetches URLs or reads paths; a stream is consumed and locked. */
|
|
140
|
+
export type UploadContent = Uint8Array | ArrayBuffer | Blob | ReadableStream<Uint8Array>;
|
|
141
|
+
export interface UploadFileRequest {
|
|
142
|
+
file: UploadContent;
|
|
143
|
+
/** 1–255 UTF-8 bytes without control characters or backslashes; stored as given, never used as a path. */
|
|
144
|
+
filename: string;
|
|
145
|
+
purpose?: "batch";
|
|
146
|
+
/** File lifetime (server default 30 days). */
|
|
147
|
+
expires_after?: ExpiresAfter;
|
|
148
|
+
}
|
|
149
|
+
/** Parameters shared by both `batches.create` forms. Defaults: `endpoint: "/v1/basal"`, `completion_window: "24h"`. */
|
|
150
|
+
export interface BatchCreateCommonParams {
|
|
151
|
+
endpoint?: "/v1/basal";
|
|
152
|
+
completion_window?: "24h";
|
|
153
|
+
metadata?: Metadata | null;
|
|
154
|
+
/** Lifetime of each result file, anchored at its creation. */
|
|
155
|
+
output_expires_after?: ExpiresAfter | null;
|
|
156
|
+
}
|
|
157
|
+
/** Create a batch from an uploaded input file; the same request as `createBatch`. */
|
|
158
|
+
export interface BatchCreateFromFileParams extends BatchCreateCommonParams {
|
|
159
|
+
input_file_id: string;
|
|
160
|
+
requests?: never;
|
|
161
|
+
}
|
|
162
|
+
/** Create a batch from records: the SDK writes JSONL in memory, uploads it (`purpose=batch`), then creates the batch. */
|
|
163
|
+
export interface BatchCreateFromRequestsParams extends BatchCreateCommonParams {
|
|
164
|
+
/** 1–50 000 records, unique `custom_id` (1–256 UTF-8 bytes), at most 200 000 000 bytes of JSONL in total. */
|
|
165
|
+
requests: Iterable<BatchInputLine>;
|
|
166
|
+
input_file_id?: never;
|
|
167
|
+
}
|
|
168
|
+
/** Exactly one of `input_file_id` or `requests`. */
|
|
169
|
+
export type BatchCreateParams = BatchCreateFromFileParams | BatchCreateFromRequestsParams;
|
|
170
|
+
export interface BatchCreateOptions extends CreateBatchOptions {
|
|
171
|
+
/** Deadline of the input upload (`requests` form only); defaults to the client `timeoutMs`. */
|
|
172
|
+
uploadTimeoutMs?: number;
|
|
173
|
+
}
|
|
174
|
+
export interface BatchWaitOptions extends RequestOptions {
|
|
175
|
+
/** Deadline of the whole wait, including in-flight retrieves and sleeps; default 600 000 ms. */
|
|
176
|
+
timeoutMs?: number;
|
|
177
|
+
/** Delay between retrieves; default 5000 ms. The first retrieve is immediate. */
|
|
178
|
+
pollIntervalMs?: number;
|
|
179
|
+
}
|
|
180
|
+
/** Each result file is downloaded whole into memory (up to `maxBytes`, default the client `maxResponseBytes`) and
|
|
181
|
+
* parsed one line at a time; `timeoutMs` is the deadline of each download.
|
|
182
|
+
*/
|
|
183
|
+
export type BatchResultsOptions = DownloadOptions;
|
|
184
|
+
/** Client-side bounds of `batches.create({requests})`, equal to the server defaults; a server may be configured lower. */
|
|
185
|
+
export declare const MAX_BATCH_REQUESTS = 50000;
|
|
186
|
+
export declare const MAX_BATCH_INPUT_BYTES = 200000000;
|
|
187
|
+
export declare const TERMINAL_BATCH_STATUSES: readonly BatchStatus[];
|
|
188
|
+
/** Path segment of a resource id. `.` and `..` would be resolved as dot segments by URL parsing. */
|
|
189
|
+
export declare function resourcePath(prefix: string, id: string, suffix?: string): string;
|
|
190
|
+
export declare function listPath(path: string, options: ListFilesOptions): string;
|
|
191
|
+
export declare function idempotencyHeader(key: string | undefined): Record<string, string>;
|
|
192
|
+
export declare function createBody(request: CreateBatchRequest): CreateBatchRequest;
|
|
193
|
+
/** A multipart body; a stream body is created per request and stops its source when `signal` aborts. */
|
|
194
|
+
export interface Multipart {
|
|
195
|
+
contentType: string;
|
|
196
|
+
body: Blob | ((signal: AbortSignal) => ReadableStream<Uint8Array>);
|
|
197
|
+
}
|
|
198
|
+
/** Multipart form of an upload: `expires_after[...]`, then `purpose`, then the file part last (the server reserves the
|
|
199
|
+
* upload session at the file part, so expiry fields must precede it).
|
|
200
|
+
*/
|
|
201
|
+
export declare function uploadForm(request: UploadFileRequest): Multipart;
|
|
202
|
+
/** JSONL input file of native records (`method` POST and `url` /v1/basal are filled in). Never sends anything. */
|
|
203
|
+
export declare function batchInput(lines: Iterable<BatchInputLine>): Uint8Array;
|
|
204
|
+
/** Parse downloaded result bytes (output or error file) into lines. Never fetches anything. */
|
|
205
|
+
export declare function parseBatchResults(content: Uint8Array): BatchResultLine[];
|
|
206
|
+
/** Validated JSONL of `batches.create({requests})`: nonempty, supported envelope, unique ids and the bounds above.
|
|
207
|
+
* The result is held in memory (at most `MAX_BATCH_INPUT_BYTES`). Never sends anything.
|
|
208
|
+
*/
|
|
209
|
+
export declare function serializeBatchRequests(requests: Iterable<BatchInputLine>): Blob;
|
|
210
|
+
/** Result lines of one downloaded file, decoded and checked one line at a time (no list of all lines). A final LF is
|
|
211
|
+
* optional; any other empty line, invalid UTF-8 (a BOM included), JSON or envelope raises `BasalBatchResultError`.
|
|
212
|
+
* Unknown fields are preserved.
|
|
213
|
+
*/
|
|
214
|
+
export declare function readBatchResultLines(content: Uint8Array, fileId: string): Generator<BatchResultLine, void, undefined>;
|