@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/dist/esm/index.js
CHANGED
|
@@ -1,40 +1,22 @@
|
|
|
1
1
|
import { buildRequest } from "./questions.js";
|
|
2
|
+
import { BasalError, BasalHTTPError, BasalProtocolError, BasalResponseTooLargeError, BasalTransportError } from "./errors.js";
|
|
3
|
+
import { createBody, idempotencyHeader, listPath, resourcePath, uploadForm, } from "./batches.js";
|
|
4
|
+
import { Batches, Files } from "./resources.js";
|
|
2
5
|
export * from "./questions.js";
|
|
3
6
|
export * from "./media.js";
|
|
4
7
|
export * from "./tasks.js";
|
|
5
|
-
export
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export class BasalHTTPError extends BasalError {
|
|
9
|
-
statusCode;
|
|
10
|
-
body;
|
|
11
|
-
retryAfter;
|
|
12
|
-
constructor(statusCode, body, retryAfter) {
|
|
13
|
-
super(`Basal HTTP ${statusCode}`);
|
|
14
|
-
this.statusCode = statusCode;
|
|
15
|
-
this.body = body;
|
|
16
|
-
this.retryAfter = retryAfter;
|
|
17
|
-
this.name = "BasalHTTPError";
|
|
18
|
-
// Node's default inspection and JSON.stringify must not print reflected state or credentials.
|
|
19
|
-
Object.defineProperty(this, "body", { enumerable: false });
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
export class BasalTransportError extends BasalError {
|
|
23
|
-
}
|
|
24
|
-
export class BasalProtocolError extends BasalError {
|
|
25
|
-
}
|
|
26
|
-
export class BasalResponseTooLargeError extends BasalError {
|
|
27
|
-
}
|
|
8
|
+
export * from "./errors.js";
|
|
9
|
+
export { batchInput, MAX_BATCH_INPUT_BYTES, MAX_BATCH_REQUESTS, parseBatchResults, TERMINAL_BATCH_STATUSES, } from "./batches.js";
|
|
10
|
+
export { Batches, Files } from "./resources.js";
|
|
28
11
|
async function readBody(response, limit, signal) {
|
|
29
12
|
// Count bytes from the actual stream, including decompressed responses; never trust Content-Length alone.
|
|
30
13
|
const reader = response.body?.getReader();
|
|
31
14
|
if (!reader)
|
|
32
|
-
return
|
|
15
|
+
return new Uint8Array();
|
|
33
16
|
const cancel = () => { void reader.cancel().catch(() => { }); };
|
|
34
17
|
signal.addEventListener("abort", cancel, { once: true });
|
|
35
18
|
if (signal.aborted)
|
|
36
19
|
cancel();
|
|
37
|
-
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
38
20
|
const parts = [];
|
|
39
21
|
let bytes = 0;
|
|
40
22
|
let complete = false;
|
|
@@ -49,18 +31,36 @@ async function readBody(response, limit, signal) {
|
|
|
49
31
|
if (bytes > limit) {
|
|
50
32
|
throw new BasalResponseTooLargeError(`Basal response exceeds ${limit} bytes`);
|
|
51
33
|
}
|
|
52
|
-
parts.push(
|
|
34
|
+
parts.push(value);
|
|
53
35
|
}
|
|
54
|
-
parts.push(decoder.decode());
|
|
55
|
-
return parts.join("");
|
|
56
36
|
}
|
|
57
37
|
finally {
|
|
58
38
|
signal.removeEventListener("abort", cancel);
|
|
59
|
-
// A
|
|
39
|
+
// A read failure must also stop the underlying HTTP body, before releasing ownership.
|
|
60
40
|
if (!complete)
|
|
61
41
|
cancel();
|
|
62
42
|
reader.releaseLock();
|
|
63
43
|
}
|
|
44
|
+
if (parts.length === 1)
|
|
45
|
+
return parts[0];
|
|
46
|
+
const out = new Uint8Array(bytes);
|
|
47
|
+
let offset = 0;
|
|
48
|
+
for (const part of parts) {
|
|
49
|
+
out.set(part, offset);
|
|
50
|
+
offset += part.byteLength;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
function positiveTimeout(value, name) {
|
|
55
|
+
if (!Number.isFinite(value) || value <= 0 || value > 2_147_483_647) {
|
|
56
|
+
throw new TypeError(`${name} must be positive and at most 2147483647`);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
function byteLimit(value, name) {
|
|
61
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
62
|
+
throw new TypeError(`${name} must be a positive safe integer`);
|
|
63
|
+
return value;
|
|
64
64
|
}
|
|
65
65
|
/** Reusable fetch client. Server root URL, optional proxy prefix, without /v1.
|
|
66
66
|
* No automatic retries. AbortSignal cancels a request; timeout includes body reading.
|
|
@@ -71,6 +71,10 @@ export class BasalClient {
|
|
|
71
71
|
timeoutMs;
|
|
72
72
|
maxResponseBytes;
|
|
73
73
|
fetch;
|
|
74
|
+
/** Files API under resource names: `create`, `retrieve`, `list`, `delete`, `content`. */
|
|
75
|
+
files;
|
|
76
|
+
/** Batch API under resource names, with `create({requests})`, `wait` and `results`. */
|
|
77
|
+
batches;
|
|
74
78
|
constructor(options = {}) {
|
|
75
79
|
const url = new URL(options.baseUrl ?? "http://127.0.0.1:8000");
|
|
76
80
|
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.href.includes("?") || url.href.includes("#")) {
|
|
@@ -95,16 +99,27 @@ export class BasalClient {
|
|
|
95
99
|
throw new TypeError("apiKey must be a nonempty, single-line token");
|
|
96
100
|
this.headers.Authorization = `Bearer ${options.apiKey}`;
|
|
97
101
|
}
|
|
102
|
+
this.files = new Files(this);
|
|
103
|
+
this.batches = new Batches(this);
|
|
104
|
+
}
|
|
105
|
+
request(method, path, body, signal) {
|
|
106
|
+
return this.call({ method, path, json: body, signal });
|
|
98
107
|
}
|
|
99
|
-
async
|
|
100
|
-
// Serialization errors are local input errors, not failures of the HTTP transport.
|
|
101
|
-
const encoded =
|
|
108
|
+
async call(call) {
|
|
109
|
+
// Serialization and option errors are local input errors, not failures of the HTTP transport.
|
|
110
|
+
const encoded = call.json === undefined ? undefined : JSON.stringify(call.json);
|
|
111
|
+
const timeoutMs = call.timeoutMs === undefined ? this.timeoutMs : positiveTimeout(call.timeoutMs, "timeoutMs");
|
|
112
|
+
const limit = call.maxBytes === undefined ? this.maxResponseBytes : byteLimit(call.maxBytes, "maxBytes");
|
|
113
|
+
const headers = { ...this.headers, ...call.headers };
|
|
114
|
+
if (encoded !== undefined)
|
|
115
|
+
headers["Content-Type"] = "application/json";
|
|
116
|
+
const { signal } = call;
|
|
102
117
|
const controller = new AbortController();
|
|
103
118
|
const abort = () => controller.abort(signal?.reason);
|
|
104
119
|
signal?.addEventListener("abort", abort, { once: true });
|
|
105
120
|
if (signal?.aborted)
|
|
106
121
|
abort();
|
|
107
|
-
const timer = setTimeout(() => controller.abort(new Error("Basal request timed out")),
|
|
122
|
+
const timer = setTimeout(() => controller.abort(new Error("Basal request timed out")), timeoutMs);
|
|
108
123
|
let onAbort = () => { };
|
|
109
124
|
const aborted = new Promise((_, reject) => {
|
|
110
125
|
onAbort = () => reject(new BasalTransportError("Basal HTTP request aborted", { cause: controller.signal.reason }));
|
|
@@ -114,24 +129,39 @@ export class BasalClient {
|
|
|
114
129
|
});
|
|
115
130
|
const work = async () => {
|
|
116
131
|
let response;
|
|
117
|
-
let
|
|
132
|
+
let content;
|
|
133
|
+
let raw = "";
|
|
118
134
|
try {
|
|
119
|
-
|
|
120
|
-
method, headers:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
135
|
+
const init = {
|
|
136
|
+
method: call.method, headers, signal: controller.signal, redirect: "error", credentials: "omit", cache: "no-store",
|
|
137
|
+
};
|
|
138
|
+
if (encoded !== undefined)
|
|
139
|
+
init.body = encoded;
|
|
140
|
+
else if (call.body instanceof Blob)
|
|
141
|
+
init.body = call.body;
|
|
142
|
+
else if (call.body !== undefined) {
|
|
143
|
+
// Streamed request bodies require half-duplex fetch (Node.js, Chromium over HTTP/2).
|
|
144
|
+
init.body = call.body(controller.signal);
|
|
145
|
+
init.duplex = "half";
|
|
146
|
+
}
|
|
147
|
+
response = await this.fetch(this.baseUrl + call.path, init);
|
|
124
148
|
if (response.redirected || ([301, 302, 303, 307, 308].includes(response.status) && response.headers.has("Location"))) {
|
|
125
149
|
await response.body?.cancel().catch(() => { });
|
|
126
150
|
throw new BasalTransportError("Basal transport returned a redirect");
|
|
127
151
|
}
|
|
128
|
-
|
|
152
|
+
// A per-download cap limits successful artifact bytes. Error envelopes retain the client's normal cap
|
|
153
|
+
// so a small artifact cap does not hide an actionable 404/429 and its Retry-After.
|
|
154
|
+
content = await readBody(response, response.ok ? limit : this.maxResponseBytes, controller.signal);
|
|
155
|
+
if (!call.bytes || !response.ok)
|
|
156
|
+
raw = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
129
157
|
}
|
|
130
158
|
catch (cause) {
|
|
131
159
|
if (cause instanceof BasalError)
|
|
132
160
|
throw cause;
|
|
133
161
|
throw new BasalTransportError("Basal HTTP transport failed", { cause });
|
|
134
162
|
}
|
|
163
|
+
if (call.bytes && response.ok)
|
|
164
|
+
return content;
|
|
135
165
|
let data;
|
|
136
166
|
try {
|
|
137
167
|
data = JSON.parse(raw);
|
|
@@ -157,6 +187,9 @@ export class BasalClient {
|
|
|
157
187
|
controller.signal.removeEventListener("abort", onAbort);
|
|
158
188
|
clearTimeout(timer);
|
|
159
189
|
signal?.removeEventListener("abort", abort);
|
|
190
|
+
// An answer can arrive before a streamed upload is fully sent (e.g. 413); stop its source.
|
|
191
|
+
if (typeof call.body === "function")
|
|
192
|
+
controller.abort(new Error("Basal request finished"));
|
|
160
193
|
}
|
|
161
194
|
}
|
|
162
195
|
async decide(state, questionsOrSignal, options = {}) {
|
|
@@ -191,4 +224,45 @@ export class BasalClient {
|
|
|
191
224
|
return typed;
|
|
192
225
|
}
|
|
193
226
|
models(signal) { return this.request("GET", "v1/models", undefined, signal); }
|
|
227
|
+
/** Upload a JSONL input file (`purpose=batch`) from caller-owned bytes, a Blob or a stream; never fetches URLs. */
|
|
228
|
+
async uploadFile(request, options = {}) {
|
|
229
|
+
const form = uploadForm(request);
|
|
230
|
+
return await this.call({
|
|
231
|
+
method: "POST", path: "v1/files", body: form.body, headers: { "Content-Type": form.contentType },
|
|
232
|
+
signal: options.signal, timeoutMs: options.timeoutMs,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
async listFiles(options = {}) {
|
|
236
|
+
return await this.call({ method: "GET", path: listPath("v1/files", options), signal: options.signal });
|
|
237
|
+
}
|
|
238
|
+
async getFile(fileId, options = {}) {
|
|
239
|
+
return await this.call({ method: "GET", path: resourcePath("v1/files", fileId), signal: options.signal });
|
|
240
|
+
}
|
|
241
|
+
async deleteFile(fileId, options = {}) {
|
|
242
|
+
return await this.call({ method: "DELETE", path: resourcePath("v1/files", fileId), signal: options.signal });
|
|
243
|
+
}
|
|
244
|
+
/** Raw file bytes (JSONL), buffered up to `maxBytes` (default `maxResponseBytes`); never JSON-parsed. */
|
|
245
|
+
async downloadFile(fileId, options = {}) {
|
|
246
|
+
return await this.call({
|
|
247
|
+
method: "GET", path: resourcePath("v1/files", fileId, "/content"), bytes: true,
|
|
248
|
+
headers: { Accept: "application/jsonl, application/json" },
|
|
249
|
+
signal: options.signal, timeoutMs: options.timeoutMs, maxBytes: options.maxBytes,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
async createBatch(request, options = {}) {
|
|
253
|
+
const headers = idempotencyHeader(options.idempotencyKey);
|
|
254
|
+
return await this.call({ method: "POST", path: "v1/batches", json: createBody(request), headers, signal: options.signal });
|
|
255
|
+
}
|
|
256
|
+
async listBatches(options = {}) {
|
|
257
|
+
const { after, limit } = options;
|
|
258
|
+
const query = { ...(after === undefined ? {} : { after }), ...(limit === undefined ? {} : { limit }) };
|
|
259
|
+
return await this.call({ method: "GET", path: listPath("v1/batches", query), signal: options.signal });
|
|
260
|
+
}
|
|
261
|
+
async getBatch(batchId, options = {}) {
|
|
262
|
+
return await this.call({ method: "GET", path: resourcePath("v1/batches", batchId), signal: options.signal });
|
|
263
|
+
}
|
|
264
|
+
/** Request cancellation; the batch moves to `cancelling` and later `cancelled`. Does not wait. */
|
|
265
|
+
async cancelBatch(batchId, options = {}) {
|
|
266
|
+
return await this.call({ method: "POST", path: resourcePath("v1/batches", batchId, "/cancel"), signal: options.signal });
|
|
267
|
+
}
|
|
194
268
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Batch, type BatchCreateOptions, type BatchCreateParams, type BatchList, type BatchResultLine, type BatchResultsOptions, type BatchWaitOptions, type DeletedFile, type DownloadOptions, type FileList, type FileObject, type ListFilesOptions, type ListOptions, type RequestOptions, type TransferOptions, type UploadFileRequest } from "./batches.js";
|
|
2
|
+
import type { BasalClient } from "./index.js";
|
|
3
|
+
/** `client.files`: resource names for the flat Files methods, with the same behavior. */
|
|
4
|
+
export declare class Files {
|
|
5
|
+
#private;
|
|
6
|
+
constructor(client: BasalClient);
|
|
7
|
+
/** `uploadFile`. */
|
|
8
|
+
create(request: UploadFileRequest, options?: TransferOptions): Promise<FileObject>;
|
|
9
|
+
/** `getFile`. */
|
|
10
|
+
retrieve(fileId: string, options?: RequestOptions): Promise<FileObject>;
|
|
11
|
+
/** `listFiles`. */
|
|
12
|
+
list(options?: ListFilesOptions): Promise<FileList>;
|
|
13
|
+
/** `deleteFile`. */
|
|
14
|
+
delete(fileId: string, options?: RequestOptions): Promise<DeletedFile>;
|
|
15
|
+
/** `downloadFile`: raw bytes, buffered up to `maxBytes` (default `maxResponseBytes`). */
|
|
16
|
+
content(fileId: string, options?: DownloadOptions): Promise<Uint8Array>;
|
|
17
|
+
}
|
|
18
|
+
/** `client.batches`: resource names for the flat Batch methods plus `create({requests})`, `wait` and `results`.
|
|
19
|
+
* Nothing runs in the background and nothing is retried.
|
|
20
|
+
*/
|
|
21
|
+
export declare class Batches {
|
|
22
|
+
#private;
|
|
23
|
+
constructor(client: BasalClient);
|
|
24
|
+
/** `{input_file_id, ...}` is `createBatch`. `{requests, ...}` validates and writes the JSONL in memory, uploads it,
|
|
25
|
+
* then creates the batch with `idempotencyKey` (generated when omitted). A create failure after the upload raises
|
|
26
|
+
* `BasalBatchSubmissionError` with the file id and key; the file is kept.
|
|
27
|
+
*/
|
|
28
|
+
create(params: BatchCreateParams, options?: BatchCreateOptions): Promise<Batch>;
|
|
29
|
+
/** `getBatch`. */
|
|
30
|
+
retrieve(batchId: string, options?: RequestOptions): Promise<Batch>;
|
|
31
|
+
/** `listBatches`. */
|
|
32
|
+
list(options?: ListOptions): Promise<BatchList>;
|
|
33
|
+
/** `cancelBatch`: requests cancellation and returns without waiting. */
|
|
34
|
+
cancel(batchId: string, options?: RequestOptions): Promise<Batch>;
|
|
35
|
+
/** Retrieve until the status is `completed`, `failed`, `expired` or `cancelled` and return that batch (check
|
|
36
|
+
* `status`, `errors` and `request_counts`). The first retrieve is immediate. `timeoutMs` bounds the whole wait,
|
|
37
|
+
* aborting an in-flight retrieve, and raises `BasalBatchTimeoutError` without cancelling the batch. HTTP and
|
|
38
|
+
* transport errors propagate without retries; an abort rejects with `BasalTransportError` (cause: the reason).
|
|
39
|
+
*/
|
|
40
|
+
wait(batchId: string, options?: BatchWaitOptions): Promise<Batch>;
|
|
41
|
+
/** Records of a finished batch: the output file, then the error file (once if both ids are equal). Does not wait:
|
|
42
|
+
* a non-terminal batch raises `BasalBatchNotReadyError`. Expired and cancelled batches yield their partial results;
|
|
43
|
+
* a completed batch can yield failed records. Batch-level validation errors are in `retrieve`/`wait`, not here.
|
|
44
|
+
* Each file is downloaded whole (bounded by `maxBytes`) and parsed one line at a time; a malformed line raises
|
|
45
|
+
* `BasalBatchResultError` after the earlier records were yielded.
|
|
46
|
+
*/
|
|
47
|
+
results(batchId: string, options?: BatchResultsOptions): AsyncGenerator<BatchResultLine, void, undefined>;
|
|
48
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { BasalBatchNotReadyError, BasalBatchSubmissionError, BasalBatchTimeoutError, BasalProtocolError, BasalTransportError, } from "./errors.js";
|
|
2
|
+
import { idempotencyHeader, readBatchResultLines, resourcePath, serializeBatchRequests, TERMINAL_BATCH_STATUSES, } from "./batches.js";
|
|
3
|
+
const TERMINAL = new Set(TERMINAL_BATCH_STATUSES);
|
|
4
|
+
const COMMON_KEYS = new Set(["endpoint", "completion_window", "metadata", "output_expires_after"]);
|
|
5
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
6
|
+
function duration(value, fallback, name) {
|
|
7
|
+
if (value === undefined)
|
|
8
|
+
return fallback;
|
|
9
|
+
if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMER_MS)
|
|
10
|
+
throw new TypeError(`${name} must be positive and at most ${MAX_TIMER_MS}`);
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
/** Resolves after `ms`, or rejects with `signal.reason` on abort; the timer and listener are always removed. */
|
|
14
|
+
function sleep(ms, signal) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
if (signal.aborted) {
|
|
17
|
+
reject(signal.reason);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const onAbort = () => { clearTimeout(timer); reject(signal.reason); };
|
|
21
|
+
const timer = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, ms);
|
|
22
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function expiresAfter(value) {
|
|
26
|
+
if (value === undefined || value === null)
|
|
27
|
+
return;
|
|
28
|
+
if (typeof value !== "object" || value.anchor !== "created_at" || !Number.isSafeInteger(value.seconds) || value.seconds < 0) {
|
|
29
|
+
throw new TypeError("output_expires_after needs anchor created_at and whole seconds");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Common parameters of the `requests` form, checked before the upload so a local mistake leaves no orphan file.
|
|
33
|
+
* Metadata limits stay with the server, as in `createBatch`.
|
|
34
|
+
*/
|
|
35
|
+
function commonParams(params) {
|
|
36
|
+
for (const [key, value] of Object.entries(params)) {
|
|
37
|
+
if (value !== undefined && key !== "requests" && !COMMON_KEYS.has(key))
|
|
38
|
+
throw new TypeError(`unsupported batch parameter ${JSON.stringify(key)}`);
|
|
39
|
+
}
|
|
40
|
+
const { endpoint, completion_window, metadata, output_expires_after } = params;
|
|
41
|
+
if (endpoint !== undefined && endpoint !== "/v1/basal")
|
|
42
|
+
throw new TypeError("endpoint must be /v1/basal");
|
|
43
|
+
if (completion_window !== undefined && completion_window !== "24h")
|
|
44
|
+
throw new TypeError("completion_window must be 24h");
|
|
45
|
+
if (metadata !== undefined && metadata !== null) {
|
|
46
|
+
if (typeof metadata !== "object" || Array.isArray(metadata) || Object.values(metadata).some(v => typeof v !== "string")) {
|
|
47
|
+
throw new TypeError("metadata must be an object of string values");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
expiresAfter(output_expires_after);
|
|
51
|
+
const common = {};
|
|
52
|
+
for (const key of COMMON_KEYS)
|
|
53
|
+
if (params[key] !== undefined)
|
|
54
|
+
Object.assign(common, { [key]: params[key] });
|
|
55
|
+
return common;
|
|
56
|
+
}
|
|
57
|
+
function randomKey() {
|
|
58
|
+
const random = new Uint8Array(16);
|
|
59
|
+
globalThis.crypto.getRandomValues(random);
|
|
60
|
+
return `basal-sdk-${Array.from(random, b => b.toString(16).padStart(2, "0")).join("")}`;
|
|
61
|
+
}
|
|
62
|
+
/** `client.files`: resource names for the flat Files methods, with the same behavior. */
|
|
63
|
+
export class Files {
|
|
64
|
+
#client;
|
|
65
|
+
constructor(client) { this.#client = client; }
|
|
66
|
+
/** `uploadFile`. */
|
|
67
|
+
create(request, options = {}) { return this.#client.uploadFile(request, options); }
|
|
68
|
+
/** `getFile`. */
|
|
69
|
+
retrieve(fileId, options = {}) { return this.#client.getFile(fileId, options); }
|
|
70
|
+
/** `listFiles`. */
|
|
71
|
+
list(options = {}) { return this.#client.listFiles(options); }
|
|
72
|
+
/** `deleteFile`. */
|
|
73
|
+
delete(fileId, options = {}) { return this.#client.deleteFile(fileId, options); }
|
|
74
|
+
/** `downloadFile`: raw bytes, buffered up to `maxBytes` (default `maxResponseBytes`). */
|
|
75
|
+
content(fileId, options = {}) { return this.#client.downloadFile(fileId, options); }
|
|
76
|
+
}
|
|
77
|
+
/** `client.batches`: resource names for the flat Batch methods plus `create({requests})`, `wait` and `results`.
|
|
78
|
+
* Nothing runs in the background and nothing is retried.
|
|
79
|
+
*/
|
|
80
|
+
export class Batches {
|
|
81
|
+
#client;
|
|
82
|
+
constructor(client) { this.#client = client; }
|
|
83
|
+
/** `{input_file_id, ...}` is `createBatch`. `{requests, ...}` validates and writes the JSONL in memory, uploads it,
|
|
84
|
+
* then creates the batch with `idempotencyKey` (generated when omitted). A create failure after the upload raises
|
|
85
|
+
* `BasalBatchSubmissionError` with the file id and key; the file is kept.
|
|
86
|
+
*/
|
|
87
|
+
async create(params, options = {}) {
|
|
88
|
+
if (!params || typeof params !== "object")
|
|
89
|
+
throw new TypeError("batch parameters must be an object");
|
|
90
|
+
const fromFile = params.input_file_id !== undefined;
|
|
91
|
+
if (fromFile === (params.requests !== undefined))
|
|
92
|
+
throw new TypeError("pass exactly one of input_file_id or requests");
|
|
93
|
+
const { uploadTimeoutMs, ...createOptions } = options;
|
|
94
|
+
if (fromFile) {
|
|
95
|
+
if (uploadTimeoutMs !== undefined)
|
|
96
|
+
throw new TypeError("uploadTimeoutMs applies only to requests");
|
|
97
|
+
return this.#client.createBatch(params, createOptions);
|
|
98
|
+
}
|
|
99
|
+
const common = commonParams(params);
|
|
100
|
+
const idempotencyKey = createOptions.idempotencyKey ?? randomKey();
|
|
101
|
+
idempotencyHeader(idempotencyKey);
|
|
102
|
+
duration(uploadTimeoutMs, 1, "uploadTimeoutMs");
|
|
103
|
+
const content = serializeBatchRequests(params.requests);
|
|
104
|
+
const transfer = {};
|
|
105
|
+
if (createOptions.signal !== undefined)
|
|
106
|
+
transfer.signal = createOptions.signal;
|
|
107
|
+
if (uploadTimeoutMs !== undefined)
|
|
108
|
+
transfer.timeoutMs = uploadTimeoutMs;
|
|
109
|
+
const file = await this.#client.uploadFile({ file: content, filename: "batch_input.jsonl" }, transfer);
|
|
110
|
+
if (!file || typeof file.id !== "string" || !file.id)
|
|
111
|
+
throw new BasalProtocolError("Basal upload response has no file id");
|
|
112
|
+
try {
|
|
113
|
+
return await this.#client.createBatch({ ...common, input_file_id: file.id }, { ...createOptions, idempotencyKey });
|
|
114
|
+
}
|
|
115
|
+
catch (cause) {
|
|
116
|
+
throw new BasalBatchSubmissionError(file, idempotencyKey, { cause });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** `getBatch`. */
|
|
120
|
+
retrieve(batchId, options = {}) { return this.#client.getBatch(batchId, options); }
|
|
121
|
+
/** `listBatches`. */
|
|
122
|
+
list(options = {}) { return this.#client.listBatches(options); }
|
|
123
|
+
/** `cancelBatch`: requests cancellation and returns without waiting. */
|
|
124
|
+
cancel(batchId, options = {}) { return this.#client.cancelBatch(batchId, options); }
|
|
125
|
+
/** Retrieve until the status is `completed`, `failed`, `expired` or `cancelled` and return that batch (check
|
|
126
|
+
* `status`, `errors` and `request_counts`). The first retrieve is immediate. `timeoutMs` bounds the whole wait,
|
|
127
|
+
* aborting an in-flight retrieve, and raises `BasalBatchTimeoutError` without cancelling the batch. HTTP and
|
|
128
|
+
* transport errors propagate without retries; an abort rejects with `BasalTransportError` (cause: the reason).
|
|
129
|
+
*/
|
|
130
|
+
async wait(batchId, options = {}) {
|
|
131
|
+
resourcePath("v1/batches", batchId);
|
|
132
|
+
const timeoutMs = duration(options.timeoutMs, 600_000, "timeoutMs");
|
|
133
|
+
const pollIntervalMs = duration(options.pollIntervalMs, 5_000, "pollIntervalMs");
|
|
134
|
+
const { signal } = options;
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const forward = () => controller.abort(signal?.reason);
|
|
137
|
+
signal?.addEventListener("abort", forward, { once: true });
|
|
138
|
+
if (signal?.aborted)
|
|
139
|
+
forward();
|
|
140
|
+
const expired = new Error("Basal batch wait timed out");
|
|
141
|
+
// Timers use a monotonic clock, so wall-clock changes do not move the deadline.
|
|
142
|
+
const timer = setTimeout(() => controller.abort(expired), timeoutMs);
|
|
143
|
+
let last = null;
|
|
144
|
+
try {
|
|
145
|
+
for (;;) {
|
|
146
|
+
try {
|
|
147
|
+
last = await this.#client.getBatch(batchId, { signal: controller.signal });
|
|
148
|
+
if (TERMINAL.has(last.status))
|
|
149
|
+
return last;
|
|
150
|
+
await sleep(pollIntervalMs, controller.signal);
|
|
151
|
+
}
|
|
152
|
+
catch (cause) {
|
|
153
|
+
if (controller.signal.reason === expired && (cause === expired || cause instanceof BasalTransportError)) {
|
|
154
|
+
throw new BasalBatchTimeoutError(batchId, last, timeoutMs, { cause });
|
|
155
|
+
}
|
|
156
|
+
if (controller.signal.aborted && cause === controller.signal.reason) {
|
|
157
|
+
throw new BasalTransportError("Basal batch wait aborted", { cause });
|
|
158
|
+
}
|
|
159
|
+
throw cause;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
signal?.removeEventListener("abort", forward);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Records of a finished batch: the output file, then the error file (once if both ids are equal). Does not wait:
|
|
169
|
+
* a non-terminal batch raises `BasalBatchNotReadyError`. Expired and cancelled batches yield their partial results;
|
|
170
|
+
* a completed batch can yield failed records. Batch-level validation errors are in `retrieve`/`wait`, not here.
|
|
171
|
+
* Each file is downloaded whole (bounded by `maxBytes`) and parsed one line at a time; a malformed line raises
|
|
172
|
+
* `BasalBatchResultError` after the earlier records were yielded.
|
|
173
|
+
*/
|
|
174
|
+
results(batchId, options = {}) {
|
|
175
|
+
resourcePath("v1/batches", batchId);
|
|
176
|
+
duration(options.timeoutMs, 1, "timeoutMs");
|
|
177
|
+
if (options.maxBytes !== undefined && (!Number.isSafeInteger(options.maxBytes) || options.maxBytes <= 0)) {
|
|
178
|
+
throw new TypeError("maxBytes must be a positive safe integer");
|
|
179
|
+
}
|
|
180
|
+
return this.#results(batchId, options);
|
|
181
|
+
}
|
|
182
|
+
async *#results(batchId, options) {
|
|
183
|
+
const request = options.signal === undefined ? {} : { signal: options.signal };
|
|
184
|
+
const batch = await this.#client.getBatch(batchId, request);
|
|
185
|
+
if (!TERMINAL.has(batch.status))
|
|
186
|
+
throw new BasalBatchNotReadyError(batchId, batch);
|
|
187
|
+
const ids = [];
|
|
188
|
+
for (const id of [batch.output_file_id, batch.error_file_id]) {
|
|
189
|
+
if (id === null || id === undefined)
|
|
190
|
+
continue;
|
|
191
|
+
if (typeof id !== "string" || !id)
|
|
192
|
+
throw new BasalProtocolError("Basal batch has an invalid result file id");
|
|
193
|
+
if (!ids.includes(id))
|
|
194
|
+
ids.push(id);
|
|
195
|
+
}
|
|
196
|
+
for (const fileId of ids) {
|
|
197
|
+
const content = await this.#client.downloadFile(fileId, options);
|
|
198
|
+
yield* readBatchResultLines(content, fileId);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|