@ai-sdk/fal 2.0.9 → 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.
@@ -0,0 +1,367 @@
1
+ import type { ImageModelV3, SharedV3Warning } from '@ai-sdk/provider';
2
+ import type { Resolvable } from '@ai-sdk/provider-utils';
3
+ import {
4
+ combineHeaders,
5
+ convertImageModelFileToDataUri,
6
+ createBinaryResponseHandler,
7
+ createJsonErrorResponseHandler,
8
+ createJsonResponseHandler,
9
+ createStatusCodeErrorResponseHandler,
10
+ FetchFunction,
11
+ getFromApi,
12
+ parseProviderOptions,
13
+ postJsonToApi,
14
+ resolve,
15
+ } from '@ai-sdk/provider-utils';
16
+ import { z } from 'zod/v4';
17
+ import { FalImageModelId, FalImageSize } from './fal-image-settings';
18
+ import { falImageProviderOptionsSchema } from './fal-image-options';
19
+
20
+ interface FalImageModelConfig {
21
+ provider: string;
22
+ baseURL: string;
23
+ headers?: Resolvable<Record<string, string | undefined>>;
24
+ fetch?: FetchFunction;
25
+ _internal?: {
26
+ currentDate?: () => Date;
27
+ };
28
+ }
29
+
30
+ export class FalImageModel implements ImageModelV3 {
31
+ readonly specificationVersion = 'v3';
32
+ readonly maxImagesPerCall = 1;
33
+
34
+ get provider(): string {
35
+ return this.config.provider;
36
+ }
37
+
38
+ constructor(
39
+ readonly modelId: FalImageModelId,
40
+ private readonly config: FalImageModelConfig,
41
+ ) {}
42
+
43
+ private async getArgs({
44
+ prompt,
45
+ n,
46
+ size,
47
+ aspectRatio,
48
+ seed,
49
+ providerOptions,
50
+ files,
51
+ mask,
52
+ }: Parameters<ImageModelV3['doGenerate']>[0]) {
53
+ const warnings: Array<SharedV3Warning> = [];
54
+
55
+ let imageSize: FalImageSize | undefined;
56
+ if (size) {
57
+ const [width, height] = size.split('x').map(Number);
58
+ imageSize = { width, height };
59
+ } else if (aspectRatio) {
60
+ imageSize = convertAspectRatioToSize(aspectRatio);
61
+ }
62
+
63
+ const falOptions = await parseProviderOptions({
64
+ provider: 'fal',
65
+ providerOptions,
66
+ schema: falImageProviderOptionsSchema,
67
+ });
68
+
69
+ const requestBody: Record<string, unknown> = {
70
+ prompt,
71
+ seed,
72
+ image_size: imageSize,
73
+ num_images: n,
74
+ };
75
+
76
+ // Handle image editing: convert files to image_url or image_urls
77
+ if (files != null && files.length > 0) {
78
+ const useMultipleImages = falOptions?.useMultipleImages === true;
79
+
80
+ if (useMultipleImages) {
81
+ // Use image_urls array for models that support multiple images (e.g., flux-2/edit)
82
+ requestBody.image_urls = files.map(file =>
83
+ convertImageModelFileToDataUri(file),
84
+ );
85
+ } else {
86
+ // Use single image_url for standard image editing models
87
+ requestBody.image_url = convertImageModelFileToDataUri(files[0]);
88
+
89
+ if (files.length > 1) {
90
+ warnings.push({
91
+ type: 'other',
92
+ message:
93
+ 'Multiple input images provided but useMultipleImages is not enabled. ' +
94
+ 'Only the first image will be used. Set providerOptions.fal.useMultipleImages ' +
95
+ 'to true for models that support multiple images (e.g., fal-ai/flux-2/edit).',
96
+ });
97
+ }
98
+ }
99
+ }
100
+
101
+ // Handle mask for inpainting
102
+ if (mask != null) {
103
+ requestBody.mask_url = convertImageModelFileToDataUri(mask);
104
+ }
105
+
106
+ if (falOptions) {
107
+ const deprecatedKeys =
108
+ '__deprecatedKeys' in falOptions
109
+ ? (falOptions.__deprecatedKeys as string[])
110
+ : undefined;
111
+
112
+ if (deprecatedKeys && deprecatedKeys.length > 0) {
113
+ warnings.push({
114
+ type: 'other',
115
+ message: `The following provider options use deprecated snake_case and will be removed in @ai-sdk/fal v2.0. Please use camelCase instead: ${deprecatedKeys
116
+ .map(key => {
117
+ const camelCase = key.replace(/_([a-z])/g, (_, letter) =>
118
+ letter.toUpperCase(),
119
+ );
120
+ return `'${key}' (use '${camelCase}')`;
121
+ })
122
+ .join(', ')}`,
123
+ });
124
+ }
125
+
126
+ const fieldMapping: Record<string, string> = {
127
+ imageUrl: 'image_url',
128
+ maskUrl: 'mask_url',
129
+ guidanceScale: 'guidance_scale',
130
+ numInferenceSteps: 'num_inference_steps',
131
+ enableSafetyChecker: 'enable_safety_checker',
132
+ outputFormat: 'output_format',
133
+ syncMode: 'sync_mode',
134
+ safetyTolerance: 'safety_tolerance',
135
+ };
136
+
137
+ for (const [key, value] of Object.entries(falOptions)) {
138
+ if (key === '__deprecatedKeys') continue;
139
+ if (key === 'useMultipleImages') continue; // Don't send to API
140
+ const apiKey = fieldMapping[key] ?? key;
141
+
142
+ if (value !== undefined) {
143
+ requestBody[apiKey] = value;
144
+ }
145
+ }
146
+ }
147
+
148
+ return { requestBody, warnings } as const;
149
+ }
150
+
151
+ async doGenerate(
152
+ options: Parameters<ImageModelV3['doGenerate']>[0],
153
+ ): Promise<Awaited<ReturnType<ImageModelV3['doGenerate']>>> {
154
+ const { requestBody, warnings } = await this.getArgs(options);
155
+
156
+ const currentDate = this.config._internal?.currentDate?.() ?? new Date();
157
+ const { value, responseHeaders } = await postJsonToApi({
158
+ url: `${this.config.baseURL}/${this.modelId}`,
159
+ headers: combineHeaders(
160
+ await resolve(this.config.headers),
161
+ options.headers,
162
+ ),
163
+ body: requestBody,
164
+ failedResponseHandler: falFailedResponseHandler,
165
+ successfulResponseHandler: createJsonResponseHandler(
166
+ falImageResponseSchema,
167
+ ),
168
+ abortSignal: options.abortSignal,
169
+ fetch: this.config.fetch,
170
+ });
171
+
172
+ const {
173
+ images: targetImages,
174
+ // prompt is just passed through and not a revised prompt per image
175
+ prompt: _prompt,
176
+ // NSFW information is normalized merged into `providerMetadata.fal.images`
177
+ has_nsfw_concepts,
178
+ nsfw_content_detected,
179
+ // pass through other properties to providerMetadata
180
+ ...responseMetaData
181
+ } = value;
182
+
183
+ // download the images:
184
+ const downloadedImages = await Promise.all(
185
+ targetImages.map(image =>
186
+ this.downloadImage(image.url, options.abortSignal),
187
+ ),
188
+ );
189
+
190
+ return {
191
+ images: downloadedImages,
192
+ warnings,
193
+ response: {
194
+ modelId: this.modelId,
195
+ timestamp: currentDate,
196
+ headers: responseHeaders,
197
+ },
198
+ providerMetadata: {
199
+ fal: {
200
+ images: targetImages.map((image, index) => {
201
+ const {
202
+ url,
203
+ content_type: contentType,
204
+ file_name: fileName,
205
+ file_data: fileData,
206
+ file_size: fileSize,
207
+ ...imageMetaData
208
+ } = image;
209
+
210
+ const nsfw =
211
+ has_nsfw_concepts?.[index] ?? nsfw_content_detected?.[index];
212
+
213
+ return {
214
+ ...imageMetaData,
215
+ ...removeOnlyUndefined({
216
+ contentType,
217
+ fileName,
218
+ fileData,
219
+ fileSize,
220
+ nsfw,
221
+ }),
222
+ };
223
+ }),
224
+ ...responseMetaData,
225
+ },
226
+ },
227
+ };
228
+ }
229
+
230
+ private async downloadImage(
231
+ url: string,
232
+ abortSignal: AbortSignal | undefined,
233
+ ): Promise<Uint8Array> {
234
+ const { value: response } = await getFromApi({
235
+ url,
236
+ // No specific headers should be needed for this request as it's a
237
+ // generated image provided by fal.ai.
238
+ abortSignal,
239
+ failedResponseHandler: createStatusCodeErrorResponseHandler(),
240
+ successfulResponseHandler: createBinaryResponseHandler(),
241
+ fetch: this.config.fetch,
242
+ });
243
+ return response;
244
+ }
245
+ }
246
+
247
+ function removeOnlyUndefined<T extends Record<string, unknown>>(obj: T) {
248
+ return Object.fromEntries(
249
+ Object.entries(obj).filter(([, v]) => v !== undefined),
250
+ ) as Partial<T>;
251
+ }
252
+
253
+ /**
254
+ Converts an aspect ratio to an image size compatible with fal.ai APIs.
255
+ @param aspectRatio - The aspect ratio to convert.
256
+ @returns The image size.
257
+ */
258
+ function convertAspectRatioToSize(
259
+ aspectRatio: `${number}:${number}`,
260
+ ): FalImageSize | undefined {
261
+ switch (aspectRatio) {
262
+ case '1:1':
263
+ return 'square_hd';
264
+ case '16:9':
265
+ return 'landscape_16_9';
266
+ case '9:16':
267
+ return 'portrait_16_9';
268
+ case '4:3':
269
+ return 'landscape_4_3';
270
+ case '3:4':
271
+ return 'portrait_4_3';
272
+ case '16:10':
273
+ return { width: 1280, height: 800 };
274
+ case '10:16':
275
+ return { width: 800, height: 1280 };
276
+ case '21:9':
277
+ return { width: 2560, height: 1080 };
278
+ case '9:21':
279
+ return { width: 1080, height: 2560 };
280
+ }
281
+ return undefined;
282
+ }
283
+
284
+ // Validation error has a particular payload to inform the exact property that is invalid
285
+ const falValidationErrorSchema = z.object({
286
+ detail: z.array(
287
+ z.object({
288
+ loc: z.array(z.string()),
289
+ msg: z.string(),
290
+ type: z.string(),
291
+ }),
292
+ ),
293
+ });
294
+
295
+ type ValidationError = z.infer<typeof falValidationErrorSchema>;
296
+
297
+ // Other errors have a message property
298
+ const falHttpErrorSchema = z.object({
299
+ message: z.string(),
300
+ });
301
+
302
+ const falErrorSchema = z.union([falValidationErrorSchema, falHttpErrorSchema]);
303
+
304
+ const falImageSchema = z.object({
305
+ url: z.string(),
306
+ width: z.number().nullish(),
307
+ height: z.number().nullish(),
308
+ // e.g. https://fal.ai/models/fal-ai/fashn/tryon/v1.6/api#schema-output
309
+ content_type: z.string().nullish(),
310
+ // e.g. https://fal.ai/models/fal-ai/flowedit/api#schema-output
311
+ file_name: z.string().nullish(),
312
+ file_data: z.string().optional(),
313
+ file_size: z.number().nullish(),
314
+ });
315
+
316
+ // https://fal.ai/models/fal-ai/lora/api#type-File
317
+ const loraFileSchema = z.object({
318
+ url: z.string(),
319
+ content_type: z.string().optional(),
320
+ file_name: z.string().nullable().optional(),
321
+ file_data: z.string().optional(),
322
+ file_size: z.number().nullable().optional(),
323
+ });
324
+
325
+ const commonResponseSchema = z.object({
326
+ timings: z
327
+ .object({
328
+ inference: z.number().optional(),
329
+ })
330
+ .optional(),
331
+ seed: z.number().optional(),
332
+ has_nsfw_concepts: z.array(z.boolean()).optional(),
333
+ prompt: z.string().optional(),
334
+ // https://fal.ai/models/fal-ai/lcm/api#schema-output
335
+ nsfw_content_detected: z.array(z.boolean()).optional(),
336
+ num_inference_steps: z.number().optional(),
337
+ // https://fal.ai/models/fal-ai/lora/api#schema-output
338
+ debug_latents: loraFileSchema.optional(),
339
+ debug_per_pass_latents: loraFileSchema.optional(),
340
+ });
341
+
342
+ // Most FAL image models respond with an array of images, but some have a response
343
+ // with a single image, e.g. https://fal.ai/models/easel-ai/easel-avatar/api#schema-output
344
+ const base = z.looseObject(commonResponseSchema.shape);
345
+ const falImageResponseSchema = z
346
+ .union([
347
+ base.extend({ images: z.array(falImageSchema) }),
348
+ base.extend({ image: falImageSchema }),
349
+ ])
350
+ .transform(v => ('images' in v ? v : { ...v, images: [v.image] }))
351
+ .pipe(base.extend({ images: z.array(falImageSchema) }));
352
+
353
+ function isValidationError(error: unknown): error is ValidationError {
354
+ return falValidationErrorSchema.safeParse(error).success;
355
+ }
356
+
357
+ const falFailedResponseHandler = createJsonErrorResponseHandler({
358
+ errorSchema: falErrorSchema,
359
+ errorToMessage: error => {
360
+ if (isValidationError(error)) {
361
+ return error.detail
362
+ .map(detail => `${detail.loc.join('.')}: ${detail.msg}`)
363
+ .join('\n');
364
+ }
365
+ return error.message ?? 'Unknown fal error';
366
+ },
367
+ });
@@ -0,0 +1,129 @@
1
+ import { InferSchema, lazySchema, zodSchema } from '@ai-sdk/provider-utils';
2
+ import { z } from 'zod/v4';
3
+
4
+ export const falImageProviderOptionsSchema = lazySchema(() =>
5
+ zodSchema(
6
+ z
7
+ .object({
8
+ /** @deprecated use prompt.images instead */
9
+ imageUrl: z.string().nullish().meta({
10
+ deprecated: true,
11
+ description: 'Use `prompt.images` instead',
12
+ }),
13
+ maskUrl: z
14
+ .string()
15
+ .nullish()
16
+ .meta({ deprecated: true, description: 'Use `prompt.mask` instead' }),
17
+ guidanceScale: z.number().min(1).max(20).nullish(),
18
+ numInferenceSteps: z.number().min(1).max(50).nullish(),
19
+ enableSafetyChecker: z.boolean().nullish(),
20
+ outputFormat: z.enum(['jpeg', 'png']).nullish(),
21
+ syncMode: z.boolean().nullish(),
22
+ strength: z.number().nullish(),
23
+ acceleration: z.enum(['none', 'regular', 'high']).nullish(),
24
+ safetyTolerance: z
25
+ .enum(['1', '2', '3', '4', '5', '6'])
26
+ .or(z.number().min(1).max(6))
27
+ .nullish(),
28
+ /**
29
+ * When true, converts multiple input images to `image_urls` array instead of `image_url` string.
30
+ */
31
+ useMultipleImages: z.boolean().nullish(),
32
+
33
+ // Deprecated snake_case versions
34
+ image_url: z.string().nullish(),
35
+ mask_url: z.string().nullish(),
36
+ guidance_scale: z.number().min(1).max(20).nullish(),
37
+ num_inference_steps: z.number().min(1).max(50).nullish(),
38
+ enable_safety_checker: z.boolean().nullish(),
39
+ output_format: z.enum(['jpeg', 'png']).nullish(),
40
+ sync_mode: z.boolean().nullish(),
41
+ safety_tolerance: z
42
+ .enum(['1', '2', '3', '4', '5', '6'])
43
+ .or(z.number().min(1).max(6))
44
+ .nullish(),
45
+ })
46
+ .passthrough()
47
+ .transform(data => {
48
+ const result: Record<string, unknown> = {};
49
+ const deprecatedKeys: string[] = [];
50
+
51
+ const mapKey = (snakeKey: string, camelKey: string) => {
52
+ const snakeValue = data[snakeKey as keyof typeof data];
53
+ const camelValue = data[camelKey as keyof typeof data];
54
+
55
+ // If snake_case is used, mark it as deprecated
56
+ if (snakeValue !== undefined && snakeValue !== null) {
57
+ deprecatedKeys.push(snakeKey);
58
+ result[camelKey] = snakeValue;
59
+ } else if (camelValue !== undefined && camelValue !== null) {
60
+ result[camelKey] = camelValue;
61
+ }
62
+ };
63
+
64
+ // Map all known parameters
65
+ mapKey('image_url', 'imageUrl');
66
+ mapKey('mask_url', 'maskUrl');
67
+ mapKey('guidance_scale', 'guidanceScale');
68
+ mapKey('num_inference_steps', 'numInferenceSteps');
69
+ mapKey('enable_safety_checker', 'enableSafetyChecker');
70
+ mapKey('output_format', 'outputFormat');
71
+ mapKey('sync_mode', 'syncMode');
72
+ mapKey('safety_tolerance', 'safetyTolerance');
73
+
74
+ // These don't have snake_case equivalents
75
+ if (data.strength !== undefined && data.strength !== null) {
76
+ result.strength = data.strength;
77
+ }
78
+ if (data.acceleration !== undefined && data.acceleration !== null) {
79
+ result.acceleration = data.acceleration;
80
+ }
81
+ if (
82
+ data.useMultipleImages !== undefined &&
83
+ data.useMultipleImages !== null
84
+ ) {
85
+ result.useMultipleImages = data.useMultipleImages;
86
+ }
87
+
88
+ for (const [key, value] of Object.entries(data)) {
89
+ if (
90
+ ![
91
+ // camelCase known keys
92
+ 'imageUrl',
93
+ 'maskUrl',
94
+ 'guidanceScale',
95
+ 'numInferenceSteps',
96
+ 'enableSafetyChecker',
97
+ 'outputFormat',
98
+ 'syncMode',
99
+ 'strength',
100
+ 'acceleration',
101
+ 'safetyTolerance',
102
+ 'useMultipleImages',
103
+ // snake_case known keys
104
+ 'image_url',
105
+ 'mask_url',
106
+ 'guidance_scale',
107
+ 'num_inference_steps',
108
+ 'enable_safety_checker',
109
+ 'output_format',
110
+ 'sync_mode',
111
+ 'safety_tolerance',
112
+ ].includes(key)
113
+ ) {
114
+ result[key] = value;
115
+ }
116
+ }
117
+
118
+ if (deprecatedKeys.length > 0) {
119
+ (result as any).__deprecatedKeys = deprecatedKeys;
120
+ }
121
+
122
+ return result;
123
+ }),
124
+ ),
125
+ );
126
+
127
+ export type FalImageProviderOptions = InferSchema<
128
+ typeof falImageProviderOptionsSchema
129
+ >;
@@ -0,0 +1,71 @@
1
+ // https://fal.ai/explore/search?categories=image-to-image,text-to-image&q=newest
2
+ export type FalImageModelId =
3
+ | 'fal-ai/aura-sr'
4
+ | 'fal-ai/bria/background/remove'
5
+ | 'fal-ai/bria/eraser'
6
+ | 'fal-ai/bria/product-shot'
7
+ | 'fal-ai/bria/reimagine'
8
+ | 'bria/text-to-image/3.2'
9
+ | 'fal-ai/bria/text-to-image/base'
10
+ | 'fal-ai/bria/text-to-image/fast'
11
+ | 'fal-ai/bria/text-to-image/hd'
12
+ | 'fal-ai/bytedance/dreamina/v3.1/text-to-image'
13
+ | 'fal-ai/ccsr'
14
+ | 'fal-ai/clarity-upscaler'
15
+ | 'fal-ai/creative-upscaler'
16
+ | 'fal-ai/esrgan'
17
+ | 'fal-ai/flux-general'
18
+ | 'fal-ai/flux-general/differential-diffusion'
19
+ | 'fal-ai/flux-general/image-to-image'
20
+ | 'fal-ai/flux-general/inpainting'
21
+ | 'fal-ai/flux-general/rf-inversion'
22
+ | 'fal-ai/flux-kontext-lora/text-to-image'
23
+ | 'fal-ai/flux-lora'
24
+ | 'fal-ai/flux-lora/image-to-image'
25
+ | 'fal-ai/flux-lora/inpainting'
26
+ | 'fal-ai/flux-pro/kontext'
27
+ | 'fal-ai/flux-pro/kontext/max'
28
+ | 'fal-ai/flux-pro/v1.1'
29
+ | 'fal-ai/flux-pro/v1.1-ultra'
30
+ | 'fal-ai/flux-pro/v1.1-ultra-finetuned'
31
+ | 'fal-ai/flux-pro/v1.1-ultra/redux'
32
+ | 'fal-ai/flux-pro/v1.1/redux'
33
+ | 'fal-ai/flux/dev'
34
+ | 'fal-ai/flux/dev/image-to-image'
35
+ | 'fal-ai/flux/dev/redux'
36
+ | 'fal-ai/flux/krea'
37
+ | 'fal-ai/flux/krea/image-to-image'
38
+ | 'fal-ai/flux/krea/redux'
39
+ | 'fal-ai/flux/schnell'
40
+ | 'fal-ai/flux/schnell/redux'
41
+ | 'fal-ai/ideogram/character'
42
+ | 'fal-ai/ideogram/character/edit'
43
+ | 'fal-ai/ideogram/character/remix'
44
+ | 'fal-ai/imagen4/preview'
45
+ | 'fal-ai/luma-photon'
46
+ | 'fal-ai/luma-photon/flash'
47
+ | 'fal-ai/object-removal'
48
+ | 'fal-ai/omnigen-v2'
49
+ | 'fal-ai/qwen-image'
50
+ | 'fal-ai/recraft/v3/text-to-image'
51
+ | 'fal-ai/recraft/v3/image-to-image'
52
+ | 'fal-ai/sana/sprint'
53
+ | 'fal-ai/sana/v1.5/4.8b'
54
+ | 'fal-ai/sana/v1.5/1.6b'
55
+ | 'fal-ai/sky-raccoon'
56
+ | 'fal-ai/wan/v2.2-5b/text-to-image'
57
+ | 'fal-ai/wan/v2.2-a14b/text-to-image'
58
+ | 'fal-ai/fashn/tryon/v1.6'
59
+ | (string & {});
60
+
61
+ export type FalImageSize =
62
+ | 'square'
63
+ | 'square_hd'
64
+ | 'landscape_16_9'
65
+ | 'landscape_4_3'
66
+ | 'portrait_16_9'
67
+ | 'portrait_4_3'
68
+ | {
69
+ width: number;
70
+ height: number;
71
+ };
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { createFal } from './fal-provider';
3
+ import { FalImageModel } from './fal-image-model';
4
+
5
+ vi.mock('./fal-image-model', () => ({
6
+ FalImageModel: vi.fn(),
7
+ }));
8
+
9
+ describe('createFal', () => {
10
+ beforeEach(() => {
11
+ vi.clearAllMocks();
12
+ });
13
+
14
+ describe('image', () => {
15
+ it('should construct an image model with default configuration', () => {
16
+ const provider = createFal();
17
+ const modelId = 'fal-ai/flux/dev';
18
+
19
+ const model = provider.image(modelId);
20
+
21
+ expect(model).toBeInstanceOf(FalImageModel);
22
+ expect(FalImageModel).toHaveBeenCalledWith(
23
+ modelId,
24
+ expect.objectContaining({
25
+ provider: 'fal.image',
26
+ baseURL: 'https://fal.run',
27
+ }),
28
+ );
29
+ });
30
+
31
+ it('should respect custom configuration options', () => {
32
+ const customBaseURL = 'https://custom.fal.run';
33
+ const customHeaders = { 'X-Custom-Header': 'value' };
34
+ const mockFetch = vi.fn();
35
+
36
+ const provider = createFal({
37
+ apiKey: 'custom-api-key',
38
+ baseURL: customBaseURL,
39
+ headers: customHeaders,
40
+ fetch: mockFetch,
41
+ });
42
+ const modelId = 'fal-ai/flux/dev';
43
+
44
+ provider.image(modelId);
45
+
46
+ expect(FalImageModel).toHaveBeenCalledWith(
47
+ modelId,
48
+ expect.objectContaining({
49
+ baseURL: customBaseURL,
50
+ headers: expect.any(Function),
51
+ fetch: mockFetch,
52
+ provider: 'fal.image',
53
+ }),
54
+ );
55
+ });
56
+ });
57
+ });