vargai 0.4.0-alpha39 → 0.4.0-alpha40

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/package.json CHANGED
@@ -68,7 +68,7 @@
68
68
  "sharp": "^0.34.5",
69
69
  "zod": "^4.2.1"
70
70
  },
71
- "version": "0.4.0-alpha39",
71
+ "version": "0.4.0-alpha40",
72
72
  "exports": {
73
73
  ".": "./src/index.ts",
74
74
  "./ai": "./src/ai-sdk/index.ts",
@@ -0,0 +1,214 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, rmSync } from "node:fs";
3
+ import { computeFileHashes, computePendingKey } from "./fal";
4
+
5
+ const TEST_PENDING_DIR = ".cache/fal-pending-test";
6
+
7
+ describe("fal queue recovery", () => {
8
+ beforeEach(() => {
9
+ if (existsSync(TEST_PENDING_DIR)) {
10
+ rmSync(TEST_PENDING_DIR, { recursive: true });
11
+ }
12
+ });
13
+
14
+ afterEach(() => {
15
+ if (existsSync(TEST_PENDING_DIR)) {
16
+ rmSync(TEST_PENDING_DIR, { recursive: true });
17
+ }
18
+ });
19
+
20
+ describe("computePendingKey", () => {
21
+ test("produces same key for same endpoint and input", () => {
22
+ const endpoint = "fal-ai/flux-schnell";
23
+ const input = { prompt: "a cat", num_images: 1 };
24
+
25
+ const key1 = computePendingKey(endpoint, input);
26
+ const key2 = computePendingKey(endpoint, input);
27
+
28
+ expect(key1).toBe(key2);
29
+ expect(key1).toMatch(/^pending_[a-f0-9]+$/);
30
+ });
31
+
32
+ test("produces different keys for different inputs", () => {
33
+ const endpoint = "fal-ai/flux-schnell";
34
+
35
+ const key1 = computePendingKey(endpoint, { prompt: "a cat" });
36
+ const key2 = computePendingKey(endpoint, { prompt: "a dog" });
37
+
38
+ expect(key1).not.toBe(key2);
39
+ });
40
+
41
+ test("uses stableKey when provided", () => {
42
+ const endpoint = "fal-ai/flux-schnell";
43
+ const input1 = { prompt: "test", image_urls: ["https://fal.media/abc"] };
44
+ const input2 = { prompt: "test", image_urls: ["https://fal.media/xyz"] };
45
+ const stableKey = "stable-key-from-file-hashes";
46
+
47
+ const key1 = computePendingKey(endpoint, input1, stableKey);
48
+ const key2 = computePendingKey(endpoint, input2, stableKey);
49
+
50
+ expect(key1).toBe(key2);
51
+ });
52
+
53
+ test("without stableKey, different image_urls produce different keys", () => {
54
+ const endpoint = "fal-ai/flux-schnell";
55
+ const input1 = { prompt: "test", image_urls: ["https://fal.media/abc"] };
56
+ const input2 = { prompt: "test", image_urls: ["https://fal.media/xyz"] };
57
+
58
+ const key1 = computePendingKey(endpoint, input1);
59
+ const key2 = computePendingKey(endpoint, input2);
60
+
61
+ expect(key1).not.toBe(key2);
62
+ });
63
+ });
64
+
65
+ describe("computeFileHashes", () => {
66
+ test("returns empty array for undefined files", async () => {
67
+ const hashes = await computeFileHashes(undefined);
68
+ expect(hashes).toEqual([]);
69
+ });
70
+
71
+ test("returns empty array for empty files", async () => {
72
+ const hashes = await computeFileHashes([]);
73
+ expect(hashes).toEqual([]);
74
+ });
75
+
76
+ test("returns URL as-is for url type files", async () => {
77
+ const files = [
78
+ { type: "url" as const, url: "https://example.com/image.png" },
79
+ ];
80
+ const hashes = await computeFileHashes(files);
81
+ expect(hashes).toEqual(["https://example.com/image.png"]);
82
+ });
83
+
84
+ test("produces stable hash for same file bytes", async () => {
85
+ const bytes = new Uint8Array([1, 2, 3, 4, 5]);
86
+ const files = [
87
+ { type: "file" as const, data: bytes, mediaType: "image/png" },
88
+ ];
89
+
90
+ const hashes1 = await computeFileHashes(files);
91
+ const hashes2 = await computeFileHashes(files);
92
+
93
+ expect(hashes1).toEqual(hashes2);
94
+ expect(hashes1[0]).toMatch(/^[a-f0-9]+$/);
95
+ });
96
+
97
+ test("produces different hashes for different bytes", async () => {
98
+ const files1 = [
99
+ {
100
+ type: "file" as const,
101
+ data: new Uint8Array([1, 2, 3]),
102
+ mediaType: "image/png",
103
+ },
104
+ ];
105
+ const files2 = [
106
+ {
107
+ type: "file" as const,
108
+ data: new Uint8Array([4, 5, 6]),
109
+ mediaType: "image/png",
110
+ },
111
+ ];
112
+
113
+ const hashes1 = await computeFileHashes(files1);
114
+ const hashes2 = await computeFileHashes(files2);
115
+
116
+ expect(hashes1[0]).not.toBe(hashes2[0]);
117
+ });
118
+
119
+ test("handles base64 encoded data", async () => {
120
+ const bytes = new Uint8Array([72, 101, 108, 108, 111]);
121
+ const base64 = btoa(String.fromCharCode(...bytes));
122
+ const files = [
123
+ { type: "file" as const, data: base64, mediaType: "image/png" },
124
+ ];
125
+
126
+ const hashes = await computeFileHashes(files);
127
+ expect(hashes[0]).toMatch(/^[a-f0-9]+$/);
128
+ });
129
+
130
+ test("same bytes as Uint8Array and base64 produce same hash", async () => {
131
+ const bytes = new Uint8Array([72, 101, 108, 108, 111]);
132
+ const base64 = btoa(String.fromCharCode(...bytes));
133
+
134
+ const filesBytes = [
135
+ { type: "file" as const, data: bytes, mediaType: "image/png" },
136
+ ];
137
+ const filesBase64 = [
138
+ { type: "file" as const, data: base64, mediaType: "image/png" },
139
+ ];
140
+
141
+ const hashesBytes = await computeFileHashes(filesBytes);
142
+ const hashesBase64 = await computeFileHashes(filesBase64);
143
+
144
+ expect(hashesBytes[0]).toBe(hashesBase64[0]);
145
+ });
146
+ });
147
+
148
+ describe("stable key integration", () => {
149
+ test("same file bytes produce same stable key across runs", async () => {
150
+ const endpoint = "fal-ai/nano-banana-pro/edit";
151
+ const bytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
152
+ const files = [
153
+ { type: "file" as const, data: bytes, mediaType: "image/png" },
154
+ ];
155
+
156
+ const fileHashes = await computeFileHashes(files);
157
+ const input = { prompt: "test prompt", num_images: 1 };
158
+ const stableKey = JSON.stringify({ endpoint, input, fileHashes });
159
+
160
+ const inputWithUrl1 = {
161
+ ...input,
162
+ image_urls: ["https://fal.media/upload1"],
163
+ };
164
+ const inputWithUrl2 = {
165
+ ...input,
166
+ image_urls: ["https://fal.media/upload2"],
167
+ };
168
+
169
+ const key1 = computePendingKey(endpoint, inputWithUrl1, stableKey);
170
+ const key2 = computePendingKey(endpoint, inputWithUrl2, stableKey);
171
+
172
+ expect(key1).toBe(key2);
173
+ });
174
+
175
+ test("different file bytes produce different stable keys", async () => {
176
+ const endpoint = "fal-ai/nano-banana-pro/edit";
177
+ const input = { prompt: "test prompt", num_images: 1 };
178
+
179
+ const files1 = [
180
+ {
181
+ type: "file" as const,
182
+ data: new Uint8Array([1, 2, 3]),
183
+ mediaType: "image/png",
184
+ },
185
+ ];
186
+ const files2 = [
187
+ {
188
+ type: "file" as const,
189
+ data: new Uint8Array([4, 5, 6]),
190
+ mediaType: "image/png",
191
+ },
192
+ ];
193
+
194
+ const hashes1 = await computeFileHashes(files1);
195
+ const hashes2 = await computeFileHashes(files2);
196
+
197
+ const stableKey1 = JSON.stringify({
198
+ endpoint,
199
+ input,
200
+ fileHashes: hashes1,
201
+ });
202
+ const stableKey2 = JSON.stringify({
203
+ endpoint,
204
+ input,
205
+ fileHashes: hashes2,
206
+ });
207
+
208
+ const key1 = computePendingKey(endpoint, input, stableKey1);
209
+ const key2 = computePendingKey(endpoint, input, stableKey2);
210
+
211
+ expect(key1).not.toBe(key2);
212
+ });
213
+ });
214
+ });
@@ -11,8 +11,22 @@ import {
11
11
  type TranscriptionModelV3CallOptions,
12
12
  } from "@ai-sdk/provider";
