@runappsai/sdk 0.0.0-stage → 0.3.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.
Files changed (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -2
  3. package/dist/activity-tracker.d.ts +113 -0
  4. package/dist/activity-tracker.d.ts.map +1 -0
  5. package/dist/activity-tracker.js +176 -0
  6. package/dist/activity-tracker.js.map +1 -0
  7. package/dist/audio.d.ts +129 -0
  8. package/dist/audio.d.ts.map +1 -0
  9. package/dist/audio.js +267 -0
  10. package/dist/audio.js.map +1 -0
  11. package/dist/browser-auth.d.ts +179 -0
  12. package/dist/browser-auth.d.ts.map +1 -0
  13. package/dist/browser-auth.js +1361 -0
  14. package/dist/browser-auth.js.map +1 -0
  15. package/dist/chat.d.ts +221 -0
  16. package/dist/chat.d.ts.map +1 -0
  17. package/dist/chat.js +155 -0
  18. package/dist/chat.js.map +1 -0
  19. package/dist/client.d.ts +155 -0
  20. package/dist/client.d.ts.map +1 -0
  21. package/dist/client.js +157 -0
  22. package/dist/client.js.map +1 -0
  23. package/dist/computer.d.ts +57 -0
  24. package/dist/computer.d.ts.map +1 -0
  25. package/dist/computer.js +21 -0
  26. package/dist/computer.js.map +1 -0
  27. package/dist/embeddings.d.ts +64 -0
  28. package/dist/embeddings.d.ts.map +1 -0
  29. package/dist/embeddings.js +30 -0
  30. package/dist/embeddings.js.map +1 -0
  31. package/dist/errors.d.ts +24 -0
  32. package/dist/errors.d.ts.map +1 -0
  33. package/dist/errors.js +29 -0
  34. package/dist/errors.js.map +1 -0
  35. package/dist/event-wrap.d.ts +59 -0
  36. package/dist/event-wrap.d.ts.map +1 -0
  37. package/dist/event-wrap.js +142 -0
  38. package/dist/event-wrap.js.map +1 -0
  39. package/dist/events.d.ts +119 -0
  40. package/dist/events.d.ts.map +1 -0
  41. package/dist/events.js +87 -0
  42. package/dist/events.js.map +1 -0
  43. package/dist/files.d.ts +218 -0
  44. package/dist/files.d.ts.map +1 -0
  45. package/dist/files.js +212 -0
  46. package/dist/files.js.map +1 -0
  47. package/dist/image.d.ts +185 -0
  48. package/dist/image.d.ts.map +1 -0
  49. package/dist/image.js +148 -0
  50. package/dist/image.js.map +1 -0
  51. package/dist/index.d.ts +30 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +26 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/media.d.ts +56 -0
  56. package/dist/media.d.ts.map +1 -0
  57. package/dist/media.js +133 -0
  58. package/dist/media.js.map +1 -0
  59. package/dist/model_options.d.ts +165 -0
  60. package/dist/model_options.d.ts.map +1 -0
  61. package/dist/model_options.js +59 -0
  62. package/dist/model_options.js.map +1 -0
  63. package/dist/models.d.ts +88 -0
  64. package/dist/models.d.ts.map +1 -0
  65. package/dist/models.js +55 -0
  66. package/dist/models.js.map +1 -0
  67. package/dist/sdk.umd.js +4 -0
  68. package/dist/sdk.umd.js.map +1 -0
  69. package/dist/transport.d.ts +120 -0
  70. package/dist/transport.d.ts.map +1 -0
  71. package/dist/transport.js +214 -0
  72. package/dist/transport.js.map +1 -0
  73. package/dist/types.d.ts +31 -0
  74. package/dist/types.d.ts.map +1 -0
  75. package/dist/types.js +2 -0
  76. package/dist/types.js.map +1 -0
  77. package/dist/validate.d.ts +16 -0
  78. package/dist/validate.d.ts.map +1 -0
  79. package/dist/validate.js +329 -0
  80. package/dist/validate.js.map +1 -0
  81. package/dist/video.d.ts +134 -0
  82. package/dist/video.d.ts.map +1 -0
  83. package/dist/video.js +60 -0
  84. package/dist/video.js.map +1 -0
  85. package/package.json +54 -4
  86. package/src/activity-tracker.ts +251 -0
  87. package/src/audio.ts +440 -0
  88. package/src/browser-auth.ts +1529 -0
  89. package/src/chat.ts +368 -0
  90. package/src/client.ts +250 -0
  91. package/src/computer.ts +80 -0
  92. package/src/embeddings.ts +97 -0
  93. package/src/errors.ts +29 -0
  94. package/src/event-wrap.ts +168 -0
  95. package/src/events.ts +172 -0
  96. package/src/files.ts +347 -0
  97. package/src/image.ts +339 -0
  98. package/src/index.ts +151 -0
  99. package/src/media.ts +147 -0
  100. package/src/model_options.ts +220 -0
  101. package/src/models.ts +121 -0
  102. package/src/transport.ts +292 -0
  103. package/src/types.ts +31 -0
  104. package/src/validate.ts +364 -0
  105. package/src/video.ts +215 -0
package/src/files.ts ADDED
@@ -0,0 +1,347 @@
1
+ // Per-project file system at /v1/files/*. Files are stored under
2
+ // (user, project) on the gateway and addressed by POSIX-style paths.
3
+ // Each FileObject's `url` is a stable public address — embed it
4
+ // directly in `<img src>`, share it, persist it.
5
+ //
6
+ // The bundle's RunApps client uses `client.files` to access these
7
+ // endpoints; the underlying token must be a project-bound resource
8
+ // token (rrt_*) issued by the grant flow.
9
+
10
+ import type { Transport } from "./transport.js";
11
+
12
+ export interface FileObject {
13
+ path: string;
14
+ size: number;
15
+ content_type?: string;
16
+ etag?: string;
17
+ last_modified?: string;
18
+ url: string;
19
+ }
20
+
21
+ export interface FileListResult {
22
+ files: FileObject[];
23
+ next_cursor?: string;
24
+ }
25
+
26
+ export interface PutOptions {
27
+ /** Override the MIME type guessed from the path extension. */
28
+ contentType?: string;
29
+ /** Refuse to overwrite an existing object (gateway returns 409). */
30
+ ifNoneMatch?: boolean;
31
+ /**
32
+ * Write only if the stored object still has this ETag — the one
33
+ * `read()` / `stat()` / a previous `put()` returned. Someone else
34
+ * having written in between makes the gateway answer 412 instead
35
+ * of silently overwriting their version; re-read, merge, put again.
36
+ */
37
+ ifMatch?: string;
38
+ signal?: AbortSignal;
39
+ }
40
+
41
+ /** The bytes at a path together with the version they are. */
42
+ export interface FileRead {
43
+ blob: Blob;
44
+ contentType: string;
45
+ /** Pass back as `ifMatch` to write only if nobody else has since. */
46
+ etag?: string;
47
+ }
48
+
49
+ export interface ListOptions {
50
+ prefix?: string;
51
+ cursor?: string;
52
+ limit?: number;
53
+ /**
54
+ * Shell-style pattern, post-filtered after the underlying S3 list:
55
+ * * any chars except `/`
56
+ * ** any chars including `/`
57
+ * ? single non-slash char
58
+ * [abc] character class
59
+ *
60
+ * Examples:
61
+ * client.files.list({ glob: "*.png" })
62
+ * client.files.list({ glob: "projects/*\/assets/*.png" })
63
+ * client.files.list({ prefix: "projects/", glob: "**\/*.wav" })
64
+ *
65
+ * S3 has no native glob — the gateway extracts the longest literal
66
+ * head of the pattern as the underlying prefix scan, then filters
67
+ * the rest in process. Patterns must match the WHOLE path; no
68
+ * implicit anchoring needed.
69
+ */
70
+ glob?: string;
71
+ signal?: AbortSignal;
72
+ }
73
+
74
+ export interface PutFromURLOptions {
75
+ contentType?: string;
76
+ ifNoneMatch?: boolean;
77
+ ifMatch?: string;
78
+ signal?: AbortSignal;
79
+ }
80
+
81
+ export type BatchOp =
82
+ | { op: "put_url"; path: string; src_url: string; content_type?: string; if_none_match?: boolean; if_match?: string }
83
+ | { op: "del"; path: string }
84
+ | { op: "move"; from: string; to: string }
85
+ | { op: "copy"; from: string; to: string }
86
+ | { op: "exists"; path: string }
87
+ | { op: "stat"; path: string };
88
+
89
+ export interface BatchResult {
90
+ ok: boolean;
91
+ error?: string;
92
+ object?: FileObject;
93
+ exists?: boolean;
94
+ }
95
+
96
+ function pathToURL(base: string, p: string): string {
97
+ const trimmed = p.replace(/^\/+/, "");
98
+ return base + "/" + trimmed.split("/").map(encodeURIComponent).join("/");
99
+ }
100
+
101
+ export class FilesService {
102
+ /**
103
+ * URL prefix for every file call.
104
+ *
105
+ * When the client was constructed with `project`, requests go to the
106
+ * project-scoped form — `/v1/p/<project>/files/...` — instead of the
107
+ * bare `/v1/files/...`.
108
+ *
109
+ * This matters because the unscoped URL is byte-identical across apps:
110
+ * two bundles built from the same template both read
111
+ * `projects/index.json`, so anything keying on URL alone (starting
112
+ * with the browser's HTTP cache, which ignores request headers and is
113
+ * partitioned by registrable domain, not by subdomain) cannot tell
114
+ * them apart. Putting the project in the path makes the URL unique
115
+ * per app. The gateway still authorizes purely from the token and
116
+ * rejects a scope that disagrees with it, so this is a correctness
117
+ * measure, not a permission one.
118
+ */
119
+ private readonly base: string;
120
+
121
+ constructor(
122
+ private readonly transport: Transport,
123
+ project?: string,
124
+ ) {
125
+ this.base = project
126
+ ? "/v1/p/" + encodeURIComponent(project) + "/files"
127
+ : "/v1/files";
128
+ }
129
+
130
+ /**
131
+ * Upload `body` to `path`. Returns the resulting FileObject — its
132
+ * `url` is the stable public address for that path.
133
+ *
134
+ * `body` accepts anything `fetch` accepts as a request body: Blob,
135
+ * File, ArrayBuffer, ArrayBufferView, FormData (rare here),
136
+ * ReadableStream, or a string.
137
+ */
138
+ async put(
139
+ path: string,
140
+ body: BodyInit,
141
+ opts?: PutOptions,
142
+ ): Promise<FileObject> {
143
+ const headers: Record<string, string> = {};
144
+ if (opts?.ifNoneMatch) headers["If-None-Match"] = "*";
145
+ if (opts?.ifMatch) headers["If-Match"] = `"${opts.ifMatch.replace(/"/g, "")}"`;
146
+ return this.transport.putBytes<FileObject>(pathToURL(this.base, path), body, {
147
+ contentType: opts?.contentType,
148
+ headers: Object.keys(headers).length ? headers : undefined,
149
+ signal: opts?.signal,
150
+ });
151
+ }
152
+
153
+ /** Convenience: upload string content with sensible content-type. */
154
+ async putString(
155
+ path: string,
156
+ content: string,
157
+ opts?: PutOptions,
158
+ ): Promise<FileObject> {
159
+ return this.put(path, content, {
160
+ ...opts,
161
+ contentType: opts?.contentType ?? "text/plain; charset=utf-8",
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Download the bytes at `path` as a Blob. Use `URL.createObjectURL`
167
+ * to get a temporary local URL, or persist the bytes elsewhere.
168
+ * Bundles that just need to render an asset should use the FileObject's
169
+ * `.url` directly instead — that's a stable public URL with no token.
170
+ */
171
+ async get(path: string, init?: { signal?: AbortSignal }): Promise<Blob> {
172
+ return (await this.read(path, init)).blob;
173
+ }
174
+
175
+ /**
176
+ * Like `get`, but also returns the ETag of the bytes, so a later
177
+ * `put(path, body, { ifMatch: etag })` writes only if the file is
178
+ * still the version that was read.
179
+ */
180
+ async read(path: string, init?: { signal?: AbortSignal }): Promise<FileRead> {
181
+ const { data, contentType, etag } = await this.transport.getRaw(pathToURL(this.base, path), init);
182
+ return {
183
+ blob: new Blob([new Uint8Array(data)], { type: contentType || "application/octet-stream" }),
184
+ contentType,
185
+ etag,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Read a JSON file and its version in one call. A missing file is
191
+ * `{ value: undefined, etag: undefined }`, so a first save can pass
192
+ * `ifNoneMatch: true` and a later one `ifMatch: etag`.
193
+ */
194
+ async readJSON<T = unknown>(
195
+ path: string,
196
+ init?: { signal?: AbortSignal },
197
+ ): Promise<{ value: T | undefined; etag?: string }> {
198
+ let r: FileRead;
199
+ try {
200
+ r = await this.read(path, init);
201
+ } catch (e) {
202
+ if ((e as { statusCode?: number; status?: number })?.statusCode === 404 ||
203
+ (e as { statusCode?: number; status?: number })?.status === 404) {
204
+ return { value: undefined, etag: undefined };
205
+ }
206
+ throw e;
207
+ }
208
+ return { value: JSON.parse(await r.blob.text()) as T, etag: r.etag };
209
+ }
210
+
211
+ /** Stat: HEAD-only object metadata. */
212
+ async stat(path: string, init?: { signal?: AbortSignal }): Promise<FileObject> {
213
+ const { status, headers } = await this.transport.head(pathToURL(this.base, path), init);
214
+ if (status === 404) {
215
+ throw Object.assign(new Error("not found"), { status: 404 });
216
+ }
217
+ if (status >= 400) {
218
+ throw Object.assign(new Error("stat failed"), { status });
219
+ }
220
+ const size = parseInt(headers.get("content-length") ?? "0", 10);
221
+ return {
222
+ path: headers.get("x-file-path") ?? path,
223
+ size,
224
+ content_type: headers.get("content-type") ?? undefined,
225
+ etag: (headers.get("etag") ?? "").replace(/"/g, "") || undefined,
226
+ url: headers.get("x-file-url") ?? "",
227
+ };
228
+ }
229
+
230
+ /** Returns true iff the path resolves to an existing object. */
231
+ async exists(path: string, init?: { signal?: AbortSignal }): Promise<boolean> {
232
+ const { status } = await this.transport.head(pathToURL(this.base, path), init);
233
+ return status >= 200 && status < 400;
234
+ }
235
+
236
+ /** Delete the object at `path`. Idempotent. */
237
+ async del(path: string, init?: { signal?: AbortSignal }): Promise<void> {
238
+ await this.transport.deletePath<{ ok: boolean }>(pathToURL(this.base, path), init);
239
+ }
240
+
241
+ /** List objects. Pass an empty / omitted prefix for the root. */
242
+ async list(opts?: ListOptions): Promise<FileListResult> {
243
+ const params = new URLSearchParams();
244
+ if (opts?.prefix) params.set("prefix", opts.prefix);
245
+ if (opts?.cursor) params.set("cursor", opts.cursor);
246
+ if (opts?.limit) params.set("limit", String(opts.limit));
247
+ if (opts?.glob) params.set("glob", opts.glob);
248
+ const qs = params.toString();
249
+ return this.transport.getJSON<FileListResult>(
250
+ this.base + (qs ? "?" + qs : ""),
251
+ opts?.signal ? { signal: opts.signal } : undefined,
252
+ );
253
+ }
254
+
255
+ /**
256
+ * Bulk delete: removes every object whose path starts with `prefix`
257
+ * AND (when set) matches `glob`. Returns the count of deleted
258
+ * objects. Refuses an empty prefix + empty glob — the caller must
259
+ * be explicit about wiping the project namespace.
260
+ *
261
+ * client.files.deleteMany({ prefix: "tmp/" }) // wipe a directory
262
+ * client.files.deleteMany({ glob: "**\/*.tmp" }) // wipe by pattern
263
+ * client.files.deleteMany({ prefix: "logs/", glob: "*.bak" })
264
+ */
265
+ async deleteMany(
266
+ opts: { prefix?: string; glob?: string },
267
+ init?: { signal?: AbortSignal },
268
+ ): Promise<{ deleted: number }> {
269
+ return this.transport.postJSON<{ deleted: number }>(
270
+ this.base + "/delete",
271
+ { prefix: opts.prefix ?? "", glob: opts.glob ?? "" },
272
+ init,
273
+ );
274
+ }
275
+
276
+ /** Atomic rename (copy + delete server-side). */
277
+ async move(from: string, to: string, init?: { signal?: AbortSignal }): Promise<FileObject> {
278
+ return this.transport.postJSON<FileObject>(this.base + "/move", { from, to }, init);
279
+ }
280
+
281
+ /** Server-side copy. */
282
+ async copy(from: string, to: string, init?: { signal?: AbortSignal }): Promise<FileObject> {
283
+ return this.transport.postJSON<FileObject>(this.base + "/copy", { from, to }, init);
284
+ }
285
+
286
+ /**
287
+ * Ask the gateway to fetch `srcURL` and store the body at `path`.
288
+ * Saves a round trip vs downloading then re-uploading. `srcURL`
289
+ * must be a public http(s) URL (private/loopback hosts rejected).
290
+ */
291
+ async putFromURL(
292
+ path: string,
293
+ srcURL: string,
294
+ opts?: PutFromURLOptions,
295
+ ): Promise<FileObject> {
296
+ return this.transport.postJSON<FileObject>(
297
+ this.base + "/put-url",
298
+ {
299
+ path,
300
+ src_url: srcURL,
301
+ content_type: opts?.contentType,
302
+ if_none_match: opts?.ifNoneMatch,
303
+ if_match: opts?.ifMatch,
304
+ },
305
+ opts?.signal ? { signal: opts.signal } : undefined,
306
+ );
307
+ }
308
+
309
+ /**
310
+ * Pipeline several file ops in one round trip. Operations execute
311
+ * sequentially server-side; one op failing does not abort the rest.
312
+ * Inspect each result.ok individually.
313
+ *
314
+ * Auto-chunks: the gateway caps a single batch at 64 ops and returns
315
+ * 400 "too many ops" above that. This method splits long input into
316
+ * 30-op chunks, awaits each chunk in order, and concatenates the
317
+ * results — so callers can hand it 1000 ops in a single call.
318
+ * Chunks run serially (not in parallel) to preserve the
319
+ * "operations execute in submission order" contract.
320
+ */
321
+ async batch(
322
+ ops: BatchOp[],
323
+ init?: { signal?: AbortSignal },
324
+ ): Promise<BatchResult[]> {
325
+ if (ops.length === 0) return [];
326
+ const chunkSize = 30;
327
+ if (ops.length <= chunkSize) {
328
+ const resp = await this.transport.postJSON<{ results: BatchResult[] }>(
329
+ this.base + "/batch",
330
+ { ops },
331
+ init,
332
+ );
333
+ return resp.results;
334
+ }
335
+ const all: BatchResult[] = [];
336
+ for (let i = 0; i < ops.length; i += chunkSize) {
337
+ const chunk = ops.slice(i, i + chunkSize);
338
+ const resp = await this.transport.postJSON<{ results: BatchResult[] }>(
339
+ this.base + "/batch",
340
+ { ops: chunk },
341
+ init,
342
+ );
343
+ all.push(...resp.results);
344
+ }
345
+ return all;
346
+ }
347
+ }
package/src/image.ts ADDED
@@ -0,0 +1,339 @@
1
+ import type { Transport } from "./transport.js";
2
+ import type { Usage } from "./types.js";
3
+ import type { SDKEvents } from "./events.js";
4
+ import { wrapEvents } from "./event-wrap.js";
5
+
6
+ /**
7
+ * Image generation request.
8
+ *
9
+ * Sizing: prefer the 2-axis `resolution` + `aspect_ratio` controls —
10
+ * the gateway translates them into a concrete WxH from the target
11
+ * model's declared size enum. `size` is the legacy explicit override;
12
+ * when set, it wins over `(resolution, aspect_ratio)`. When none of
13
+ * the three is set, the gateway / upstream applies its own default.
14
+ */
15
+ export interface ImageGenerateParams {
16
+ prompt: string;
17
+ /** Legacy explicit "WxH" override. Prefer `resolution` +
18
+ * `aspect_ratio` for new code. */
19
+ size?: string;
20
+ /** Short-side pixel tier — "512P" | "1K" | "2K" | "4K".
21
+ * Model-specific subset; `/v1/models` advertises which tiers each
22
+ * image model supports. */
23
+ resolution?: string;
24
+ /** Output shape — "1:1" | "9:16" | "16:9" | "3:4" | "4:3" | "3:2"
25
+ * | "2:3" | "5:4" | "4:5" | "21:9" | "adaptive". Model-specific
26
+ * subset; see `/v1/models`. */
27
+ aspect_ratio?: string;
28
+ n?: number;
29
+ style?: string;
30
+ reference_image_urls?: string[];
31
+ user?: string;
32
+ /**
33
+ * Pass-through for provider-specific knobs.
34
+ *
35
+ * Note: `quality` (OpenAI's render-quality preset
36
+ * — "low"/"medium"/"high"/"auto") was removed from the canonical
37
+ * surface. Only one upstream used it and its own default ("auto") is
38
+ * already optimal; the field caused naming confusion with the
39
+ * "画质" resolution-tier control. The gateway silently drops it
40
+ * from inbound requests, so legacy callers keep working.
41
+ */
42
+ [extra: string]: unknown;
43
+ }
44
+
45
+ /**
46
+ * Single generated image. `url` carries the bytes via one of two
47
+ * transport modes — `<img src={url}>` works for both:
48
+ * - "https://api.runapps.ai/v1/blobs/<id>" — async / hosted blob
49
+ * - "data:<mime>;base64,<payload>" — sync / inline
50
+ *
51
+ * `decodeMediaUrl(url)` resolves either shape into `Uint8Array` +
52
+ * mime type when raw bytes are wanted (file save, post-process).
53
+ *
54
+ * `size` is the actual dimensions of this image — Seedream sequential
55
+ * generation may produce results different from the requested size.
56
+ */
57
+ export interface ImageResult {
58
+ url: string;
59
+ revised_prompt?: string;
60
+ size?: string;
61
+ /**
62
+ * Human-readable credit line that the client SHOULD display
63
+ * alongside the image. Populated by stock-library models (Pexels)
64
+ * whose terms of service require crediting the photographer;
65
+ * AI-generation models leave it empty. Render verbatim — don't
66
+ * try to parse the format.
67
+ *
68
+ * Example: "Photo by Jane Doe on Pexels (https://www.pexels.com/photo/12345/)"
69
+ */
70
+ attribution?: string;
71
+ }
72
+
73
+ export interface ImageUsage extends Usage {
74
+ generated_images?: number;
75
+ output_tokens?: number;
76
+ total_tokens?: number;
77
+ /** Tool name → invocation count (e.g. Seedream 5.0 lite web_search). */
78
+ tool_usage?: Record<string, number>;
79
+ }
80
+
81
+ export interface ImageResponse {
82
+ created: number;
83
+ data: ImageResult[];
84
+ usage: ImageUsage;
85
+ }
86
+
87
+ /**
88
+ * Status payload returned by the resumable async-image endpoints
89
+ * (`/v1/async/images/generations` and friends). Returned verbatim by
90
+ * `submitGenerate()` (job is still queued / running), and again by
91
+ * `getAsyncStatus()` (status may now be terminal — `data[]` is
92
+ * populated when `status === 'succeeded'`).
93
+ *
94
+ * The fully-managed `generateAsync()` hides this type behind an
95
+ * `ImageResponse`; `submitGenerate` + `getAsyncStatus` expose it for
96
+ * callers who need to PERSIST the job id (e.g. resume polling across
97
+ * a page refresh, drive their own UI progress, etc.).
98
+ */
99
+ export interface AsyncImageJob {
100
+ id: string;
101
+ status: "queued" | "running" | "succeeded" | "failed" | (string & {});
102
+ data?: ImageResult[];
103
+ usage?: ImageUsage;
104
+ error?: string;
105
+ /** Unix epoch seconds — same semantics as VideoStatus.created_at /
106
+ * current_time. Pair to compute server-truth elapsed. */
107
+ created_at?: number;
108
+ current_time?: number;
109
+ }
110
+
111
+ /** Minimal interface for an image / mask file passed to `edit()`. */
112
+ export interface ImageFileInput {
113
+ /** File contents — Blob, ArrayBuffer, Uint8Array, or string. */
114
+ data: Blob | ArrayBuffer | Uint8Array | string;
115
+ /** File name including extension (sent in the multipart field). */
116
+ filename: string;
117
+ /** Optional MIME type — inferred from the extension when omitted. */
118
+ contentType?: string;
119
+ }
120
+
121
+ export interface ImageEditParams {
122
+ image: ImageFileInput;
123
+ mask?: ImageFileInput;
124
+ prompt: string;
125
+ size?: string;
126
+ n?: number;
127
+ user?: string;
128
+ }
129
+
130
+ /* ------------------------------------------------------------------ */
131
+
132
+ export class ImageService {
133
+ constructor(
134
+ private readonly transport: Transport,
135
+ private readonly events: SDKEvents,
136
+ ) {}
137
+
138
+ /**
139
+ * Generate images from a text prompt via the synchronous endpoint.
140
+ * For long-running requests (large Seedream batches, slow upstreams)
141
+ * use `generateAsync()` to avoid 100-second origin timeouts.
142
+ */
143
+ async generate(
144
+ model: string,
145
+ params: ImageGenerateParams,
146
+ init?: { signal?: AbortSignal },
147
+ ): Promise<ImageResponse> {
148
+ return wrapEvents(
149
+ this.events,
150
+ { model, capability: "image_generation" },
151
+ () =>
152
+ this.transport.postJSON<ImageResponse>(
153
+ "/v1/images/generations",
154
+ { model, ...params },
155
+ init,
156
+ ),
157
+ (r) => ({ costUSD: r.usage?.total_cost }),
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Async equivalent of `generate()`. Submits a job to
163
+ * `/v1/async/images/generations`, polls `/v1/async/images/generations/:id`
164
+ * until terminal, then resolves to a sync-shape `ImageResponse`. Use
165
+ * this when a request may exceed the ~100s origin timeout (Cloudflare
166
+ * otherwise replaces the real upstream error with its own 502 page).
167
+ *
168
+ * Implementation is `submitGenerate` + `getAsyncStatus` poll loop, so
169
+ * the path layout matches the rest of the async API (`/v1/async/...`
170
+ * everywhere). The caller's `signal` aborts both the submit and the
171
+ * poll wait; `pollIntervalMs` defaults to 2s.
172
+ */
173
+ async generateAsync(
174
+ model: string,
175
+ params: ImageGenerateParams,
176
+ init?: { signal?: AbortSignal; pollIntervalMs?: number },
177
+ ): Promise<ImageResponse> {
178
+ const interval = init?.pollIntervalMs ?? 2000;
179
+ const signal = init?.signal;
180
+ return wrapEvents(
181
+ this.events,
182
+ { model, capability: "image_generation" },
183
+ async () => {
184
+ const job = await this.transport.postJSON<AsyncImageJob>(
185
+ "/v1/async/images/generations",
186
+ { model, ...params },
187
+ { signal },
188
+ );
189
+ let current = job;
190
+ while (current.status !== "succeeded" && current.status !== "failed") {
191
+ await sleep(interval, signal);
192
+ current = await this.getAsyncStatus(current.id, { signal });
193
+ }
194
+ if (current.status === "failed") {
195
+ throw new Error(current.error ?? "image generation failed");
196
+ }
197
+ return {
198
+ created: current.created_at ?? Math.floor(Date.now() / 1000),
199
+ data: current.data ?? [],
200
+ usage: current.usage ?? { total_cost: 0 },
201
+ } satisfies ImageResponse;
202
+ },
203
+ (r) => ({ costUSD: r.usage?.total_cost }),
204
+ );
205
+ }
206
+
207
+ /**
208
+ * Submit-only variant of `generate()`. Returns the gateway's job
209
+ * descriptor (`id`, `status`, `usage`) without polling. Use this
210
+ * when you want to PERSIST the job id and resume polling later
211
+ * (e.g. across page reloads, in a separate worker process). Pair
212
+ * with `getAsyncStatus(id)` to drive the polling loop yourself.
213
+ *
214
+ * For a one-shot "submit and wait" call site, use `generateAsync()`
215
+ * — that wraps the same underlying endpoint with a built-in poll.
216
+ *
217
+ * Telemetry note: only the SUBMIT step fires start/end on
218
+ * `client.events` — the per-call latency reflects the gateway
219
+ * accepting the job, NOT how long the upstream takes to render.
220
+ * Track render progress via your own poll loop's status responses.
221
+ */
222
+ async submitGenerate(
223
+ model: string,
224
+ params: ImageGenerateParams,
225
+ init?: { signal?: AbortSignal },
226
+ ): Promise<AsyncImageJob> {
227
+ return wrapEvents(
228
+ this.events,
229
+ { model, capability: "image_generation" },
230
+ () =>
231
+ this.transport.postJSON<AsyncImageJob>(
232
+ "/v1/async/images/generations",
233
+ { model, ...params },
234
+ init,
235
+ ),
236
+ );
237
+ }
238
+
239
+ /**
240
+ * Single poll of an in-flight async image job. Returns the same
241
+ * shape `submitGenerate()` returned, with `data[]` populated once
242
+ * `status` flips to `succeeded`. Caller decides cadence + bail-out;
243
+ * the gateway's per-poll `current_time` / `created_at` together
244
+ * give a server-truth elapsed reading regardless of client clock skew.
245
+ */
246
+ async getAsyncStatus(
247
+ id: string,
248
+ init?: { signal?: AbortSignal },
249
+ ): Promise<AsyncImageJob> {
250
+ return this.transport.getJSON<AsyncImageJob>(
251
+ `/v1/async/images/generations/${encodeURIComponent(id)}`,
252
+ init,
253
+ );
254
+ }
255
+
256
+ /**
257
+ * Edit an image (inpaint / outpaint / variations) via multipart upload.
258
+ * Provider-dependent — only available on `gpt-image-1` and similar.
259
+ */
260
+ async edit(
261
+ model: string,
262
+ params: ImageEditParams,
263
+ init?: { signal?: AbortSignal },
264
+ ): Promise<ImageResponse> {
265
+ const form = buildEditForm(model, params);
266
+ return wrapEvents(
267
+ this.events,
268
+ { model, capability: "image_edit" },
269
+ () =>
270
+ this.transport.postMultipart<ImageResponse>(
271
+ "/v1/images/edits",
272
+ form,
273
+ init,
274
+ ),
275
+ (r) => ({ costUSD: r.usage?.total_cost }),
276
+ );
277
+ }
278
+ }
279
+
280
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
281
+ return new Promise((resolve, reject) => {
282
+ if (signal?.aborted) {
283
+ reject(signal.reason ?? new Error("aborted"));
284
+ return;
285
+ }
286
+ const timer = setTimeout(() => {
287
+ signal?.removeEventListener("abort", onAbort);
288
+ resolve();
289
+ }, ms);
290
+ const onAbort = () => {
291
+ clearTimeout(timer);
292
+ reject(signal!.reason ?? new Error("aborted"));
293
+ };
294
+ signal?.addEventListener("abort", onAbort, { once: true });
295
+ });
296
+ }
297
+
298
+ function buildEditForm(model: string, params: ImageEditParams): FormData {
299
+ const form = new FormData();
300
+ form.set("model", model);
301
+ form.set("prompt", params.prompt);
302
+ if (params.size) form.set("size", params.size);
303
+ if (params.n !== undefined) form.set("n", String(params.n));
304
+ if (params.user) form.set("user", params.user);
305
+ form.set("image", toBlob(params.image), params.image.filename);
306
+ if (params.mask) form.set("mask", toBlob(params.mask), params.mask.filename);
307
+ return form;
308
+ }
309
+
310
+ /** Internal: coerce ImageFileInput to a Blob the multipart layer accepts. */
311
+ function toBlob(input: ImageFileInput): Blob {
312
+ const ct = input.contentType ?? guessMime(input.filename);
313
+ if (input.data instanceof Blob) {
314
+ return input.data.type === ct ? input.data : new Blob([input.data], { type: ct });
315
+ }
316
+ if (typeof input.data === "string") {
317
+ return new Blob([input.data], { type: ct });
318
+ }
319
+ // ArrayBuffer | Uint8Array — wrap in Blob. Cast widens TS's strict
320
+ // Uint8Array<ArrayBuffer> back to BlobPart (it's accepted at runtime).
321
+ return new Blob([input.data as BlobPart], { type: ct });
322
+ }
323
+
324
+ function guessMime(filename: string): string {
325
+ const ext = filename.toLowerCase().split(".").pop() ?? "";
326
+ switch (ext) {
327
+ case "png":
328
+ return "image/png";
329
+ case "jpg":
330
+ case "jpeg":
331
+ return "image/jpeg";
332
+ case "webp":
333
+ return "image/webp";
334
+ case "gif":
335
+ return "image/gif";
336
+ default:
337
+ return "application/octet-stream";
338
+ }
339
+ }