@ai-sdk/black-forest-labs 1.0.8 → 1.0.9

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @ai-sdk/black-forest-labs
2
2
 
3
+ ## 1.0.9
4
+
5
+ ### Patch Changes
6
+
7
+ - 8dc54db: chore: add src folders to package bundle
8
+
3
9
  ## 1.0.8
4
10
 
5
11
  ### Patch Changes
package/dist/index.js CHANGED
@@ -392,7 +392,7 @@ function bflErrorToMessage(error) {
392
392
  }
393
393
 
394
394
  // src/version.ts
395
- var VERSION = true ? "1.0.8" : "0.0.0-test";
395
+ var VERSION = true ? "1.0.9" : "0.0.0-test";
396
396
 
397
397
  // src/black-forest-labs-provider.ts
398
398
  var defaultBaseURL = "https://api.bfl.ai/v1";
package/dist/index.mjs CHANGED
@@ -381,7 +381,7 @@ function bflErrorToMessage(error) {
381
381
  }
382
382
 
383
383
  // src/version.ts
384
- var VERSION = true ? "1.0.8" : "0.0.0-test";
384
+ var VERSION = true ? "1.0.9" : "0.0.0-test";
385
385
 
386
386
  // src/black-forest-labs-provider.ts
387
387
  var defaultBaseURL = "https://api.bfl.ai/v1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/black-forest-labs",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "license": "Apache-2.0",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -8,6 +8,7 @@
8
8
  "types": "./dist/index.d.ts",
9
9
  "files": [
10
10
  "dist/**/*",
11
+ "src",
11
12
  "CHANGELOG.md"
12
13
  ],
13
14
  "exports": {
@@ -27,7 +28,7 @@
27
28
  "tsup": "^8",
28
29
  "typescript": "5.8.3",
29
30
  "zod": "3.25.76",
30
- "@ai-sdk/test-server": "1.0.1",
31
+ "@ai-sdk/test-server": "1.0.2",
31
32
  "@vercel/ai-tsconfig": "0.0.0"
32
33
  },
