@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.
@@ -0,0 +1,15 @@
1
+ export declare class BasalError extends Error {
2
+ }
3
+ /** Non-2xx response. Server body and Retry-After are available without logging them. */
4
+ export declare class BasalHTTPError extends BasalError {
5
+ readonly statusCode: number;
6
+ readonly body: unknown;
7
+ readonly retryAfter: string | null;
8
+ constructor(statusCode: number, body: unknown, retryAfter: string | null);
9
+ }
10
+ export declare class BasalTransportError extends BasalError {
11
+ }
12
+ export declare class BasalProtocolError extends BasalError {
13
+ }
14
+ export declare class BasalResponseTooLargeError extends BasalError {
15
+ }
@@ -0,0 +1,23 @@
1
+ export class BasalError extends Error {
2
+ }
3
+ /** Non-2xx response. Server body and Retry-After are available without logging them. */
4
+ export class BasalHTTPError extends BasalError {
5
+ statusCode;
6
+ body;
7
+ retryAfter;
8
+ constructor(statusCode, body, retryAfter) {
9
+ super(`Basal HTTP ${statusCode}`);
10
+ this.statusCode = statusCode;
11
+ this.body = body;
12
+ this.retryAfter = retryAfter;
13
+ this.name = "BasalHTTPError";
14
+ // Node's default inspection and JSON.stringify must not print reflected state or credentials.
15
+ Object.defineProperty(this, "body", { enumerable: false });
16
+ }
17
+ }
18
+ export class BasalTransportError extends BasalError {
19
+ }
20
+ export class BasalProtocolError extends BasalError {
21
+ }
22
+ export class BasalResponseTooLargeError extends BasalError {
23
+ }
@@ -1,7 +1,11 @@
1
1
  import { type QuestionSet, type TypedDecisionResponse, type DecisionOptions } from "./questions.js";
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.js";
2
3
  export * from "./questions.js";
3
4
  export * from "./media.js";
4
5
  export * from "./tasks.js";
6
+ export * from "./errors.js";
7
+ export { batchInput, parseBatchResults } from "./batches.js";
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.js";
5
9
  import type { TaskAnswer } from "./tasks.js";
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
  }
package/dist/esm/index.js CHANGED
@@ -1,40 +1,20 @@
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";
2
4
  export * from "./questions.js";
3
5
  export * from "./media.js";
4
6
  export * from "./tasks.js";
5
- export class BasalError extends Error {
6
- }
7
- /** Non-2xx response. Server body and Retry-After are available without logging them. */
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
- }
7
+ export * from "./errors.js";
8
+ export { batchInput, parseBatchResults } from "./batches.js";
28
9
  async function readBody(response, limit, signal) {
29
10
  // Count bytes from the actual stream, including decompressed responses; never trust Content-Length alone.
30
11
  const reader = response.body?.getReader();
31
12
  if (!reader)
32
- return "";
13
+ return new Uint8Array();
33
14
  const cancel = () => { void reader.cancel().catch(() => { }); };
34
15
  signal.addEventListener("abort", cancel, { once: true });
35
16
  if (signal.aborted)
36
17
  cancel();
37
- const decoder = new TextDecoder("utf-8", { fatal: true });
38
18
  const parts = [];
39
19
  let bytes = 0;
40
20
  let complete = false;
@@ -49,18 +29,36 @@ async function readBody(response, limit, signal) {
49
29
  if (bytes > limit) {
50
30
  throw new BasalResponseTooLargeError(`Basal response exceeds ${limit} bytes`);
51
31
  }
52
- parts.push(decoder.decode(value, { stream: true }));
32
+ parts.push(value);
53
33
  }
54
- parts.push(decoder.decode());
55
- return parts.join("");
56
34
  }
57
35
  finally {
58
36
  signal.removeEventListener("abort", cancel);
59
- // A decoder/read failure must also stop the underlying HTTP body, before releasing ownership.
37
+ // A read failure must also stop the underlying HTTP body, before releasing ownership.
60
38
  if (!complete)
61
39
  cancel();
62
40
  reader.releaseLock();
63
41
  }
