@lobehub/lobehub 2.0.0-next.166 → 2.0.0-next.168

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.
Files changed (75) hide show
  1. package/.husky/pre-commit +1 -1
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +50 -0
  4. package/CLAUDE.md +2 -2
  5. package/GEMINI.md +1 -1
  6. package/README.md +1 -1
  7. package/README.zh-CN.md +1 -1
  8. package/apps/desktop/package.json +4 -4
  9. package/apps/desktop/tsconfig.json +4 -13
  10. package/changelog/v1.json +18 -0
  11. package/docs/development/database-schema.dbml +1 -0
  12. package/locales/ar/models.json +12 -0
  13. package/locales/ar/providers.json +3 -0
  14. package/locales/bg-BG/models.json +12 -0
  15. package/locales/bg-BG/providers.json +3 -0
  16. package/locales/de-DE/models.json +12 -0
  17. package/locales/de-DE/providers.json +3 -0
  18. package/locales/en-US/models.json +12 -0
  19. package/locales/en-US/providers.json +3 -0
  20. package/locales/es-ES/models.json +12 -0
  21. package/locales/es-ES/providers.json +3 -0
  22. package/locales/fa-IR/models.json +12 -0
  23. package/locales/fa-IR/providers.json +3 -0
  24. package/locales/fr-FR/models.json +12 -0
  25. package/locales/fr-FR/providers.json +3 -0
  26. package/locales/it-IT/models.json +12 -0
  27. package/locales/it-IT/providers.json +3 -0
  28. package/locales/ja-JP/models.json +12 -0
  29. package/locales/ja-JP/providers.json +3 -0
  30. package/locales/ko-KR/models.json +12 -0
  31. package/locales/ko-KR/providers.json +3 -0
  32. package/locales/nl-NL/models.json +12 -0
  33. package/locales/nl-NL/providers.json +3 -0
  34. package/locales/pl-PL/models.json +12 -0
  35. package/locales/pl-PL/providers.json +3 -0
  36. package/locales/pt-BR/models.json +12 -0
  37. package/locales/pt-BR/providers.json +3 -0
  38. package/locales/ru-RU/models.json +12 -0
  39. package/locales/ru-RU/providers.json +3 -0
  40. package/locales/tr-TR/models.json +12 -0
  41. package/locales/tr-TR/providers.json +3 -0
  42. package/locales/vi-VN/models.json +12 -0
  43. package/locales/vi-VN/providers.json +3 -0
  44. package/locales/zh-CN/models.json +12 -0
  45. package/locales/zh-CN/providers.json +3 -0
  46. package/locales/zh-TW/models.json +12 -0
  47. package/locales/zh-TW/providers.json +3 -0
  48. package/package.json +43 -43
  49. package/packages/database/migrations/0060_add_user_last_active_at.sql +1 -0
  50. package/packages/database/migrations/meta/0060_snapshot.json +8481 -0
  51. package/packages/database/migrations/meta/_journal.json +8 -1
  52. package/packages/database/src/core/migrations.json +9 -1
  53. package/packages/database/src/schemas/user.ts +1 -0
  54. package/packages/fetch-sse/src/__tests__/headers.test.ts +2 -2
  55. package/packages/model-bank/package.json +1 -0
  56. package/packages/model-bank/src/aiModels/index.ts +3 -0
  57. package/packages/model-bank/src/aiModels/replicate.ts +90 -0
  58. package/packages/model-bank/src/const/modelProvider.ts +1 -0
  59. package/packages/model-runtime/docs/test-coverage.md +5 -5
  60. package/packages/model-runtime/package.json +2 -1
  61. package/packages/model-runtime/src/core/ModelRuntime.ts +11 -1
  62. package/packages/model-runtime/src/providers/replicate/index.ts +424 -0
  63. package/packages/model-runtime/src/runtimeMap.ts +2 -0
  64. package/packages/model-runtime/src/utils/modelParse.ts +13 -0
  65. package/packages/ssrf-safe-fetch/index.browser.ts +22 -2
  66. package/packages/ssrf-safe-fetch/index.ts +30 -6
  67. package/packages/types/src/aiProvider.ts +2 -0
  68. package/src/config/modelProviders/index.ts +3 -0
  69. package/src/config/modelProviders/replicate.ts +23 -0
  70. package/src/libs/oidc-provider/provider.ts +2 -73
  71. package/src/server/routers/lambda/__tests__/user.test.ts +2 -0
  72. package/src/server/routers/lambda/market/index.ts +5 -2
  73. package/src/server/routers/lambda/user.ts +5 -0
  74. package/src/services/mcp.ts +1 -0
  75. package/src/store/test-coverage.md +19 -19
