@deepraxis/sdk 0.1.0-dev.a5cf3ce → 0.1.0-dev.b7ae389

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,24 @@
1
+ import type { Praxis } from '../core/client.js';
2
+ import type { OwnFileOpenAiFile } from '../generated/types.gen.js';
3
+ export interface UploadFileOptions {
4
+ /** user_data for conversation attachments; assistants for assistant files; vision for PNG/JPEG/GIF/WebP. */
5
+ purpose: 'user_data' | 'assistants' | 'vision';
6
+ /** Defaults to File.name, or upload for a Blob. */
7
+ filename?: string;
8
+ /** Fixed part size in bytes, 5 MiB–64 MB; defaults to 50 MiB. */
9
+ partSize?: number;
10
+ /** Concurrent part requests, a positive integer; defaults to 3. */
11
+ concurrency?: number;
12
+ /** Aborts outstanding requests and cancels an unfinished Upload. */
13
+ signal?: AbortSignal;
14
+ /** Acknowledged byte fraction, 0–1. A value of 1 does not imply processing has finished. */
15
+ onProgress?: (fraction: number) => void;
16
+ }
17
+ export type UploadedFile = OwnFileOpenAiFile;
18
+ /** Upload a nonempty file up to 2 GiB, returning only after processing reaches active.
19
+ * Files up to 32 MiB use one request; larger files use fixed-size Uploads parts.
20
+ * Parts and complete each retry transient failures at most five times with exponential backoff.
21
+ * A terminal failure or abort cancels the Upload. Completed files cannot be cancelled;
22
+ * if abort occurs during processing, delete the resulting file explicitly if no longer needed.
23
+ */
24
+ export declare function uploadFile(client: Pick<Praxis, 'file'>, file: Blob | File, options: UploadFileOptions): Promise<UploadedFile>;
@@ -0,0 +1,113 @@
1
+ import { PraxisError, PraxisConnectionError, PraxisTimeoutError } from "../core/errors.js";
2
+ import { sleep } from "../core/retry.js";
3
+ const MAX_RETRIES = 5;
4
+ function retryable(error) {
5
+ return error instanceof PraxisConnectionError || error instanceof PraxisTimeoutError
6
+ || error instanceof SyntaxError
7
+ || (error instanceof PraxisError && (error.status === 408 || error.status === 429 || (error.status !== undefined && error.status >= 500 && error.status <= 599)));
8
+ }
9
+ async function retry(signal, operation) {
10
+ for (let used = 0;; used++) {
11
+ signal.throwIfAborted();
12
+ try {
13
+ return await operation();
14
+ }
15
+ catch (error) {
16
+ signal.throwIfAborted();
17
+ if (used >= MAX_RETRIES || !retryable(error))
18
+ throw error;
19
+ await sleep(500 * 2 ** used, signal);
20
+ }
21
+ }
22
+ }
23
+ /** Upload a nonempty file up to 2 GiB, returning only after processing reaches active.
24
+ * Files up to 32 MiB use one request; larger files use fixed-size Uploads parts.
25
+ * Parts and complete each retry transient failures at most five times with exponential backoff.
26
+ * A terminal failure or abort cancels the Upload. Completed files cannot be cancelled;
27
+ * if abort occurs during processing, delete the resulting file explicitly if no longer needed.
28
+ */
29
+ export async function uploadFile(client, file, options) {
30
+ options.signal?.throwIfAborted();
31
+ if (file.size < 1 || file.size > 2 * 1024 ** 3)
32
+ throw new RangeError('File size must be 1 byte through 2 GiB');
33
+ const partSize = options.partSize ?? 50 * 1024 ** 2;
34
+ const concurrency = options.concurrency ?? 3;
35
+ if (!Number.isSafeInteger(partSize) || partSize < 5 * 1024 ** 2 || partSize > 64_000_000)
36
+ throw new RangeError('partSize must be an integer from 5242880 through 64000000');
37
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1)
38
+ throw new RangeError('concurrency must be a positive integer');
39
+ const filename = options.filename ?? (typeof File !== 'undefined' && file instanceof File ? file.name : 'upload');
40
+ const work = new AbortController();
41
+ const abort = () => work.abort(options.signal?.reason);
42
+ options.signal?.addEventListener('abort', abort, { once: true });
43
+ const signal = work.signal;
44
+ const request = { signal, maxRetries: 0, timeoutMs: 120_000 };
45
+ let uploadId;
46
+ const active = async (initial) => {
47
+ let result = initial;
48
+ for (let polls = 0;; polls++) {
49
+ signal.throwIfAborted();
50
+ if (result.state === 'active')
51
+ return result;
52
+ if (result.state !== 'processing')
53
+ throw new PraxisError({ code: 'FILE_PROCESSING_FAILED', message: `File ${result.id} is not active (${result.state})` });
54
+ await sleep(Math.min(5000, 1000 * 2 ** polls), signal);
55
+ result = await retry(signal, () => client.file.files.retrieve({ path: { file_id: result.id }, ...request }));
56
+ }
57
+ };
58
+ try {
59
+ signal.throwIfAborted();
60
+ options.onProgress?.(0);
61
+ if (file.size <= 32 * 1024 ** 2) {
62
+ const result = await client.file.files.create({ body: { file: new File([file], filename, { type: file.type }), purpose: options.purpose }, ...request });
63
+ options.onProgress?.(1);
64
+ return await active(result);
65
+ }
66
+ const upload = await client.file.uploads.create({ body: { bytes: file.size, filename, mime_type: file.type || 'application/octet-stream', purpose: options.purpose, part_size: partSize }, ...request });
67
+ uploadId = upload.id;
68
+ signal.throwIfAborted();
69
+ const path = { upload_id: uploadId };
70
+ const count = Math.ceil(file.size / partSize);
71
+ const ids = new Array(count);
72
+ let cursor = 0, confirmed = 0;
73
+ const worker = async () => {
74
+ while (cursor < count) {
75
+ signal.throwIfAborted();
76
+ const index = cursor++;
77
+ const chunk = file.slice(index * partSize, Math.min(file.size, (index + 1) * partSize));
78
+ const part = await retry(signal, () => client.file.uploads.parts.create({ path, query: { part_number: index + 1 }, body: { data: new File([chunk], filename) }, ...request }));
79
+ ids[index] = part.id;
80
+ confirmed += chunk.size;
81
+ options.onProgress?.(confirmed / file.size);
82
+ }
83
+ };
84
+ const workers = Array.from({ length: Math.min(concurrency, count) }, worker);
85
+ try {
86
+ await Promise.all(workers);
87
+ }
88
+ catch (error) {
89
+ work.abort();
90
+ await Promise.allSettled(workers);
91
+ throw error;
92
+ }
93
+ const result = await retry(signal, () => client.file.uploads.complete({ path, body: { part_ids: ids }, ...request }));
94
+ if (result.error)
95
+ throw new PraxisError({ code: result.error.code, message: result.error.message });
96
+ if (result.status !== 'completed' || !result.file)
97
+ throw new PraxisError({ code: 'INVALID_RESPONSE', message: 'Complete did not return a completed Upload with a file' });
98
+ return await active(result.file);
99
+ }
100
+ catch (error) {
101
+ work.abort();
102
+ if (uploadId) {
103
+ // Cleanup uses its own timeout and must not inherit the aborted work signal.
104
+ await client.file.uploads.cancel({ path: { upload_id: uploadId }, maxRetries: 0, timeoutMs: 10_000 }).catch(() => { });
105
+ }
106
+ if (options.signal?.aborted)
107
+ throw options.signal.reason;
108
+ throw error;
109
+ }
110
+ finally {
111
+ options.signal?.removeEventListener('abort', abort);
112
+ }
113
+ }
package/dist/index.d.ts CHANGED
@@ -9,3 +9,5 @@ export { publish } from './helpers/application-publish.js';
9
9
  export type { PublishInput, PublishResult, AssetsConfig } from './helpers/application-publish.js';
10
10
  export { count } from './helpers/database-count.js';
11
11
  export type { CountInput, CountResult } from './helpers/database-count.js';
12
+ export { uploadFile } from './helpers/file-upload.js';
13
+ export type { UploadFileOptions, UploadedFile } from './helpers/file-upload.js';
package/dist/index.js CHANGED
@@ -4,3 +4,4 @@ export { Page } from "./core/pagination.js";
4
4
  export { parseSSE } from "./core/sse.js";
5
5
  export { publish } from "./helpers/application-publish.js";
6
6
  export { count } from "./helpers/database-count.js";
7
+ export { uploadFile } from "./helpers/file-upload.js";