42
+ if (parts.length === 1)
43
+ return parts[0];
44
+ const out = new Uint8Array(bytes);
45
+ let offset = 0;
46
+ for (const part of parts) {
47
+ out.set(part, offset);
48
+ offset += part.byteLength;
49
+ }
50
+ return out;
51
+ }
52
+ function positiveTimeout(value, name) {
53
+ if (!Number.isFinite(value) || value <= 0 || value > 2_147_483_647) {
54
+ throw new TypeError(`${name} must be positive and at most 2147483647`);
55
+ }
56
+ return value;
57
+ }
58
+ function byteLimit(value, name) {
59
+ if (!Number.isSafeInteger(value) || value <= 0)
60
+ throw new TypeError(`${name} must be a positive safe integer`);
61
+ return value;
64
62
  }
65
63
  /** Reusable fetch client. Server root URL, optional proxy prefix, without /v1.
66
64
  * No automatic retries. AbortSignal cancels a request; timeout includes body reading.
@@ -96,15 +94,24 @@ export class BasalClient {
96
94
  this.headers.Authorization = `Bearer ${options.apiKey}`;
97
95
  }
98
96
  }
99
- async request(method, path, body, signal) {
100
- // Serialization errors are local input errors, not failures of the HTTP transport.
101
- const encoded = body === undefined ? undefined : JSON.stringify(body);
97
+ request(method, path, body, signal) {
98
+ return this.call({ method, path, json: body, signal });
99
+ }
100
+ async call(call) {
101
+ // Serialization and option errors are local input errors, not failures of the HTTP transport.
102
+ const encoded = call.json === undefined ? undefined : JSON.stringify(call.json);
103
+ const timeoutMs = call.timeoutMs === undefined ? this.timeoutMs : positiveTimeout(call.timeoutMs, "timeoutMs");
104
+ const limit = call.maxBytes === undefined ? this.maxResponseBytes : byteLimit(call.maxBytes, "maxBytes");
105
+ const headers = { ...this.headers, ...call.headers };
106
+ if (encoded !== undefined)
107
+ headers["Content-Type"] = "application/json";
108
+ const { signal } = call;
102
109
  const controller = new AbortController();
103
110
  const abort = () => controller.abort(signal?.reason);
104
111
  signal?.addEventListener("abort", abort, { once: true });
105
112
  if (signal?.aborted)
106
113
  abort();
107
- const timer = setTimeout(() => controller.abort(new Error("Basal request timed out")), this.timeoutMs);
114
+ const timer = setTimeout(() => controller.abort(new Error("Basal request timed out")), timeoutMs);
108
115
  let onAbort = () => { };
109
116
  const aborted = new Promise((_, reject) => {
110
117
  onAbort = () => reject(new BasalTransportError("Basal HTTP request aborted", { cause: controller.signal.reason }));
@@ -114,24 +121,39 @@ export class BasalClient {
114
121
  });
115
122
  const work = async () => {
116
123
  let response;
117
- let raw;
124
+ let content;
125
+ let raw = "";
118
126
  try {
119
- response = await this.fetch(this.baseUrl + path, {
120
- method, headers: body === undefined ? this.headers : { ...this.headers, "Content-Type": "application/json" },
121
- ...(encoded === undefined ? {} : { body: encoded }),
122
- signal: controller.signal, redirect: "error", credentials: "omit", cache: "no-store",
123
- });
127
+ const init = {
128
+ method: call.method, headers, signal: controller.signal, redirect: "error", credentials: "omit", cache: "no-store",
129
+ };
130
+ if (encoded !== undefined)
131
+ init.body = encoded;
132
+ else if (call.body instanceof Blob)
133
+ init.body = call.body;
134
+ else if (call.body !== undefined) {
135
+ // Streamed request bodies require half-duplex fetch (Node.js, Chromium over HTTP/2).
136
+ init.body = call.body(controller.signal);
137
+ init.duplex = "half";
138
+ }
139
+ response = await this.fetch(this.baseUrl + call.path, init);
124
140
  if (response.redirected || ([301, 302, 303, 307, 308].includes(response.status) && response.headers.has("Location"))) {
125
141
  await response.body?.cancel().catch(() => { });
126
142
  throw new BasalTransportError("Basal transport returned a redirect");
127
143
  }
128
- raw = await readBody(response, this.maxResponseBytes, controller.signal);
144
+ // A per-download cap limits successful artifact bytes. Error envelopes retain the client's normal cap
145
+ // so a small artifact cap does not hide an actionable 404/429 and its Retry-After.
146
+ content = await readBody(response, response.ok ? limit : this.maxResponseBytes, controller.signal);
147
+ if (!call.bytes || !response.ok)
148
+ raw = new TextDecoder("utf-8", { fatal: true }).decode(content);
129
149
  }
130
150
  catch (cause) {
131
151
  if (cause instanceof BasalError)
132
152
  throw cause;
133
153
  throw new BasalTransportError("Basal HTTP transport failed", { cause });
134
154
  }
155
+ if (call.bytes && response.ok)
156
+ return content;
135
157
  let data;
136
158
  try {
137
159
  data = JSON.parse(raw);
@@ -157,6 +179,9 @@ export class BasalClient {
157
179
  controller.signal.removeEventListener("abort", onAbort);
158
180
  clearTimeout(timer);
159
181
  signal?.removeEventListener("abort", abort);
182
+ // An answer can arrive before a streamed upload is fully sent (e.g. 413); stop its source.
183
+ if (typeof call.body === "function")
184
+ controller.abort(new Error("Basal request finished"));
160
185
  }
161
186
  }
162
187
  async decide(state, questionsOrSignal, options = {}) {
@@ -191,4 +216,45 @@ export class BasalClient {
191
216
  return typed;
192
217
  }
193
218
  models(signal) { return this.request("GET", "v1/models", undefined, signal); }
219
+ /** Upload a JSONL input file (`purpose=batch`) from caller-owned bytes, a Blob or a stream; never fetches URLs. */
220
+ async uploadFile(request, options = {}) {
221
+ const form = uploadForm(request);
222
+ return await this.call({
223
+ method: "POST", path: "v1/files", body: form.body, headers: { "Content-Type": form.contentType },
224
+ signal: options.signal, timeoutMs: options.timeoutMs,
225
+ });
226
+ }
227
+ async listFiles(options = {}) {
228
+ return await this.call({ method: "GET", path: listPath("v1/files", options), signal: options.signal });
229
+ }
230
+ async getFile(fileId, options = {}) {
231
+ return await this.call({ method: "GET", path: resourcePath("v1/files", fileId), signal: options.signal });
232
+ }
233
+ async deleteFile(fileId, options = {}) {
234
+ return await this.call({ method: "DELETE", path: resourcePath("v1/files", fileId), signal: options.signal });
235
+ }
236
+ /** Raw file bytes (JSONL), buffered up to `maxBytes` (default `maxResponseBytes`); never JSON-parsed. */
237
+ async downloadFile(fileId, options = {}) {
238
+ return await this.call({
239
+ method: "GET", path: resourcePath("v1/files", fileId, "/content"), bytes: true,
240
+ headers: { Accept: "application/jsonl, application/json" },
241
+ signal: options.signal, timeoutMs: options.timeoutMs, maxBytes: options.maxBytes,
242
+ });
243
+ }
244
+ async createBatch(request, options = {}) {
245
+ const headers = idempotencyHeader(options.idempotencyKey);
246
+ return await this.call({ method: "POST", path: "v1/batches", json: createBody(request), headers, signal: options.signal });
247
+ }
248
+ async listBatches(options = {}) {
249
+ const { after, limit } = options;
250
+ const query = { ...(after === undefined ? {} : { after }), ...(limit === undefined ? {} : { limit }) };
251
+ return await this.call({ method: "GET", path: listPath("v1/batches", query), signal: options.signal });
252
+ }
253
+ async getBatch(batchId, options = {}) {
254
+ return await this.call({ method: "GET", path: resourcePath("v1/batches", batchId), signal: options.signal });
255
+ }
256
+ /** Request cancellation; the batch moves to `cancelling` and later `cancelled`. Does not wait. */
257
+ async cancelBatch(batchId, options = {}) {
258
+ return await this.call({ method: "POST", path: resourcePath("v1/batches", batchId, "/cancel"), signal: options.signal });
259
+ }
194
260
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itsoltech/basal-client",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "TypeScript and JavaScript client for the native Basal HTTP API",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.cjs",