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 +1 -1
- package/src/ai-sdk/providers/fal.test.ts +214 -0
- package/src/ai-sdk/providers/fal.ts +190 -10
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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 ?? [];
|