13
13
  import { fal } from "@fal-ai/client";
14
+ import { fileCache } from "../file-cache";
14
15
  import type { VideoModelV3, VideoModelV3CallOptions } from "../video-model";
15
16
 
17
+ interface PendingRequest {
18
+ request_id: string;
19
+ endpoint: string;
20
+ submitted_at: number;
21
+ }
22
+
23
+ const pendingStorage = fileCache({ dir: ".cache/fal-pending" });
24
+
25
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
26
+ const FAL_TIMEOUT_MS = process.env.FAL_TIMEOUT_MS
27
+ ? Number.parseInt(process.env.FAL_TIMEOUT_MS, 10)
28
+ : DEFAULT_TIMEOUT_MS;
29
+
16
30
  const VIDEO_MODELS: Record<string, { t2v: string; i2v: string }> = {
17
31
  // Kling v2.6 - latest with native audio generation
18
32
  "kling-v2.6": {
@@ -182,6 +196,143 @@ async function uploadBuffer(buffer: ArrayBuffer): Promise<string> {
182
196
  return fal.storage.upload(new Blob([buffer]));
183
197
  }
184
198
 
199
+ export function computePendingKey(
200
+ endpoint: string,
201
+ input: Record<string, unknown>,
202
+ stableKey?: string,
203
+ ): string {
204
+ const keyData = stableKey ?? JSON.stringify({ endpoint, input });
205
+ const hash = Bun.hash(keyData).toString(16);
206
+ return `pending_${hash}`;
207
+ }
208
+
209
+ export async function computeFileHashes(
210
+ files: ImageModelV3File[] | undefined,
211
+ ): Promise<string[]> {
212
+ if (!files || files.length === 0) return [];
213
+ return Promise.all(
214
+ files.map(async (f) => {
215
+ if (f.type === "url") return f.url;
216
+ const bytes =
217
+ typeof f.data === "string"
218
+ ? Uint8Array.from(atob(f.data), (c) => c.charCodeAt(0))
219
+ : f.data;
220
+ return Bun.hash(bytes).toString(16);
221
+ }),
222
+ );
223
+ }
224
+
225
+ async function executeWithQueueRecovery<T>(
226
+ endpoint: string,
227
+ input: Record<string, unknown>,
228
+ options: {
229
+ logs?: boolean;
230
+ onQueueUpdate?: (status: { status: string }) => void;
231
+ stableKey?: string;
232
+ } = {},
233
+ ): Promise<T> {
234
+ const { logs = true, onQueueUpdate, stableKey } = options;
235
+ const pendingKey = computePendingKey(endpoint, input, stableKey);
236
+
237
+ const pending = (await pendingStorage.get(pendingKey)) as
238
+ | PendingRequest
239
+ | undefined;
240
+
241
+ if (pending) {
242
+ try {
243
+ const status = await fal.queue.status(pending.endpoint, {
244
+ requestId: pending.request_id,
245
+ logs,
246
+ });
247
+
248
+ if (status.status === "COMPLETED") {
249
+ console.log(
250
+ `\x1b[32m⚡ recovered completed job from queue (${pending.request_id.slice(0, 8)}...)\x1b[0m`,
251
+ );
252
+ const result = await fal.queue.result(pending.endpoint, {
253
+ requestId: pending.request_id,
254
+ });
255
+ await pendingStorage.delete(pendingKey);
256
+ return result as T;
257
+ }
258
+
259
+ if (status.status === "IN_QUEUE" || status.status === "IN_PROGRESS") {
260
+ console.log(
261
+ `\x1b[33m⏳ resuming pending job (${pending.request_id.slice(0, 8)}...) - status: ${status.status}\x1b[0m`,
262
+ );
263
+ await fal.queue.subscribeToStatus(pending.endpoint, {
264
+ requestId: pending.request_id,
265
+ logs,
266
+ timeout: FAL_TIMEOUT_MS,
267
+ onQueueUpdate,
268
+ });
269
+ const result = await fal.queue.result(pending.endpoint, {
270
+ requestId: pending.request_id,
271
+ });
272
+ await pendingStorage.delete(pendingKey);
273
+ return result as T;
274
+ }
275
+
276
+ await pendingStorage.delete(pendingKey);
277
+ } catch (error) {
278
+ const isNotFound =
279
+ error instanceof Error &&
280
+ (error.message.includes("not found") ||
281
+ error.message.includes("404") ||
282
+ error.message.includes("does not exist"));
283
+
284
+ if (isNotFound) {
285
+ console.log(
286
+ `\x1b[33m⚠ pending job expired or not found, submitting new request\x1b[0m`,
287
+ );
288
+ await pendingStorage.delete(pendingKey);
289
+ } else {
290
+ console.log(
291
+ `\x1b[33m⚠ pending job check failed (${error instanceof Error ? error.message : "unknown"}), keeping for retry\x1b[0m`,
292
+ );
293
+ throw error;
294
+ }
295
+ }
296
+ }
297
+
298
+ const { request_id } = await fal.queue.submit(endpoint, { input });
299
+
300
+ await pendingStorage.set(
301
+ pendingKey,
302
+ {
303
+ request_id,
304
+ endpoint,
305
+ submitted_at: Date.now(),
306
+ } satisfies PendingRequest,
307
+ 24 * 60 * 60 * 1000,
308
+ );
309
+
310
+ console.log(
311
+ `\x1b[36m📋 queued job ${request_id.slice(0, 8)}... (recoverable on timeout)\x1b[0m`,
312
+ );
313
+
314
+ try {
315
+ await fal.queue.subscribeToStatus(endpoint, {
316
+ requestId: request_id,
317
+ logs,
318
+ timeout: FAL_TIMEOUT_MS,
319
+ onQueueUpdate,
320
+ });
321
+
322
+ const result = await fal.queue.result(endpoint, {
323
+ requestId: request_id,
324
+ });
325
+
326
+ await pendingStorage.delete(pendingKey);
327
+ return result as T;
328
+ } catch (error) {
329
+ console.log(
330
+ `\x1b[33m⚠ job ${request_id.slice(0, 8)}... saved for recovery on next run\x1b[0m`,
331
+ );
332
+ throw error;
333
+ }
334
+ }
335
+
185
336
  class FalVideoModel implements VideoModelV3 {
186
337
  readonly specificationVersion = "v3" as const;
187
338
  readonly provider = "fal";
@@ -220,6 +371,8 @@ class FalVideoModel implements VideoModelV3 {
220
371
  const isLtx2 = this.modelId === "ltx-2-19b-distilled";
221
372
  const isGrokImagine = this.modelId === "grok-imagine";
222
373
 
374
+ const fileHashes = await computeFileHashes(files as ImageModelV3File[]);
375
+
223
376
  const endpoint = isLipsync
224
377
  ? this.resolveLipsyncEndpoint()
225
378
  : isMotionControl
@@ -402,10 +555,24 @@ class FalVideoModel implements VideoModelV3 {
402
555
  }
403
556
  }
404
557
 
405
- const result = await fal.subscribe(endpoint, {
558
+ const stableKey =
559
+ fileHashes.length > 0
560
+ ? JSON.stringify({
561
+ endpoint,
562
+ prompt,
563
+ duration,
564
+ aspectRatio,
565
+ providerOptions,
566
+ modelId: this.modelId,
567
+ fileHashes,
568
+ })
569
+ : undefined;
570
+
571
+ const result = await executeWithQueueRecovery<{ data: unknown }>(
572
+ endpoint,
406
573
  input,
407
- logs: true,
408
- });
574
+ { logs: true, stableKey },
575
+ );
409
576
 
410
577
  const data = result.data as { video?: { url?: string } };
411
578
  const videoUrl = data?.video?.url;
@@ -563,11 +730,25 @@ class FalImageModel implements ImageModelV3 {
563
730
  }
564
731
 
565
732
  const hasFiles = files && files.length > 0;
566
- if (hasFiles) {
733
+ const finalEndpoint = this.resolveEndpoint();
734
+
735
+ let stableKey: string | undefined;
736
+ if (hasFiles && files) {
737
+ const fileHashes = await computeFileHashes(files);
738
+ stableKey = JSON.stringify({
739
+ endpoint: finalEndpoint,
740
+ prompt,
741
+ n,
742
+ size,
743
+ aspectRatio,
744
+ seed,
745
+ providerOptions,
746
+ modelId: this.modelId,
747
+ fileHashes,
748
+ });
567
749
  input.image_urls = await Promise.all(files.map((f) => fileToUrl(f)));
568
750
  }
569
751
 
570
- // Qwen Angles requires image_urls
571
752
  if (isQwenAngles && !input.image_urls) {
572
753
  throw new Error("qwen-angles requires at least one image file");
573
754
  }
@@ -581,12 +762,11 @@ class FalImageModel implements ImageModelV3 {
581
762
  }
582
763
  }
583
764
 
584
- const finalEndpoint = this.resolveEndpoint();
585
-
586
- const result = await fal.subscribe(finalEndpoint, {
765
+ const result = await executeWithQueueRecovery<{ data: unknown }>(
766
+ finalEndpoint,
587
767
  input,
588
- logs: true,
589
- });
768
+ { logs: true, stableKey },
769
+ );
590
770
 
591
771
  const data = result.data as { images?: Array<{ url?: string }> };
592
772
  const images = data?.images ?? [];