@lobehub/chat 1.112.5 → 1.113.1
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/.vscode/settings.json +1 -1
- package/CHANGELOG.md +50 -0
- package/changelog/v1.json +10 -0
- package/package.json +3 -3
- package/packages/const/src/image.ts +9 -1
- package/packages/model-runtime/src/bfl/createImage.test.ts +846 -0
- package/packages/model-runtime/src/bfl/createImage.ts +279 -0
- package/packages/model-runtime/src/bfl/index.test.ts +269 -0
- package/packages/model-runtime/src/bfl/index.ts +49 -0
- package/packages/model-runtime/src/bfl/types.ts +113 -0
- package/packages/model-runtime/src/index.ts +1 -0
- package/packages/model-runtime/src/qwen/createImage.ts +37 -82
- package/packages/model-runtime/src/runtimeMap.ts +2 -0
- package/packages/model-runtime/src/utils/asyncifyPolling.test.ts +491 -0
- package/packages/model-runtime/src/utils/asyncifyPolling.ts +175 -0
- package/src/app/(backend)/api/webhooks/casdoor/route.ts +2 -1
- package/src/app/(backend)/api/webhooks/clerk/route.ts +2 -1
- package/src/app/(backend)/api/webhooks/logto/route.ts +2 -1
- package/src/app/(backend)/webapi/user/avatar/[id]/[image]/route.ts +2 -1
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/index.tsx +1 -1
- package/src/config/aiModels/bfl.ts +145 -0
- package/src/config/aiModels/index.ts +3 -0
- package/src/config/llm.ts +6 -1
- package/src/config/modelProviders/bfl.ts +21 -0
- package/src/config/modelProviders/index.ts +3 -0
- package/src/database/server/models/ragEval/dataset.ts +9 -7
- package/src/database/server/models/ragEval/datasetRecord.ts +12 -10
- package/src/database/server/models/ragEval/evaluation.ts +10 -8
- package/src/database/server/models/ragEval/evaluationRecord.ts +11 -9
- package/src/server/routers/async/file.ts +1 -1
- package/src/server/routers/async/ragEval.ts +4 -4
- package/src/server/routers/lambda/chunk.ts +1 -1
- package/src/server/routers/lambda/ragEval.ts +4 -4
- package/src/server/routers/lambda/user.ts +1 -1
- package/src/server/services/chunk/index.ts +2 -2
- package/src/server/services/nextAuthUser/index.test.ts +1 -1
- package/src/server/services/nextAuthUser/index.ts +6 -4
- package/src/server/services/user/index.test.ts +3 -1
- package/src/server/services/user/index.ts +14 -8
- package/src/store/image/slices/generationConfig/hooks.ts +6 -10
@@ -0,0 +1,846 @@
|
|
1
|
+
// @vitest-environment node
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
3
|
+
|
4
|
+
import { CreateImagePayload } from '@/libs/model-runtime/types/image';
|
5
|
+
|
6
|
+
import { createBflImage } from './createImage';
|
7
|
+
import { BflStatusResponse } from './types';
|
8
|
+
|
9
|
+
// Mock external dependencies
|
10
|
+
vi.mock('@/utils/imageToBase64', () => ({
|
11
|
+
imageUrlToBase64: vi.fn(),
|
12
|
+
}));
|
13
|
+
|
14
|
+
vi.mock('../utils/uriParser', () => ({
|
15
|
+
parseDataUri: vi.fn(),
|
16
|
+
}));
|
17
|
+
|
18
|
+
vi.mock('../utils/asyncifyPolling', () => ({
|
19
|
+
asyncifyPolling: vi.fn(),
|
20
|
+
}));
|
21
|
+
|
22
|
+
// Mock fetch
|
23
|
+
global.fetch = vi.fn();
|
24
|
+
const mockFetch = vi.mocked(fetch);
|
25
|
+
|
26
|
+
// Mock the console.error to avoid polluting test output
|
27
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
28
|
+
|
29
|
+
const mockOptions = {
|
30
|
+
apiKey: 'test-api-key',
|
31
|
+
provider: 'bfl' as const,
|
32
|
+
};
|
33
|
+
|
34
|
+
beforeEach(() => {
|
35
|
+
vi.clearAllMocks();
|
36
|
+
});
|
37
|
+
|
38
|
+
afterEach(() => {
|
39
|
+
vi.clearAllMocks();
|
40
|
+
});
|
41
|
+
|
42
|
+
describe('createBflImage', () => {
|
43
|
+
describe('Parameter mapping and defaults', () => {
|
44
|
+
it('should map standard parameters to BFL-specific parameters', async () => {
|
45
|
+
// Arrange
|
46
|
+
const { asyncifyPolling } = await import('../utils/asyncifyPolling');
|
47
|
+
const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
|
48
|
+
|
49
|
+
mockFetch.mockResolvedValueOnce({
|
50
|
+
ok: true,
|
51
|
+
json: () =>
|
52
|
+
Promise.resolve({
|
53
|
+
id: 'task-123',
|
54
|
+
polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
|
55
|
+
}),
|
56
|
+
} as Response);
|
57
|
+
|
58
|
+
mockAsyncifyPolling.mockResolvedValue({
|
59
|
+
imageUrl: 'https://example.com/result.jpg',
|
60
|
+
});
|
61
|
+
|
62
|
+
const payload: CreateImagePayload = {
|
63
|
+
model: 'flux-dev',
|
64
|
+
params: {
|
65
|
+
prompt: 'A beautiful landscape',
|
66
|
+
aspectRatio: '16:9',
|
67
|
+
cfg: 7.5,
|
68
|
+
steps: 20,
|
69
|
+
seed: 12345,
|
70
|
+
},
|
71
|
+
};
|
72
|
+
|
73
|
+
// Act
|
74
|
+
await createBflImage(payload, mockOptions);
|
75
|
+
|
76
|
+
// Assert
|
77
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
78
|
+
'https://api.bfl.ai/v1/flux-dev',
|
79
|
+
expect.objectContaining({
|
80
|
+
method: 'POST',
|
81
|
+
headers: {
|
82
|
+
'Content-Type': 'application/json',
|
83
|
+
'x-key': 'test-api-key',
|
84
|
+
},
|
85
|
+
body: JSON.stringify({
|
86
|
+
output_format: 'png',
|
87
|
+
safety_tolerance: 6,
|
88
|
+
prompt: 'A beautiful landscape',
|
89
|
+
aspect_ratio: '16:9',
|
90
|
+
guidance: 7.5,
|
91
|
+
steps: 20,
|
92
|
+
seed: 12345,
|
93
|
+
}),
|
94
|
+
}),
|
95
|
+
);
|
96
|
+
});
|
97
|
+
|
98
|
+
it('should add raw: true for ultra models', async () => {
|
99
|
+
// Arrange
|
100
|
+
const { asyncifyPolling } = await import('../utils/asyncifyPolling');
|
101
|
+
const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
|
102
|
+
|
103
|
+
mockFetch.mockResolvedValueOnce({
|
104
|
+
ok: true,
|
105
|
+
json: () =>
|
106
|
+
Promise.resolve({
|
107
|
+
id: 'task-123',
|
108
|
+
polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
|
109
|
+
}),
|
110
|
+
} as Response);
|
111
|
+
|
112
|
+
mockAsyncifyPolling.mockResolvedValue({
|
113
|
+
imageUrl: 'https://example.com/result.jpg',
|
114
|
+
});
|
115
|
+
|
116
|
+
const payload: CreateImagePayload = {
|
117
|
+
model: 'flux-pro-1.1-ultra',
|
118
|
+
params: {
|
119
|
+
prompt: 'Ultra quality image',
|
120
|
+
},
|
121
|
+
};
|
122
|
+
|
123
|
+
// Act
|
124
|
+
await createBflImage(payload, mockOptions);
|
125
|
+
|
126
|
+
// Assert
|
127
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
128
|
+
'https://api.bfl.ai/v1/flux-pro-1.1-ultra',
|
129
|
+
expect.objectContaining({
|
130
|
+
body: JSON.stringify({
|
131
|
+
output_format: 'png',
|
132
|
+
safety_tolerance: 6,
|
133
|
+
raw: true,
|
134
|
+
prompt: 'Ultra quality image',
|
135
|
+
}),
|
136
|
+
}),
|
137
|
+
);
|
138
|
+
});
|
139
|
+
|
140
|
+
it('should filter out undefined values', async () => {
|
141
|
+
// Arrange
|
142
|
+
const { asyncifyPolling } = await import('../utils/asyncifyPolling');
|
143
|
+
const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
|
144
|
+
|
145
|
+
mockFetch.mockResolvedValueOnce({
|
146
|
+
ok: true,
|
147
|
+
json: () =>
|
148
|
+
Promise.resolve({
|
149
|
+
id: 'task-123',
|
150
|
+
polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
|
151
|
+
}),
|
152
|
+
} as Response);
|
153
|
+
|
154
|
+
mockAsyncifyPolling.mockResolvedValue({
|
155
|
+
imageUrl: 'https://example.com/result.jpg',
|
156
|
+
});
|
157
|
+
|
158
|
+
const payload: CreateImagePayload = {
|
159
|
+
model: 'flux-dev',
|
160
|
+
params: {
|
161
|
+
prompt: 'Test image',
|
162
|
+
cfg: undefined,
|
163
|
+
seed: 12345,
|
164
|
+
steps: undefined,
|
165
|
+
} as any,
|
166
|
+
};
|
167
|
+
|
168
|
+
// Act
|
169
|
+
await createBflImage(payload, mockOptions);
|
170
|
+
|
171
|
+
// Assert
|
172
|
+
const callArgs = mockFetch.mock.calls[0][1];
|
173
|
+
const requestBody = JSON.parse(callArgs?.body as string);
|
174
|
+
|
175
|
+
expect(requestBody).toEqual({
|
176
|
+
output_format: 'png',
|
177
|
+
safety_tolerance: 6,
|
178
|
+
prompt: 'Test image',
|
179
|
+
seed: 12345,
|
180
|
+
});
|
181
|
+
|
182
|
+
expect(requestBody).not.toHaveProperty('guidance');
|
183
|
+
expect(requestBody).not.toHaveProperty('steps');
|
184
|
+
});
|
185
|
+
});
|
186
|
+
|
187
|
+
describe('Image URL handling', () => {
|
188
|
+
it('should convert single imageUrl to image_prompt base64', async () => {
|
189
|
+
// Arrange
|
190
|
+
const { parseDataUri } = await import('../utils/uriParser');
|
191
|
+
const { imageUrlToBase64 } = await import('@/utils/imageToBase64');
|
192
|
+
const { asyncifyPolling } = await import('../utils/asyncifyPolling');
|
193
|
+
|
194
|
+
const mockParseDataUri = vi.mocked(parseDataUri);
|
195
|
+
const mockImageUrlToBase64 = vi.mocked(imageUrlToBase64);
|
196
|
+
const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
|
197
|
+
|
198
|
+
mockParseDataUri.mockReturnValue({ type: 'url', base64: null, mimeType: null });
|
199
|
+
mockImageUrlToBase64.mockResolvedValue({
|
200
|
+
base64: 'base64EncodedImage',
|
201
|
+
mimeType: 'image/jpeg',
|
202
|
+
});
|
203
|
+
|
204
|
+
mockFetch.mockResolvedValueOnce({
|
205
|
+
ok: true,
|
206
|
+
json: () =>
|
207
|
+
Promise.resolve({
|
208
|
+
id: 'task-123',
|
209
|
+
polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
|
210
|
+
}),
|
211
|
+
} as Response);
|
212
|
+
|
213
|
+
mockAsyncifyPolling.mockResolvedValue({
|
214
|
+
imageUrl: 'https://example.com/result.jpg',
|
215
|
+
});
|
216
|
+
|
217
|
+
const payload: CreateImagePayload = {
|
218
|
+
model: 'flux-pro-1.1',
|
219
|
+
params: {
|
220
|
+
prompt: 'Transform this image',
|
221
|
+
imageUrl: 'https://example.com/input.jpg',
|
222
|
+
},
|
223
|
+
};
|
224
|
+
|
225
|
+
// Act
|
226
|
+
await createBflImage(payload, mockOptions);
|
227
|
+
|
228
|
+
// Assert
|
229
|
+
expect(mockParseDataUri).toHaveBeenCalledWith('https://example.com/input.jpg');
|
230
|
+
expect(mockImageUrlToBase64).toHaveBeenCalledWith('https://example.com/input.jpg');
|
231
|
+
|
232
|
+
const callArgs = mockFetch.mock.calls[0][1];
|
233
|
+
const requestBody = JSON.parse(callArgs?.body as string);
|
234
|
+
|
235
|
+
expect(requestBody).toEqual({
|
236
|
+
output_format: 'png',
|
237
|
+
safety_tolerance: 6,
|
238
|
+
prompt: 'Transform this image',
|
239
|
+
image_prompt: 'base64EncodedImage',
|
240
|
+
});
|
241
|
+
|
242
|
+
expect(requestBody).not.toHaveProperty('imageUrl');
|
243
|
+
});
|
244
|
+
|
245
|
+
it('should handle base64 imageUrl directly', async () => {
|
246
|
+
// Arrange
|
247
|
+
const { parseDataUri } = await import('../utils/uriParser');
|
248
|
+
const { asyncifyPolling } = await import('../utils/asyncifyPolling');
|
249
|
+
|
250
|
+
const mockParseDataUri = vi.mocked(parseDataUri);
|
251
|
+
const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
|
252
|
+
|
253
|
+
mockParseDataUri.mockReturnValue({
|
254
|
+
type: 'base64',
|
255
|
+
base64: '/9j/4AAQSkZJRgABAQEAYABgAAD',
|
256
|
+
mimeType: 'image/jpeg',
|
257
|
+
});
|
258
|
+
|
259
|
+
mockFetch.mockResolvedValueOnce({
|
260
|
+
ok: true,
|
261
|
+
json: () =>
|
262
|
+
Promise.resolve({
|
263
|
+
id: 'task-123',
|
264
|
+
polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
|
265
|
+
}),
|
266
|
+
} as Response);
|
267
|
+
|
268
|
+
mockAsyncifyPolling.mockResolvedValue({
|
269
|
+
imageUrl: 'https://example.com/result.jpg',
|
270
|
+
});
|
271
|
+
|
272
|
+
const base64Image = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD';
|
273
|
+
const payload: CreateImagePayload = {
|
274
|
+
model: 'flux-pro-1.1',
|
275
|
+
params: {
|
276
|
+
prompt: 'Transform this image',
|
277
|
+
imageUrl: base64Image,
|
278
|
+
},
|
279
|
+
};
|
280
|
+
|
281
|
+
// Act
|
282
|
+
await createBflImage(payload, mockOptions);
|
283
|
+
|
284
|
+
// Assert
|
285
|
+
const callArgs = mockFetch.mock.calls[0][1];
|
286
|
+
const requestBody = JSON.parse(callArgs?.body as string);
|
287
|
+
|
288
|
+
expect(requestBody.image_prompt).toBe('/9j/4AAQSkZJRgABAQEAYABgAAD');
|
289
|
+
});
|
290
|
+
|
291
|
+
it('should convert multiple imageUrls for Kontext models', async () => {
|
292
|
+
// Arrange
|
293
|
+
const { parseDataUri } = await import('../utils/uriParser');
|
294
|
+
const { imageUrlToBase64 } = await import('@/utils/imageToBase64');
|
295
|
+
const { asyncifyPolling } = await import('../utils/asyncifyPolling');
|
296
|
+
|
297
|
+
const mockParseDataUri = vi.mocked(parseDataUri);
|
298
|
+
const mockImageUrlToBase64 = vi.mocked(imageUrlToBase64);
|
299
|
+
const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
|
300
|
+
|
301
|
+
mockParseDataUri.mockReturnValue({ type: 'url', base64: null, mimeType: null });
|
302
|
+
mockImageUrlToBase64
|
303
|
+
.mockResolvedValueOnce({ base64: 'base64image1', mimeType: 'image/jpeg' })
|
304
|
+
.mockResolvedValueOnce({ base64: 'base64image2', mimeType: 'image/jpeg' })
|
305
|
+
.mockResolvedValueOnce({ base64: 'base64image3', mimeType: 'image/jpeg' });
|
306
|
+
|
307
|
+
mockFetch.mockResolvedValueOnce({
|
308
|
+
ok: true,
|
309
|
+
json: () =>
|
310
|
+
Promise.resolve({
|
311
|
+
id: 'task-123',
|
312
|
+
polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
|
313
|
+
}),
|
314
|
+
} as Response);
|
315
|
+
|
316
|
+
mockAsyncifyPolling.mockResolvedValue({
|
317
|
+
imageUrl: 'https://example.com/result.jpg',
|
318
|
+
});
|
319
|
+
|
320
|
+
const payload: CreateImagePayload = {
|
321
|
+
model: 'flux-kontext-pro',
|
322
|
+
params: {
|
323
|
+
prompt: 'Create variation of these images',
|
324
|
+
imageUrls: [
|
325
|
+
'https://example.com/input1.jpg',
|
326
|
+
'https://example.com/input2.jpg',
|
327
|
+
'https://example.com/input3.jpg',
|
328
|
+
],
|
329
|
+
},
|
330
|
+
};
|
331
|
+
|
332
|
+
// Act
|
333
|
+
await createBflImage(payload, mockOptions);
|
334
|
+
|
335
|
+
// Assert
|
336
|
+
const callArgs = mockFetch.mock.calls[0][1];
|
337
|
+
const requestBody = JSON.parse(callArgs?.body as string);
|
338
|
+
|
339
|
+
expect(requestBody).toEqual({
|
340
|
+
output_format: 'png',
|
341
|
+
safety_tolerance: 6,
|
342
|
+
prompt: 'Create variation of these images',
|
343
|
+
input_image: 'base64image1',
|
344
|
+
input_image_2: 'base64image2',
|
345
|
+
input_image_3: 'base64image3',
|
346
|
+
});
|
347
|
+
|
348
|
+
expect(requestBody).not.toHaveProperty('imageUrls');
|
349
|
+
});
|
350
|
+
|
351
|
+
it('should limit imageUrls to maximum 4 images', async () => {
|
352
|
+
// Arrange
|
353
|
+
const { parseDataUri } = await import('../utils/uriParser');
|
354
|
+
const { imageUrlToBase64 } = await import('@/utils/imageToBase64');
|
355
|
+
const { asyncifyPolling } = await import('../utils/asyncifyPolling');
|
356
|
+
|
357
|
+
const mockParseDataUri = vi.mocked(parseDataUri);
|
358
|
+
const mockImageUrlToBase64 = vi.mocked(imageUrlToBase64);
|
359
|
+
const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
|
360
|
+
|
361
|
+
mockParseDataUri.mockReturnValue({ type: 'url', base64: null, mimeType: null });
|
362
|
+
mockImageUrlToBase64
|
363
|
+
.mockResolvedValueOnce({ base64: 'base64image1', mimeType: 'image/jpeg' })
|
364
|
+
.mockResolvedValueOnce({ base64: 'base64image2', mimeType: 'image/jpeg' })
|
365
|
+
.mockResolvedValueOnce({ base64: 'base64image3', mimeType: 'image/jpeg' })
|
366
|
+
.mockResolvedValueOnce({ base64: 'base64image4', mimeType: 'image/jpeg' });
|
367
|
+
|
368
|
+
mockFetch.mockResolvedValueOnce({
|
369
|
+
ok: true,
|
370
|
+
json: () =>
|
371
|
+
Promise.resolve({
|
372
|
+
id: 'task-123',
|
373
|
+
polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
|
374
|
+
}),
|
375
|
+
} as Response);
|
376
|
+
|
377
|
+
mockAsyncifyPolling.mockResolvedValue({
|
378
|
+
imageUrl: 'https://example.com/result.jpg',
|
379
|
+
});
|
380
|
+
|
381
|
+
const payload: CreateImagePayload = {
|
382
|
+
model: 'flux-kontext-max',
|
383
|
+
params: {
|
384
|
+
prompt: 'Create variation of these images',
|
385
|
+
imageUrls: [
|
386
|
+
'https://example.com/input1.jpg',
|
387
|
+
'https://example.com/input2.jpg',
|
388
|
+
'https://example.com/input3.jpg',
|
389
|
+
'https://example.com/input4.jpg',
|
390
|
+
'https://example.com/input5.jpg', // This should be ignored
|
391
|
+
],
|
392
|
+
},
|
393
|
+
};
|
394
|
+
|
395
|
+
// Act
|
396
|
+
await createBflImage(payload, mockOptions);
|
397
|
+
|
398
|
+
// Assert
|
399
|
+
expect(mockImageUrlToBase64).toHaveBeenCalledTimes(4);
|
400
|
+
|
401
|
+
const callArgs = mockFetch.mock.calls[0][1];
|
402
|
+
const requestBody = JSON.parse(callArgs?.body as string);
|
403
|
+
|
404
|
+
expect(requestBody).toEqual({
|
405
|
+
output_format: 'png',
|
406
|
+
safety_tolerance: 6,
|
407
|
+
prompt: 'Create variation of these images',
|
408
|
+
input_image: 'base64image1',
|
409
|
+
input_image_2: 'base64image2',
|
410
|
+
input_image_3: 'base64image3',
|
411
|
+
input_image_4: 'base64image4',
|
412
|
+
});
|
413
|
+
|
414
|
+
expect(requestBody).not.toHaveProperty('input_image_5');
|
415
|
+
});
|
416
|
+
});
|
417
|
+
|
418
|
+
describe('Model endpoint mapping', () => {
|
419
|
+
it('should map models to correct endpoints', async () => {
|
420
|
+
// Arrange
|
421
|
+
const { asyncifyPolling } = await import('../utils/asyncifyPolling');
|
422
|
+
const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
|
423
|
+
|
424
|
+
mockFetch.mockResolvedValue({
|
425
|
+
ok: true,
|
426
|
+
json: () =>
|
427
|
+
Promise.resolve({
|
428
|
+
id: 'task-123',
|
429
|
+
polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
|
430
|
+
}),
|
431
|
+
} as Response);
|
432
|
+
|
433
|
+
mockAsyncifyPolling.mockResolvedValue({
|
434
|
+
imageUrl: 'https://example.com/result.jpg',
|
435
|
+
});
|
436
|
+
|
437
|
+
const testCases = [
|
438
|
+
{ model: 'flux-dev', endpoint: '/v1/flux-dev' },
|
439
|
+
{ model: 'flux-pro', endpoint: '/v1/flux-pro' },
|
440
|
+
{ model: 'flux-pro-1.1', endpoint: '/v1/flux-pro-1.1' },
|
441
|
+
{ model: 'flux-pro-1.1-ultra', endpoint: '/v1/flux-pro-1.1-ultra' },
|
442
|
+
{ model: 'flux-kontext-pro', endpoint: '/v1/flux-kontext-pro' },
|
443
|
+
{ model: 'flux-kontext-max', endpoint: '/v1/flux-kontext-max' },
|
444
|
+
];
|
445
|
+
|
446
|
+
// Act & Assert
|
447
|
+
for (const { model, endpoint } of testCases) {
|
448
|
+
vi.clearAllMocks();
|
449
|
+
|
450
|
+
const payload: CreateImagePayload = {
|
451
|
+
model,
|
452
|
+
params: {
|
453
|
+
prompt: `Test image for ${model}`,
|
454
|
+
},
|
455
|
+
};
|
456
|
+
|
457
|
+
await createBflImage(payload, mockOptions);
|
458
|
+
|
459
|
+
expect(mockFetch).toHaveBeenCalledWith(`https://api.bfl.ai${endpoint}`, expect.any(Object));
|
460
|
+
}
|
461
|
+
});
|
462
|
+
|
463
|
+
it('should throw error for unsupported model', async () => {
|
464
|
+
// Arrange
|
465
|
+
const payload: CreateImagePayload = {
|
466
|
+
model: 'unsupported-model',
|
467
|
+
params: {
|
468
|
+
prompt: 'Test image',
|
469
|
+
},
|
470
|
+
};
|
471
|
+
|
472
|
+
// Act & Assert
|
473
|
+
await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({
|
474
|
+
error: expect.objectContaining({
|
475
|
+
message: 'Unsupported BFL model: unsupported-model',
|
476
|
+
}),
|
477
|
+
errorType: 'ModelNotFound',
|
478
|
+
provider: 'bfl',
|
479
|
+
});
|
480
|
+
});
|
481
|
+
|
482
|
+
it('should use custom baseURL when provided', async () => {
|
483
|
+
// Arrange
|
484
|
+
const { asyncifyPolling } = await import('../utils/asyncifyPolling');
|
485
|
+
const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
|
486
|
+
|
487
|
+
mockFetch.mockResolvedValueOnce({
|
488
|
+
ok: true,
|
489
|
+
json: () =>
|
490
|
+
Promise.resolve({
|
491
|
+
id: 'task-123',
|
492
|
+
polling_url: 'https://custom-api.bfl.ai/v1/get_result?id=task-123',
|
493
|
+
}),
|
494
|
+
} as Response);
|
495
|
+
|
496
|
+
mockAsyncifyPolling.mockResolvedValue({
|
497
|
+
imageUrl: 'https://example.com/result.jpg',
|
498
|
+
});
|
499
|
+
|
500
|
+
const customOptions = {
|
501
|
+
...mockOptions,
|
502
|
+
baseURL: 'https://custom-api.bfl.ai',
|
503
|
+
};
|
504
|
+
|
505
|
+
const payload: CreateImagePayload = {
|
506
|
+
model: 'flux-dev',
|
507
|
+
params: {
|
508
|
+
prompt: 'Test with custom URL',
|
509
|
+
},
|
510
|
+
};
|
511
|
+
|
512
|
+
// Act
|
513
|
+
await createBflImage(payload, customOptions);
|
514
|
+
|
515
|
+
// Assert
|
516
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
517
|
+
'https://custom-api.bfl.ai/v1/flux-dev',
|
518
|
+
expect.any(Object),
|
519
|
+
);
|
520
|
+
});
|
521
|
+
});
|
522
|
+
|
523
|
+
describe('Status handling', () => {
|
524
|
+
it('should return success when status is Ready with result', async () => {
|
525
|
+
// Arrange
|
526
|
+
const { asyncifyPolling } = await import('../utils/asyncifyPolling');
|
527
|
+
const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
|
528
|
+
|
529
|
+
mockFetch.mockResolvedValueOnce({
|
530
|
+
ok: true,
|
531
|
+
json: () =>
|
532
|
+
Promise.resolve({
|
533
|
+
id: 'task-123',
|
534
|
+
polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
|
535
|
+
}),
|
536
|
+
} as Response);
|
537
|
+
|
538
|
+
// Mock the asyncifyPolling to call checkStatus with Ready status
|
539
|
+
mockAsyncifyPolling.mockImplementation(async ({ checkStatus }) => {
|
540
|
+
const result = checkStatus({
|
541
|
+
id: 'task-123',
|
542
|
+
status: BflStatusResponse.Ready,
|
543
|
+
result: {
|
544
|
+
sample: 'https://example.com/generated-image.jpg',
|
545
|
+
},
|
546
|
+
});
|
547
|
+
|
548
|
+
if (result.status === 'success') {
|
549
|
+
return result.data;
|
550
|
+
}
|
551
|
+
throw result.error;
|
552
|
+
});
|
553
|
+
|
554
|
+
const payload: CreateImagePayload = {
|
555
|
+
model: 'flux-dev',
|
556
|
+
params: {
|
557
|
+
prompt: 'Test successful generation',
|
558
|
+
},
|
559
|
+
};
|
560
|
+
|
561
|
+
// Act
|
562
|
+
const result = await createBflImage(payload, mockOptions);
|
563
|
+
|
564
|
+
// Assert
|
565
|
+
expect(result).toEqual({
|
566
|
+
imageUrl: 'https://example.com/generated-image.jpg',
|
567
|
+
});
|
568
|
+
});
|
569
|
+
|
570
|
+
it('should throw error when status is Ready but no result', async () => {
|
571
|
+
// Arrange
|
572
|
+
const { asyncifyPolling } = await import('../utils/asyncifyPolling');
|
573
|
+
const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
|
574
|
+
|
575
|
+
mockFetch.mockResolvedValueOnce({
|
576
|
+
ok: true,
|
577
|
+
json: () =>
|
578
|
+
Promise.resolve({
|
579
|
+
id: 'task-123',
|
580
|
+
polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
|
581
|
+
}),
|
582
|
+
} as Response);
|
583
|
+
|
584
|
+
mockAsyncifyPolling.mockImplementation(async ({ checkStatus }) => {
|
585
|
+
const result = checkStatus({
|
586
|
+
id: 'task-123',
|
587
|
+
status: BflStatusResponse.Ready,
|
588
|
+
result: null,
|
589
|
+
});
|
590
|
+
|
591
|
+
if (result.status === 'success') {
|
592
|
+
return result.data;
|
593
|
+
}
|
594
|
+
throw result.error;
|
595
|
+
});
|
596
|
+
|
597
|
+
const payload: CreateImagePayload = {
|
598
|
+
model: 'flux-dev',
|
599
|
+
params: {
|
600
|
+
prompt: 'Test no result error',
|
601
|
+
},
|
602
|
+
};
|
603
|
+
|
604
|
+
// Act & Assert
|
605
|
+
await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({
|
606
|
+
error: expect.any(Object),
|
607
|
+
errorType: 'ProviderBizError',
|
608
|
+
provider: 'bfl',
|
609
|
+
});
|
610
|
+
});
|
611
|
+
|
612
|
+
it('should handle error statuses', async () => {
|
613
|
+
// Arrange
|
614
|
+
const { asyncifyPolling } = await import('../utils/asyncifyPolling');
|
615
|
+
const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
|
616
|
+
|
617
|
+
mockFetch.mockResolvedValueOnce({
|
618
|
+
ok: true,
|
619
|
+
json: () =>
|
620
|
+
Promise.resolve({
|
621
|
+
id: 'task-123',
|
622
|
+
polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
|
623
|
+
}),
|
624
|
+
} as Response);
|
625
|
+
|
626
|
+
const errorStatuses = [
|
627
|
+
BflStatusResponse.Error,
|
628
|
+
BflStatusResponse.ContentModerated,
|
629
|
+
BflStatusResponse.RequestModerated,
|
630
|
+
];
|
631
|
+
|
632
|
+
for (const status of errorStatuses) {
|
633
|
+
mockAsyncifyPolling.mockImplementation(async ({ checkStatus }) => {
|
634
|
+
const result = checkStatus({
|
635
|
+
id: 'task-123',
|
636
|
+
status,
|
637
|
+
details: { error: 'Test error details' },
|
638
|
+
});
|
639
|
+
|
640
|
+
if (result.status === 'success') {
|
641
|
+
return result.data;
|
642
|
+
}
|
643
|
+
throw result.error;
|
644
|
+
});
|
645
|
+
|
646
|
+
const payload: CreateImagePayload = {
|
647
|
+
model: 'flux-dev',
|
648
|
+
params: {
|
649
|
+
prompt: `Test ${status} error`,
|
650
|
+
},
|
651
|
+
};
|
652
|
+
|
653
|
+
// Act & Assert
|
654
|
+
await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({
|
655
|
+
error: expect.any(Object),
|
656
|
+
errorType: 'ProviderBizError',
|
657
|
+
provider: 'bfl',
|
658
|
+
});
|
659
|
+
}
|
660
|
+
});
|
661
|
+
|
662
|
+
it('should handle TaskNotFound status', async () => {
|
663
|
+
// Arrange
|
664
|
+
const { asyncifyPolling } = await import('../utils/asyncifyPolling');
|
665
|
+
const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
|
666
|
+
|
667
|
+
mockFetch.mockResolvedValueOnce({
|
668
|
+
ok: true,
|
669
|
+
json: () =>
|
670
|
+
Promise.resolve({
|
671
|
+
id: 'task-123',
|
672
|
+
polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
|
673
|
+
}),
|
674
|
+
} as Response);
|
675
|
+
|
676
|
+
mockAsyncifyPolling.mockImplementation(async ({ checkStatus }) => {
|
677
|
+
const result = checkStatus({
|
678
|
+
id: 'task-123',
|
679
|
+
status: BflStatusResponse.TaskNotFound,
|
680
|
+
});
|
681
|
+
|
682
|
+
if (result.status === 'success') {
|
683
|
+
return result.data;
|
684
|
+
}
|
685
|
+
throw result.error;
|
686
|
+
});
|
687
|
+
|
688
|
+
const payload: CreateImagePayload = {
|
689
|
+
model: 'flux-dev',
|
690
|
+
params: {
|
691
|
+
prompt: 'Test task not found',
|
692
|
+
},
|
693
|
+
};
|
694
|
+
|
695
|
+
// Act & Assert
|
696
|
+
await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({
|
697
|
+
error: expect.any(Object),
|
698
|
+
errorType: 'ProviderBizError',
|
699
|
+
provider: 'bfl',
|
700
|
+
});
|
701
|
+
});
|
702
|
+
|
703
|
+
it('should continue polling for Pending status', async () => {
|
704
|
+
// Arrange
|
705
|
+
const { asyncifyPolling } = await import('../utils/asyncifyPolling');
|
706
|
+
const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
|
707
|
+
|
708
|
+
mockFetch.mockResolvedValueOnce({
|
709
|
+
ok: true,
|
710
|
+
json: () =>
|
711
|
+
Promise.resolve({
|
712
|
+
id: 'task-123',
|
713
|
+
polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
|
714
|
+
}),
|
715
|
+
} as Response);
|
716
|
+
|
717
|
+
mockAsyncifyPolling.mockImplementation(async ({ checkStatus }) => {
|
718
|
+
// First call - Pending status
|
719
|
+
const pendingResult = checkStatus({
|
720
|
+
id: 'task-123',
|
721
|
+
status: BflStatusResponse.Pending,
|
722
|
+
});
|
723
|
+
|
724
|
+
expect(pendingResult.status).toBe('pending');
|
725
|
+
|
726
|
+
// Simulate successful completion
|
727
|
+
const successResult = checkStatus({
|
728
|
+
id: 'task-123',
|
729
|
+
status: BflStatusResponse.Ready,
|
730
|
+
result: {
|
731
|
+
sample: 'https://example.com/generated-image.jpg',
|
732
|
+
},
|
733
|
+
});
|
734
|
+
|
735
|
+
return successResult.data;
|
736
|
+
});
|
737
|
+
|
738
|
+
const payload: CreateImagePayload = {
|
739
|
+
model: 'flux-dev',
|
740
|
+
params: {
|
741
|
+
prompt: 'Test pending status',
|
742
|
+
},
|
743
|
+
};
|
744
|
+
|
745
|
+
// Act
|
746
|
+
const result = await createBflImage(payload, mockOptions);
|
747
|
+
|
748
|
+
// Assert
|
749
|
+
expect(result).toEqual({
|
750
|
+
imageUrl: 'https://example.com/generated-image.jpg',
|
751
|
+
});
|
752
|
+
});
|
753
|
+
});
|
754
|
+
|
755
|
+
describe('Error handling', () => {
|
756
|
+
it('should handle fetch errors during task submission', async () => {
|
757
|
+
// Arrange
|
758
|
+
mockFetch.mockRejectedValue(new Error('Network error'));
|
759
|
+
|
760
|
+
const payload: CreateImagePayload = {
|
761
|
+
model: 'flux-dev',
|
762
|
+
params: {
|
763
|
+
prompt: 'Test network error',
|
764
|
+
},
|
765
|
+
};
|
766
|
+
|
767
|
+
// Act & Assert
|
768
|
+
await expect(createBflImage(payload, mockOptions)).rejects.toThrow();
|
769
|
+
});
|
770
|
+
|
771
|
+
it('should handle HTTP error responses', async () => {
|
772
|
+
// Arrange
|
773
|
+
mockFetch.mockResolvedValueOnce({
|
774
|
+
ok: false,
|
775
|
+
status: 400,
|
776
|
+
statusText: 'Bad Request',
|
777
|
+
json: () =>
|
778
|
+
Promise.resolve({
|
779
|
+
detail: [{ msg: 'Invalid prompt' }],
|
780
|
+
}),
|
781
|
+
} as Response);
|
782
|
+
|
783
|
+
const payload: CreateImagePayload = {
|
784
|
+
model: 'flux-dev',
|
785
|
+
params: {
|
786
|
+
prompt: 'Test HTTP error',
|
787
|
+
},
|
788
|
+
};
|
789
|
+
|
790
|
+
// Act & Assert
|
791
|
+
await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({
|
792
|
+
error: expect.any(Object),
|
793
|
+
errorType: 'ProviderBizError',
|
794
|
+
provider: 'bfl',
|
795
|
+
});
|
796
|
+
});
|
797
|
+
|
798
|
+
it('should handle HTTP error responses without detail', async () => {
|
799
|
+
// Arrange
|
800
|
+
mockFetch.mockResolvedValueOnce({
|
801
|
+
ok: false,
|
802
|
+
status: 500,
|
803
|
+
statusText: 'Internal Server Error',
|
804
|
+
json: () => Promise.resolve({}),
|
805
|
+
} as Response);
|
806
|
+
|
807
|
+
const payload: CreateImagePayload = {
|
808
|
+
model: 'flux-dev',
|
809
|
+
params: {
|
810
|
+
prompt: 'Test HTTP error without detail',
|
811
|
+
},
|
812
|
+
};
|
813
|
+
|
814
|
+
// Act & Assert
|
815
|
+
await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({
|
816
|
+
error: expect.any(Object),
|
817
|
+
errorType: 'ProviderBizError',
|
818
|
+
provider: 'bfl',
|
819
|
+
});
|
820
|
+
});
|
821
|
+
|
822
|
+
it('should handle non-JSON error responses', async () => {
|
823
|
+
// Arrange
|
824
|
+
mockFetch.mockResolvedValueOnce({
|
825
|
+
ok: false,
|
826
|
+
status: 500,
|
827
|
+
statusText: 'Internal Server Error',
|
828
|
+
json: () => Promise.reject(new Error('Invalid JSON')),
|
829
|
+
} as Response);
|
830
|
+
|
831
|
+
const payload: CreateImagePayload = {
|
832
|
+
model: 'flux-dev',
|
833
|
+
params: {
|
834
|
+
prompt: 'Test non-JSON error',
|
835
|
+
},
|
836
|
+
};
|
837
|
+
|
838
|
+
// Act & Assert
|
839
|
+
await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({
|
840
|
+
error: expect.any(Object),
|
841
|
+
errorType: 'ProviderBizError',
|
842
|
+
provider: 'bfl',
|
843
|
+
});
|
844
|
+
});
|
845
|
+
});
|
846
|
+
});
|