33
34
  "peerDependencies": {
@@ -0,0 +1,576 @@
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 { BlackForestLabsImageModel } from './black-forest-labs-image-model';
5
+
6
+ const prompt = 'A cute baby sea otter';
7
+
8
+ function createBasicModel({
9
+ headers,
10
+ fetch,
11
+ currentDate,
12
+ pollIntervalMillis,
13
+ pollTimeoutMillis,
14
+ }: {
15
+ headers?: () => Record<string, string | undefined>;
16
+ fetch?: FetchFunction;
17
+ currentDate?: () => Date;
18
+ pollIntervalMillis?: number;
19
+ pollTimeoutMillis?: number;
20
+ } = {}) {
21
+ return new BlackForestLabsImageModel('test-model', {
22
+ provider: 'black-forest-labs.image',
23
+ baseURL: 'https://api.example.com/v1',
24
+ headers: headers ?? (() => ({ 'x-key': 'test-key' })),
25
+ fetch,
26
+ pollIntervalMillis,
27
+ pollTimeoutMillis,
28
+ _internal: {
29
+ currentDate,
30
+ },
31
+ });
32
+ }
33
+
34
+ describe('BlackForestLabsImageModel', () => {
35
+ const server = createTestServer({
36
+ 'https://api.example.com/v1/test-model': {
37
+ response: {
38
+ type: 'json-value',
39
+ body: {
40
+ id: 'req-123',
41
+ polling_url: 'https://api.example.com/poll',
42
+ },
43
+ },
44
+ },
45
+ 'https://api.example.com/poll': {
46
+ response: {
47
+ type: 'json-value',
48
+ body: {
49
+ status: 'Ready',
50
+ result: {
51
+ sample: 'https://api.example.com/image.png',
52
+ },
53
+ },
54
+ },
55
+ },
56
+ 'https://api.example.com/image.png': {
57
+ response: {
58
+ type: 'binary',
59
+ body: Buffer.from('test-binary-content'),
60
+ },
61
+ },
62
+ });
63
+
64
+ describe('doGenerate', () => {
65
+ it('passes the correct parameters including aspect ratio and providerOptions', async () => {
66
+ const model = createBasicModel();
67
+
68
+ await model.doGenerate({
69
+ prompt,
70
+ files: undefined,
71
+ mask: undefined,
72
+ n: 1,
73
+ size: undefined,
74
+ aspectRatio: '16:9',
75
+ seed: undefined,
76
+ providerOptions: {
77
+ blackForestLabs: {
78
+ promptUpsampling: true,
79
+ unsupportedProperty: 'value',
80
+ },
81
+ },
82
+ });
83
+
84
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
85
+ prompt,
86
+ aspect_ratio: '16:9',
87
+ prompt_upsampling: true,
88
+ });
89
+ });
90
+
91
+ it('includes seed in providerMetadata images when provided by API', async () => {
92
+ server.urls['https://api.example.com/poll'].response = {
93
+ type: 'json-value',
94
+ body: {
95
+ status: 'Ready',
96
+ result: {
97
+ sample: 'https://api.example.com/image.png',
98
+ seed: 12345,
99
+ },
100
+ },
101
+ };
102
+
103
+ const model = createBasicModel();
104
+ const result = await model.doGenerate({
105
+ prompt,
106
+ files: undefined,
107
+ mask: undefined,
108
+ n: 1,
109
+ size: undefined,
110
+ seed: undefined,
111
+ aspectRatio: '1:1',
112
+ providerOptions: {},
113
+ });
114
+
115
+ expect(result.providerMetadata?.blackForestLabs.images[0]).toMatchObject({
116
+ seed: 12345,
117
+ });
118
+ });
119
+
120
+ it('includes all cost and megapixel fields when provided by submit API', async () => {
121
+ server.urls['https://api.example.com/v1/test-model'].response = {
122
+ type: 'json-value',
123
+ body: {
124
+ id: 'req-123',
125
+ polling_url: 'https://api.example.com/poll',
126
+ cost: 0.08,
127
+ input_mp: 1.5,
128
+ output_mp: 2.0,
129
+ },
130
+ };
131
+
132
+ const model = createBasicModel();
133
+ const result = await model.doGenerate({
134
+ prompt,
135
+ files: undefined,
136
+ mask: undefined,
137
+ n: 1,
138
+ size: undefined,
139
+ seed: undefined,
140
+ aspectRatio: '1:1',
141
+ providerOptions: {},
142
+ });
143
+
144
+ expect(result.providerMetadata?.blackForestLabs.images[0]).toMatchObject({
145
+ cost: 0.08,
146
+ inputMegapixels: 1.5,
147
+ outputMegapixels: 2.0,
148
+ });
149
+ });
150
+
151
+ it('omits cost and megapixel fields from providerMetadata when not provided by submit API', async () => {
152
+ server.urls['https://api.example.com/v1/test-model'].response = {
153
+ type: 'json-value',
154
+ body: {
155
+ id: 'req-123',
156
+ polling_url: 'https://api.example.com/poll',
157
+ },
158
+ };
159
+
160
+ const model = createBasicModel();
161
+ const result = await model.doGenerate({
162
+ prompt,
163
+ files: undefined,
164
+ mask: undefined,
165
+ n: 1,
166
+ size: undefined,
167
+ seed: undefined,
168
+ aspectRatio: '1:1',
169
+ providerOptions: {},
170
+ });
171
+
172
+ const metadata = result.providerMetadata?.blackForestLabs.images[0];
173
+ expect(metadata).toBeDefined();
174
+ expect(metadata).not.toHaveProperty('cost');
175
+ expect(metadata).not.toHaveProperty('inputMegapixels');
176
+ expect(metadata).not.toHaveProperty('outputMegapixels');
177
+ });
178
+
179
+ it('handles null cost and megapixel fields from submit API', async () => {
180
+ server.urls['https://api.example.com/v1/test-model'].response = {
181
+ type: 'json-value',
182
+ body: {
183
+ id: 'req-123',
184
+ polling_url: 'https://api.example.com/poll',
185
+ cost: null,
186
+ input_mp: null,
187
+ output_mp: null,
188
+ },
189
+ };
190
+
191
+ const model = createBasicModel();
192
+ const result = await model.doGenerate({
193
+ prompt,
194
+ files: undefined,
195
+ mask: undefined,
196
+ n: 1,
197
+ size: undefined,
198
+ seed: undefined,
199
+ aspectRatio: '1:1',
200
+ providerOptions: {},
201
+ });
202
+
203
+ const metadata = result.providerMetadata?.blackForestLabs.images[0];
204
+ expect(metadata).toBeDefined();
205
+ expect(metadata).not.toHaveProperty('cost');
206
+ expect(metadata).not.toHaveProperty('inputMegapixels');
207
+ expect(metadata).not.toHaveProperty('outputMegapixels');
208
+ });
209
+
210
+ it('calls the expected URLs in sequence', async () => {
211
+ const model = createBasicModel();
212
+
213
+ await model.doGenerate({
214
+ prompt,
215
+ files: undefined,
216
+ mask: undefined,
217
+ n: 1,
218
+ aspectRatio: '16:9',
219
+ providerOptions: {},
220
+ size: undefined,
221
+ seed: undefined,
222
+ });
223
+
224
+ expect(server.calls[0].requestMethod).toBe('POST');
225
+ expect(server.calls[0].requestUrl).toBe(
226
+ 'https://api.example.com/v1/test-model',
227
+ );
228
+ expect(server.calls[1].requestMethod).toBe('GET');
229
+ expect(server.calls[1].requestUrl).toBe(
230
+ 'https://api.example.com/poll?id=req-123',
231
+ );
232
+ expect(server.calls[2].requestMethod).toBe('GET');
233
+ expect(server.calls[2].requestUrl).toBe(
234
+ 'https://api.example.com/image.png',
235
+ );
236
+ });
237
+
238
+ it('merges provider and request headers for submit call', async () => {
239
+ const modelWithHeaders = createBasicModel({
240
+ headers: () => ({
241
+ 'Custom-Provider-Header': 'provider-header-value',
242
+ 'x-key': 'test-key',
243
+ }),
244
+ });
245
+
246
+ await modelWithHeaders.doGenerate({
247
+ prompt,
248
+ files: undefined,
249
+ mask: undefined,
250
+ n: 1,
251
+ providerOptions: {},
252
+ headers: {
253
+ 'Custom-Request-Header': 'request-header-value',
254
+ },
255
+ size: undefined,
256
+ seed: undefined,
257
+ aspectRatio: undefined,
258
+ });
259
+
260
+ expect(server.calls[0].requestHeaders).toStrictEqual({
261
+ 'content-type': 'application/json',
262
+ 'custom-provider-header': 'provider-header-value',
263
+ 'custom-request-header': 'request-header-value',
264
+ 'x-key': 'test-key',
265
+ });
266
+ });
267
+
268
+ it('passes merged headers to polling requests', async () => {
269
+ const modelWithHeaders = createBasicModel({
270
+ headers: () => ({
271
+ 'Custom-Provider-Header': 'provider-header-value',
272
+ 'x-key': 'test-key',
273
+ }),
274
+ });
275
+
276
+ await modelWithHeaders.doGenerate({
277
+ prompt,
278
+ files: undefined,
279
+ mask: undefined,
280
+ n: 1,
281
+ providerOptions: {},
282
+ headers: {
283
+ 'Custom-Request-Header': 'request-header-value',
284
+ },
285
+ size: undefined,
286
+ seed: undefined,
287
+ aspectRatio: undefined,
288
+ });
289
+
290
+ expect(server.calls[1].requestHeaders).toStrictEqual({
291
+ 'custom-provider-header': 'provider-header-value',
292
+ 'custom-request-header': 'request-header-value',
293
+ 'x-key': 'test-key',
294
+ });
295
+ });
296
+
297
+ it('warns and derives aspect_ratio when size is provided', async () => {
298
+ const model = createBasicModel();
299
+
300
+ const result = await model.doGenerate({
301
+ prompt,
302
+ files: undefined,
303
+ mask: undefined,
304
+ n: 1,
305
+ size: '1024x1024',
306
+ providerOptions: {},
307
+ seed: undefined,
308
+ aspectRatio: undefined,
309
+ });
310
+
311
+ expect(result.warnings).toMatchInlineSnapshot(`
312
+ [
313
+ {
314
+ "details": "Deriving aspect_ratio from size. Use the width and height provider options to specify dimensions for models that support them.",
315
+ "feature": "size",
316
+ "type": "unsupported",
317
+ },
318
+ ]
319
+ `);
320
+ });
321
+
322
+ it('warns and ignores size when both size and aspectRatio are provided', async () => {
323
+ const model = createBasicModel();
324
+
325
+ const result = await model.doGenerate({
326
+ prompt,
327
+ files: undefined,
328
+ mask: undefined,
329
+ n: 1,
330
+ size: '1920x1080',
331
+ providerOptions: {},
332
+ seed: undefined,
333
+ aspectRatio: '16:9',
334
+ });
335
+
336
+ expect(result.warnings).toMatchInlineSnapshot(`
337
+ [
338
+ {
339
+ "details": "Black Forest Labs ignores size when aspectRatio is provided. Use the width and height provider options to specify dimensions for models that support them",
340
+ "feature": "size",
341
+ "type": "unsupported",
342
+ },
343
+ ]
344
+ `);
345
+ });
346
+
347
+ it('handles API errors with message and detail', async () => {
348
+ server.urls['https://api.example.com/v1/test-model'].response = {
349
+ type: 'error',
350
+ status: 400,
351
+ body: JSON.stringify({
352
+ message: 'Top-level message',
353
+ detail: { error: 'Invalid prompt' },
354
+ }),
355
+ };
356
+
357
+ const model = createBasicModel();
358
+
359
+ await expect(
360
+ model.doGenerate({
361
+ prompt,
362
+ files: undefined,
363
+ mask: undefined,
364
+ n: 1,
365
+ providerOptions: {},
366
+ size: undefined,
367
+ seed: undefined,
368
+ aspectRatio: undefined,
369
+ }),
370
+ ).rejects.toMatchObject({
371
+ message: '{"error":"Invalid prompt"}',
372
+ statusCode: 400,
373
+ url: 'https://api.example.com/v1/test-model',
374
+ });
375
+ });
376
+
377
+ it('handles poll responses with state instead of status', async () => {
378
+ server.urls['https://api.example.com/poll'].response = {
379
+ type: 'json-value',
380
+ body: {
381
+ state: 'Ready',
382
+ result: {
383
+ sample: 'https://api.example.com/image.png',
384
+ },
385
+ },
386
+ };
387
+
388
+ const model = createBasicModel();
389
+
390
+ const result = await model.doGenerate({
391
+ prompt,
392
+ files: undefined,
393
+ mask: undefined,
394
+ n: 1,
395
+ size: undefined,
396
+ seed: undefined,
397
+ aspectRatio: '1:1',
398
+ providerOptions: {},
399
+ });
400
+
401
+ expect(result.images).toHaveLength(1);
402
+ expect(result.images[0]).toBeInstanceOf(Uint8Array);
403
+ });
404
+
405
+ it('polls multiple times using configured interval until Ready', async () => {
406
+ let pollHitCount = 0;
407
+ server.urls['https://api.example.com/poll'].response = () => {
408
+ pollHitCount += 1;
409
+ if (pollHitCount < 3) {
410
+ return {
411
+ type: 'json-value',
412
+ body: { status: 'Pending' },
413
+ };
414
+ }
415
+ return {
416
+ type: 'json-value',
417
+ body: {
418
+ status: 'Ready',
419
+ result: { sample: 'https://api.example.com/image.png' },
420
+ },
421
+ };
422
+ };
423
+
424
+ const model = createBasicModel({
425
+ pollIntervalMillis: 10,
426
+ pollTimeoutMillis: 1000,
427
+ });
428
+
429
+ await model.doGenerate({
430
+ prompt,
431
+ files: undefined,
432
+ mask: undefined,
433
+ n: 1,
434
+ size: undefined,
435
+ seed: undefined,
436
+ aspectRatio: '1:1',
437
+ providerOptions: {},
438
+ });
439
+
440
+ const pollCalls = server.calls.filter(
441
+ c =>
442
+ c.requestMethod === 'GET' &&
443
+ c.requestUrl.startsWith('https://api.example.com/poll'),
444
+ );
445
+ expect(pollCalls.length).toBe(3);
446
+ });
447
+
448
+ it('uses configured pollTimeoutMillis and pollIntervalMillis to time out', async () => {
449
+ server.urls['https://api.example.com/poll'].response = ({
450
+ callNumber,
451
+ }) => ({
452
+ type: 'json-value',
453
+ body: { status: 'Pending', callNumber },
454
+ });
455
+
456
+ const pollIntervalMillis = 10;
457
+ const pollTimeoutMillis = 25;
458
+ const model = createBasicModel({
459
+ pollIntervalMillis,
460
+ pollTimeoutMillis,
461
+ });
462
+
463
+ await expect(
464
+ model.doGenerate({
465
+ prompt,
466
+ files: undefined,
467
+ mask: undefined,
468
+ n: 1,
469
+ size: undefined,
470
+ seed: undefined,
471
+ aspectRatio: '1:1',
472
+ providerOptions: {},
473
+ }),
474
+ ).rejects.toThrow('Black Forest Labs generation timed out.');
475
+
476
+ const pollCalls = server.calls.filter(
477
+ c =>
478
+ c.requestMethod === 'GET' &&
479
+ c.requestUrl.startsWith('https://api.example.com/poll'),
480
+ );
481
+ expect(pollCalls.length).toBe(
482
+ Math.ceil(pollTimeoutMillis / pollIntervalMillis),
483
+ );
484
+ const imageFetchCalls = server.calls.filter(c =>
485
+ c.requestUrl.startsWith('https://api.example.com/image.png'),
486
+ );
487
+ expect(imageFetchCalls.length).toBe(0);
488
+ });
489
+
490
+ it('throws when poll is Ready but sample is missing', async () => {
491
+ server.urls['https://api.example.com/poll'].response = {
492
+ type: 'json-value',
493
+ body: {
494
+ status: 'Ready',
495
+ result: null,
496
+ },
497
+ };
498
+
499
+ const model = createBasicModel();
500
+
501
+ await expect(
502
+ model.doGenerate({
503
+ prompt,
504
+ files: undefined,
505
+ mask: undefined,
506
+ n: 1,
507
+ size: undefined,
508
+ seed: undefined,
509
+ aspectRatio: '1:1',
510
+ providerOptions: {},
511
+ }),
512
+ ).rejects.toThrow(
513
+ 'Black Forest Labs poll response is Ready but missing result.sample',
514
+ );
515
+ });
516
+
517
+ it('throws when poll returns Error or Failed', async () => {
518
+ server.urls['https://api.example.com/poll'].response = {
519
+ type: 'json-value',
520
+ body: {
521
+ status: 'Error',
522
+ },
523
+ };
524
+
525
+ const model = createBasicModel();
526
+
527
+ await expect(
528
+ model.doGenerate({
529
+ prompt,
530
+ files: undefined,
531
+ mask: undefined,
532
+ n: 1,
533
+ size: undefined,
534
+ seed: undefined,
535
+ aspectRatio: '1:1',
536
+ providerOptions: {},
537
+ }),
538
+ ).rejects.toThrow('Black Forest Labs generation failed.');
539
+ });
540
+
541
+ it('includes timestamp, headers, and modelId in response metadata', async () => {
542
+ const testDate = new Date('2025-01-01T00:00:00Z');
543
+ const model = createBasicModel({
544
+ currentDate: () => testDate,
545
+ });
546
+
547
+ const result = await model.doGenerate({
548
+ prompt,
549
+ files: undefined,
550
+ mask: undefined,
551
+ n: 1,
552
+ providerOptions: {},
553
+ size: undefined,
554
+ seed: undefined,
555
+ aspectRatio: '1:1',
556
+ });
557
+
558
+ expect(result.response).toStrictEqual({
559
+ timestamp: testDate,
560
+ modelId: 'test-model',
561
+ headers: expect.any(Object),
562
+ });
563
+ });
564
+ });
565
+
566
+ describe('constructor', () => {
567
+ it('exposes correct provider and model information', () => {
568
+ const model = createBasicModel();
569
+
570
+ expect(model.provider).toBe('black-forest-labs.image');
571
+ expect(model.modelId).toBe('test-model');
572
+ expect(model.specificationVersion).toBe('v3');
573
+ expect(model.maxImagesPerCall).toBe(1);
574
+ });
575
+ });
576
+ });
@@ -0,0 +1,473 @@
1
+ import type { ImageModelV3, SharedV3Warning } from '@ai-sdk/provider';
2
+ import type { InferSchema, Resolvable } from '@ai-sdk/provider-utils';
3
+ import {
4
+ FetchFunction,
5
+ combineHeaders,
6
+ createBinaryResponseHandler,
7
+ createJsonErrorResponseHandler,
8
+ createJsonResponseHandler,
9
+ createStatusCodeErrorResponseHandler,
10
+ delay,
11
+ getFromApi,
12
+ lazySchema,
13
+ parseProviderOptions,
14
+ postJsonToApi,
15
+ resolve,
16
+ zodSchema,
17
+ } from '@ai-sdk/provider-utils';
18
+ import { z } from 'zod/v4';
19
+ import type { BlackForestLabsAspectRatio } from './black-forest-labs-image-settings';
20
+ import { BlackForestLabsImageModelId } from './black-forest-labs-image-settings';
21
+
22
+ const DEFAULT_POLL_INTERVAL_MILLIS = 500;
23
+ const DEFAULT_POLL_TIMEOUT_MILLIS = 60000;
24
+
25
+ interface BlackForestLabsImageModelConfig {
26
+ provider: string;
27
+ baseURL: string;
28
+ headers?: Resolvable<Record<string, string | undefined>>;
29
+ fetch?: FetchFunction;
30
+ /**
31
+ Poll interval in milliseconds between status checks. Defaults to 500ms.
32
+ */
33
+ pollIntervalMillis?: number;
34
+ /**
35
+ Overall timeout in milliseconds for polling before giving up. Defaults to 60s.
36
+ */
37
+ pollTimeoutMillis?: number;
38
+ _internal?: {
39
+ currentDate?: () => Date;
40
+ };
41
+ }
42
+
43
+ export class BlackForestLabsImageModel implements ImageModelV3 {
44
+ readonly specificationVersion = 'v3';
45
+ readonly maxImagesPerCall = 1;
46
+
47
+ get provider(): string {
48
+ return this.config.provider;
49
+ }
50
+
51
+ constructor(
52
+ readonly modelId: BlackForestLabsImageModelId,
53
+ private readonly config: BlackForestLabsImageModelConfig,
54
+ ) {}
55
+
56
+ private async getArgs({
57
+ prompt,
58
+ files,
59
+ mask,
60
+ size,
61
+ aspectRatio,
62
+ seed,
63
+ providerOptions,
64
+ }: Parameters<ImageModelV3['doGenerate']>[0]) {
65
+ const warnings: Array<SharedV3Warning> = [];
66
+
67
+ const finalAspectRatio =
68
+ aspectRatio ?? (size ? convertSizeToAspectRatio(size) : undefined);
69
+
70
+ if (size && !aspectRatio) {
71
+ warnings.push({
72
+ type: 'unsupported',
73
+ feature: 'size',
74
+ details:
75
+ 'Deriving aspect_ratio from size. Use the width and height provider options to specify dimensions for models that support them.',
76
+ });
77
+ } else if (size && aspectRatio) {
78
+ warnings.push({
79
+ type: 'unsupported',
80
+ feature: 'size',
81
+ details:
82
+ 'Black Forest Labs ignores size when aspectRatio is provided. Use the width and height provider options to specify dimensions for models that support them',
83
+ });
84
+ }
85
+
86
+ const bflOptions = await parseProviderOptions({
87
+ provider: 'blackForestLabs',
88
+ providerOptions,
89
+ schema: blackForestLabsImageProviderOptionsSchema,
90
+ });
91
+
92
+ const [widthStr, heightStr] = size?.split('x') ?? [];
93
+
94
+ const inputImages: string[] =
95
+ files?.map(file => {
96
+ if (file.type === 'url') {
97
+ return file.url;
98
+ }
99
+
100
+ if (typeof file.data === 'string') {
101
+ return file.data;
102
+ }
103
+
104
+ return Buffer.from(file.data).toString('base64');
105
+ }) || [];
106
+
107
+ if (inputImages.length > 10) {
108
+ throw new Error('Black Forest Labs supports up to 10 input images.');
109
+ }
110
+
111
+ const inputImagesObj: Record<string, string> = inputImages.reduce<
112
+ Record<string, string>
113
+ >((acc, img, index) => {
114
+ acc[`input_image${index === 0 ? '' : `_${index + 1}`}`] = img;
115
+ return acc;
116
+ }, {});
117
+
118
+ let maskValue: string | undefined;
119
+ if (mask) {
120
+ if (mask.type === 'url') {
121
+ maskValue = mask.url;
122
+ } else {
123
+ if (typeof mask.data === 'string') {
124
+ maskValue = mask.data;
125
+ } else {
126
+ maskValue = Buffer.from(mask.data).toString('base64');
127
+ }
128
+ }
129
+ }
130
+
131
+ const body: Record<string, unknown> = {
132
+ prompt,
133
+ seed,
134
+ aspect_ratio: finalAspectRatio,
135
+ width: bflOptions?.width ?? (size ? Number(widthStr) : undefined),
136
+ height: bflOptions?.height ?? (size ? Number(heightStr) : undefined),
137
+ steps: bflOptions?.steps,
138
+ guidance: bflOptions?.guidance,
139
+ image_prompt_strength: bflOptions?.imagePromptStrength,
140
+ image_prompt: bflOptions?.imagePrompt,
141
+ ...inputImagesObj,
142
+ mask: maskValue,
143
+ output_format: bflOptions?.outputFormat,
144
+ prompt_upsampling: bflOptions?.promptUpsampling,
145
+ raw: bflOptions?.raw,
146
+ safety_tolerance: bflOptions?.safetyTolerance,
147
+ webhook_secret: bflOptions?.webhookSecret,
148
+ webhook_url: bflOptions?.webhookUrl,
149
+ };
150
+
151
+ return { body, warnings };
152
+ }
153
+
154
+ async doGenerate({
155
+ prompt,
156
+ files,
157
+ mask,
158
+ size,
159
+ aspectRatio,
160
+ seed,
161
+ providerOptions,
162
+ headers,
163
+ abortSignal,
164
+ }: Parameters<ImageModelV3['doGenerate']>[0]): Promise<
165
+ Awaited<ReturnType<ImageModelV3['doGenerate']>>
166
+ > {
167
+ const { body, warnings } = await this.getArgs({
168
+ prompt,
169
+ files,
170
+ mask,
171
+ size,
172
+ aspectRatio,
173
+ seed,
174
+ providerOptions,
175
+ n: 1,
176
+ headers,
177
+ abortSignal,
178
+ } as Parameters<ImageModelV3['doGenerate']>[0]);
179
+
180
+ const bflOptions = await parseProviderOptions({
181
+ provider: 'blackForestLabs',
182
+ providerOptions,
183
+ schema: blackForestLabsImageProviderOptionsSchema,
184
+ });
185
+
186
+ const currentDate = this.config._internal?.currentDate?.() ?? new Date();
187
+ const combinedHeaders = combineHeaders(
188
+ await resolve(this.config.headers),
189
+ headers,
190
+ );
191
+
192
+ const submit = await postJsonToApi({
193
+ url: `${this.config.baseURL}/${this.modelId}`,
194
+ headers: combinedHeaders,
195
+ body,
196
+ failedResponseHandler: bflFailedResponseHandler,
197
+ successfulResponseHandler: createJsonResponseHandler(bflSubmitSchema),
198
+ abortSignal,
199
+ fetch: this.config.fetch,
200
+ });
201
+
202
+ const pollUrl = submit.value.polling_url;
203
+ const requestId = submit.value.id;
204
+
205
+ const {
206
+ imageUrl,
207
+ seed: resultSeed,
208
+ start_time: resultStartTime,
209
+ end_time: resultEndTime,
210
+ duration: resultDuration,
211
+ } = await this.pollForImageUrl({
212
+ pollUrl,
213
+ requestId,
214
+ headers: combinedHeaders,
215
+ abortSignal,
216
+ pollOverrides: {
217
+ pollIntervalMillis: bflOptions?.pollIntervalMillis,
218
+ pollTimeoutMillis: bflOptions?.pollTimeoutMillis,
219
+ },
220
+ });
221
+
222
+ const { value: imageBytes, responseHeaders } = await getFromApi({
223
+ url: imageUrl,
224
+ headers: combinedHeaders,
225
+ abortSignal,
226
+ failedResponseHandler: createStatusCodeErrorResponseHandler(),
227
+ successfulResponseHandler: createBinaryResponseHandler(),
228
+ fetch: this.config.fetch,
229
+ });
230
+
231
+ return {
232
+ images: [imageBytes],
233
+ warnings,
234
+ providerMetadata: {
235
+ blackForestLabs: {
236
+ images: [
237
+ {
238
+ ...(resultSeed != null && { seed: resultSeed }),
239
+ ...(resultStartTime != null && { start_time: resultStartTime }),
240
+ ...(resultEndTime != null && { end_time: resultEndTime }),
241
+ ...(resultDuration != null && { duration: resultDuration }),
242
+ ...(submit.value.cost != null && { cost: submit.value.cost }),
243
+ ...(submit.value.input_mp != null && {
244
+ inputMegapixels: submit.value.input_mp,
245
+ }),
246
+ ...(submit.value.output_mp != null && {
247
+ outputMegapixels: submit.value.output_mp,
248
+ }),
249
+ },
250
+ ],
251
+ },
252
+ },
253
+ response: {
254
+ modelId: this.modelId,
255
+ timestamp: currentDate,
256
+ headers: responseHeaders,
257
+ },
258
+ };
259
+ }
260
+
261
+ private async pollForImageUrl({
262
+ pollUrl,
263
+ requestId,
264
+ headers,
265
+ abortSignal,
266
+ pollOverrides,
267
+ }: {
268
+ pollUrl: string;
269
+ requestId: string;
270
+ headers: Record<string, string | undefined>;
271
+ abortSignal: AbortSignal | undefined;
272
+ pollOverrides?: {
273
+ pollIntervalMillis?: number;
274
+ pollTimeoutMillis?: number;
275
+ };
276
+ }): Promise<{
277
+ imageUrl: string;
278
+ seed?: number;
279
+ start_time?: number;
280
+ end_time?: number;
281
+ duration?: number;
282
+ }> {
283
+ const pollIntervalMillis =
284
+ pollOverrides?.pollIntervalMillis ??
285
+ this.config.pollIntervalMillis ??
286
+ DEFAULT_POLL_INTERVAL_MILLIS;
287
+ const pollTimeoutMillis =
288
+ pollOverrides?.pollTimeoutMillis ??
289
+ this.config.pollTimeoutMillis ??
290
+ DEFAULT_POLL_TIMEOUT_MILLIS;
291
+ const maxPollAttempts = Math.ceil(
292
+ pollTimeoutMillis / Math.max(1, pollIntervalMillis),
293
+ );
294
+
295
+ const url = new URL(pollUrl);
296
+ if (!url.searchParams.has('id')) {
297
+ url.searchParams.set('id', requestId);
298
+ }
299
+
300
+ for (let i = 0; i < maxPollAttempts; i++) {
301
+ const { value } = await getFromApi({
302
+ url: url.toString(),
303
+ headers,
304
+ failedResponseHandler: bflFailedResponseHandler,
305
+ successfulResponseHandler: createJsonResponseHandler(bflPollSchema),
306
+ abortSignal,
307
+ fetch: this.config.fetch,
308
+ });
309
+
310
+ const status = value.status;
311
+ if (status === 'Ready') {
312
+ if (typeof value.result?.sample === 'string') {
313
+ return {
314
+ imageUrl: value.result.sample,
315
+ seed: value.result.seed ?? undefined,
316
+ start_time: value.result.start_time ?? undefined,
317
+ end_time: value.result.end_time ?? undefined,
318
+ duration: value.result.duration ?? undefined,
319
+ };
320
+ }
321
+ throw new Error(
322
+ 'Black Forest Labs poll response is Ready but missing result.sample',
323
+ );
324
+ }
325
+ if (status === 'Error' || status === 'Failed') {
326
+ throw new Error('Black Forest Labs generation failed.');
327
+ }
328
+
329
+ await delay(pollIntervalMillis);
330
+ }
331
+
332
+ throw new Error('Black Forest Labs generation timed out.');
333
+ }
334
+ }
335
+
336
+ export const blackForestLabsImageProviderOptionsSchema = lazySchema(() =>
337
+ zodSchema(
338
+ z.object({
339
+ imagePrompt: z.string().optional(),
340
+ imagePromptStrength: z.number().min(0).max(1).optional(),
341
+ /** @deprecated use prompt.images instead */
342
+ inputImage: z.string().optional(),
343
+ /** @deprecated use prompt.images instead */
344
+ inputImage2: z.string().optional(),
345
+ /** @deprecated use prompt.images instead */
346
+ inputImage3: z.string().optional(),
347
+ /** @deprecated use prompt.images instead */
348
+ inputImage4: z.string().optional(),
349
+ /** @deprecated use prompt.images instead */
350
+ inputImage5: z.string().optional(),
351
+ /** @deprecated use prompt.images instead */
352
+ inputImage6: z.string().optional(),
353
+ /** @deprecated use prompt.images instead */
354
+ inputImage7: z.string().optional(),
355
+ /** @deprecated use prompt.images instead */
356
+ inputImage8: z.string().optional(),
357
+ /** @deprecated use prompt.images instead */
358
+ inputImage9: z.string().optional(),
359
+ /** @deprecated use prompt.images instead */
360
+ inputImage10: z.string().optional(),
361
+ steps: z.number().int().positive().optional(),
362
+ guidance: z.number().min(0).optional(),
363
+ width: z.number().int().min(256).max(1920).optional(),
364
+ height: z.number().int().min(256).max(1920).optional(),
365
+ outputFormat: z.enum(['jpeg', 'png']).optional(),
366
+ promptUpsampling: z.boolean().optional(),
367
+ raw: z.boolean().optional(),
368
+ safetyTolerance: z.number().int().min(0).max(6).optional(),
369
+ webhookSecret: z.string().optional(),
370
+ webhookUrl: z.url().optional(),
371
+ pollIntervalMillis: z.number().int().positive().optional(),
372
+ pollTimeoutMillis: z.number().int().positive().optional(),
373
+ }),
374
+ ),
375
+ );
376
+
377
+ export type BlackForestLabsImageProviderOptions = InferSchema<
378
+ typeof blackForestLabsImageProviderOptionsSchema
379
+ >;
380
+
381
+ function convertSizeToAspectRatio(
382
+ size: string,
383
+ ): BlackForestLabsAspectRatio | undefined {
384
+ const [wStr, hStr] = size.split('x');
385
+ const width = Number(wStr);
386
+ const height = Number(hStr);
387
+ if (
388
+ !Number.isFinite(width) ||
389
+ !Number.isFinite(height) ||
390
+ width <= 0 ||
391
+ height <= 0
392
+ ) {
393
+ return undefined;
394
+ }
395
+ const g = gcd(width, height);
396
+ return `${Math.round(width / g)}:${Math.round(height / g)}`;
397
+ }
398
+
399
+ function gcd(a: number, b: number): number {
400
+ let x = Math.abs(a);
401
+ let y = Math.abs(b);
402
+ while (y !== 0) {
403
+ const t = y;
404
+ y = x % y;
405
+ x = t;
406
+ }
407
+ return x;
408
+ }
409
+
410
+ const bflSubmitSchema = z.object({
411
+ id: z.string(),
412
+ polling_url: z.url(),
413
+ cost: z.number().nullish(),
414
+ input_mp: z.number().nullish(),
415
+ output_mp: z.number().nullish(),
416
+ });
417
+
418
+ const bflStatus = z.union([
419
+ z.literal('Pending'),
420
+ z.literal('Ready'),
421
+ z.literal('Error'),
422
+ z.literal('Failed'),
423
+ z.literal('Request Moderated'),
424
+ ]);
425
+
426
+ const bflPollSchema = z
427
+ .object({
428
+ status: bflStatus.optional(),
429
+ state: bflStatus.optional(),
430
+ details: z.unknown().optional(),
431
+ result: z
432
+ .object({
433
+ sample: z.url(),
434
+ seed: z.number().optional(),
435
+ start_time: z.number().optional(),
436
+ end_time: z.number().optional(),
437
+ duration: z.number().optional(),
438
+ })
439
+ .nullish(),
440
+ })
441
+ .refine(v => v.status != null || v.state != null, {
442
+ message: 'Missing status in Black Forest Labs poll response',
443
+ })
444
+ .transform(v => ({
445
+ status: (v.status ?? v.state)!,
446
+ result: v.result,
447
+ }));
448
+
449
+ const bflErrorSchema = z.object({
450
+ message: z.string().optional(),
451
+ detail: z.any().optional(),
452
+ });
453
+
454
+ const bflFailedResponseHandler = createJsonErrorResponseHandler({
455
+ errorSchema: bflErrorSchema,
456
+ errorToMessage: error =>
457
+ bflErrorToMessage(error) ?? 'Unknown Black Forest Labs error',
458
+ });
459
+
460
+ function bflErrorToMessage(error: unknown): string | undefined {
461
+ const parsed = bflErrorSchema.safeParse(error);
462
+ if (!parsed.success) return undefined;
463
+ const { message, detail } = parsed.data;
464
+ if (typeof detail === 'string') return detail;
465
+ if (detail != null) {
466
+ try {
467
+ return JSON.stringify(detail);
468
+ } catch {
469
+ // ignore
470
+ }
471
+ }
472
+ return message;
473
+ }
@@ -0,0 +1,9 @@
1
+ export type BlackForestLabsImageModelId =
2
+ | 'flux-kontext-pro'
3
+ | 'flux-kontext-max'
4
+ | 'flux-pro-1.1-ultra'
5
+ | 'flux-pro-1.1'
6
+ | 'flux-pro-1.0-fill'
7
+ | (string & {});
8
+
9
+ export type BlackForestLabsAspectRatio = `${number}:${number}`;
@@ -0,0 +1,139 @@
1
+ import { createTestServer } from '@ai-sdk/test-server/with-vitest';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createBlackForestLabs } from './black-forest-labs-provider';
4
+
5
+ const server = createTestServer({
6
+ 'https://api.example.com/v1/flux-pro-1.1': {
7
+ response: {
8
+ type: 'json-value',
9
+ body: {
10
+ id: 'req-123',
11
+ polling_url: 'https://api.example.com/poll',
12
+ },
13
+ },
14
+ },
15
+ 'https://api.example.com/poll': {
16
+ response: {
17
+ type: 'json-value',
18
+ body: {
19
+ status: 'Ready',
20
+ result: {
21
+ sample: 'https://api.example.com/image.png',
22
+ },
23
+ },
24
+ },
25
+ },
26
+ 'https://api.example.com/image.png': {
27
+ response: {
28
+ type: 'binary',
29
+ body: Buffer.from([1, 2, 3]),
30
+ },
31
+ },
32
+ });
33
+
34
+ describe('BlackForestLabs provider', () => {
35
+ it('creates image models via .image and .imageModel', () => {
36
+ const provider = createBlackForestLabs();
37
+
38
+ const imageModel = provider.image('flux-pro-1.1');
39
+ const imageModel2 = provider.imageModel('flux-pro-1.1-ultra');
40
+
41
+ expect(imageModel.provider).toBe('black-forest-labs.image');
42
+ expect(imageModel.modelId).toBe('flux-pro-1.1');
43
+ expect(imageModel2.modelId).toBe('flux-pro-1.1-ultra');
44
+ expect(imageModel.specificationVersion).toBe('v3');
45
+ });
46
+
47
+ it('configures baseURL and headers correctly', async () => {
48
+ const provider = createBlackForestLabs({
49
+ apiKey: 'test-api-key',
50
+ baseURL: 'https://api.example.com/v1',
51
+ headers: {
52
+ 'x-extra-header': 'extra',
53
+ },
54
+ });
55
+
56
+ const model = provider.image('flux-pro-1.1');
57
+
58
+ await model.doGenerate({
59
+ prompt: 'A serene mountain landscape at sunset',
60
+ files: undefined,
61
+ mask: undefined,
62
+ n: 1,
63
+ size: undefined,
64
+ seed: undefined,
65
+ aspectRatio: '1:1',
66
+ providerOptions: {},
67
+ });
68
+
69
+ expect(server.calls[0].requestUrl).toBe(
70
+ 'https://api.example.com/v1/flux-pro-1.1',
71
+ );
72
+ expect(server.calls[0].requestMethod).toBe('POST');
73
+ expect(server.calls[0].requestHeaders['x-key']).toBe('test-api-key');
74
+ expect(server.calls[0].requestHeaders['x-extra-header']).toBe('extra');
75
+ expect(await server.calls[0].requestBodyJson).toMatchObject({
76
+ prompt: 'A serene mountain landscape at sunset',
77
+ aspect_ratio: '1:1',
78
+ });
79
+
80
+ expect(server.calls[0].requestUserAgent).toContain(
81
+ 'ai-sdk/black-forest-labs/',
82
+ );
83
+ expect(server.calls[1].requestUserAgent).toContain(
84
+ 'ai-sdk/black-forest-labs/',
85
+ );
86
+ expect(server.calls[2].requestUserAgent).toContain(
87
+ 'ai-sdk/black-forest-labs/',
88
+ );
89
+ });
90
+
91
+ it('uses provider polling options for timeout behavior', async () => {
92
+ server.urls['https://api.example.com/poll'].response = ({
93
+ callNumber,
94
+ }) => ({
95
+ type: 'json-value',
96
+ body: { status: 'Pending', callNumber },
97
+ });
98
+
99
+ const provider = createBlackForestLabs({
100
+ apiKey: 'test-api-key',
101
+ baseURL: 'https://api.example.com/v1',
102
+ pollIntervalMillis: 10,
103
+ pollTimeoutMillis: 25,
104
+ });
105
+
106
+ const model = provider.image('flux-pro-1.1');
107
+
108
+ await expect(
109
+ model.doGenerate({
110
+ prompt: 'Timeout test',
111
+ files: undefined,
112
+ mask: undefined,
113
+ n: 1,
114
+ size: undefined,
115
+ seed: undefined,
116
+ aspectRatio: '1:1',
117
+ providerOptions: {},
118
+ }),
119
+ ).rejects.toThrow('Black Forest Labs generation timed out.');
120
+
121
+ const pollCalls = server.calls.filter(
122
+ c =>
123
+ c.requestMethod === 'GET' &&
124
+ c.requestUrl.startsWith('https://api.example.com/poll'),
125
+ );
126
+ expect(pollCalls.length).toBe(3);
127
+ });
128
+
129
+ it('throws NoSuchModelError for unsupported model types', () => {
130
+ const provider = createBlackForestLabs();
131
+
132
+ expect(() => provider.languageModel('some-id')).toThrowError(
133
+ 'No such languageModel',
134
+ );
135
+ expect(() => provider.embeddingModel('some-id')).toThrowError(
136
+ 'No such embeddingModel',
137
+ );
138
+ });
139
+ });
@@ -0,0 +1,113 @@
1
+ import { ImageModelV3, NoSuchModelError, ProviderV3 } from '@ai-sdk/provider';
2
+ import type { FetchFunction } from '@ai-sdk/provider-utils';
3
+ import {
4
+ loadApiKey,
5
+ withoutTrailingSlash,
6
+ withUserAgentSuffix,
7
+ } from '@ai-sdk/provider-utils';
8
+ import { BlackForestLabsImageModel } from './black-forest-labs-image-model';
9
+ import { BlackForestLabsImageModelId } from './black-forest-labs-image-settings';
10
+ import { VERSION } from './version';
11
+
12
+ export interface BlackForestLabsProviderSettings {
13
+ /**
14
+ Black Forest Labs API key. Default value is taken from the `BFL_API_KEY` environment variable.
15
+ */
16
+ apiKey?: string;
17
+
18
+ /**
19
+ Base URL for the API calls. Defaults to `https://api.bfl.ai/v1`.
20
+ */
21
+ baseURL?: string;
22
+
23
+ /**
24
+ Custom headers to include in the requests.
25
+ */
26
+ headers?: Record<string, string>;
27
+
28
+ /**
29
+ Custom fetch implementation. You can use it as a middleware to intercept
30
+ requests, or to provide a custom fetch implementation for e.g. testing.
31
+ */
32
+ fetch?: FetchFunction;
33
+
34
+ /**
35
+ Poll interval in milliseconds between status checks. Defaults to 500ms.
36
+ */
37
+ pollIntervalMillis?: number;
38
+
39
+ /**
40
+ Overall timeout in milliseconds for polling before giving up. Defaults to 60s.
41
+ */
42
+ pollTimeoutMillis?: number;
43
+ }
44
+
45
+ export interface BlackForestLabsProvider extends ProviderV3 {
46
+ /**
47
+ Creates a model for image generation.
48
+ */
49
+ image(modelId: BlackForestLabsImageModelId): ImageModelV3;
50
+
51
+ /**
52
+ Creates a model for image generation.
53
+ */
54
+ imageModel(modelId: BlackForestLabsImageModelId): ImageModelV3;
55
+
56
+ /**
57
+ * @deprecated Use `embeddingModel` instead.
58
+ */
59
+ textEmbeddingModel(modelId: string): never;
60
+ }
61
+
62
+ const defaultBaseURL = 'https://api.bfl.ai/v1';
63
+
64
+ export function createBlackForestLabs(
65
+ options: BlackForestLabsProviderSettings = {},
66
+ ): BlackForestLabsProvider {
67
+ const baseURL = withoutTrailingSlash(options.baseURL ?? defaultBaseURL);
68
+ const getHeaders = () =>
69
+ withUserAgentSuffix(
70
+ {
71
+ 'x-key': loadApiKey({
72
+ apiKey: options.apiKey,
73
+ environmentVariableName: 'BFL_API_KEY',
74
+ description: 'Black Forest Labs',
75
+ }),
76
+ ...options.headers,
77
+ },
78
+ `ai-sdk/black-forest-labs/${VERSION}`,
79
+ );
80
+
81
+ const createImageModel = (modelId: BlackForestLabsImageModelId) =>
82
+ new BlackForestLabsImageModel(modelId, {
83
+ provider: 'black-forest-labs.image',
84
+ baseURL: baseURL ?? defaultBaseURL,
85
+ headers: getHeaders,
86
+ fetch: options.fetch,
87
+ pollIntervalMillis: options.pollIntervalMillis,
88
+ pollTimeoutMillis: options.pollTimeoutMillis,
89
+ });
90
+
91
+ const embeddingModel = (modelId: string) => {
92
+ throw new NoSuchModelError({
93
+ modelId,
94
+ modelType: 'embeddingModel',
95
+ });
96
+ };
97
+
98
+ return {
99
+ specificationVersion: 'v3',
100
+ imageModel: createImageModel,
101
+ image: createImageModel,
102
+ languageModel: (modelId: string) => {
103
+ throw new NoSuchModelError({
104
+ modelId,
105
+ modelType: 'languageModel',
106
+ });
107
+ },
108
+ embeddingModel,
109
+ textEmbeddingModel: embeddingModel,
110
+ };
111
+ }
112
+
113
+ export const blackForestLabs = createBlackForestLabs();
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export {
2
+ createBlackForestLabs,
3
+ blackForestLabs,
4
+ } from './black-forest-labs-provider';
5
+ export type {
6
+ BlackForestLabsProvider,
7
+ BlackForestLabsProviderSettings,
8
+ } from './black-forest-labs-provider';
9
+ export type {
10
+ BlackForestLabsImageModelId,
11
+ BlackForestLabsAspectRatio,
12
+ } from './black-forest-labs-image-settings';
13
+ export type { BlackForestLabsImageProviderOptions } from './black-forest-labs-image-model';
14
+ export { VERSION } from './version';
package/src/version.ts ADDED
@@ -0,0 +1,6 @@
1
+ // Version string of this package injected at build time.
2
+ declare const __PACKAGE_VERSION__: string | undefined;
3
+ export const VERSION: string =
4
+ typeof __PACKAGE_VERSION__ !== 'undefined'
5
+ ? __PACKAGE_VERSION__
6
+ : '0.0.0-test';