@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.
@@ -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.BasalResponseTooLargeError = exports.BasalProtocolError = exports.BasalTransportError = exports.BasalHTTPError = exports.BasalError = void 0;
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
- class BasalError extends Error {
23
- }
24
- exports.BasalError = BasalError;
25
- /** Non-2xx response. Server body and Retry-After are available without logging them. */
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(decoder.decode(value, { stream: true }));
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 decoder/read failure must also stop the underlying HTTP body, before releasing ownership.
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
- async request(method, path, body, signal) {
122
- // Serialization errors are local input errors, not failures of the HTTP transport.
123
- const encoded = body === undefined ? undefined : JSON.stringify(body);
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")), this.timeoutMs);
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 raw;
143
+ let content;
144
+ let raw = "";
140
145
  try {
141
- response = await this.fetch(this.baseUrl + path, {
142
- method, headers: body === undefined ? this.headers : { ...this.headers, "Content-Type": "application/json" },
143
- ...(encoded === undefined ? {} : { body: encoded }),
144
- signal: controller.signal, redirect: "error", credentials: "omit", cache: "no-store",
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
- raw = await readBody(response, this.maxResponseBytes, controller.signal);
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;
@@ -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
+ }