@ai-sdk/black-forest-labs 0.0.0-70e0935a-20260114150030 → 0.0.0-98261322-20260122142521

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,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
+ });