@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,279 @@
|
|
1
|
+
import createDebug from 'debug';
|
2
|
+
|
3
|
+
import { RuntimeImageGenParamsValue } from '@/libs/standard-parameters/index';
|
4
|
+
import { imageUrlToBase64 } from '@/utils/imageToBase64';
|
5
|
+
|
6
|
+
import { AgentRuntimeErrorType } from '../error';
|
7
|
+
import { CreateImagePayload, CreateImageResponse } from '../types/image';
|
8
|
+
import { type TaskResult, asyncifyPolling } from '../utils/asyncifyPolling';
|
9
|
+
import { AgentRuntimeError } from '../utils/createError';
|
10
|
+
import { parseDataUri } from '../utils/uriParser';
|
11
|
+
import {
|
12
|
+
BFL_ENDPOINTS,
|
13
|
+
BflAsyncResponse,
|
14
|
+
BflModelId,
|
15
|
+
BflRequest,
|
16
|
+
BflResultResponse,
|
17
|
+
BflStatusResponse,
|
18
|
+
} from './types';
|
19
|
+
|
20
|
+
const log = createDebug('lobe-image:bfl');
|
21
|
+
|
22
|
+
const BASE_URL = 'https://api.bfl.ai';
|
23
|
+
|
24
|
+
interface BflCreateImageOptions {
|
25
|
+
apiKey: string;
|
26
|
+
baseURL?: string;
|
27
|
+
provider: string;
|
28
|
+
}
|
29
|
+
|
30
|
+
/**
|
31
|
+
* Convert image URL to base64 format required by BFL API
|
32
|
+
*/
|
33
|
+
async function convertImageToBase64(imageUrl: string): Promise<string> {
|
34
|
+
try {
|
35
|
+
const { type } = parseDataUri(imageUrl);
|
36
|
+
|
37
|
+
if (type === 'base64') {
|
38
|
+
// Already in base64 format, extract the base64 part
|
39
|
+
const base64Match = imageUrl.match(/^data:[^;]+;base64,(.+)$/);
|
40
|
+
if (base64Match) {
|
41
|
+
return base64Match[1];
|
42
|
+
}
|
43
|
+
throw new Error('Invalid base64 format');
|
44
|
+
}
|
45
|
+
|
46
|
+
if (type === 'url') {
|
47
|
+
// Convert URL to base64
|
48
|
+
const { base64 } = await imageUrlToBase64(imageUrl);
|
49
|
+
return base64;
|
50
|
+
}
|
51
|
+
|
52
|
+
throw new Error(`Invalid image URL format: ${imageUrl}`);
|
53
|
+
} catch (error) {
|
54
|
+
log('Error converting image to base64: %O', error);
|
55
|
+
throw error;
|
56
|
+
}
|
57
|
+
}
|
58
|
+
|
59
|
+
/**
|
60
|
+
* Build request payload for different BFL models
|
61
|
+
*/
|
62
|
+
async function buildRequestPayload(
|
63
|
+
model: BflModelId,
|
64
|
+
params: CreateImagePayload['params'],
|
65
|
+
): Promise<BflRequest> {
|
66
|
+
log('Building request payload for model: %s', model);
|
67
|
+
|
68
|
+
// Define parameter mapping (BFL API specific)
|
69
|
+
const paramsMap = new Map<RuntimeImageGenParamsValue, string>([
|
70
|
+
['aspectRatio', 'aspect_ratio'],
|
71
|
+
['cfg', 'guidance'],
|
72
|
+
]);
|
73
|
+
|
74
|
+
// Fixed parameters for all BFL models
|
75
|
+
const defaultPayload: Record<string, unknown> = {
|
76
|
+
output_format: 'png',
|
77
|
+
safety_tolerance: 6,
|
78
|
+
...(model.includes('ultra') && { raw: true }),
|
79
|
+
};
|
80
|
+
|
81
|
+
// Map user parameters, filtering out undefined values
|
82
|
+
const userPayload: Record<string, unknown> = Object.fromEntries(
|
83
|
+
(Object.entries(params) as [keyof typeof params, any][])
|
84
|
+
.filter(([, value]) => value !== undefined)
|
85
|
+
.map(([key, value]) => [paramsMap.get(key) ?? key, value]),
|
86
|
+
);
|
87
|
+
|
88
|
+
// Handle multiple input images (imageUrls) for Kontext models
|
89
|
+
if (params.imageUrls && params.imageUrls.length > 0) {
|
90
|
+
for (let i = 0; i < Math.min(params.imageUrls.length, 4); i++) {
|
91
|
+
const fieldName = i === 0 ? 'input_image' : `input_image_${i + 1}`;
|
92
|
+
userPayload[fieldName] = await convertImageToBase64(params.imageUrls[i]);
|
93
|
+
}
|
94
|
+
// Remove the original imageUrls field as it's now mapped to input_image_*
|
95
|
+
delete userPayload.imageUrls;
|
96
|
+
}
|
97
|
+
|
98
|
+
// Handle single image input (imageUrl)
|
99
|
+
if (params.imageUrl) {
|
100
|
+
userPayload.image_prompt = await convertImageToBase64(params.imageUrl);
|
101
|
+
// Remove the original imageUrl field as it's now mapped to image_prompt
|
102
|
+
delete userPayload.imageUrl;
|
103
|
+
}
|
104
|
+
|
105
|
+
// Combine default and user payload
|
106
|
+
const payload = {
|
107
|
+
...defaultPayload,
|
108
|
+
...userPayload,
|
109
|
+
};
|
110
|
+
|
111
|
+
return payload as BflRequest;
|
112
|
+
}
|
113
|
+
|
114
|
+
/**
|
115
|
+
* Submit image generation task to BFL API
|
116
|
+
*/
|
117
|
+
async function submitTask(
|
118
|
+
model: BflModelId,
|
119
|
+
payload: BflRequest,
|
120
|
+
options: BflCreateImageOptions,
|
121
|
+
): Promise<BflAsyncResponse> {
|
122
|
+
const endpoint = BFL_ENDPOINTS[model];
|
123
|
+
const url = `${options.baseURL || BASE_URL}${endpoint}`;
|
124
|
+
|
125
|
+
log('Submitting task to: %s', url);
|
126
|
+
|
127
|
+
const response = await fetch(url, {
|
128
|
+
body: JSON.stringify(payload),
|
129
|
+
headers: {
|
130
|
+
'Content-Type': 'application/json',
|
131
|
+
'x-key': options.apiKey,
|
132
|
+
},
|
133
|
+
method: 'POST',
|
134
|
+
});
|
135
|
+
|
136
|
+
if (!response.ok) {
|
137
|
+
let errorData;
|
138
|
+
try {
|
139
|
+
errorData = await response.json();
|
140
|
+
} catch {
|
141
|
+
// Failed to parse JSON error response
|
142
|
+
}
|
143
|
+
|
144
|
+
throw new Error(
|
145
|
+
`BFL API error (${response.status}): ${errorData?.detail?.[0]?.msg || response.statusText}`,
|
146
|
+
);
|
147
|
+
}
|
148
|
+
|
149
|
+
const data: BflAsyncResponse = await response.json();
|
150
|
+
log('Task submitted successfully with ID: %s', data.id);
|
151
|
+
|
152
|
+
return data;
|
153
|
+
}
|
154
|
+
|
155
|
+
/**
|
156
|
+
* Query task status using BFL API
|
157
|
+
*/
|
158
|
+
async function queryTaskStatus(
|
159
|
+
pollingUrl: string,
|
160
|
+
options: BflCreateImageOptions,
|
161
|
+
): Promise<BflResultResponse> {
|
162
|
+
log('Querying task status using polling URL: %s', pollingUrl);
|
163
|
+
|
164
|
+
const response = await fetch(pollingUrl, {
|
165
|
+
headers: {
|
166
|
+
'accept': 'application/json',
|
167
|
+
'x-key': options.apiKey,
|
168
|
+
},
|
169
|
+
method: 'GET',
|
170
|
+
});
|
171
|
+
|
172
|
+
if (!response.ok) {
|
173
|
+
let errorData;
|
174
|
+
try {
|
175
|
+
errorData = await response.json();
|
176
|
+
} catch {
|
177
|
+
// Failed to parse JSON error response
|
178
|
+
}
|
179
|
+
|
180
|
+
throw new Error(
|
181
|
+
`Failed to query task status (${response.status}): ${errorData?.detail?.[0]?.msg || response.statusText}`,
|
182
|
+
);
|
183
|
+
}
|
184
|
+
|
185
|
+
return response.json();
|
186
|
+
}
|
187
|
+
|
188
|
+
/**
|
189
|
+
* Create image using BFL API with async task polling
|
190
|
+
*/
|
191
|
+
export async function createBflImage(
|
192
|
+
payload: CreateImagePayload,
|
193
|
+
options: BflCreateImageOptions,
|
194
|
+
): Promise<CreateImageResponse> {
|
195
|
+
const { model, params } = payload;
|
196
|
+
|
197
|
+
if (!BFL_ENDPOINTS[model as BflModelId]) {
|
198
|
+
throw AgentRuntimeError.createImage({
|
199
|
+
error: new Error(`Unsupported BFL model: ${model}`),
|
200
|
+
errorType: AgentRuntimeErrorType.ModelNotFound,
|
201
|
+
provider: options.provider,
|
202
|
+
});
|
203
|
+
}
|
204
|
+
|
205
|
+
try {
|
206
|
+
// 1. Build request payload
|
207
|
+
const requestPayload = await buildRequestPayload(model as BflModelId, params);
|
208
|
+
|
209
|
+
// 2. Submit image generation task
|
210
|
+
const taskResponse = await submitTask(model as BflModelId, requestPayload, options);
|
211
|
+
|
212
|
+
// 3. Poll task status until completion using asyncifyPolling
|
213
|
+
return await asyncifyPolling<BflResultResponse, CreateImageResponse>({
|
214
|
+
checkStatus: (taskStatus: BflResultResponse): TaskResult<CreateImageResponse> => {
|
215
|
+
log('Task %s status: %s', taskResponse.id, taskStatus.status);
|
216
|
+
|
217
|
+
switch (taskStatus.status) {
|
218
|
+
case BflStatusResponse.Ready: {
|
219
|
+
if (!taskStatus.result?.sample) {
|
220
|
+
return {
|
221
|
+
error: new Error('Task succeeded but no image generated'),
|
222
|
+
status: 'failed',
|
223
|
+
};
|
224
|
+
}
|
225
|
+
|
226
|
+
const imageUrl = taskStatus.result.sample;
|
227
|
+
log('Image generated successfully: %s', imageUrl);
|
228
|
+
|
229
|
+
return {
|
230
|
+
data: { imageUrl },
|
231
|
+
status: 'success',
|
232
|
+
};
|
233
|
+
}
|
234
|
+
case BflStatusResponse.Error:
|
235
|
+
case BflStatusResponse.ContentModerated:
|
236
|
+
case BflStatusResponse.RequestModerated: {
|
237
|
+
// Extract error details if available, otherwise use status
|
238
|
+
let errorMessage = `Image generation failed with status: ${taskStatus.status}`;
|
239
|
+
|
240
|
+
// Check for additional error details in various possible fields
|
241
|
+
if (taskStatus.details && typeof taskStatus.details === 'object') {
|
242
|
+
errorMessage += ` - Details: ${JSON.stringify(taskStatus.details)}`;
|
243
|
+
} else if (taskStatus.result && typeof taskStatus.result === 'object') {
|
244
|
+
errorMessage += ` - Result: ${JSON.stringify(taskStatus.result)}`;
|
245
|
+
}
|
246
|
+
|
247
|
+
return {
|
248
|
+
error: new Error(errorMessage),
|
249
|
+
status: 'failed',
|
250
|
+
};
|
251
|
+
}
|
252
|
+
case BflStatusResponse.TaskNotFound: {
|
253
|
+
return {
|
254
|
+
error: new Error('Task not found - may have expired'),
|
255
|
+
status: 'failed',
|
256
|
+
};
|
257
|
+
}
|
258
|
+
default: {
|
259
|
+
// Continue polling for Pending status or other unknown statuses
|
260
|
+
return { status: 'pending' };
|
261
|
+
}
|
262
|
+
}
|
263
|
+
},
|
264
|
+
logger: {
|
265
|
+
debug: (message: any, ...args: any[]) => log(message, ...args),
|
266
|
+
error: (message: any, ...args: any[]) => log(message, ...args),
|
267
|
+
},
|
268
|
+
pollingQuery: () => queryTaskStatus(taskResponse.polling_url, options),
|
269
|
+
});
|
270
|
+
} catch (error) {
|
271
|
+
log('Error in createBflImage: %O', error);
|
272
|
+
|
273
|
+
throw AgentRuntimeError.createImage({
|
274
|
+
error: error as any,
|
275
|
+
errorType: 'ProviderBizError',
|
276
|
+
provider: options.provider,
|
277
|
+
});
|
278
|
+
}
|
279
|
+
}
|
@@ -0,0 +1,269 @@
|
|
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 { LobeBflAI } from './index';
|
7
|
+
|
8
|
+
// Mock the createBflImage function
|
9
|
+
vi.mock('./createImage', () => ({
|
10
|
+
createBflImage: vi.fn(),
|
11
|
+
}));
|
12
|
+
|
13
|
+
// Mock the console.error to avoid polluting test output
|
14
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
15
|
+
|
16
|
+
const bizErrorType = 'ProviderBizError';
|
17
|
+
const invalidErrorType = 'InvalidProviderAPIKey';
|
18
|
+
|
19
|
+
let instance: LobeBflAI;
|
20
|
+
|
21
|
+
beforeEach(() => {
|
22
|
+
vi.clearAllMocks();
|
23
|
+
instance = new LobeBflAI({ apiKey: 'test-api-key' });
|
24
|
+
});
|
25
|
+
|
26
|
+
afterEach(() => {
|
27
|
+
vi.clearAllMocks();
|
28
|
+
});
|
29
|
+
|
30
|
+
describe('LobeBflAI', () => {
|
31
|
+
describe('init', () => {
|
32
|
+
it('should correctly initialize with an API key', () => {
|
33
|
+
const instance = new LobeBflAI({ apiKey: 'test_api_key' });
|
34
|
+
expect(instance).toBeInstanceOf(LobeBflAI);
|
35
|
+
});
|
36
|
+
|
37
|
+
it('should initialize with custom baseURL', () => {
|
38
|
+
const customBaseURL = 'https://custom-api.bfl.ai';
|
39
|
+
const instance = new LobeBflAI({
|
40
|
+
apiKey: 'test_api_key',
|
41
|
+
baseURL: customBaseURL,
|
42
|
+
});
|
43
|
+
expect(instance).toBeInstanceOf(LobeBflAI);
|
44
|
+
});
|
45
|
+
|
46
|
+
it('should throw InvalidProviderAPIKey if no apiKey is provided', () => {
|
47
|
+
expect(() => {
|
48
|
+
new LobeBflAI({});
|
49
|
+
}).toThrow();
|
50
|
+
});
|
51
|
+
|
52
|
+
it('should throw InvalidProviderAPIKey if apiKey is undefined', () => {
|
53
|
+
expect(() => {
|
54
|
+
new LobeBflAI({ apiKey: undefined });
|
55
|
+
}).toThrow();
|
56
|
+
});
|
57
|
+
});
|
58
|
+
|
59
|
+
describe('createImage', () => {
|
60
|
+
let mockCreateBflImage: any;
|
61
|
+
|
62
|
+
beforeEach(async () => {
|
63
|
+
const { createBflImage } = await import('./createImage');
|
64
|
+
mockCreateBflImage = vi.mocked(createBflImage);
|
65
|
+
});
|
66
|
+
|
67
|
+
it('should create image successfully with basic parameters', async () => {
|
68
|
+
// Arrange
|
69
|
+
const mockImageResponse = {
|
70
|
+
imageUrl: 'https://example.com/generated-image.jpg',
|
71
|
+
};
|
72
|
+
mockCreateBflImage.mockResolvedValue(mockImageResponse);
|
73
|
+
|
74
|
+
const payload: CreateImagePayload = {
|
75
|
+
model: 'flux-dev',
|
76
|
+
params: {
|
77
|
+
prompt: 'A beautiful landscape with mountains',
|
78
|
+
width: 1024,
|
79
|
+
height: 1024,
|
80
|
+
},
|
81
|
+
};
|
82
|
+
|
83
|
+
// Act
|
84
|
+
const result = await instance.createImage(payload);
|
85
|
+
|
86
|
+
// Assert
|
87
|
+
expect(mockCreateBflImage).toHaveBeenCalledWith(payload, {
|
88
|
+
apiKey: 'test-api-key',
|
89
|
+
baseURL: undefined,
|
90
|
+
provider: 'bfl',
|
91
|
+
});
|
92
|
+
expect(result).toEqual(mockImageResponse);
|
93
|
+
});
|
94
|
+
|
95
|
+
it('should pass custom baseURL to createBflImage', async () => {
|
96
|
+
// Arrange
|
97
|
+
const customBaseURL = 'https://custom-api.bfl.ai';
|
98
|
+
const customInstance = new LobeBflAI({
|
99
|
+
apiKey: 'test-api-key',
|
100
|
+
baseURL: customBaseURL,
|
101
|
+
});
|
102
|
+
|
103
|
+
const mockImageResponse = {
|
104
|
+
imageUrl: 'https://example.com/generated-image.jpg',
|
105
|
+
};
|
106
|
+
mockCreateBflImage.mockResolvedValue(mockImageResponse);
|
107
|
+
|
108
|
+
const payload: CreateImagePayload = {
|
109
|
+
model: 'flux-pro',
|
110
|
+
params: {
|
111
|
+
prompt: 'Test image',
|
112
|
+
},
|
113
|
+
};
|
114
|
+
|
115
|
+
// Act
|
116
|
+
await customInstance.createImage(payload);
|
117
|
+
|
118
|
+
// Assert
|
119
|
+
expect(mockCreateBflImage).toHaveBeenCalledWith(payload, {
|
120
|
+
apiKey: 'test-api-key',
|
121
|
+
baseURL: customBaseURL,
|
122
|
+
provider: 'bfl',
|
123
|
+
});
|
124
|
+
});
|
125
|
+
|
126
|
+
describe('Error handling', () => {
|
127
|
+
it('should throw InvalidProviderAPIKey on 401 error', async () => {
|
128
|
+
// Arrange
|
129
|
+
const apiError = new Error('Unauthorized') as Error & { status: number };
|
130
|
+
apiError.status = 401;
|
131
|
+
mockCreateBflImage.mockRejectedValue(apiError);
|
132
|
+
|
133
|
+
const payload: CreateImagePayload = {
|
134
|
+
model: 'flux-dev',
|
135
|
+
params: {
|
136
|
+
prompt: 'Test image',
|
137
|
+
},
|
138
|
+
};
|
139
|
+
|
140
|
+
// Act & Assert
|
141
|
+
await expect(instance.createImage(payload)).rejects.toEqual({
|
142
|
+
error: { error: apiError },
|
143
|
+
errorType: invalidErrorType,
|
144
|
+
});
|
145
|
+
});
|
146
|
+
|
147
|
+
it('should throw ProviderBizError on other errors', async () => {
|
148
|
+
// Arrange
|
149
|
+
const apiError = new Error('Some other error');
|
150
|
+
mockCreateBflImage.mockRejectedValue(apiError);
|
151
|
+
|
152
|
+
const payload: CreateImagePayload = {
|
153
|
+
model: 'flux-dev',
|
154
|
+
params: {
|
155
|
+
prompt: 'Test image',
|
156
|
+
},
|
157
|
+
};
|
158
|
+
|
159
|
+
// Act & Assert
|
160
|
+
await expect(instance.createImage(payload)).rejects.toEqual({
|
161
|
+
error: { error: apiError },
|
162
|
+
errorType: bizErrorType,
|
163
|
+
});
|
164
|
+
});
|
165
|
+
|
166
|
+
it('should throw ProviderBizError on non-401 status errors', async () => {
|
167
|
+
// Arrange
|
168
|
+
const apiError = new Error('Server error') as Error & { status: number };
|
169
|
+
apiError.status = 500;
|
170
|
+
mockCreateBflImage.mockRejectedValue(apiError);
|
171
|
+
|
172
|
+
const payload: CreateImagePayload = {
|
173
|
+
model: 'flux-dev',
|
174
|
+
params: {
|
175
|
+
prompt: 'Test image',
|
176
|
+
},
|
177
|
+
};
|
178
|
+
|
179
|
+
// Act & Assert
|
180
|
+
await expect(instance.createImage(payload)).rejects.toEqual({
|
181
|
+
error: { error: apiError },
|
182
|
+
errorType: bizErrorType,
|
183
|
+
});
|
184
|
+
});
|
185
|
+
|
186
|
+
it('should throw ProviderBizError on errors without status property', async () => {
|
187
|
+
// Arrange
|
188
|
+
const apiError = new Error('Network error');
|
189
|
+
mockCreateBflImage.mockRejectedValue(apiError);
|
190
|
+
|
191
|
+
const payload: CreateImagePayload = {
|
192
|
+
model: 'flux-pro-1.1',
|
193
|
+
params: {
|
194
|
+
prompt: 'Test image',
|
195
|
+
},
|
196
|
+
};
|
197
|
+
|
198
|
+
// Act & Assert
|
199
|
+
await expect(instance.createImage(payload)).rejects.toEqual({
|
200
|
+
error: { error: apiError },
|
201
|
+
errorType: bizErrorType,
|
202
|
+
});
|
203
|
+
});
|
204
|
+
});
|
205
|
+
|
206
|
+
describe('Edge cases', () => {
|
207
|
+
it('should handle different model types', async () => {
|
208
|
+
// Arrange
|
209
|
+
const mockImageResponse = {
|
210
|
+
imageUrl: 'https://example.com/generated-image.jpg',
|
211
|
+
};
|
212
|
+
mockCreateBflImage.mockResolvedValue(mockImageResponse);
|
213
|
+
|
214
|
+
const models = [
|
215
|
+
'flux-dev',
|
216
|
+
'flux-pro',
|
217
|
+
'flux-pro-1.1',
|
218
|
+
'flux-pro-1.1-ultra',
|
219
|
+
'flux-kontext-pro',
|
220
|
+
'flux-kontext-max',
|
221
|
+
];
|
222
|
+
|
223
|
+
// Act & Assert
|
224
|
+
for (const model of models) {
|
225
|
+
const payload: CreateImagePayload = {
|
226
|
+
model,
|
227
|
+
params: {
|
228
|
+
prompt: `Test image for ${model}`,
|
229
|
+
},
|
230
|
+
};
|
231
|
+
|
232
|
+
await instance.createImage(payload);
|
233
|
+
|
234
|
+
expect(mockCreateBflImage).toHaveBeenCalledWith(payload, {
|
235
|
+
apiKey: 'test-api-key',
|
236
|
+
baseURL: undefined,
|
237
|
+
provider: 'bfl',
|
238
|
+
});
|
239
|
+
}
|
240
|
+
});
|
241
|
+
|
242
|
+
it('should handle empty params object', async () => {
|
243
|
+
// Arrange
|
244
|
+
const mockImageResponse = {
|
245
|
+
imageUrl: 'https://example.com/generated-image.jpg',
|
246
|
+
};
|
247
|
+
mockCreateBflImage.mockResolvedValue(mockImageResponse);
|
248
|
+
|
249
|
+
const payload: CreateImagePayload = {
|
250
|
+
model: 'flux-dev',
|
251
|
+
params: {
|
252
|
+
prompt: 'Empty params test',
|
253
|
+
},
|
254
|
+
};
|
255
|
+
|
256
|
+
// Act
|
257
|
+
const result = await instance.createImage(payload);
|
258
|
+
|
259
|
+
// Assert
|
260
|
+
expect(mockCreateBflImage).toHaveBeenCalledWith(payload, {
|
261
|
+
apiKey: 'test-api-key',
|
262
|
+
baseURL: undefined,
|
263
|
+
provider: 'bfl',
|
264
|
+
});
|
265
|
+
expect(result).toEqual(mockImageResponse);
|
266
|
+
});
|
267
|
+
});
|
268
|
+
});
|
269
|
+
});
|
@@ -0,0 +1,49 @@
|
|
1
|
+
import createDebug from 'debug';
|
2
|
+
import { ClientOptions } from 'openai';
|
3
|
+
|
4
|
+
import { LobeRuntimeAI } from '../BaseAI';
|
5
|
+
import { AgentRuntimeErrorType } from '../error';
|
6
|
+
import { CreateImagePayload, CreateImageResponse } from '../types/image';
|
7
|
+
import { AgentRuntimeError } from '../utils/createError';
|
8
|
+
import { createBflImage } from './createImage';
|
9
|
+
|
10
|
+
const log = createDebug('lobe-image:bfl');
|
11
|
+
|
12
|
+
export class LobeBflAI implements LobeRuntimeAI {
|
13
|
+
private apiKey: string;
|
14
|
+
baseURL?: string;
|
15
|
+
|
16
|
+
constructor({ apiKey, baseURL }: ClientOptions = {}) {
|
17
|
+
if (!apiKey) throw AgentRuntimeError.createError(AgentRuntimeErrorType.InvalidProviderAPIKey);
|
18
|
+
|
19
|
+
this.apiKey = apiKey;
|
20
|
+
this.baseURL = baseURL || undefined;
|
21
|
+
|
22
|
+
log('BFL AI initialized');
|
23
|
+
}
|
24
|
+
|
25
|
+
async createImage(payload: CreateImagePayload): Promise<CreateImageResponse> {
|
26
|
+
const { model, params } = payload;
|
27
|
+
log('Creating image with model: %s and params: %O', model, params);
|
28
|
+
|
29
|
+
try {
|
30
|
+
return await createBflImage(payload, {
|
31
|
+
apiKey: this.apiKey,
|
32
|
+
baseURL: this.baseURL,
|
33
|
+
provider: 'bfl',
|
34
|
+
});
|
35
|
+
} catch (error) {
|
36
|
+
log('Error in createImage: %O', error);
|
37
|
+
|
38
|
+
// Check for authentication errors based on HTTP status or error properties
|
39
|
+
if (error instanceof Error && 'status' in error && (error as any).status === 401) {
|
40
|
+
throw AgentRuntimeError.createError(AgentRuntimeErrorType.InvalidProviderAPIKey, {
|
41
|
+
error,
|
42
|
+
});
|
43
|
+
}
|
44
|
+
|
45
|
+
// Wrap other errors
|
46
|
+
throw AgentRuntimeError.createError(AgentRuntimeErrorType.ProviderBizError, { error });
|
47
|
+
}
|
48
|
+
}
|
49
|
+
}
|
@@ -0,0 +1,113 @@
|
|
1
|
+
// BFL API Types
|
2
|
+
|
3
|
+
export enum BflStatusResponse {
|
4
|
+
ContentModerated = 'Content Moderated',
|
5
|
+
Error = 'Error',
|
6
|
+
Pending = 'Pending',
|
7
|
+
Ready = 'Ready',
|
8
|
+
RequestModerated = 'Request Moderated',
|
9
|
+
TaskNotFound = 'Task not found',
|
10
|
+
}
|
11
|
+
|
12
|
+
export interface BflAsyncResponse {
|
13
|
+
id: string;
|
14
|
+
polling_url: string;
|
15
|
+
}
|
16
|
+
|
17
|
+
export interface BflAsyncWebhookResponse {
|
18
|
+
id: string;
|
19
|
+
status: string;
|
20
|
+
webhook_url: string;
|
21
|
+
}
|
22
|
+
|
23
|
+
export interface BflResultResponse {
|
24
|
+
details?: Record<string, any> | null;
|
25
|
+
id: string;
|
26
|
+
preview?: Record<string, any> | null;
|
27
|
+
progress?: number | null;
|
28
|
+
result?: any;
|
29
|
+
status: BflStatusResponse;
|
30
|
+
}
|
31
|
+
|
32
|
+
// Kontext series request (flux-kontext-pro, flux-kontext-max)
|
33
|
+
export interface BflFluxKontextRequest {
|
34
|
+
aspect_ratio?: string | null;
|
35
|
+
input_image?: string | null;
|
36
|
+
input_image_2?: string | null;
|
37
|
+
input_image_3?: string | null;
|
38
|
+
input_image_4?: string | null;
|
39
|
+
output_format?: 'jpeg' | 'png' | null;
|
40
|
+
prompt: string;
|
41
|
+
prompt_upsampling?: boolean;
|
42
|
+
safety_tolerance?: number;
|
43
|
+
seed?: number | null;
|
44
|
+
webhook_secret?: string | null;
|
45
|
+
webhook_url?: string | null;
|
46
|
+
}
|
47
|
+
|
48
|
+
// FLUX 1.1 Pro request
|
49
|
+
export interface BflFluxPro11Request {
|
50
|
+
height?: number;
|
51
|
+
image_prompt?: string | null;
|
52
|
+
output_format?: 'jpeg' | 'png' | null;
|
53
|
+
prompt?: string | null;
|
54
|
+
prompt_upsampling?: boolean;
|
55
|
+
safety_tolerance?: number;
|
56
|
+
seed?: number | null;
|
57
|
+
webhook_secret?: string | null;
|
58
|
+
webhook_url?: string | null;
|
59
|
+
width?: number;
|
60
|
+
}
|
61
|
+
|
62
|
+
// FLUX 1.1 Pro Ultra request
|
63
|
+
export interface BflFluxPro11UltraRequest {
|
64
|
+
aspect_ratio?: string;
|
65
|
+
prompt: string;
|
66
|
+
raw?: boolean;
|
67
|
+
safety_tolerance?: number;
|
68
|
+
seed?: number | null;
|
69
|
+
}
|
70
|
+
|
71
|
+
// FLUX Pro request
|
72
|
+
export interface BflFluxProRequest {
|
73
|
+
guidance?: number;
|
74
|
+
height?: number;
|
75
|
+
image_prompt?: string | null;
|
76
|
+
prompt?: string | null;
|
77
|
+
safety_tolerance?: number;
|
78
|
+
seed?: number | null;
|
79
|
+
steps?: number;
|
80
|
+
width?: number;
|
81
|
+
}
|
82
|
+
|
83
|
+
// FLUX Dev request
|
84
|
+
export interface BflFluxDevRequest {
|
85
|
+
guidance?: number;
|
86
|
+
height?: number;
|
87
|
+
image_prompt?: string | null;
|
88
|
+
prompt: string;
|
89
|
+
safety_tolerance?: number;
|
90
|
+
seed?: number | null;
|
91
|
+
steps?: number;
|
92
|
+
width?: number;
|
93
|
+
}
|
94
|
+
|
95
|
+
// Model endpoint mapping
|
96
|
+
export const BFL_ENDPOINTS = {
|
97
|
+
'flux-dev': '/v1/flux-dev',
|
98
|
+
'flux-kontext-max': '/v1/flux-kontext-max',
|
99
|
+
'flux-kontext-pro': '/v1/flux-kontext-pro',
|
100
|
+
'flux-pro': '/v1/flux-pro',
|
101
|
+
'flux-pro-1.1': '/v1/flux-pro-1.1',
|
102
|
+
'flux-pro-1.1-ultra': '/v1/flux-pro-1.1-ultra',
|
103
|
+
} as const;
|
104
|
+
|
105
|
+
export type BflModelId = keyof typeof BFL_ENDPOINTS;
|
106
|
+
|
107
|
+
// Union type for all request types
|
108
|
+
export type BflRequest =
|
109
|
+
| BflFluxKontextRequest
|
110
|
+
| BflFluxPro11Request
|
111
|
+
| BflFluxPro11UltraRequest
|
112
|
+
| BflFluxProRequest
|
113
|
+
| BflFluxDevRequest;
|