@ai-sdk/fal 2.0.8 → 2.0.10
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/CHANGELOG.md +14 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -4
- package/src/fal-api-types.ts +189 -0
- package/src/fal-config.ts +9 -0
- package/src/fal-error.test.ts +34 -0
- package/src/fal-error.ts +16 -0
- package/src/fal-image-model.test.ts +930 -0
- package/src/fal-image-model.ts +367 -0
- package/src/fal-image-options.ts +129 -0
- package/src/fal-image-settings.ts +71 -0
- package/src/fal-provider.test.ts +57 -0
- package/src/fal-provider.ts +183 -0
- package/src/fal-speech-model.test.ts +128 -0
- package/src/fal-speech-model.ts +156 -0
- package/src/fal-speech-settings.ts +10 -0
- package/src/fal-transcription-model.test.ts +181 -0
- package/src/fal-transcription-model.ts +270 -0
- package/src/fal-transcription-options.ts +1 -0
- package/src/index.ts +4 -0
- package/src/transcript-test.mp3 +0 -0
- package/src/version.ts +6 -0
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
import { FetchFunction } from '@ai-sdk/provider-utils';
|
|
2
|
+
import { createTestServer } from '@ai-sdk/test-server/with-vitest';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { FalImageModel } from './fal-image-model';
|
|
5
|
+
|
|
6
|
+
const prompt = 'A cute baby sea otter';
|
|
7
|
+
|
|
8
|
+
function createBasicModel({
|
|
9
|
+
headers,
|
|
10
|
+
fetch,
|
|
11
|
+
currentDate,
|
|
12
|
+
}: {
|
|
13
|
+
headers?: Record<string, string | undefined>;
|
|
14
|
+
fetch?: FetchFunction;
|
|
15
|
+
currentDate?: () => Date;
|
|
16
|
+
settings?: any;
|
|
17
|
+
} = {}) {
|
|
18
|
+
return new FalImageModel('fal-ai/qwen-image', {
|
|
19
|
+
provider: 'fal.image',
|
|
20
|
+
baseURL: 'https://api.example.com',
|
|
21
|
+
headers: headers ?? { 'api-key': 'test-key' },
|
|
22
|
+
fetch,
|
|
23
|
+
_internal: {
|
|
24
|
+
currentDate,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('FalImageModel', () => {
|
|
30
|
+
const server = createTestServer({
|
|
31
|
+
'https://api.example.com/fal-ai/qwen-image': {
|
|
32
|
+
response: {
|
|
33
|
+
type: 'json-value',
|
|
34
|
+
body: {
|
|
35
|
+
images: [
|
|
36
|
+
{
|
|
37
|
+
url: 'https://api.example.com/image.png',
|
|
38
|
+
width: 1024,
|
|
39
|
+
height: 1024,
|
|
40
|
+
content_type: 'image/png',
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
'https://api.example.com/image.png': {
|
|
47
|
+
response: {
|
|
48
|
+
type: 'binary',
|
|
49
|
+
body: Buffer.from('test-binary-content'),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('doGenerate', () => {
|
|
55
|
+
it('should pass the correct parameters including size', async () => {
|
|
56
|
+
const model = createBasicModel();
|
|
57
|
+
|
|
58
|
+
await model.doGenerate({
|
|
59
|
+
prompt,
|
|
60
|
+
files: undefined,
|
|
61
|
+
mask: undefined,
|
|
62
|
+
n: 1,
|
|
63
|
+
size: '1024x1024',
|
|
64
|
+
aspectRatio: undefined,
|
|
65
|
+
seed: 123,
|
|
66
|
+
providerOptions: { fal: { additional_param: 'value' } },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(await server.calls[0].requestBodyJson).toStrictEqual({
|
|
70
|
+
prompt,
|
|
71
|
+
seed: 123,
|
|
72
|
+
image_size: { width: 1024, height: 1024 },
|
|
73
|
+
num_images: 1,
|
|
74
|
+
additional_param: 'value',
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should convert camelCase provider options to snake_case for API', async () => {
|
|
79
|
+
const model = createBasicModel();
|
|
80
|
+
|
|
81
|
+
const result = await model.doGenerate({
|
|
82
|
+
prompt,
|
|
83
|
+
files: undefined,
|
|
84
|
+
mask: undefined,
|
|
85
|
+
n: 1,
|
|
86
|
+
size: undefined,
|
|
87
|
+
aspectRatio: undefined,
|
|
88
|
+
seed: undefined,
|
|
89
|
+
providerOptions: {
|
|
90
|
+
fal: {
|
|
91
|
+
imageUrl: 'https://example.com/image.png',
|
|
92
|
+
guidanceScale: 7.5,
|
|
93
|
+
numInferenceSteps: 50,
|
|
94
|
+
enableSafetyChecker: false,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(await server.calls[0].requestBodyJson).toStrictEqual({
|
|
100
|
+
prompt,
|
|
101
|
+
num_images: 1,
|
|
102
|
+
image_url: 'https://example.com/image.png',
|
|
103
|
+
guidance_scale: 7.5,
|
|
104
|
+
num_inference_steps: 50,
|
|
105
|
+
enable_safety_checker: false,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(result.warnings).toHaveLength(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should accept deprecated snake_case provider options with warning', async () => {
|
|
112
|
+
const model = createBasicModel();
|
|
113
|
+
|
|
114
|
+
const result = await model.doGenerate({
|
|
115
|
+
prompt,
|
|
116
|
+
files: undefined,
|
|
117
|
+
mask: undefined,
|
|
118
|
+
n: 1,
|
|
119
|
+
size: undefined,
|
|
120
|
+
aspectRatio: undefined,
|
|
121
|
+
seed: undefined,
|
|
122
|
+
providerOptions: {
|
|
123
|
+
fal: {
|
|
124
|
+
image_url: 'https://example.com/image.png',
|
|
125
|
+
guidance_scale: 7.5,
|
|
126
|
+
num_inference_steps: 50,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(await server.calls[0].requestBodyJson).toStrictEqual({
|
|
132
|
+
prompt,
|
|
133
|
+
num_images: 1,
|
|
134
|
+
image_url: 'https://example.com/image.png',
|
|
135
|
+
guidance_scale: 7.5,
|
|
136
|
+
num_inference_steps: 50,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(result.warnings).toHaveLength(1);
|
|
140
|
+
expect(result.warnings[0]).toMatchObject({
|
|
141
|
+
type: 'other',
|
|
142
|
+
message: expect.stringContaining('deprecated snake_case'),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const warning = result.warnings[0];
|
|
146
|
+
if (warning.type === 'other') {
|
|
147
|
+
expect(warning.message).toContain("'image_url' (use 'imageUrl')");
|
|
148
|
+
expect(warning.message).toContain(
|
|
149
|
+
"'guidance_scale' (use 'guidanceScale')",
|
|
150
|
+
);
|
|
151
|
+
expect(warning.message).toContain(
|
|
152
|
+
"'num_inference_steps' (use 'numInferenceSteps')",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should convert aspect ratio to size', async () => {
|
|
158
|
+
const model = createBasicModel();
|
|
159
|
+
|
|
160
|
+
await model.doGenerate({
|
|
161
|
+
prompt,
|
|
162
|
+
files: undefined,
|
|
163
|
+
mask: undefined,
|
|
164
|
+
n: 1,
|
|
165
|
+
size: undefined,
|
|
166
|
+
aspectRatio: '16:9',
|
|
167
|
+
seed: undefined,
|
|
168
|
+
providerOptions: {},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(await server.calls[0].requestBodyJson).toStrictEqual({
|
|
172
|
+
prompt,
|
|
173
|
+
image_size: 'landscape_16_9',
|
|
174
|
+
num_images: 1,
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should pass headers', async () => {
|
|
179
|
+
const modelWithHeaders = createBasicModel({
|
|
180
|
+
headers: {
|
|
181
|
+
'Custom-Provider-Header': 'provider-header-value',
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await modelWithHeaders.doGenerate({
|
|
186
|
+
prompt,
|
|
187
|
+
files: undefined,
|
|
188
|
+
mask: undefined,
|
|
189
|
+
n: 1,
|
|
190
|
+
providerOptions: {},
|
|
191
|
+
headers: {
|
|
192
|
+
'Custom-Request-Header': 'request-header-value',
|
|
193
|
+
},
|
|
194
|
+
size: undefined,
|
|
195
|
+
seed: undefined,
|
|
196
|
+
aspectRatio: undefined,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(server.calls[0].requestHeaders).toStrictEqual({
|
|
200
|
+
'content-type': 'application/json',
|
|
201
|
+
'custom-provider-header': 'provider-header-value',
|
|
202
|
+
'custom-request-header': 'request-header-value',
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should handle API errors', async () => {
|
|
207
|
+
server.urls['https://api.example.com/fal-ai/qwen-image'].response = {
|
|
208
|
+
type: 'error',
|
|
209
|
+
status: 400,
|
|
210
|
+
body: JSON.stringify({
|
|
211
|
+
detail: [
|
|
212
|
+
{
|
|
213
|
+
loc: ['prompt'],
|
|
214
|
+
msg: 'Invalid prompt',
|
|
215
|
+
type: 'value_error',
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
}),
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const model = createBasicModel();
|
|
222
|
+
await expect(
|
|
223
|
+
model.doGenerate({
|
|
224
|
+
prompt,
|
|
225
|
+
files: undefined,
|
|
226
|
+
mask: undefined,
|
|
227
|
+
n: 1,
|
|
228
|
+
providerOptions: {},
|
|
229
|
+
size: undefined,
|
|
230
|
+
seed: undefined,
|
|
231
|
+
aspectRatio: undefined,
|
|
232
|
+
}),
|
|
233
|
+
).rejects.toMatchObject({
|
|
234
|
+
message: 'prompt: Invalid prompt',
|
|
235
|
+
statusCode: 400,
|
|
236
|
+
url: 'https://api.example.com/fal-ai/qwen-image',
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('response metadata', () => {
|
|
241
|
+
it('should include timestamp, headers and modelId in response', async () => {
|
|
242
|
+
const testDate = new Date('2024-01-01T00:00:00Z');
|
|
243
|
+
const model = createBasicModel({
|
|
244
|
+
currentDate: () => testDate,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const result = await model.doGenerate({
|
|
248
|
+
prompt,
|
|
249
|
+
files: undefined,
|
|
250
|
+
mask: undefined,
|
|
251
|
+
n: 1,
|
|
252
|
+
providerOptions: {},
|
|
253
|
+
size: undefined,
|
|
254
|
+
seed: undefined,
|
|
255
|
+
aspectRatio: undefined,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(result.response).toStrictEqual({
|
|
259
|
+
timestamp: testDate,
|
|
260
|
+
modelId: 'fal-ai/qwen-image',
|
|
261
|
+
headers: expect.any(Object),
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('providerMetaData', () => {
|
|
267
|
+
// https://fal.ai/models/fal-ai/lora/api#schema-output
|
|
268
|
+
it('for lora', async () => {
|
|
269
|
+
const responseMetaData = {
|
|
270
|
+
prompt: '<prompt>',
|
|
271
|
+
seed: 123,
|
|
272
|
+
has_nsfw_concepts: [true],
|
|
273
|
+
debug_latents: {
|
|
274
|
+
url: '<debug_latents url>',
|
|
275
|
+
content_type: '<debug_latents content_type>',
|
|
276
|
+
file_name: '<debug_latents file_name>',
|
|
277
|
+
file_data: '<debug_latents file_data>',
|
|
278
|
+
file_size: 123,
|
|
279
|
+
},
|
|
280
|
+
debug_per_pass_latents: {
|
|
281
|
+
url: '<debug_per_pass_latents url>',
|
|
282
|
+
content_type: '<debug_per_pass_latents content_type>',
|
|
283
|
+
file_name: '<debug_per_pass_latents file_name>',
|
|
284
|
+
file_data: '<debug_per_pass_latents file_data>',
|
|
285
|
+
file_size: 456,
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
server.urls['https://api.example.com/fal-ai/qwen-image'].response = {
|
|
289
|
+
type: 'json-value',
|
|
290
|
+
body: {
|
|
291
|
+
images: [
|
|
292
|
+
{
|
|
293
|
+
url: 'https://api.example.com/image.png',
|
|
294
|
+
width: 1024,
|
|
295
|
+
height: 1024,
|
|
296
|
+
content_type: 'image/png',
|
|
297
|
+
file_data: '<image file_data>',
|
|
298
|
+
file_size: 123,
|
|
299
|
+
file_name: '<image file_name>',
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
...responseMetaData,
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
const model = createBasicModel();
|
|
306
|
+
const result = await model.doGenerate({
|
|
307
|
+
prompt,
|
|
308
|
+
files: undefined,
|
|
309
|
+
mask: undefined,
|
|
310
|
+
n: 1,
|
|
311
|
+
providerOptions: {},
|
|
312
|
+
size: undefined,
|
|
313
|
+
seed: undefined,
|
|
314
|
+
aspectRatio: undefined,
|
|
315
|
+
});
|
|
316
|
+
expect(result.providerMetadata).toStrictEqual({
|
|
317
|
+
fal: {
|
|
318
|
+
images: [
|
|
319
|
+
{
|
|
320
|
+
width: 1024,
|
|
321
|
+
height: 1024,
|
|
322
|
+
contentType: 'image/png',
|
|
323
|
+
fileName: '<image file_name>',
|
|
324
|
+
fileData: '<image file_data>',
|
|
325
|
+
fileSize: 123,
|
|
326
|
+
nsfw: true,
|
|
327
|
+
},
|
|
328
|
+
],
|
|
329
|
+
seed: 123,
|
|
330
|
+
debug_latents: {
|
|
331
|
+
url: '<debug_latents url>',
|
|
332
|
+
content_type: '<debug_latents content_type>',
|
|
333
|
+
file_name: '<debug_latents file_name>',
|
|
334
|
+
file_data: '<debug_latents file_data>',
|
|
335
|
+
file_size: 123,
|
|
336
|
+
},
|
|
337
|
+
debug_per_pass_latents: {
|
|
338
|
+
url: '<debug_per_pass_latents url>',
|
|
339
|
+
content_type: '<debug_per_pass_latents content_type>',
|
|
340
|
+
file_name: '<debug_per_pass_latents file_name>',
|
|
341
|
+
file_data: '<debug_per_pass_latents file_data>',
|
|
342
|
+
file_size: 456,
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('for lcm', async () => {
|
|
349
|
+
const responseMetaData = {
|
|
350
|
+
seed: 123,
|
|
351
|
+
num_inference_steps: 456,
|
|
352
|
+
nsfw_content_detected: [false],
|
|
353
|
+
};
|
|
354
|
+
server.urls['https://api.example.com/fal-ai/qwen-image'].response = {
|
|
355
|
+
type: 'json-value',
|
|
356
|
+
body: {
|
|
357
|
+
images: [
|
|
358
|
+
{
|
|
359
|
+
url: 'https://api.example.com/image.png',
|
|
360
|
+
width: 1024,
|
|
361
|
+
height: 1024,
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
...responseMetaData,
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
const model = createBasicModel();
|
|
368
|
+
const result = await model.doGenerate({
|
|
369
|
+
prompt,
|
|
370
|
+
files: undefined,
|
|
371
|
+
mask: undefined,
|
|
372
|
+
n: 1,
|
|
373
|
+
providerOptions: {},
|
|
374
|
+
size: undefined,
|
|
375
|
+
seed: undefined,
|
|
376
|
+
aspectRatio: undefined,
|
|
377
|
+
});
|
|
378
|
+
expect(result.providerMetadata).toStrictEqual({
|
|
379
|
+
fal: {
|
|
380
|
+
images: [
|
|
381
|
+
{
|
|
382
|
+
width: 1024,
|
|
383
|
+
height: 1024,
|
|
384
|
+
nsfw: false,
|
|
385
|
+
},
|
|
386
|
+
],
|
|
387
|
+
seed: 123,
|
|
388
|
+
num_inference_steps: 456,
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe('constructor', () => {
|
|
396
|
+
it('should expose correct provider and model information', () => {
|
|
397
|
+
const model = createBasicModel();
|
|
398
|
+
|
|
399
|
+
expect(model.provider).toBe('fal.image');
|
|
400
|
+
expect(model.modelId).toBe('fal-ai/qwen-image');
|
|
401
|
+
expect(model.specificationVersion).toBe('v3');
|
|
402
|
+
expect(model.maxImagesPerCall).toBe(1);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe('Image Editing', () => {
|
|
407
|
+
it('should send edit request with files as data URI', async () => {
|
|
408
|
+
const imageData = new Uint8Array([137, 80, 78, 71]); // PNG magic bytes
|
|
409
|
+
|
|
410
|
+
await createBasicModel().doGenerate({
|
|
411
|
+
prompt: 'Turn the cat into a dog',
|
|
412
|
+
files: [
|
|
413
|
+
{
|
|
414
|
+
type: 'file',
|
|
415
|
+
data: imageData,
|
|
416
|
+
mediaType: 'image/png',
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
mask: undefined,
|
|
420
|
+
n: 1,
|
|
421
|
+
size: undefined,
|
|
422
|
+
aspectRatio: undefined,
|
|
423
|
+
seed: undefined,
|
|
424
|
+
providerOptions: {},
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const requestBody = await server.calls[0].requestBodyJson;
|
|
428
|
+
expect(requestBody).toMatchInlineSnapshot(`
|
|
429
|
+
{
|
|
430
|
+
"image_url": "data:image/png;base64,iVBORw==",
|
|
431
|
+
"num_images": 1,
|
|
432
|
+
"prompt": "Turn the cat into a dog",
|
|
433
|
+
}
|
|
434
|
+
`);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('should send edit request with files and mask', async () => {
|
|
438
|
+
const imageData = new Uint8Array([137, 80, 78, 71]);
|
|
439
|
+
const maskData = new Uint8Array([255, 255, 255, 0]);
|
|
440
|
+
|
|
441
|
+
await createBasicModel().doGenerate({
|
|
442
|
+
prompt: 'Add a flamingo to the pool',
|
|
443
|
+
files: [
|
|
444
|
+
{
|
|
445
|
+
type: 'file',
|
|
446
|
+
data: imageData,
|
|
447
|
+
mediaType: 'image/png',
|
|
448
|
+
},
|
|
449
|
+
],
|
|
450
|
+
mask: {
|
|
451
|
+
type: 'file',
|
|
452
|
+
data: maskData,
|
|
453
|
+
mediaType: 'image/png',
|
|
454
|
+
},
|
|
455
|
+
n: 1,
|
|
456
|
+
size: undefined,
|
|
457
|
+
aspectRatio: undefined,
|
|
458
|
+
seed: undefined,
|
|
459
|
+
providerOptions: {},
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const requestBody = await server.calls[0].requestBodyJson;
|
|
463
|
+
expect(requestBody).toMatchInlineSnapshot(`
|
|
464
|
+
{
|
|
465
|
+
"image_url": "data:image/png;base64,iVBORw==",
|
|
466
|
+
"mask_url": "data:image/png;base64,////AA==",
|
|
467
|
+
"num_images": 1,
|
|
468
|
+
"prompt": "Add a flamingo to the pool",
|
|
469
|
+
}
|
|
470
|
+
`);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('should send edit request with URL-based file', async () => {
|
|
474
|
+
await createBasicModel().doGenerate({
|
|
475
|
+
prompt: 'Edit this image',
|
|
476
|
+
files: [
|
|
477
|
+
{
|
|
478
|
+
type: 'url',
|
|
479
|
+
url: 'https://example.com/input.png',
|
|
480
|
+
},
|
|
481
|
+
],
|
|
482
|
+
mask: undefined,
|
|
483
|
+
n: 1,
|
|
484
|
+
size: undefined,
|
|
485
|
+
aspectRatio: undefined,
|
|
486
|
+
seed: undefined,
|
|
487
|
+
providerOptions: {},
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const requestBody = await server.calls[0].requestBodyJson;
|
|
491
|
+
expect(requestBody).toMatchInlineSnapshot(`
|
|
492
|
+
{
|
|
493
|
+
"image_url": "https://example.com/input.png",
|
|
494
|
+
"num_images": 1,
|
|
495
|
+
"prompt": "Edit this image",
|
|
496
|
+
}
|
|
497
|
+
`);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('should send edit request with base64 string data', async () => {
|
|
501
|
+
await createBasicModel().doGenerate({
|
|
502
|
+
prompt: 'Edit this image',
|
|
503
|
+
files: [
|
|
504
|
+
{
|
|
505
|
+
type: 'file',
|
|
506
|
+
data: 'iVBORw0KGgoAAAANSUhEUgAAAAE=',
|
|
507
|
+
mediaType: 'image/png',
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
mask: undefined,
|
|
511
|
+
n: 1,
|
|
512
|
+
size: undefined,
|
|
513
|
+
aspectRatio: undefined,
|
|
514
|
+
seed: undefined,
|
|
515
|
+
providerOptions: {},
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const requestBody = await server.calls[0].requestBodyJson;
|
|
519
|
+
expect(requestBody).toMatchInlineSnapshot(`
|
|
520
|
+
{
|
|
521
|
+
"image_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE=",
|
|
522
|
+
"num_images": 1,
|
|
523
|
+
"prompt": "Edit this image",
|
|
524
|
+
}
|
|
525
|
+
`);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('should warn when multiple files are provided', async () => {
|
|
529
|
+
const imageData = new Uint8Array([137, 80, 78, 71]);
|
|
530
|
+
|
|
531
|
+
const result = await createBasicModel().doGenerate({
|
|
532
|
+
prompt: 'Edit images',
|
|
533
|
+
files: [
|
|
534
|
+
{
|
|
535
|
+
type: 'file',
|
|
536
|
+
data: imageData,
|
|
537
|
+
mediaType: 'image/png',
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
type: 'file',
|
|
541
|
+
data: imageData,
|
|
542
|
+
mediaType: 'image/png',
|
|
543
|
+
},
|
|
544
|
+
],
|
|
545
|
+
mask: undefined,
|
|
546
|
+
n: 1,
|
|
547
|
+
size: undefined,
|
|
548
|
+
aspectRatio: undefined,
|
|
549
|
+
seed: undefined,
|
|
550
|
+
providerOptions: {},
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
expect(result.warnings).toHaveLength(1);
|
|
554
|
+
expect(result.warnings[0]).toMatchObject({
|
|
555
|
+
type: 'other',
|
|
556
|
+
message: expect.stringContaining('useMultipleImages is not enabled'),
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('should send image_urls when useMultipleImages is true', async () => {
|
|
561
|
+
const imageData = new Uint8Array([137, 80, 78, 71]);
|
|
562
|
+
|
|
563
|
+
await createBasicModel().doGenerate({
|
|
564
|
+
prompt: 'Edit these images',
|
|
565
|
+
files: [
|
|
566
|
+
{
|
|
567
|
+
type: 'file',
|
|
568
|
+
data: imageData,
|
|
569
|
+
mediaType: 'image/png',
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
type: 'file',
|
|
573
|
+
data: imageData,
|
|
574
|
+
mediaType: 'image/png',
|
|
575
|
+
},
|
|
576
|
+
],
|
|
577
|
+
mask: undefined,
|
|
578
|
+
n: 1,
|
|
579
|
+
size: undefined,
|
|
580
|
+
aspectRatio: undefined,
|
|
581
|
+
seed: undefined,
|
|
582
|
+
providerOptions: {
|
|
583
|
+
fal: {
|
|
584
|
+
useMultipleImages: true,
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
const requestBody = await server.calls[0].requestBodyJson;
|
|
590
|
+
expect(requestBody).toMatchObject({
|
|
591
|
+
image_urls: [
|
|
592
|
+
'data:image/png;base64,iVBORw==',
|
|
593
|
+
'data:image/png;base64,iVBORw==',
|
|
594
|
+
],
|
|
595
|
+
num_images: 1,
|
|
596
|
+
prompt: 'Edit these images',
|
|
597
|
+
});
|
|
598
|
+
expect(requestBody.image_url).toBeUndefined();
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it('should not warn when multiple files provided with useMultipleImages', async () => {
|
|
602
|
+
const imageData = new Uint8Array([137, 80, 78, 71]);
|
|
603
|
+
|
|
604
|
+
const result = await createBasicModel().doGenerate({
|
|
605
|
+
prompt: 'Edit images',
|
|
606
|
+
files: [
|
|
607
|
+
{ type: 'file', data: imageData, mediaType: 'image/png' },
|
|
608
|
+
{ type: 'file', data: imageData, mediaType: 'image/png' },
|
|
609
|
+
],
|
|
610
|
+
mask: undefined,
|
|
611
|
+
n: 1,
|
|
612
|
+
size: undefined,
|
|
613
|
+
aspectRatio: undefined,
|
|
614
|
+
seed: undefined,
|
|
615
|
+
providerOptions: {
|
|
616
|
+
fal: {
|
|
617
|
+
useMultipleImages: true,
|
|
618
|
+
},
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
expect(result.warnings).toHaveLength(0);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('should send single image as image_urls array when useMultipleImages is true', async () => {
|
|
626
|
+
const imageData = new Uint8Array([137, 80, 78, 71]);
|
|
627
|
+
|
|
628
|
+
await createBasicModel().doGenerate({
|
|
629
|
+
prompt: 'Edit this image',
|
|
630
|
+
files: [
|
|
631
|
+
{
|
|
632
|
+
type: 'file',
|
|
633
|
+
data: imageData,
|
|
634
|
+
mediaType: 'image/png',
|
|
635
|
+
},
|
|
636
|
+
],
|
|
637
|
+
mask: undefined,
|
|
638
|
+
n: 1,
|
|
639
|
+
size: undefined,
|
|
640
|
+
aspectRatio: undefined,
|
|
641
|
+
seed: undefined,
|
|
642
|
+
providerOptions: {
|
|
643
|
+
fal: {
|
|
644
|
+
useMultipleImages: true,
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
const requestBody = await server.calls[0].requestBodyJson;
|
|
650
|
+
expect(requestBody).toMatchObject({
|
|
651
|
+
image_urls: ['data:image/png;base64,iVBORw=='],
|
|
652
|
+
num_images: 1,
|
|
653
|
+
prompt: 'Edit this image',
|
|
654
|
+
});
|
|
655
|
+
expect(requestBody.image_url).toBeUndefined();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('should allow imageUrl via provider options', async () => {
|
|
659
|
+
await createBasicModel().doGenerate({
|
|
660
|
+
prompt: 'Edit via provider options',
|
|
661
|
+
files: undefined,
|
|
662
|
+
mask: undefined,
|
|
663
|
+
n: 1,
|
|
664
|
+
size: undefined,
|
|
665
|
+
aspectRatio: undefined,
|
|
666
|
+
seed: undefined,
|
|
667
|
+
providerOptions: {
|
|
668
|
+
fal: {
|
|
669
|
+
imageUrl: 'https://example.com/provider-image.png',
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
const requestBody = await server.calls[0].requestBodyJson;
|
|
675
|
+
expect(requestBody).toMatchInlineSnapshot(`
|
|
676
|
+
{
|
|
677
|
+
"image_url": "https://example.com/provider-image.png",
|
|
678
|
+
"num_images": 1,
|
|
679
|
+
"prompt": "Edit via provider options",
|
|
680
|
+
}
|
|
681
|
+
`);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it('should allow maskUrl via provider options', async () => {
|
|
685
|
+
await createBasicModel().doGenerate({
|
|
686
|
+
prompt: 'Inpaint this',
|
|
687
|
+
files: undefined,
|
|
688
|
+
mask: undefined,
|
|
689
|
+
n: 1,
|
|
690
|
+
size: undefined,
|
|
691
|
+
aspectRatio: undefined,
|
|
692
|
+
seed: undefined,
|
|
693
|
+
providerOptions: {
|
|
694
|
+
fal: {
|
|
695
|
+
imageUrl: 'https://example.com/image.png',
|
|
696
|
+
maskUrl: 'https://example.com/mask.png',
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const requestBody = await server.calls[0].requestBodyJson;
|
|
702
|
+
expect(requestBody).toMatchInlineSnapshot(`
|
|
703
|
+
{
|
|
704
|
+
"image_url": "https://example.com/image.png",
|
|
705
|
+
"mask_url": "https://example.com/mask.png",
|
|
706
|
+
"num_images": 1,
|
|
707
|
+
"prompt": "Inpaint this",
|
|
708
|
+
}
|
|
709
|
+
`);
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
describe('response schema validation', () => {
|
|
714
|
+
it('should parse single image response', async () => {
|
|
715
|
+
server.urls['https://api.example.com/fal-ai/qwen-image'].response = {
|
|
716
|
+
type: 'json-value',
|
|
717
|
+
body: {
|
|
718
|
+
image: {
|
|
719
|
+
url: 'https://api.example.com/image.png',
|
|
720
|
+
width: 1024,
|
|
721
|
+
height: 1024,
|
|
722
|
+
content_type: 'image/png',
|
|
723
|
+
},
|
|
724
|
+
},
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
const model = createBasicModel();
|
|
728
|
+
const result = await model.doGenerate({
|
|
729
|
+
prompt,
|
|
730
|
+
files: undefined,
|
|
731
|
+
mask: undefined,
|
|
732
|
+
n: 1,
|
|
733
|
+
providerOptions: {},
|
|
734
|
+
size: undefined,
|
|
735
|
+
seed: undefined,
|
|
736
|
+
aspectRatio: undefined,
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
expect(result.images).toHaveLength(1);
|
|
740
|
+
expect(result.images[0]).toBeInstanceOf(Uint8Array);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it('should parse multiple images response', async () => {
|
|
744
|
+
server.urls['https://api.example.com/fal-ai/qwen-image'].response = {
|
|
745
|
+
type: 'json-value',
|
|
746
|
+
body: {
|
|
747
|
+
images: [
|
|
748
|
+
{
|
|
749
|
+
url: 'https://api.example.com/image.png',
|
|
750
|
+
width: 1024,
|
|
751
|
+
height: 1024,
|
|
752
|
+
content_type: 'image/png',
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
url: 'https://api.example.com/image.png',
|
|
756
|
+
width: 1024,
|
|
757
|
+
height: 1024,
|
|
758
|
+
content_type: 'image/png',
|
|
759
|
+
},
|
|
760
|
+
],
|
|
761
|
+
},
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
const model = createBasicModel();
|
|
765
|
+
const result = await model.doGenerate({
|
|
766
|
+
prompt,
|
|
767
|
+
files: undefined,
|
|
768
|
+
mask: undefined,
|
|
769
|
+
n: 2,
|
|
770
|
+
providerOptions: {},
|
|
771
|
+
size: undefined,
|
|
772
|
+
seed: undefined,
|
|
773
|
+
aspectRatio: undefined,
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
expect(result.images).toHaveLength(2);
|
|
777
|
+
expect(result.images[0]).toBeInstanceOf(Uint8Array);
|
|
778
|
+
expect(result.images[1]).toBeInstanceOf(Uint8Array);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it('should handle null file_name and file_size values', async () => {
|
|
782
|
+
server.urls['https://api.example.com/fal-ai/qwen-image'].response = {
|
|
783
|
+
type: 'json-value',
|
|
784
|
+
body: {
|
|
785
|
+
images: [
|
|
786
|
+
{
|
|
787
|
+
url: 'https://api.example.com/image.png',
|
|
788
|
+
content_type: 'image/png',
|
|
789
|
+
file_name: null,
|
|
790
|
+
file_size: null,
|
|
791
|
+
width: 944,
|
|
792
|
+
height: 1104,
|
|
793
|
+
},
|
|
794
|
+
],
|
|
795
|
+
timings: { inference: 5.875932216644287 },
|
|
796
|
+
seed: 328395684,
|
|
797
|
+
has_nsfw_concepts: [false],
|
|
798
|
+
prompt:
|
|
799
|
+
'A female model holding this book, keeping the book unchanged.',
|
|
800
|
+
},
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
const model = createBasicModel();
|
|
804
|
+
const result = await model.doGenerate({
|
|
805
|
+
prompt,
|
|
806
|
+
files: undefined,
|
|
807
|
+
mask: undefined,
|
|
808
|
+
n: 1,
|
|
809
|
+
providerOptions: {},
|
|
810
|
+
size: undefined,
|
|
811
|
+
seed: undefined,
|
|
812
|
+
aspectRatio: undefined,
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
expect(result.images).toHaveLength(1);
|
|
816
|
+
expect(result.images[0]).toBeInstanceOf(Uint8Array);
|
|
817
|
+
expect(result.providerMetadata?.fal).toMatchObject({
|
|
818
|
+
images: [
|
|
819
|
+
{
|
|
820
|
+
width: 944,
|
|
821
|
+
height: 1104,
|
|
822
|
+
contentType: 'image/png',
|
|
823
|
+
fileName: null,
|
|
824
|
+
fileSize: null,
|
|
825
|
+
nsfw: false,
|
|
826
|
+
},
|
|
827
|
+
],
|
|
828
|
+
timings: { inference: 5.875932216644287 },
|
|
829
|
+
seed: 328395684,
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('should handle empty timings object', async () => {
|
|
834
|
+
server.urls['https://api.example.com/fal-ai/qwen-image'].response = {
|
|
835
|
+
type: 'json-value',
|
|
836
|
+
body: {
|
|
837
|
+
images: [
|
|
838
|
+
{
|
|
839
|
+
url: 'https://api.example.com/image.png',
|
|
840
|
+
content_type: 'image/png',
|
|
841
|
+
file_name: null,
|
|
842
|
+
file_size: null,
|
|
843
|
+
width: 880,
|
|
844
|
+
height: 1184,
|
|
845
|
+
},
|
|
846
|
+
],
|
|
847
|
+
timings: {},
|
|
848
|
+
seed: 235205040,
|
|
849
|
+
has_nsfw_concepts: [false],
|
|
850
|
+
prompt: 'Change the plates to colorful ones',
|
|
851
|
+
},
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
const model = createBasicModel();
|
|
855
|
+
const result = await model.doGenerate({
|
|
856
|
+
prompt,
|
|
857
|
+
files: undefined,
|
|
858
|
+
mask: undefined,
|
|
859
|
+
n: 1,
|
|
860
|
+
providerOptions: {},
|
|
861
|
+
size: undefined,
|
|
862
|
+
seed: undefined,
|
|
863
|
+
aspectRatio: undefined,
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
expect(result.images).toHaveLength(1);
|
|
867
|
+
expect(result.images[0]).toBeInstanceOf(Uint8Array);
|
|
868
|
+
expect(result.providerMetadata?.fal).toMatchObject({
|
|
869
|
+
images: [
|
|
870
|
+
{
|
|
871
|
+
width: 880,
|
|
872
|
+
height: 1184,
|
|
873
|
+
contentType: 'image/png',
|
|
874
|
+
fileName: null,
|
|
875
|
+
fileSize: null,
|
|
876
|
+
nsfw: false,
|
|
877
|
+
},
|
|
878
|
+
],
|
|
879
|
+
timings: {},
|
|
880
|
+
seed: 235205040,
|
|
881
|
+
});
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it('should handle null width and height values with images array only', async () => {
|
|
885
|
+
server.urls['https://api.example.com/fal-ai/qwen-image'].response = {
|
|
886
|
+
type: 'json-value',
|
|
887
|
+
body: {
|
|
888
|
+
images: [
|
|
889
|
+
{
|
|
890
|
+
url: 'https://api.example.com/image.png',
|
|
891
|
+
content_type: 'image/png',
|
|
892
|
+
file_name: 'output.png',
|
|
893
|
+
file_size: 663399,
|
|
894
|
+
width: null,
|
|
895
|
+
height: null,
|
|
896
|
+
},
|
|
897
|
+
],
|
|
898
|
+
description: 'here is an image with null width and height',
|
|
899
|
+
},
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
const model = createBasicModel();
|
|
903
|
+
const result = await model.doGenerate({
|
|
904
|
+
prompt,
|
|
905
|
+
files: undefined,
|
|
906
|
+
mask: undefined,
|
|
907
|
+
n: 1,
|
|
908
|
+
providerOptions: {},
|
|
909
|
+
size: undefined,
|
|
910
|
+
seed: undefined,
|
|
911
|
+
aspectRatio: undefined,
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
expect(result.images).toHaveLength(1);
|
|
915
|
+
expect(result.images[0]).toBeInstanceOf(Uint8Array);
|
|
916
|
+
expect(result.providerMetadata?.fal).toMatchObject({
|
|
917
|
+
images: [
|
|
918
|
+
{
|
|
919
|
+
width: null,
|
|
920
|
+
height: null,
|
|
921
|
+
contentType: 'image/png',
|
|
922
|
+
fileName: 'output.png',
|
|
923
|
+
fileSize: 663399,
|
|
924
|
+
},
|
|
925
|
+
],
|
|
926
|
+
description: 'here is an image with null width and height',
|
|
927
|
+
});
|
|
928
|
+
});
|
|
929
|
+
});
|
|
930
|
+
});
|