@itsoltech/basal-client 0.3.0 → 0.4.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 +55 -1
- package/dist/cjs/batches.cjs +191 -0
- package/dist/cjs/batches.d.cts +166 -0
- package/dist/cjs/errors.cjs +31 -0
- package/dist/cjs/errors.d.cts +15 -0
- package/dist/cjs/index.cjs +118 -55
- package/dist/cjs/index.d.cts +17 -15
- package/dist/esm/batches.d.ts +166 -0
- package/dist/esm/batches.js +182 -0
- package/dist/esm/errors.d.ts +15 -0
- package/dist/esm/errors.js +23 -0
- package/dist/esm/index.d.ts +17 -15
- package/dist/esm/index.js +106 -40
- package/package.json +1 -1
package/dist/cjs/index.cjs
CHANGED
|
@@ -14,49 +14,26 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.BasalClient = exports.
|
|
17
|
+
exports.BasalClient = exports.parseBatchResults = exports.batchInput = void 0;
|
|
18
18
|
const questions_cjs_1 = require("./questions.cjs");
|
|
19
|
+
const errors_cjs_1 = require("./errors.cjs");
|
|
20
|
+
const batches_cjs_1 = require("./batches.cjs");
|
|
19
21
|
__exportStar(require("./questions.cjs"), exports);
|
|
20
22
|
__exportStar(require("./media.cjs"), exports);
|
|
21
23
|
__exportStar(require("./tasks.cjs"), exports);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
exports.
|
|
25
|
-
|
|
26
|
-
class BasalHTTPError extends BasalError {
|
|
27
|
-
statusCode;
|
|
28
|
-
body;
|
|
29
|
-
retryAfter;
|
|
30
|
-
constructor(statusCode, body, retryAfter) {
|
|
31
|
-
super(`Basal HTTP ${statusCode}`);
|
|
32
|
-
this.statusCode = statusCode;
|
|
33
|
-
this.body = body;
|
|
34
|
-
this.retryAfter = retryAfter;
|
|
35
|
-
this.name = "BasalHTTPError";
|
|
36
|
-
// Node's default inspection and JSON.stringify must not print reflected state or credentials.
|
|
37
|
-
Object.defineProperty(this, "body", { enumerable: false });
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
exports.BasalHTTPError = BasalHTTPError;
|
|
41
|
-
class BasalTransportError extends BasalError {
|
|
42
|
-
}
|
|
43
|
-
exports.BasalTransportError = BasalTransportError;
|
|
44
|
-
class BasalProtocolError extends BasalError {
|
|
45
|
-
}
|
|
46
|
-
exports.BasalProtocolError = BasalProtocolError;
|
|
47
|
-
class BasalResponseTooLargeError extends BasalError {
|
|
48
|
-
}
|
|
49
|
-
exports.BasalResponseTooLargeError = BasalResponseTooLargeError;
|
|
24
|
+
__exportStar(require("./errors.cjs"), exports);
|
|
25
|
+
var batches_cjs_2 = require("./batches.cjs");
|
|
26
|
+
Object.defineProperty(exports, "batchInput", { enumerable: true, get: function () { return batches_cjs_2.batchInput; } });
|
|
27
|
+
Object.defineProperty(exports, "parseBatchResults", { enumerable: true, get: function () { return batches_cjs_2.parseBatchResults; } });
|
|
50
28
|
async function readBody(response, limit, signal) {
|
|
51
29
|
// Count bytes from the actual stream, including decompressed responses; never trust Content-Length alone.
|
|
52
30
|
const reader = response.body?.getReader();
|
|
53
31
|
if (!reader)
|
|
54
|
-
return
|
|
32
|
+
return new Uint8Array();
|
|
55
33
|
const cancel = () => { void reader.cancel().catch(() => { }); };
|
|
56
34
|
signal.addEventListener("abort", cancel, { once: true });
|
|
57
35
|
if (signal.aborted)
|
|
58
36
|
cancel();
|
|
59
|
-
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
60
37
|
const parts = [];
|
|
61
38
|
let bytes = 0;
|
|
62
39
|
let complete = false;
|
|
@@ -69,20 +46,38 @@ async function readBody(response, limit, signal) {
|
|
|
69
46
|
}
|
|
70
47
|
bytes += value.byteLength;
|
|
71
48
|
if (bytes > limit) {
|
|
72
|
-
throw new BasalResponseTooLargeError(`Basal response exceeds ${limit} bytes`);
|
|
49
|
+
throw new errors_cjs_1.BasalResponseTooLargeError(`Basal response exceeds ${limit} bytes`);
|
|
73
50
|
}
|
|
74
|
-
parts.push(
|
|
51
|
+
parts.push(value);
|
|
75
52
|
}
|
|
76
|
-
parts.push(decoder.decode());
|
|
77
|
-
return parts.join("");
|
|
78
53
|
}
|
|
79
54
|
finally {
|
|
80
55
|
signal.removeEventListener("abort", cancel);
|
|
81
|
-
// A
|
|
56
|
+
// A read failure must also stop the underlying HTTP body, before releasing ownership.
|
|
82
57
|
if (!complete)
|
|
83
58
|
cancel();
|
|
84
59
|
reader.releaseLock();
|
|
85
60
|
}
|
|
61
|
+
if (parts.length === 1)
|
|
62
|
+
return parts[0];
|
|
63
|
+
const out = new Uint8Array(bytes);
|
|
64
|
+
let offset = 0;
|
|
65
|
+
for (const part of parts) {
|
|
66
|
+
out.set(part, offset);
|
|
67
|
+
offset += part.byteLength;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
function positiveTimeout(value, name) {
|
|
72
|
+
if (!Number.isFinite(value) || value <= 0 || value > 2_147_483_647) {
|
|
73
|
+
throw new TypeError(`${name} must be positive and at most 2147483647`);
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
function byteLimit(value, name) {
|
|
78
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
79
|
+
throw new TypeError(`${name} must be a positive safe integer`);
|
|
80
|
+
return value;
|
|
86
81
|
}
|
|
87
82
|
/** Reusable fetch client. Server root URL, optional proxy prefix, without /v1.
|
|
88
83
|
* No automatic retries. AbortSignal cancels a request; timeout includes body reading.
|
|
@@ -118,55 +113,79 @@ class BasalClient {
|
|
|
118
113
|
this.headers.Authorization = `Bearer ${options.apiKey}`;
|
|
119
114
|
}
|
|
120
115
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
116
|
+
request(method, path, body, signal) {
|
|
117
|
+
return this.call({ method, path, json: body, signal });
|
|
118
|
+
}
|
|
119
|
+
async call(call) {
|
|
120
|
+
// Serialization and option errors are local input errors, not failures of the HTTP transport.
|
|
121
|
+
const encoded = call.json === undefined ? undefined : JSON.stringify(call.json);
|
|
122
|
+
const timeoutMs = call.timeoutMs === undefined ? this.timeoutMs : positiveTimeout(call.timeoutMs, "timeoutMs");
|
|
123
|
+
const limit = call.maxBytes === undefined ? this.maxResponseBytes : byteLimit(call.maxBytes, "maxBytes");
|
|
124
|
+
const headers = { ...this.headers, ...call.headers };
|
|
125
|
+
if (encoded !== undefined)
|
|
126
|
+
headers["Content-Type"] = "application/json";
|
|
127
|
+
const { signal } = call;
|
|
124
128
|
const controller = new AbortController();
|
|
125
129
|
const abort = () => controller.abort(signal?.reason);
|
|
126
130
|
signal?.addEventListener("abort", abort, { once: true });
|
|
127
131
|
if (signal?.aborted)
|
|
128
132
|
abort();
|
|
129
|
-
const timer = setTimeout(() => controller.abort(new Error("Basal request timed out")),
|
|
133
|
+
const timer = setTimeout(() => controller.abort(new Error("Basal request timed out")), timeoutMs);
|
|
130
134
|
let onAbort = () => { };
|
|
131
135
|
const aborted = new Promise((_, reject) => {
|
|
132
|
-
onAbort = () => reject(new BasalTransportError("Basal HTTP request aborted", { cause: controller.signal.reason }));
|
|
136
|
+
onAbort = () => reject(new errors_cjs_1.BasalTransportError("Basal HTTP request aborted", { cause: controller.signal.reason }));
|
|
133
137
|
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
134
138
|
if (controller.signal.aborted)
|
|
135
139
|
onAbort();
|
|
136
140
|
});
|
|
137
141
|
const work = async () => {
|
|
138
142
|
let response;
|
|
139
|
-
let
|
|
143
|
+
let content;
|
|
144
|
+
let raw = "";
|
|
140
145
|
try {
|
|
141
|
-
|
|
142
|
-
method, headers:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
const init = {
|
|
147
|
+
method: call.method, headers, signal: controller.signal, redirect: "error", credentials: "omit", cache: "no-store",
|
|
148
|
+
};
|
|
149
|
+
if (encoded !== undefined)
|
|
150
|
+
init.body = encoded;
|
|
151
|
+
else if (call.body instanceof Blob)
|
|
152
|
+
init.body = call.body;
|
|
153
|
+
else if (call.body !== undefined) {
|
|
154
|
+
// Streamed request bodies require half-duplex fetch (Node.js, Chromium over HTTP/2).
|
|
155
|
+
init.body = call.body(controller.signal);
|
|
156
|
+
init.duplex = "half";
|
|
157
|
+
}
|
|
158
|
+
response = await this.fetch(this.baseUrl + call.path, init);
|
|
146
159
|
if (response.redirected || ([301, 302, 303, 307, 308].includes(response.status) && response.headers.has("Location"))) {
|
|
147
160
|
await response.body?.cancel().catch(() => { });
|
|
148
|
-
throw new BasalTransportError("Basal transport returned a redirect");
|
|
161
|
+
throw new errors_cjs_1.BasalTransportError("Basal transport returned a redirect");
|
|
149
162
|
}
|
|
150
|
-
|
|
163
|
+
// A per-download cap limits successful artifact bytes. Error envelopes retain the client's normal cap
|
|
164
|
+
// so a small artifact cap does not hide an actionable 404/429 and its Retry-After.
|
|
165
|
+
content = await readBody(response, response.ok ? limit : this.maxResponseBytes, controller.signal);
|
|
166
|
+
if (!call.bytes || !response.ok)
|
|
167
|
+
raw = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
151
168
|
}
|
|
152
169
|
catch (cause) {
|
|
153
|
-
if (cause instanceof BasalError)
|
|
170
|
+
if (cause instanceof errors_cjs_1.BasalError)
|
|
154
171
|
throw cause;
|
|
155
|
-
throw new BasalTransportError("Basal HTTP transport failed", { cause });
|
|
172
|
+
throw new errors_cjs_1.BasalTransportError("Basal HTTP transport failed", { cause });
|
|
156
173
|
}
|
|
174
|
+
if (call.bytes && response.ok)
|
|
175
|
+
return content;
|
|
157
176
|
let data;
|
|
158
177
|
try {
|
|
159
178
|
data = JSON.parse(raw);
|
|
160
179
|
}
|
|
161
180
|
catch (cause) {
|
|
162
181
|
if (response.ok)
|
|
163
|
-
throw new BasalProtocolError("Basal returned invalid JSON", { cause });
|
|
182
|
+
throw new errors_cjs_1.BasalProtocolError("Basal returned invalid JSON", { cause });
|
|
164
183
|
data = raw;
|
|
165
184
|
}
|
|
166
185
|
if (!response.ok)
|
|
167
|
-
throw new BasalHTTPError(response.status, data, response.headers.get("Retry-After"));
|
|
186
|
+
throw new errors_cjs_1.BasalHTTPError(response.status, data, response.headers.get("Retry-After"));
|
|
168
187
|
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
169
|
-
throw new BasalProtocolError("Basal returned a JSON value other than an object");
|
|
188
|
+
throw new errors_cjs_1.BasalProtocolError("Basal returned a JSON value other than an object");
|
|
170
189
|
}
|
|
171
190
|
return data;
|
|
172
191
|
};
|
|
@@ -179,6 +198,9 @@ class BasalClient {
|
|
|
179
198
|
controller.signal.removeEventListener("abort", onAbort);
|
|
180
199
|
clearTimeout(timer);
|
|
181
200
|
signal?.removeEventListener("abort", abort);
|
|
201
|
+
// An answer can arrive before a streamed upload is fully sent (e.g. 413); stop its source.
|
|
202
|
+
if (typeof call.body === "function")
|
|
203
|
+
controller.abort(new Error("Basal request finished"));
|
|
182
204
|
}
|
|
183
205
|
}
|
|
184
206
|
async decide(state, questionsOrSignal, options = {}) {
|
|
@@ -198,7 +220,7 @@ class BasalClient {
|
|
|
198
220
|
parsed.set(id, question.read(response, id));
|
|
199
221
|
}
|
|
200
222
|
catch (cause) {
|
|
201
|
-
throw new BasalProtocolError("Invalid typed Basal answer", { cause });
|
|
223
|
+
throw new errors_cjs_1.BasalProtocolError("Invalid typed Basal answer", { cause });
|
|
202
224
|
}
|
|
203
225
|
const typed = { ...response, answers: Object.fromEntries(parsed) };
|
|
204
226
|
Object.defineProperty(typed, "raw", { value: response });
|
|
@@ -213,5 +235,46 @@ class BasalClient {
|
|
|
213
235
|
return typed;
|
|
214
236
|
}
|
|
215
237
|
models(signal) { return this.request("GET", "v1/models", undefined, signal); }
|
|
238
|
+
/** Upload a JSONL input file (`purpose=batch`) from caller-owned bytes, a Blob or a stream; never fetches URLs. */
|
|
239
|
+
async uploadFile(request, options = {}) {
|
|
240
|
+
const form = (0, batches_cjs_1.uploadForm)(request);
|
|
241
|
+
return await this.call({
|
|
242
|
+
method: "POST", path: "v1/files", body: form.body, headers: { "Content-Type": form.contentType },
|
|
243
|
+
signal: options.signal, timeoutMs: options.timeoutMs,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
async listFiles(options = {}) {
|
|
247
|
+
return await this.call({ method: "GET", path: (0, batches_cjs_1.listPath)("v1/files", options), signal: options.signal });
|
|
248
|
+
}
|
|
249
|
+
async getFile(fileId, options = {}) {
|
|
250
|
+
return await this.call({ method: "GET", path: (0, batches_cjs_1.resourcePath)("v1/files", fileId), signal: options.signal });
|
|
251
|
+
}
|
|
252
|
+
async deleteFile(fileId, options = {}) {
|
|
253
|
+
return await this.call({ method: "DELETE", path: (0, batches_cjs_1.resourcePath)("v1/files", fileId), signal: options.signal });
|
|
254
|
+
}
|
|
255
|
+
/** Raw file bytes (JSONL), buffered up to `maxBytes` (default `maxResponseBytes`); never JSON-parsed. */
|
|
256
|
+
async downloadFile(fileId, options = {}) {
|
|
257
|
+
return await this.call({
|
|
258
|
+
method: "GET", path: (0, batches_cjs_1.resourcePath)("v1/files", fileId, "/content"), bytes: true,
|
|
259
|
+
headers: { Accept: "application/jsonl, application/json" },
|
|
260
|
+
signal: options.signal, timeoutMs: options.timeoutMs, maxBytes: options.maxBytes,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
async createBatch(request, options = {}) {
|
|
264
|
+
const headers = (0, batches_cjs_1.idempotencyHeader)(options.idempotencyKey);
|
|
265
|
+
return await this.call({ method: "POST", path: "v1/batches", json: (0, batches_cjs_1.createBody)(request), headers, signal: options.signal });
|
|
266
|
+
}
|
|
267
|
+
async listBatches(options = {}) {
|
|
268
|
+
const { after, limit } = options;
|
|
269
|
+
const query = { ...(after === undefined ? {} : { after }), ...(limit === undefined ? {} : { limit }) };
|
|
270
|
+
return await this.call({ method: "GET", path: (0, batches_cjs_1.listPath)("v1/batches", query), signal: options.signal });
|
|
271
|
+
}
|
|
272
|
+
async getBatch(batchId, options = {}) {
|
|
273
|
+
return await this.call({ method: "GET", path: (0, batches_cjs_1.resourcePath)("v1/batches", batchId), signal: options.signal });
|
|
274
|
+
}
|
|
275
|
+
/** Request cancellation; the batch moves to `cancelling` and later `cancelled`. Does not wait. */
|
|
276
|
+
async cancelBatch(batchId, options = {}) {
|
|
277
|
+
return await this.call({ method: "POST", path: (0, batches_cjs_1.resourcePath)("v1/batches", batchId, "/cancel"), signal: options.signal });
|
|
278
|
+
}
|
|
216
279
|
}
|
|
217
280
|
exports.BasalClient = BasalClient;
|
package/dist/cjs/index.d.cts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { type QuestionSet, type TypedDecisionResponse, type DecisionOptions } from "./questions.cjs";
|
|
2
|
+
import { type Batch, type BatchList, type CreateBatchOptions, type CreateBatchRequest, type DeletedFile, type DownloadOptions, type FileList, type FileObject, type ListFilesOptions, type ListOptions, type RequestOptions, type TransferOptions, type UploadFileRequest } from "./batches.cjs";
|
|
2
3
|
export * from "./questions.cjs";
|
|
3
4
|
export * from "./media.cjs";
|
|
4
5
|
export * from "./tasks.cjs";
|
|
6
|
+
export * from "./errors.cjs";
|
|
7
|
+
export { batchInput, parseBatchResults } from "./batches.cjs";
|
|
8
|
+
export type { Batch, BatchErrorCode, BatchErrorEntry, BatchErrors, BatchInputLine, BatchList, BatchResultLine, BatchStatus, CreateBatchOptions, CreateBatchRequest, DeletedFile, DownloadOptions, ExpiresAfter, FileList, FileObject, FilePurpose, ListFilesOptions, ListOptions, ListPage, Metadata, RequestCounts, RequestOptions, TransferOptions, UploadContent, UploadFileRequest, } from "./batches.cjs";
|
|
5
9
|
import type { TaskAnswer } from "./tasks.cjs";
|
|
6
10
|
/** Native Basal wire types. Criterion order is significant; do not sort keys. */
|
|
7
11
|
export type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
@@ -164,21 +168,6 @@ export interface ModelMetadata {
|
|
|
164
168
|
export interface ModelsResponse {
|
|
165
169
|
models: ModelMetadata[];
|
|
166
170
|
}
|
|
167
|
-
export declare class BasalError extends Error {
|
|
168
|
-
}
|
|
169
|
-
/** Non-2xx response. Server body and Retry-After are available without logging them. */
|
|
170
|
-
export declare class BasalHTTPError extends BasalError {
|
|
171
|
-
readonly statusCode: number;
|
|
172
|
-
readonly body: unknown;
|
|
173
|
-
readonly retryAfter: string | null;
|
|
174
|
-
constructor(statusCode: number, body: unknown, retryAfter: string | null);
|
|
175
|
-
}
|
|
176
|
-
export declare class BasalTransportError extends BasalError {
|
|
177
|
-
}
|
|
178
|
-
export declare class BasalProtocolError extends BasalError {
|
|
179
|
-
}
|
|
180
|
-
export declare class BasalResponseTooLargeError extends BasalError {
|
|
181
|
-
}
|
|
182
171
|
/** Web Fetch input accepted by browsers and server runtimes. */
|
|
183
172
|
export type RequestInput = string | URL | Request;
|
|
184
173
|
/** A transport adapter must honor signal/redirect and return a standard Web Response. */
|
|
@@ -202,7 +191,20 @@ export declare class BasalClient {
|
|
|
202
191
|
private readonly fetch;
|
|
203
192
|
constructor(options?: BasalClientOptions);
|
|
204
193
|
private request;
|
|
194
|
+
private call;
|
|
205
195
|
decide(request: DecisionRequest, signal?: AbortSignal): Promise<DecisionResponse>;
|
|
206
196
|
decide<const Q extends QuestionSet>(state: JsonValue, questions: Q, options?: DecisionOptions): Promise<TypedDecisionResponse<Q>>;
|
|
207
197
|
models(signal?: AbortSignal): Promise<ModelsResponse>;
|
|
198
|
+
/** Upload a JSONL input file (`purpose=batch`) from caller-owned bytes, a Blob or a stream; never fetches URLs. */
|
|
199
|
+
uploadFile(request: UploadFileRequest, options?: TransferOptions): Promise<FileObject>;
|
|
200
|
+
listFiles(options?: ListFilesOptions): Promise<FileList>;
|
|
201
|
+
getFile(fileId: string, options?: RequestOptions): Promise<FileObject>;
|
|
202
|
+
deleteFile(fileId: string, options?: RequestOptions): Promise<DeletedFile>;
|
|
203
|
+
/** Raw file bytes (JSONL), buffered up to `maxBytes` (default `maxResponseBytes`); never JSON-parsed. */
|
|
204
|
+
downloadFile(fileId: string, options?: DownloadOptions): Promise<Uint8Array>;
|
|
205
|
+
createBatch(request: CreateBatchRequest, options?: CreateBatchOptions): Promise<Batch>;
|
|
206
|
+
listBatches(options?: ListOptions): Promise<BatchList>;
|
|
207
|
+
getBatch(batchId: string, options?: RequestOptions): Promise<Batch>;
|
|
208
|
+
/** Request cancellation; the batch moves to `cancelling` and later `cancelled`. Does not wait. */
|
|
209
|
+
cancelBatch(batchId: string, options?: RequestOptions): Promise<Batch>;
|
|
208
210
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { DecisionRequest, JsonValue } from "./index.js";
|
|
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
|
+
/** Path segment of a resource id. `.` and `..` would be resolved as dot segments by URL parsing. */
|
|
150
|
+
export declare function resourcePath(prefix: string, id: string, suffix?: string): string;
|
|
151
|
+
export declare function listPath(path: string, options: ListFilesOptions): string;
|
|
152
|
+
export declare function idempotencyHeader(key: string | undefined): Record<string, string>;
|
|
153
|
+
export declare function createBody(request: CreateBatchRequest): CreateBatchRequest;
|
|
154
|
+
/** A multipart body; a stream body is created per request and stops its source when `signal` aborts. */
|
|
155
|
+
export interface Multipart {
|
|
156
|
+
contentType: string;
|
|
157
|
+
body: Blob | ((signal: AbortSignal) => ReadableStream<Uint8Array>);
|
|
158
|
+
}
|
|
159
|
+
/** Multipart form of an upload: `expires_after[...]`, then `purpose`, then the file part last (the server reserves the
|
|
160
|
+
* upload session at the file part, so expiry fields must precede it).
|
|
161
|
+
*/
|
|
162
|
+
export declare function uploadForm(request: UploadFileRequest): Multipart;
|
|
163
|
+
/** JSONL input file of native records (`method` POST and `url` /v1/basal are filled in). Never sends anything. */
|
|
164
|
+
export declare function batchInput(lines: Iterable<BatchInputLine>): Uint8Array;
|
|
165
|
+
/** Parse downloaded result bytes (output or error file) into lines. Never fetches anything. */
|
|
166
|
+
export declare function parseBatchResults(content: Uint8Array): BatchResultLine[];
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { BasalProtocolError } from "./errors.js";
|
|
2
|
+
const encoder = new TextEncoder();
|
|
3
|
+
/** Path segment of a resource id. `.` and `..` would be resolved as dot segments by URL parsing. */
|
|
4
|
+
export function resourcePath(prefix, id, suffix = "") {
|
|
5
|
+
if (typeof id !== "string" || !id || id === "." || id === "..")
|
|
6
|
+
throw new TypeError("resource id must be a nonempty string");
|
|
7
|
+
return `${prefix}/${encodeURIComponent(id)}${suffix}`;
|
|
8
|
+
}
|
|
9
|
+
export function listPath(path, options) {
|
|
10
|
+
const query = new URLSearchParams();
|
|
11
|
+
if (options.after !== undefined) {
|
|
12
|
+
if (typeof options.after !== "string" || !options.after)
|
|
13
|
+
throw new TypeError("after must be a nonempty id");
|
|
14
|
+
query.set("after", options.after);
|
|
15
|
+
}
|
|
16
|
+
if (options.limit !== undefined) {
|
|
17
|
+
if (!Number.isSafeInteger(options.limit) || options.limit < 1)
|
|
18
|
+
throw new TypeError("limit must be a positive integer");
|
|
19
|
+
query.set("limit", String(options.limit));
|
|
20
|
+
}
|
|
21
|
+
if (options.purpose !== undefined) {
|
|
22
|
+
if (typeof options.purpose !== "string" || !options.purpose)
|
|
23
|
+
throw new TypeError("purpose must be a nonempty string");
|
|
24
|
+
query.set("purpose", options.purpose);
|
|
25
|
+
}
|
|
26
|
+
const encoded = query.toString();
|
|
27
|
+
return encoded ? `${path}?${encoded}` : path;
|
|
28
|
+
}
|
|
29
|
+
export function idempotencyHeader(key) {
|
|
30
|
+
if (key === undefined)
|
|
31
|
+
return {};
|
|
32
|
+
if (typeof key !== "string" || !/^[!-~]{1,128}$/.test(key)) {
|
|
33
|
+
throw new TypeError("idempotencyKey must have 1-128 visible ASCII characters");
|
|
34
|
+
}
|
|
35
|
+
return { "Idempotency-Key": key };
|
|
36
|
+
}
|
|
37
|
+
export function createBody(request) {
|
|
38
|
+
if (!request || typeof request !== "object" || typeof request.input_file_id !== "string" || !request.input_file_id) {
|
|
39
|
+
throw new TypeError("input_file_id must be a nonempty string");
|
|
40
|
+
}
|
|
41
|
+
// Extra caller fields are forwarded; the server refuses fields it does not know.
|
|
42
|
+
return { endpoint: "/v1/basal", completion_window: "24h", ...request };
|
|
43
|
+
}
|
|
44
|
+
function expiresFields(expires) {
|
|
45
|
+
if (expires === undefined)
|
|
46
|
+
return [];
|
|
47
|
+
if (!expires || expires.anchor !== "created_at" || !Number.isSafeInteger(expires.seconds) || expires.seconds < 0) {
|
|
48
|
+
throw new TypeError("expires_after needs anchor created_at and whole seconds");
|
|
49
|
+
}
|
|
50
|
+
return [["expires_after[anchor]", expires.anchor], ["expires_after[seconds]", String(expires.seconds)]];
|
|
51
|
+
}
|
|
52
|
+
/** Multipart form of an upload: `expires_after[...]`, then `purpose`, then the file part last (the server reserves the
|
|
53
|
+
* upload session at the file part, so expiry fields must precede it).
|
|
54
|
+
*/
|
|
55
|
+
export function uploadForm(request) {
|
|
56
|
+
if (!request || typeof request !== "object")
|
|
57
|
+
throw new TypeError("upload request must be an object");
|
|
58
|
+
const { file, filename } = request;
|
|
59
|
+
if (request.purpose !== undefined && request.purpose !== "batch")
|
|
60
|
+
throw new TypeError("purpose must be batch");
|
|
61
|
+
// Control characters (CR/LF) would split the part header; a backslash is ambiguous in a quoted filename.
|
|
62
|
+
if (typeof filename !== "string" || !filename || encoder.encode(filename).length > 255 || /[\p{Cc}\\]/u.test(filename)) {
|
|
63
|
+
throw new TypeError("filename must have 1-255 UTF-8 bytes without control characters or backslashes");
|
|
64
|
+
}
|
|
65
|
+
const random = new Uint8Array(16);
|
|
66
|
+
globalThis.crypto.getRandomValues(random);
|
|
67
|
+
const boundary = `basal-${Array.from(random, b => b.toString(16).padStart(2, "0")).join("")}`;
|
|
68
|
+
let head = "";
|
|
69
|
+
for (const [name, value] of [...expiresFields(request.expires_after), ["purpose", "batch"]]) {
|
|
70
|
+
head += `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
|
|
71
|
+
}
|
|
72
|
+
head += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename.replaceAll('"', '\\"')}"\r\n`
|
|
73
|
+
+ "Content-Type: application/jsonl\r\n\r\n";
|
|
74
|
+
const prefix = encoder.encode(head);
|
|
75
|
+
const suffix = encoder.encode(`\r\n--${boundary}--\r\n`);
|
|
76
|
+
const contentType = `multipart/form-data; boundary=${boundary}`;
|
|
77
|
+
if (file instanceof ReadableStream) {
|
|
78
|
+
if (file.locked)
|
|
79
|
+
throw new TypeError("upload stream is locked");
|
|
80
|
+
return { contentType, body: signal => framedStream(prefix, file, suffix, signal) };
|
|
81
|
+
}
|
|
82
|
+
let content;
|
|
83
|
+
if (file instanceof Blob || file instanceof ArrayBuffer)
|
|
84
|
+
content = file;
|
|
85
|
+
else if (file instanceof Uint8Array)
|
|
86
|
+
content = file.buffer instanceof ArrayBuffer ? file : new Uint8Array(file);
|
|
87
|
+
else
|
|
88
|
+
throw new TypeError("file must be a Uint8Array, ArrayBuffer, Blob or ReadableStream");
|
|
89
|
+
return { contentType, body: new Blob([prefix, content, suffix]) };
|
|
90
|
+
}
|
|
91
|
+
/** Streams the multipart prefix, the caller's chunks and the suffix without buffering the file.
|
|
92
|
+
* Fetch implementations may keep pulling a request body after abort, so `signal` cancels the source here.
|
|
93
|
+
*/
|
|
94
|
+
function framedStream(prefix, source, suffix, signal) {
|
|
95
|
+
let reader;
|
|
96
|
+
let stage = "prefix";
|
|
97
|
+
const stop = (reason) => {
|
|
98
|
+
signal.removeEventListener("abort", onAbort);
|
|
99
|
+
if (stage === "finished" || stage === "stopped")
|
|
100
|
+
return Promise.resolve();
|
|
101
|
+
stage = "stopped";
|
|
102
|
+
return (reader ? reader.cancel(reason) : source.cancel(reason)).catch(() => { });
|
|
103
|
+
};
|
|
104
|
+
const onAbort = () => { void stop(signal.reason); };
|
|
105
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
106
|
+
if (signal.aborted)
|
|
107
|
+
onAbort();
|
|
108
|
+
return new ReadableStream({
|
|
109
|
+
async pull(controller) {
|
|
110
|
+
if (stage === "stopped")
|
|
111
|
+
throw signal.reason ?? new Error("upload stopped");
|
|
112
|
+
if (stage === "prefix") {
|
|
113
|
+
stage = "file";
|
|
114
|
+
controller.enqueue(prefix);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (stage === "finished") {
|
|
118
|
+
controller.close();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
reader ??= source.getReader();
|
|
122
|
+
const { done, value } = await reader.read();
|
|
123
|
+
// A read cancelled by `stop` ends as done; it must not be framed as a complete file.
|
|
124
|
+
if (stage !== "file")
|
|
125
|
+
throw signal.reason ?? new Error("upload stopped");
|
|
126
|
+
if (!done) {
|
|
127
|
+
if (!(value instanceof Uint8Array)) {
|
|
128
|
+
const error = new TypeError("upload stream chunks must be Uint8Array");
|
|
129
|
+
await stop(error);
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
controller.enqueue(value);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
reader.releaseLock();
|
|
136
|
+
reader = undefined;
|
|
137
|
+
stage = "finished";
|
|
138
|
+
signal.removeEventListener("abort", onAbort);
|
|
139
|
+
controller.enqueue(suffix);
|
|
140
|
+
},
|
|
141
|
+
cancel(reason) { return stop(reason); },
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** JSONL input file of native records (`method` POST and `url` /v1/basal are filled in). Never sends anything. */
|
|
145
|
+
export function batchInput(lines) {
|
|
146
|
+
const out = [];
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
if (!line || typeof line.custom_id !== "string" || !line.custom_id || !line.body || typeof line.body !== "object") {
|
|
149
|
+
throw new TypeError("each batch line needs a custom_id and a native request body");
|
|
150
|
+
}
|
|
151
|
+
out.push(JSON.stringify({ custom_id: line.custom_id, method: line.method ?? "POST", url: line.url ?? "/v1/basal", body: line.body }));
|
|
152
|
+
}
|
|
153
|
+
if (!out.length)
|
|
154
|
+
throw new TypeError("a batch input needs at least one line");
|
|
155
|
+
return encoder.encode(out.join("\n") + "\n");
|
|
156
|
+
}
|
|
157
|
+
/** Parse downloaded result bytes (output or error file) into lines. Never fetches anything. */
|
|
158
|
+
export function parseBatchResults(content) {
|
|
159
|
+
let text;
|
|
160
|
+
try {
|
|
161
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
162
|
+
}
|
|
163
|
+
catch (cause) {
|
|
164
|
+
throw new BasalProtocolError("Batch result file is not UTF-8", { cause });
|
|
165
|
+
}
|
|
166
|
+
const lines = text.split("\n");
|
|
167
|
+
if (lines[lines.length - 1] === "")
|
|
168
|
+
lines.pop();
|
|
169
|
+
return lines.map((line, index) => {
|
|
170
|
+
let value;
|
|
171
|
+
try {
|
|
172
|
+
value = JSON.parse(line);
|
|
173
|
+
}
|
|
174
|
+
catch (cause) {
|
|
175
|
+
throw new BasalProtocolError(`Batch result line ${index + 1} is not JSON`, { cause });
|
|
176
|
+
}
|
|
177
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
178
|
+
throw new BasalProtocolError(`Batch result line ${index + 1} is not a JSON object`);
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
});
|
|
182
|
+
}
|