@@ -0,0 +1,424 @@
1
+ import Replicate from 'replicate';
2
+
3
+ import { LobeRuntimeAI } from '../../core/BaseAI';
4
+ import {
5
+ type ChatCompletionErrorPayload,
6
+ ChatMethodOptions,
7
+ ChatStreamPayload,
8
+ CreateImagePayload,
9
+ } from '../../types';
10
+ import { AgentRuntimeErrorType } from '../../types/error';
11
+ import { AgentRuntimeError } from '../../utils/createError';
12
+ import { desensitizeUrl } from '../../utils/desensitizeUrl';
13
+ import { MODEL_LIST_CONFIGS, processModelList } from '../../utils/modelParse';
14
+
15
+ const DEFAULT_BASE_URL = 'https://api.replicate.com';
16
+
17
+ interface ReplicateAIParams {
18
+ apiKey?: string;
19
+ baseURL?: string;
20
+ id?: string;
21
+ }
22
+
23
+ export class LobeReplicateAI implements LobeRuntimeAI {
24
+ private client: Replicate;
25
+
26
+ baseURL: string;
27
+ apiKey?: string;
28
+ private id: string;
29
+
30
+ constructor({ apiKey, baseURL = DEFAULT_BASE_URL, id }: ReplicateAIParams = {}) {
31
+ if (!apiKey) {
32
+ throw AgentRuntimeError.createError(AgentRuntimeErrorType.InvalidProviderAPIKey);
33
+ }
34
+
35
+ this.client = new Replicate({
36
+ auth: apiKey,
37
+ baseUrl: baseURL !== DEFAULT_BASE_URL ? baseURL : undefined,
38
+ useFileOutput: false, // Return URLs instead of binary data
39
+ });
40
+
41
+ this.baseURL = baseURL;
42
+ this.apiKey = apiKey;
43
+ this.id = id || 'replicate';
44
+ }
45
+
46
+ /**
47
+ * Connectivity check for Replicate (non-chat provider)
48
+ * We verify the model exists and stream a minimal SSE "text" event so the checker UI can pass.
49
+ */
50
+ async chat(payload: ChatStreamPayload, options?: ChatMethodOptions) {
51
+ const modelId = payload.model;
52
+
53
+ try {
54
+ if (!modelId || typeof modelId !== 'string' || !modelId.includes('/')) {
55
+ throw new Error('Invalid model id for Replicate connectivity check');
56
+ }
57
+
58
+ const [owner, ...nameParts] = modelId.split('/');
59
+ const nameWithVersion = nameParts.join('/');
60
+ const [name] = nameWithVersion.split(':'); // drop :version if present
61
+
62
+ // Fast auth + existence check via SDK; no inference cost
63
+ await this.client.models.get(owner, name, { signal: options?.signal });
64
+
65
+ const encoder = new TextEncoder();
66
+ const stream = new ReadableStream({
67
+ start(controller) {
68
+ const textPayload = JSON.stringify(`Replicate connectivity ok for ${modelId}`);
69
+ const stopPayload = JSON.stringify('stop');
70
+ controller.enqueue(encoder.encode(`event: text\ndata: ${textPayload}\n\n`));
71
+ controller.enqueue(encoder.encode(`event: stop\ndata: ${stopPayload}\n\n`));
72
+ controller.close();
73
+ },
74
+ });
75
+
76
+ return new Response(stream, {
77
+ headers: {
78
+ 'Content-Type': 'text/event-stream',
79
+ },
80
+ });
81
+ } catch (error) {
82
+ throw this.handleError(error);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Image generation support for LobeChat async image generation (FLUX, Stable Diffusion, etc.)
88
+ */
89
+ async createImage(payload: CreateImagePayload) {
90
+ try {
91
+ const { model, params } = payload;
92
+ const { prompt, width, height, cfg, steps, seed, imageUrl, aspectRatio } = params;
93
+
94
+ this.debugLog('[Replicate createImage] === START ===');
95
+ this.debugLog('[Replicate createImage] Model:', model);
96
+ this.debugLog('[Replicate createImage] Params received:', JSON.stringify(params, null, 2));
97
+
98
+ const input: Record<string, any> = {};
99
+
100
+ // Redux models don't use prompt - they only use the input image
101
+ if (!model.includes('redux')) {
102
+ input.prompt = prompt;
103
+ this.debugLog('[Replicate createImage] Added prompt:', prompt);
104
+ } else {
105
+ this.debugLog('[Replicate createImage] Skipping prompt (Redux model)');
106
+ }
107
+
108
+ // Handle image-to-image models
109
+ if (imageUrl) {
110
+ this.debugLog('[Replicate createImage] imageUrl provided:', imageUrl);
111
+
112
+ // Determine the parameter name based on model type
113
+ let imageParamName: string;
114
+ if (model.includes('redux')) {
115
+ imageParamName = 'redux_image';
116
+ this.debugLog('[Replicate createImage] Will map to redux_image');
117
+ } else if (model.includes('canny') || model.includes('depth')) {
118
+ imageParamName = 'control_image';
119
+ this.debugLog('[Replicate createImage] Will map to control_image');
120
+ } else if (model.includes('fill')) {
121
+ imageParamName = 'image';
122
+ this.debugLog('[Replicate createImage] Will map to image (fill)');
123
+ } else {
124
+ imageParamName = 'image';
125
+ this.debugLog('[Replicate createImage] Will map to image (generic)');
126
+ }
127
+
128
+ // Check if URL is accessible from internet or local
129
+ // Parse via URL and classify by hostname so it works for any scheme (http, https, etc.)
130
+ let isLocalUrl = false;
131
+ try {
132
+ const parsedUrl = new URL(imageUrl);
133
+ const hostname = parsedUrl.hostname;
134
+
135
+ const isLoopbackHost =
136
+ hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
137
+
138
+ const isPrivate10Range = hostname.startsWith('10.');
139
+ const isPrivate192Range = hostname.startsWith('192.168.');
140
+
141
+ // 172.16.0.0 – 172.31.255.255
142
+ const isPrivate172Range = /^172\.(1[6-9]|2\d|3[01])\./.test(hostname);
143
+
144
+ const isLocalTld = hostname.endsWith('.local');
145
+
146
+ isLocalUrl =
147
+ isLoopbackHost ||
148
+ isPrivate10Range ||
149
+ isPrivate172Range ||
150
+ isPrivate192Range ||
151
+ isLocalTld;
152
+ } catch {
153
+ // If the URL cannot be parsed as an absolute URL, treat it as local/untrusted
154
+ // to ensure we take the SSRF-safe path.
155
+ isLocalUrl = true;
156
+ }
157
+
158
+ if (isLocalUrl) {
159
+ this.debugLog(
160
+ '[Replicate createImage] Local URL detected, will fetch and upload as data',
161
+ );
162
+ try {
163
+ const { ssrfSafeFetch } = await import('ssrf-safe-fetch');
164
+ const imageResponse = await ssrfSafeFetch(imageUrl);
165
+ if (!imageResponse.ok) {
166
+ throw new Error(
167
+ `Failed to fetch image: ${imageResponse.status} ${imageResponse.statusText}`,
168
+ );
169
+ }
170
+
171
+ // Get image as buffer
172
+ const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
173
+ this.debugLog(
174
+ '[Replicate createImage] Fetched image, size:',
175
+ imageBuffer.length,
176
+ 'bytes',
177
+ );
178
+
179
+ // Check size limit (100MB)
180
+ if (imageBuffer.length > 100 * 1024 * 1024) {
181
+ throw new Error(`Image too large: ${imageBuffer.length} bytes (max 100MB)`);
182
+ }
183
+
184
+ // Replicate SDK accepts Buffer objects directly
185
+ input[imageParamName] = imageBuffer;
186
+ this.debugLog('[Replicate createImage] Mapped to', imageParamName, 'as Buffer');
187
+ } catch (fetchError: any) {
188
+ this.debugLog('[Replicate createImage] Error fetching local image:', fetchError);
189
+ throw new Error(`Failed to fetch local image: ${fetchError.message}`);
190
+ }
191
+ } else {
192
+ // Public URL - use directly
193
+ input[imageParamName] = imageUrl;
194
+ this.debugLog('[Replicate createImage] Public URL, mapped directly to', imageParamName);
195
+ }
196
+ } else {
197
+ this.debugLog('[Replicate createImage] No imageUrl provided');
198
+ }
199
+
200
+ // Map LobeChat params to Replicate params
201
+ if (width && height) {
202
+ input.width = width;
203
+ input.height = height;
204
+ this.debugLog('[Replicate createImage] Set dimensions:', width, 'x', height);
205
+ }
206
+
207
+ // For FLUX models, convert to aspect_ratio
208
+ if (model.includes('flux')) {
209
+ // Use explicit aspectRatio if provided (for Redux models)
210
+ if (aspectRatio) {
211
+ input.aspect_ratio = aspectRatio;
212
+ this.debugLog('[Replicate createImage] Set aspect_ratio from param:', aspectRatio);
213
+ } else if (width && height) {
214
+ if (width === height) {
215
+ input.aspect_ratio = '1:1';
216
+ } else if (width === 1280 && height === 720) {
217
+ input.aspect_ratio = '16:9';
218
+ } else if (width === 720 && height === 1280) {
219
+ input.aspect_ratio = '9:16';
220
+ } else if (width > height) {
221
+ input.aspect_ratio = '16:9';
222
+ } else {
223
+ input.aspect_ratio = '9:16';
224
+ }
225
+ this.debugLog('[Replicate createImage] Calculated aspect_ratio:', input.aspect_ratio);
226
+ }
227
+ // Remove width/height for FLUX models (unless it's Fill which needs dimensions)
228
+ if (!model.includes('fill')) {
229
+ delete input.width;
230
+ delete input.height;
231
+ this.debugLog('[Replicate createImage] Removed width/height (using aspect_ratio)');
232
+ }
233
+ }
234
+
235
+ // Add optional parameters
236
+ if (cfg !== undefined) {
237
+ input.guidance_scale = cfg;
238
+ this.debugLog('[Replicate createImage] Set guidance_scale:', cfg);
239
+ }
240
+ if (steps !== undefined) {
241
+ // Redux uses num_inference_steps, control models use steps
242
+ if (model.includes('redux')) {
243
+ input.num_inference_steps = steps;
244
+ this.debugLog('[Replicate createImage] Set num_inference_steps:', steps);
245
+ } else if (model.includes('canny') || model.includes('depth') || model.includes('fill')) {
246
+ input.steps = steps;
247
+ this.debugLog('[Replicate createImage] Set steps:', steps);
248
+ } else {
249
+ input.num_inference_steps = steps;
250
+ this.debugLog('[Replicate createImage] Set num_inference_steps:', steps);
251
+ }
252
+ }
253
+ if (seed !== undefined && seed !== null) {
254
+ input.seed = seed;
255
+ this.debugLog('[Replicate createImage] Set seed:', seed);
256
+ }
257
+
258
+ // Run prediction - with useFileOutput: false, returns URL strings
259
+ // Log input object without Buffer data (which would be huge)
260
+ const inputForLogging = { ...input };
261
+ for (const key in inputForLogging) {
262
+ if (Buffer.isBuffer(inputForLogging[key])) {
263
+ inputForLogging[key] = `<Buffer ${inputForLogging[key].length} bytes>`;
264
+ }
265
+ }
266
+ this.debugLog(
267
+ '[Replicate createImage] Final input object:',
268
+ JSON.stringify(inputForLogging, null, 2),
269
+ );
270
+ this.debugLog('[Replicate createImage] Calling client.run...');
271
+
272
+ const output = await this.client.run(model as any, { input });
273
+
274
+ this.debugLog('[Replicate createImage] Raw output:', output);
275
+ this.debugLog(
276
+ '[Replicate createImage] Output type:',
277
+ typeof output,
278
+ 'Is array:',
279
+ Array.isArray(output),
280
+ );
281
+
282
+ // Extract URL from output
283
+ let outputImageUrl: string;
284
+
285
+ if (Array.isArray(output)) {
286
+ if (output.length === 0) {
287
+ throw new Error('Replicate returned empty array');
288
+ }
289
+ // First item should be the URL string
290
+ outputImageUrl = output[0];
291
+ this.debugLog('[Replicate] Extracted URL from array:', outputImageUrl);
292
+ } else if (typeof output === 'string') {
293
+ outputImageUrl = output;
294
+ this.debugLog('[Replicate] Output is direct string URL:', outputImageUrl);
295
+ } else {
296
+ throw new Error(`Unexpected output format from Replicate: ${typeof output}`);
297
+ }
298
+
299
+ if (typeof outputImageUrl !== 'string') {
300
+ throw new Error(`Expected URL string, got ${typeof outputImageUrl}: ${outputImageUrl}`);
301
+ }
302
+
303
+ this.debugLog('[Replicate] Final imageUrl:', outputImageUrl);
304
+
305
+ return {
306
+ height: height,
307
+ imageUrl: outputImageUrl,
308
+ width: width,
309
+ };
310
+ } catch (error) {
311
+ throw this.handleError(error);
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Fetch image generation models from Replicate using search API
317
+ * Uses targeted searches for relevant model categories instead of listing all public models
318
+ */
319
+ async models() {
320
+ try {
321
+ const modelMap = new Map<string, { created?: number; displayName?: string; id: string }>();
322
+
323
+ // Search queries for different image model categories
324
+ const searchQueries = [
325
+ 'flux image generation',
326
+ 'stable diffusion',
327
+ 'sdxl',
328
+ 'ideogram',
329
+ 'image to image',
330
+ 'text to image',
331
+ ];
332
+
333
+ // Search for each category and collect unique models
334
+ for (const query of searchQueries) {
335
+ try {
336
+ // Use paginate for search results (limited results per query)
337
+ for await (const models of this.client.paginate(() => this.client.models.search(query))) {
338
+ for (const model of models) {
339
+ const modelId = `${model.owner}/${model.name}`;
340
+ // Deduplicate by model ID
341
+ if (!modelMap.has(modelId)) {
342
+ modelMap.set(modelId, {
343
+ created: model.latest_version
344
+ ? new Date(model.latest_version.created_at).getTime()
345
+ : undefined,
346
+ displayName: model.name,
347
+ id: modelId,
348
+ });
349
+ }
350
+ }
351
+ // Limit to first page of results per query to avoid too many results
352
+ break;
353
+ }
354
+ } catch {
355
+ // Continue with other searches if one fails
356
+ this.debugLog(`[Replicate models] Search failed for query: ${query}`);
357
+ }
358
+ }
359
+
360
+ const modelList = [...modelMap.values()];
361
+ this.debugLog(`[Replicate models] Found ${modelList.length} unique models`);
362
+
363
+ return processModelList(modelList, MODEL_LIST_CONFIGS.replicate, 'replicate');
364
+ } catch (error) {
365
+ throw this.handleError(error);
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Error handling
371
+ */
372
+ private handleError(error: any): ChatCompletionErrorPayload {
373
+ let desensitizedEndpoint = this.baseURL;
374
+
375
+ if (this.baseURL !== DEFAULT_BASE_URL) {
376
+ desensitizedEndpoint = desensitizeUrl(this.baseURL);
377
+ }
378
+
379
+ // Handle authentication errors
380
+ if (error?.message?.includes('authentication') || error?.message?.includes('API token')) {
381
+ throw AgentRuntimeError.chat({
382
+ endpoint: desensitizedEndpoint,
383
+ error: error as any,
384
+ errorType: AgentRuntimeErrorType.InvalidProviderAPIKey,
385
+ provider: this.id,
386
+ });
387
+ }
388
+
389
+ // Handle model not found
390
+ if (error?.message?.includes('not found')) {
391
+ throw AgentRuntimeError.chat({
392
+ endpoint: desensitizedEndpoint,
393
+ error: error as any,
394
+ errorType: AgentRuntimeErrorType.ModelNotFound,
395
+ provider: this.id,
396
+ });
397
+ }
398
+
399
+ // Generic error
400
+ throw AgentRuntimeError.chat({
401
+ endpoint: desensitizedEndpoint,
402
+ error: error,
403
+ errorType: AgentRuntimeErrorType.ProviderBizError,
404
+ provider: this.id,
405
+ });
406
+ }
407
+
408
+ /**
409
+ * Gate verbose logging to avoid noisy output in production
410
+ */
411
+ private debugLog(...args: any[]) {
412
+ const isReplicateDebug =
413
+ process.env.DEBUG_REPLICATE === '1' ||
414
+ process.env.DEBUG_REPLICATE_CHAT_COMPLETION === '1' ||
415
+ process.env.NODE_ENV !== 'production';
416
+
417
+ if (!isReplicateDebug) return;
418
+
419
+ // eslint-disable-next-line no-console
420
+ console.log(...args);
421
+ }
422
+ }
423
+
424
+ export default LobeReplicateAI;
@@ -44,6 +44,7 @@ import { LobePerplexityAI } from './providers/perplexity';
44
44
  import { LobePPIOAI } from './providers/ppio';
45
45
  import { LobeQiniuAI } from './providers/qiniu';
46
46
  import { LobeQwenAI } from './providers/qwen';
47
+ import { LobeReplicateAI } from './providers/replicate';
47
48
  import { LobeSambaNovaAI } from './providers/sambanova';
48
49
  import { LobeSearch1API } from './providers/search1api';
49
50
  import { LobeSenseNovaAI } from './providers/sensenova';
@@ -112,6 +113,7 @@ export const providerRuntimeMap = {
112
113
  ppio: LobePPIOAI,
113
114
  qiniu: LobeQiniuAI,
114
115
  qwen: LobeQwenAI,
116
+ replicate: LobeReplicateAI,
115
117
  router: LobeNewAPIAI,
116
118
  sambanova: LobeSambaNovaAI,
117
119
  search1api: LobeSearch1API,
@@ -88,6 +88,18 @@ export const MODEL_LIST_CONFIGS = {
88
88
  reasoningKeywords: ['qvq', 'qwq', 'qwen3', '!-instruct-', '!-coder-', '!-max-'],
89
89
  visionKeywords: ['qvq', '-vl', '-omni'],
90
90
  },
91
+ replicate: {
92
+ imageOutputKeywords: [
93
+ 'flux',
94
+ 'stable-diffusion',
95
+ 'sdxl',
96
+ 'ideogram',
97
+ 'canny',
98
+ 'depth',
99
+ 'fill',
100
+ 'redux',
101
+ ],
102
+ },
91
103
  v0: {
92
104
  functionCallKeywords: ['v0'],
93
105
  reasoningKeywords: ['v0-1.5'],
@@ -132,6 +144,7 @@ export const MODEL_OWNER_DETECTION_CONFIG = {
132
144
  moonshot: ['moonshot', 'kimi'],
133
145
  openai: ['o1', 'o3', 'o4', 'gpt-'],
134
146
  qwen: ['qwen', 'qwq', 'qvq'],
147
+ replicate: [],
135
148
  v0: ['v0'],
136
149
  volcengine: ['doubao'],
137
150
  wenxin: ['ernie', 'qianfan'],
@@ -4,11 +4,31 @@
4
4
  * as SSRF attacks are not applicable in client-side code
5
5
  */
6
6
 
7
+ /**
8
+ * Options for per-call SSRF configuration overrides
9
+ * (ignored in browser - kept for API parity with server version)
10
+ */
11
+ export interface SSRFOptions {
12
+ /** List of IP addresses to allow */
13
+ allowIPAddressList?: string[];
14
+ /** Whether to allow private/local IP addresses */
15
+ allowPrivateIPAddress?: boolean;
16
+ }
17
+
7
18
  /**
8
19
  * Browser-safe fetch implementation
9
20
  * Uses native fetch API in browser environments
21
+ * @param url - The URL to fetch
22
+ * @param options - Standard fetch options
23
+ * @param _ssrfOptions - Ignored in browser (kept for API parity)
10
24
  */
11
- // eslint-disable-next-line no-undef
12
- export const ssrfSafeFetch = async (url: string, options?: RequestInit): Promise<Response> => {
25
+ export const ssrfSafeFetch = async (
26
+ url: string,
27
+ // eslint-disable-next-line no-undef
28
+ options?: RequestInit,
29
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
30
+ _ssrfOptions?: SSRFOptions,
31
+ // eslint-disable-next-line no-undef
32
+ ): Promise<Response> => {
13
33
  return fetch(url, options);
14
34
  };
@@ -1,20 +1,44 @@
1
1
  import fetch from 'node-fetch';
2
2
  import { RequestFilteringAgentOptions, useAgent as ssrfAgent } from 'request-filtering-agent';
3
3
 
4
+ /**
5
+ * Options for per-call SSRF configuration overrides
6
+ */
7
+ export interface SSRFOptions {
8
+ /** List of IP addresses to allow */
9
+ allowIPAddressList?: string[];
10
+ /** Whether to allow private/local IP addresses */
11
+ allowPrivateIPAddress?: boolean;
12
+ }
13
+
4
14
  /**
5
15
  * SSRF-safe fetch implementation for server-side use
6
16
  * Uses request-filtering-agent to prevent requests to private IP addresses
7
17
  *
18
+ * @param url - The URL to fetch
19
+ * @param options - Standard fetch options
20
+ * @param ssrfOptions - Optional per-call SSRF configuration overrides
8
21
  * @see https://lobehub.com/docs/self-hosting/environment-variables/basic#ssrf-allow-private-ip-address
9
22
  */
10
- // eslint-disable-next-line no-undef
11
- export const ssrfSafeFetch = async (url: string, options?: RequestInit): Promise<Response> => {
23
+ export const ssrfSafeFetch = async (
24
+ url: string,
25
+ // eslint-disable-next-line no-undef
26
+ options?: RequestInit,
27
+ ssrfOptions?: SSRFOptions,
28
+ // eslint-disable-next-line no-undef
29
+ ): Promise<Response> => {
12
30
  try {
13
- // Configure SSRF protection options
31
+ // Configure SSRF protection options with proper precedence using nullish coalescing
32
+ const envAllowPrivate = process.env.SSRF_ALLOW_PRIVATE_IP_ADDRESS === '1';
33
+ const allowPrivate = ssrfOptions?.allowPrivateIPAddress ?? envAllowPrivate;
34
+
14
35
  const agentOptions: RequestFilteringAgentOptions = {
15
- allowIPAddressList: process.env.SSRF_ALLOW_IP_ADDRESS_LIST?.split(',') || [],
16
- allowMetaIPAddress: process.env.SSRF_ALLOW_PRIVATE_IP_ADDRESS === '1',
17
- allowPrivateIPAddress: process.env.SSRF_ALLOW_PRIVATE_IP_ADDRESS === '1',
36
+ allowIPAddressList:
37
+ ssrfOptions?.allowIPAddressList ??
38
+ process.env.SSRF_ALLOW_IP_ADDRESS_LIST?.split(',').filter(Boolean) ??
39
+ [],
40
+ allowMetaIPAddress: allowPrivate,
41
+ allowPrivateIPAddress: allowPrivate,
18
42
  denyIPAddressList: [],
19
43
  };
20
44
 
@@ -31,6 +31,7 @@ export const AiProviderSDKEnum = {
31
31
  Ollama: 'ollama',
32
32
  Openai: 'openai',
33
33
  Qwen: 'qwen',
34
+ Replicate: 'replicate',
34
35
  Router: 'router',
35
36
  Volcengine: 'volcengine',
36
37
  } as const;
@@ -48,6 +49,7 @@ const AiProviderSdkTypes = [
48
49
  'cloudflare',
49
50
  'google',
50
51
  'huggingface',
52
+ 'replicate',
51
53
  'router',
52
54
  'volcengine',
53
55
  'qwen',
@@ -46,6 +46,7 @@ import PerplexityProvider from './perplexity';
46
46
  import PPIOProvider from './ppio';
47
47
  import QiniuProvider from './qiniu';
48
48
  import QwenProvider from './qwen';
49
+ import ReplicateProvider from './replicate';
49
50
  import SambaNovaProvider from './sambanova';
50
51
  import Search1APIProvider from './search1api';
51
52
  import SenseNovaProvider from './sensenova';
@@ -187,6 +188,7 @@ export const DEFAULT_MODEL_PROVIDER_LIST = [
187
188
  InfiniAIProvider,
188
189
  AkashChatProvider,
189
190
  QiniuProvider,
191
+ ReplicateProvider,
190
192
  NebiusProvider,
191
193
  CometAPIProvider,
192
194
  VercelAIGatewayProvider,
@@ -250,6 +252,7 @@ export { default as PerplexityProviderCard } from './perplexity';
250
252
  export { default as PPIOProviderCard } from './ppio';
251
253
  export { default as QiniuProviderCard } from './qiniu';
252
254
  export { default as QwenProviderCard } from './qwen';
255
+ export { default as ReplicateProviderCard } from './replicate';
253
256
  export { default as SambaNovaProviderCard } from './sambanova';
254
257
  export { default as Search1APIProviderCard } from './search1api';
255
258
  export { default as SenseNovaProviderCard } from './sensenova';
@@ -0,0 +1,23 @@
1
+ import { ModelProviderCard } from '@/types/llm';
2
+
3
+ // Ref: https://replicate.com/docs
4
+ const Replicate: ModelProviderCard = {
5
+ chatModels: [],
6
+ checkModel: 'black-forest-labs/flux-1.1-pro',
7
+ description: 'Replicate 通过简单的云 API 运行 FLUX 和 Stable Diffusion 等开源图像模型。',
8
+ id: 'replicate',
9
+ modelList: { showModelFetcher: true },
10
+ modelsUrl: 'https://replicate.com/explore',
11
+ name: 'Replicate',
12
+ settings: {
13
+ disableBrowserRequest: true,
14
+ proxyUrl: {
15
+ placeholder: 'https://api.replicate.com',
16
+ },
17
+ sdkType: 'replicate',
18
+ showModelFetcher: true,
19
+ },
20
+ url: 'https://replicate.com',
21
+ };
22
+
23
+ export default Replicate;