@lobehub/lobehub 2.0.0-next.231 → 2.0.0-next.233

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 (37) hide show
  1. package/.github/workflows/bundle-analyzer.yml +1 -1
  2. package/.github/workflows/e2e.yml +67 -52
  3. package/.github/workflows/manual-build-desktop.yml +5 -5
  4. package/.github/workflows/pr-build-desktop.yml +4 -4
  5. package/.github/workflows/pr-build-docker.yml +2 -2
  6. package/.github/workflows/release-desktop-beta.yml +4 -4
  7. package/.github/workflows/release-docker.yml +2 -2
  8. package/.github/workflows/test.yml +44 -7
  9. package/CHANGELOG.md +50 -0
  10. package/changelog/v1.json +14 -0
  11. package/docs/self-hosting/environment-variables/auth.mdx +7 -0
  12. package/docs/self-hosting/environment-variables/auth.zh-CN.mdx +7 -0
  13. package/package.json +1 -1
  14. package/packages/business/config/src/llm.ts +6 -1
  15. package/packages/const/src/settings/image.ts +1 -1
  16. package/packages/model-bank/src/aiModels/azure.ts +2 -2
  17. package/packages/model-bank/src/aiModels/google.ts +1 -0
  18. package/packages/model-bank/src/aiModels/lobehub.ts +33 -13
  19. package/packages/model-bank/src/aiModels/openai.ts +21 -4
  20. package/packages/model-runtime/src/core/openaiCompatibleFactory/createImage.ts +4 -1
  21. package/packages/model-runtime/src/providers/openai/__snapshots__/index.test.ts.snap +1 -1
  22. package/packages/ssrf-safe-fetch/index.test.ts +5 -34
  23. package/packages/ssrf-safe-fetch/index.ts +12 -2
  24. package/src/app/[variants]/(main)/image/_layout/ConfigPanel/components/MultiImagesUpload/index.tsx +3 -3
  25. package/src/app/[variants]/(main)/image/features/GenerationFeed/index.tsx +3 -10
  26. package/src/app/[variants]/(main)/image/index.tsx +1 -1
  27. package/src/components/Loading/BrandTextLoading/index.module.css +81 -0
  28. package/src/components/Loading/BrandTextLoading/index.tsx +24 -17
  29. package/src/envs/auth.ts +15 -0
  30. package/src/hooks/useFetchAiImageConfig.ts +54 -10
  31. package/src/libs/redis/manager.ts +63 -0
  32. package/src/libs/trpc/utils/internalJwt.ts +2 -2
  33. package/src/server/services/agent/index.test.ts +15 -8
  34. package/src/server/services/agent/index.ts +11 -4
  35. package/src/store/image/slices/generationConfig/initialState.ts +5 -5
  36. package/src/store/image/slices/generationConfig/selectors.test.ts +11 -4
  37. package/vitest.config.mts +10 -6
@@ -1219,6 +1219,26 @@ export const openaiSTTModels: AISTTModelCard[] = [
1219
1219
 
1220
1220
  // Image generation models
1221
1221
  export const openaiImageModels: AIImageModelCard[] = [
1222
+ {
1223
+ description:
1224
+ 'An enhanced GPT Image 1 model with 4× faster generation, more precise editing, and improved text rendering.',
1225
+ displayName: 'GPT Image 1.5',
1226
+ enabled: true,
1227
+ id: 'gpt-image-1.5',
1228
+ parameters: gptImage1ParamsSchema,
1229
+ pricing: {
1230
+ approximatePricePerImage: 0.034,
1231
+ units: [
1232
+ { name: 'textInput', rate: 5, strategy: 'fixed', unit: 'millionTokens' },
1233
+ { name: 'textInput_cacheRead', rate: 1.25, strategy: 'fixed', unit: 'millionTokens' },
1234
+ { name: 'imageInput', rate: 8, strategy: 'fixed', unit: 'millionTokens' },
1235
+ { name: 'imageInput_cacheRead', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
1236
+ { name: 'imageOutput', rate: 32, strategy: 'fixed', unit: 'millionTokens' },
1237
+ ],
1238
+ },
1239
+ releasedAt: '2025-12-16',
1240
+ type: 'image',
1241
+ },
1222
1242
  // https://platform.openai.com/docs/models/gpt-image-1
1223
1243
  {
1224
1244
  description: 'ChatGPT native multimodal image generation model.',
@@ -1236,7 +1256,6 @@ export const openaiImageModels: AIImageModelCard[] = [
1236
1256
  { name: 'imageOutput', rate: 40, strategy: 'fixed', unit: 'millionTokens' },
1237
1257
  ],
1238
1258
  },
1239
- resolutions: ['1024x1024', '1024x1536', '1536x1024'],
1240
1259
  type: 'image',
1241
1260
  },
1242
1261
  {
@@ -1257,13 +1276,13 @@ export const openaiImageModels: AIImageModelCard[] = [
1257
1276
  ],
1258
1277
  },
1259
1278
  releasedAt: '2025-10-06',
1260
- resolutions: ['1024x1024', '1024x1536', '1536x1024'],
1261
1279
  type: 'image',
1262
1280
  },
1263
1281
  {
1264
1282
  description:
1265
1283
  'The latest DALL·E model, released in November 2023, supports more realistic, accurate image generation with stronger detail.',
1266
1284
  displayName: 'DALL·E 3',
1285
+ enabled: true,
1267
1286
  id: 'dall-e-3',
1268
1287
  parameters: {
1269
1288
  prompt: { default: '' },
@@ -1296,7 +1315,6 @@ export const openaiImageModels: AIImageModelCard[] = [
1296
1315
  },
1297
1316
  ],
1298
1317
  },
1299
- resolutions: ['1024x1024', '1024x1792', '1792x1024'],
1300
1318
  type: 'image',
1301
1319
  },
1302
1320
  {
@@ -1329,7 +1347,6 @@ export const openaiImageModels: AIImageModelCard[] = [
1329
1347
  },
1330
1348
  ],
1331
1349
  },
1332
- resolutions: ['256x256', '512x512', '1024x1024'],
1333
1350
  type: 'image',
1334
1351
  },
1335
1352
  ];
@@ -67,7 +67,10 @@ async function generateByImageMode(
67
67
  const defaultInput = {
68
68
  n: 1,
69
69
  ...(model.includes('dall-e') ? { response_format: 'b64_json' } : {}),
70
- ...(isImageEdit && model === 'gpt-image-1' ? { input_fidelity: 'high' } : {}),
70
+ // https://platform.openai.com/docs/api-reference/images/createEdit#images_createedit-input_fidelity
71
+ ...(isImageEdit && model.includes('gpt-image-') && !model.includes('mini')
72
+ ? { input_fidelity: 'high' }
73
+ : {}),
71
74
  };
72
75
 
73
76
  const options = cleanObject({
@@ -311,7 +311,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
311
311
  "contextWindowTokens": undefined,
312
312
  "description": "The latest DALL·E model, released in November 2023, supports more realistic, accurate image generation with stronger detail.",
313
313
  "displayName": "DALL·E 3",
314
- "enabled": false,
314
+ "enabled": true,
315
315
  "functionCall": false,
316
316
  "id": "dall-e-3",
317
317
  "imageOutput": false,
@@ -49,14 +49,7 @@ describe('ssrfSafeFetch', () => {
49
49
  expect(mockFetch).toHaveBeenCalledWith(
50
50
  'https://httpbin.org/get',
51
51
  expect.objectContaining({
52
- agent: expect.objectContaining({
53
- requestFilterOptions: expect.objectContaining({
54
- allowIPAddressList: [],
55
- allowMetaIPAddress: false,
56
- allowPrivateIPAddress: false,
57
- denyIPAddressList: [],
58
- }),
59
- }),
52
+ agent: expect.any(Function),
60
53
  }),
61
54
  );
62
55
  expect(response).toBeInstanceOf(Response);
@@ -80,14 +73,7 @@ describe('ssrfSafeFetch', () => {
80
73
  'https://httpbin.org/post',
81
74
  expect.objectContaining({
82
75
  ...requestOptions,
83
- agent: expect.objectContaining({
84
- requestFilterOptions: expect.objectContaining({
85
- allowIPAddressList: [],
86
- allowMetaIPAddress: false,
87
- allowPrivateIPAddress: false,
88
- denyIPAddressList: [],
89
- }),
90
- }),
76
+ agent: expect.any(Function),
91
77
  }),
92
78
  );
93
79
  });
@@ -302,14 +288,7 @@ describe('ssrfSafeFetch', () => {
302
288
  'https://api.example.com/data',
303
289
  expect.objectContaining({
304
290
  ...requestOptions,
305
- agent: expect.objectContaining({
306
- requestFilterOptions: expect.objectContaining({
307
- allowIPAddressList: ['127.0.0.1'],
308
- allowMetaIPAddress: true,
309
- allowPrivateIPAddress: true,
310
- denyIPAddressList: [],
311
- }),
312
- }),
291
+ agent: expect.any(Function),
313
292
  }),
314
293
  );
315
294
 
@@ -323,19 +302,11 @@ describe('ssrfSafeFetch', () => {
323
302
 
324
303
  await ssrfSafeFetch('https://secure.example.com/api');
325
304
 
326
- // Verify that the agent is properly configured for HTTPS
305
+ // Verify that the agent function is passed
327
306
  expect(mockFetch).toHaveBeenCalledWith(
328
307
  'https://secure.example.com/api',
329
308
  expect.objectContaining({
330
- agent: expect.objectContaining({
331
- protocol: 'https:',
332
- requestFilterOptions: expect.objectContaining({
333
- allowIPAddressList: [],
334
- allowMetaIPAddress: false,
335
- allowPrivateIPAddress: false,
336
- denyIPAddressList: [],
337
- }),
338
- }),
309
+ agent: expect.any(Function),
339
310
  }),
340
311
  );
341
312
  });
@@ -1,5 +1,9 @@
1
1
  import fetch from 'node-fetch';
2
- import { RequestFilteringAgentOptions, useAgent as ssrfAgent } from 'request-filtering-agent';
2
+ import {
3
+ RequestFilteringAgentOptions,
4
+ RequestFilteringHttpAgent,
5
+ RequestFilteringHttpsAgent,
6
+ } from 'request-filtering-agent';
3
7
 
4
8
  /**
5
9
  * Options for per-call SSRF configuration overrides
@@ -42,10 +46,16 @@ export const ssrfSafeFetch = async (
42
46
  denyIPAddressList: [],
43
47
  };
44
48
 
49
+ // Create agents for both protocols
50
+ const httpAgent = new RequestFilteringHttpAgent(agentOptions);
51
+ const httpsAgent = new RequestFilteringHttpsAgent(agentOptions);
52
+
45
53
  // Use node-fetch with SSRF protection agent
54
+ // Pass a function to dynamically select agent based on URL protocol
55
+ // This handles redirects from HTTP to HTTPS correctly
46
56
  const response = await fetch(url, {
47
57
  ...options,
48
- agent: ssrfAgent(url, agentOptions),
58
+ agent: (parsedURL: URL) => (parsedURL.protocol === 'https:' ? httpsAgent : httpAgent),
49
59
  } as any);
50
60
 
51
61
  // Convert node-fetch Response to standard Response
@@ -93,8 +93,8 @@ const styles = createStaticStyles(({ css }) => {
93
93
 
94
94
  overflow: hidden;
95
95
 
96
- width: ${thumbnailSize};
97
- height: ${thumbnailSize};
96
+ width: ${thumbnailSize}px;
97
+ height: ${thumbnailSize}px;
98
98
  border-radius: ${cssVar.borderRadius};
99
99
 
100
100
  background: ${cssVar.colorBgContainer};
@@ -112,7 +112,7 @@ const styles = createStaticStyles(({ css }) => {
112
112
  gap: 8px;
113
113
 
114
114
  width: 100%;
115
- height: ${thumbnailSize};
115
+ height: ${thumbnailSize}px;
116
116
  padding: 0;
117
117
  border-radius: ${cssVar.borderRadiusLG};
118
118
 
@@ -77,15 +77,8 @@ const GenerationFeed = memo(() => {
77
77
  }
78
78
 
79
79
  return (
80
- <>
81
- <Flexbox
82
- gap={16}
83
- ref={parent}
84
- style={{
85
- minHeight: 'calc(100vh - 180px)',
86
- }}
87
- width="100%"
88
- >
80
+ <Flexbox flex={1}>
81
+ <Flexbox gap={16} ref={parent} width="100%">
89
82
  {currentGenerationBatches.map((batch, index) => (
90
83
  <Fragment key={batch.id}>
91
84
  {Boolean(index !== 0) && <Divider dashed style={{ margin: 0 }} />}
@@ -95,7 +88,7 @@ const GenerationFeed = memo(() => {
95
88
  </Flexbox>
96
89
  {/* Invisible element for scroll target */}
97
90
  <div ref={containerRef} style={{ height: 1 }} />
98
- </>
91
+ </Flexbox>
99
92
  );
100
93
  });
101
94
 
@@ -15,7 +15,7 @@ const DesktopImagePage = memo(() => {
15
15
  <>
16
16
  <NavHeader right={<WideScreenButton />} />
17
17
  <Flexbox height={'100%'} style={{ overflowY: 'auto', position: 'relative' }} width={'100%'}>
18
- <WideScreenContainer>
18
+ <WideScreenContainer height={'100%'} wrapperStyle={{ height: '100%' }}>
19
19
  <Suspense fallback={<SkeletonList />}>
20
20
  <ImageWorkspace />
21
21
  </Suspense>
@@ -0,0 +1,81 @@
1
+ .container {
2
+ --brand-text-color: var(--colorText, #1f1f1f);
3
+ --brand-muted-color: var(--colorTextSecondary, #8c8c8c);
4
+ --brand-border-color: var(--colorBorder, #d9d9d9);
5
+ --brand-tag-bg: var(--colorFillTertiary, rgba(0, 0, 0, 0.04));
6
+
7
+ display: flex;
8
+ flex-direction: column;
9
+ align-items: center;
10
+ justify-content: center;
11
+
12
+ width: 100%;
13
+ height: 100vh;
14
+ height: 100dvh;
15
+ gap: 12px;
16
+ }
17
+
18
+ [data-theme='dark'] .container {
19
+ --brand-text-color: var(--colorText, #f0f0f0);
20
+ --brand-muted-color: var(--colorTextSecondary, #a6a6a6);
21
+ --brand-border-color: var(--colorBorder, #424242);
22
+ --brand-tag-bg: var(--colorFillTertiary, rgba(255, 255, 255, 0.08));
23
+ }
24
+
25
+ .brand {
26
+ display: flex;
27
+ align-items: center;
28
+ gap: 12px;
29
+
30
+ opacity: 0.6;
31
+ color: var(--brand-text-color);
32
+ }
33
+
34
+ .brand :global(.lobe-brand-loading) {
35
+ display: block;
36
+ color: inherit;
37
+ }
38
+
39
+ .debug {
40
+ display: flex;
41
+ flex-direction: column;
42
+ align-items: center;
43
+ gap: 4px;
44
+
45
+ padding: 16px;
46
+ }
47
+
48
+ .debugRow {
49
+ display: flex;
50
+ gap: 8px;
51
+ align-items: center;
52
+
53
+ font-size: 12px;
54
+ color: var(--brand-muted-color);
55
+ }
56
+
57
+ .debugRow code {
58
+ font-family:
59
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
60
+ monospace;
61
+ }
62
+
63
+ .debugTag {
64
+ display: inline-flex;
65
+ align-items: center;
66
+
67
+ padding: 2px 8px;
68
+ border: 1px solid var(--brand-border-color);
69
+ border-radius: 6px;
70
+
71
+ background: var(--brand-tag-bg);
72
+ }
73
+
74
+ .debugTag code {
75
+ color: var(--brand-text-color);
76
+ }
77
+
78
+ .debugHint {
79
+ font-size: 12px;
80
+ color: var(--brand-muted-color);
81
+ }
@@ -1,34 +1,41 @@
1
- import { Center, Tag, Text } from '@lobehub/ui';
2
1
  import { BrandLoading, LobeHubText } from '@lobehub/ui/brand';
3
2
 
4
3
  import { isCustomBranding } from '@/const/version';
5
4
 
6
5
  import CircleLoading from '../CircleLoading';
6
+ import styles from './index.module.css';
7
7
 
8
8
  interface BrandTextLoadingProps {
9
9
  debugId: string;
10
10
  }
11
11
 
12
12
  const BrandTextLoading = ({ debugId }: BrandTextLoadingProps) => {
13
- if (isCustomBranding) return <CircleLoading />;
13
+ if (isCustomBranding)
14
+ return (
15
+ <div className={styles.container}>
16
+ <CircleLoading />
17
+ </div>
18
+ );
19
+
20
+ const showDebug = process.env.NODE_ENV === 'development' && debugId;
14
21
 
15
22
  return (
16
- <Center height={'100%'} width={'100%'}>
17
- <BrandLoading size={40} style={{ opacity: 0.6 }} text={LobeHubText} />
18
- {process.env.NODE_ENV === 'development' && debugId && (
19
- <Center gap={4} padding={16}>
20
- <Text code style={{ alignItems: 'center', display: 'flex' }}>
21
- Debug ID:{' '}
22
- <Tag size={'large'}>
23
- <Text code>{debugId}</Text>
24
- </Tag>
25
- </Text>
26
- <Text fontSize={12} type={'secondary'}>
27
- only visible in development
28
- </Text>
29
- </Center>
23
+ <div className={styles.container}>
24
+ <div aria-label="Loading" className={styles.brand} role="status">
25
+ <BrandLoading size={40} text={LobeHubText} />
26
+ </div>
27
+ {showDebug && (
28
+ <div className={styles.debug}>
29
+ <div className={styles.debugRow}>
30
+ <code>Debug ID:</code>
31
+ <span className={styles.debugTag}>
32
+ <code>{debugId}</code>
33
+ </span>
34
+ </div>
35
+ <div className={styles.debugHint}>only visible in development</div>
36
+ </div>
30
37
  )}
31
- </Center>
38
+ </div>
32
39
  );
33
40
  };
34
41
 
package/src/envs/auth.ts CHANGED
@@ -158,6 +158,15 @@ declare global {
158
158
  * Can be generated using `node scripts/generate-oidc-jwk.mjs`.
159
159
  */
160
160
  JWKS_KEY?: string;
161
+
162
+ /**
163
+ * Internal JWT expiration time for lambda → async calls.
164
+ * Format: number followed by unit (s=seconds, m=minutes, h=hours)
165
+ * Examples: '10s', '1m', '1h'
166
+ * Should be as short as possible for security, but long enough to account for network latency and server processing time.
167
+ * @default '30s'
168
+ */
169
+ INTERNAL_JWT_EXPIRATION?: string;
161
170
  }
162
171
  }
163
172
  }
@@ -285,6 +294,9 @@ export const getAuthConfig = () => {
285
294
  // Generic JWKS key for signing/verifying JWTs
286
295
  JWKS_KEY: z.string().optional(),
287
296
  ENABLE_OIDC: z.boolean(),
297
+
298
+ // Internal JWT expiration time (e.g., '10s', '1m', '1h')
299
+ INTERNAL_JWT_EXPIRATION: z.string().default('30s'),
288
300
  },
289
301
 
290
302
  runtimeEnv: {
@@ -415,6 +427,9 @@ export const getAuthConfig = () => {
415
427
  // Generic JWKS key (fallback to OIDC_JWKS_KEY for backward compatibility)
416
428
  JWKS_KEY: process.env.JWKS_KEY || process.env.OIDC_JWKS_KEY,
417
429
  ENABLE_OIDC: !!(process.env.JWKS_KEY || process.env.OIDC_JWKS_KEY),
430
+
431
+ // Internal JWT expiration time
432
+ INTERNAL_JWT_EXPIRATION: process.env.INTERNAL_JWT_EXPIRATION,
418
433
  },
419
434
  });
420
435
  };
@@ -1,12 +1,26 @@
1
- import { useEffect } from 'react';
1
+ import { useEffect, useMemo } from 'react';
2
2
 
3
3
  import { aiProviderSelectors, useAiInfraStore } from '@/store/aiInfra';
4
4
  import { useGlobalStore } from '@/store/global';
5
5
  import { systemStatusSelectors } from '@/store/global/selectors';
6
6
  import { useImageStore } from '@/store/image';
7
+ import {
8
+ DEFAULT_AI_IMAGE_MODEL,
9
+ DEFAULT_AI_IMAGE_PROVIDER,
10
+ } from '@/store/image/slices/generationConfig/initialState';
7
11
  import { useUserStore } from '@/store/user';
8
12
  import { authSelectors } from '@/store/user/selectors';
9
13
 
14
+ const checkModelEnabled = (
15
+ enabledImageModelList: ReturnType<typeof aiProviderSelectors.enabledImageModelList>,
16
+ provider: string,
17
+ model: string,
18
+ ) => {
19
+ return enabledImageModelList.some(
20
+ (p) => p.id === provider && p.children.some((m) => m.id === model),
21
+ );
22
+ };
23
+
10
24
  export const useFetchAiImageConfig = () => {
11
25
  const isStatusInit = useGlobalStore(systemStatusSelectors.isStatusInit);
12
26
  const isInitAiProviderRuntimeState = useAiInfraStore(
@@ -29,16 +43,46 @@ export const useFetchAiImageConfig = () => {
29
43
  const isInitializedImageConfig = useImageStore((s) => s.isInit);
30
44
  const initializeImageConfig = useImageStore((s) => s.initializeImageConfig);
31
45
 
46
+ const enabledImageModelList = useAiInfraStore(aiProviderSelectors.enabledImageModelList);
47
+
48
+ // Determine which model/provider to use for initialization
49
+ const initParams = useMemo(() => {
50
+ // 1. Try lastSelected if enabled
51
+ if (
52
+ lastSelectedImageModel &&
53
+ lastSelectedImageProvider &&
54
+ checkModelEnabled(enabledImageModelList, lastSelectedImageProvider, lastSelectedImageModel)
55
+ ) {
56
+ return { model: lastSelectedImageModel, provider: lastSelectedImageProvider };
57
+ }
58
+
59
+ // 2. Try default model from any enabled provider (prefer default provider first)
60
+ if (
61
+ checkModelEnabled(enabledImageModelList, DEFAULT_AI_IMAGE_PROVIDER, DEFAULT_AI_IMAGE_MODEL)
62
+ ) {
63
+ return { model: undefined, provider: undefined }; // Use initialState defaults
64
+ }
65
+ const providerWithDefaultModel = enabledImageModelList.find((p) =>
66
+ p.children.some((m) => m.id === DEFAULT_AI_IMAGE_MODEL),
67
+ );
68
+ if (providerWithDefaultModel) {
69
+ return { model: DEFAULT_AI_IMAGE_MODEL, provider: providerWithDefaultModel.id };
70
+ }
71
+
72
+ // 3. Fallback to first enabled model
73
+ const firstProvider = enabledImageModelList[0];
74
+ const firstModel = firstProvider?.children[0];
75
+ if (firstProvider && firstModel) {
76
+ return { model: firstModel.id, provider: firstProvider.id };
77
+ }
78
+
79
+ // No enabled models
80
+ return { model: undefined, provider: undefined };
81
+ }, [lastSelectedImageModel, lastSelectedImageProvider, enabledImageModelList]);
82
+
32
83
  useEffect(() => {
33
84
  if (!isInitializedImageConfig && isReadyForInit) {
34
- initializeImageConfig(isLogin, lastSelectedImageModel, lastSelectedImageProvider);
85
+ initializeImageConfig(isLogin, initParams.model, initParams.provider);
35
86
  }
36
- }, [
37
- isReadyForInit,
38
- isInitializedImageConfig,
39
- isLogin,
40
- lastSelectedImageModel,
41
- lastSelectedImageProvider,
42
- initializeImageConfig,
43
- ]);
87
+ }, [isReadyForInit, isInitializedImageConfig, isLogin, initParams, initializeImageConfig]);
44
88
  };
@@ -94,3 +94,66 @@ export const createRedisWithPrefix = async (
94
94
  await provider.initialize();
95
95
  return provider;
96
96
  };
97
+
98
+ /**
99
+ * Manages singleton Redis clients per prefix
100
+ */
101
+ class PrefixedRedisManager {
102
+ private static instances = new Map<string, BaseRedisProvider>();
103
+ private static initPromises = new Map<string, Promise<BaseRedisProvider | null>>();
104
+
105
+ static async initialize(config: RedisConfig, prefix: string): Promise<BaseRedisProvider | null> {
106
+ const existing = this.instances.get(prefix);
107
+ if (existing) return existing;
108
+
109
+ const pendingPromise = this.initPromises.get(prefix);
110
+ if (pendingPromise) return pendingPromise;
111
+
112
+ const initPromise = (async () => {
113
+ const provider = createProvider(config, prefix);
114
+ if (!provider) return null;
115
+
116
+ await provider.initialize();
117
+ this.instances.set(prefix, provider);
118
+ return provider;
119
+ })().catch((error) => {
120
+ this.initPromises.delete(prefix);
121
+ throw error;
122
+ });
123
+
124
+ this.initPromises.set(prefix, initPromise);
125
+ return initPromise;
126
+ }
127
+
128
+ static async reset(prefix?: string) {
129
+ if (prefix) {
130
+ const instance = this.instances.get(prefix);
131
+ if (instance) {
132
+ await instance.disconnect();
133
+ this.instances.delete(prefix);
134
+ this.initPromises.delete(prefix);
135
+ }
136
+ } else {
137
+ for (const instance of this.instances.values()) {
138
+ await instance.disconnect();
139
+ }
140
+ this.instances.clear();
141
+ this.initPromises.clear();
142
+ }
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Initialize a singleton Redis client with custom prefix
148
+ *
149
+ * Unlike createRedisWithPrefix, this reuses the same client for each prefix,
150
+ * avoiding connection leaks when called frequently.
151
+ *
152
+ * @param config - Redis config
153
+ * @param prefix - Custom prefix for all keys (e.g., 'aiGeneration')
154
+ * @returns Redis client or null if Redis is disabled
155
+ */
156
+ export const initializeRedisWithPrefix = (config: RedisConfig, prefix: string) =>
157
+ PrefixedRedisManager.initialize(config, prefix);
158
+
159
+ export const resetPrefixedRedisClient = (prefix?: string) => PrefixedRedisManager.reset(prefix);
@@ -66,7 +66,7 @@ const getVerificationKey = async () => {
66
66
 
67
67
  /**
68
68
  * Sign JWT for internal lambda → async calls
69
- * Uses JWKS private key with short expiration (3s)
69
+ * Uses JWKS private key with configurable expiration (default: 30s)
70
70
  * The JWT only proves the request is from lambda, payload is sent via LOBE_CHAT_AUTH_HEADER
71
71
  */
72
72
  export const signInternalJWT = async (): Promise<string> => {
@@ -75,7 +75,7 @@ export const signInternalJWT = async (): Promise<string> => {
75
75
  return new SignJWT({ purpose: INTERNAL_JWT_PURPOSE })
76
76
  .setProtectedHeader({ alg: 'RS256', kid })
77
77
  .setIssuedAt()
78
- .setExpirationTime('3s')
78
+ .setExpirationTime(authEnv.INTERNAL_JWT_EXPIRATION)
79
79
  .sign(key);
80
80
  };
81
81
 
@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
5
  import { AgentModel } from '@/database/models/agent';
6
6
  import { SessionModel } from '@/database/models/session';
7
7
  import { UserModel } from '@/database/models/user';
8
- import { RedisKeys, createRedisWithPrefix } from '@/libs/redis';
8
+ import { RedisKeys, initializeRedisWithPrefix, isRedisEnabled } from '@/libs/redis';
9
9
  import { parseAgentConfig } from '@/server/globalConfig/parseDefaultAgent';
10
10
 
11
11
  import { AgentService } from './index';
@@ -43,7 +43,8 @@ vi.mock('@/libs/redis', async (importOriginal) => {
43
43
  const original = await importOriginal<typeof import('@/libs/redis')>();
44
44
  return {
45
45
  ...original,
46
- createRedisWithPrefix: vi.fn(),
46
+ initializeRedisWithPrefix: vi.fn(),
47
+ isRedisEnabled: vi.fn(),
47
48
  };
48
49
  });
49
50
 
@@ -290,7 +291,8 @@ describe('AgentService', () => {
290
291
  const mockRedisClient = { get: mockRedisGet };
291
292
 
292
293
  beforeEach(() => {
293
- vi.mocked(createRedisWithPrefix).mockReset();
294
+ vi.mocked(initializeRedisWithPrefix).mockReset();
295
+ vi.mocked(isRedisEnabled).mockReset();
294
296
  mockRedisGet.mockReset();
295
297
  });
296
298
 
@@ -310,7 +312,8 @@ describe('AgentService', () => {
310
312
 
311
313
  (AgentModel as any).mockImplementation(() => mockAgentModel);
312
314
  (parseAgentConfig as any).mockReturnValue({});
313
- vi.mocked(createRedisWithPrefix).mockResolvedValue(mockRedisClient as any);
315
+ vi.mocked(isRedisEnabled).mockReturnValue(true);
316
+ vi.mocked(initializeRedisWithPrefix).mockResolvedValue(mockRedisClient as any);
314
317
  mockRedisGet.mockResolvedValue(JSON.stringify(welcomeData));
315
318
 
316
319
  const newService = new AgentService(mockDb, mockUserId);
@@ -334,7 +337,7 @@ describe('AgentService', () => {
334
337
 
335
338
  (AgentModel as any).mockImplementation(() => mockAgentModel);
336
339
  (parseAgentConfig as any).mockReturnValue({});
337
- vi.mocked(createRedisWithPrefix).mockResolvedValue(null);
340
+ vi.mocked(isRedisEnabled).mockReturnValue(false);
338
341
 
339
342
  const newService = new AgentService(mockDb, mockUserId);
340
343
  const result = await newService.getAgentConfigById('agent-1');
@@ -343,6 +346,7 @@ describe('AgentService', () => {
343
346
  expect(result?.openingMessage).toBe('Default message');
344
347
  // openingQuestions comes from DEFAULT_AGENT_CONFIG (empty array)
345
348
  expect(result?.openingQuestions).toEqual([]);
349
+ expect(initializeRedisWithPrefix).not.toHaveBeenCalled();
346
350
  });
347
351
 
348
352
  it('should return normal config when Redis key does not exist', async () => {
@@ -357,7 +361,8 @@ describe('AgentService', () => {
357
361
 
358
362
  (AgentModel as any).mockImplementation(() => mockAgentModel);
359
363
  (parseAgentConfig as any).mockReturnValue({});
360
- vi.mocked(createRedisWithPrefix).mockResolvedValue(mockRedisClient as any);
364
+ vi.mocked(isRedisEnabled).mockReturnValue(true);
365
+ vi.mocked(initializeRedisWithPrefix).mockResolvedValue(mockRedisClient as any);
361
366
  mockRedisGet.mockResolvedValue(null);
362
367
 
363
368
  const newService = new AgentService(mockDb, mockUserId);
@@ -381,7 +386,8 @@ describe('AgentService', () => {
381
386
 
382
387
  (AgentModel as any).mockImplementation(() => mockAgentModel);
383
388
  (parseAgentConfig as any).mockReturnValue({});
384
- vi.mocked(createRedisWithPrefix).mockRejectedValue(new Error('Redis connection failed'));
389
+ vi.mocked(isRedisEnabled).mockReturnValue(true);
390
+ vi.mocked(initializeRedisWithPrefix).mockRejectedValue(new Error('Redis connection failed'));
385
391
 
386
392
  const newService = new AgentService(mockDb, mockUserId);
387
393
  const result = await newService.getAgentConfigById('agent-1');
@@ -403,7 +409,8 @@ describe('AgentService', () => {
403
409
 
404
410
  (AgentModel as any).mockImplementation(() => mockAgentModel);
405
411
  (parseAgentConfig as any).mockReturnValue({});
406
- vi.mocked(createRedisWithPrefix).mockResolvedValue(mockRedisClient as any);
412
+ vi.mocked(isRedisEnabled).mockReturnValue(true);
413
+ vi.mocked(initializeRedisWithPrefix).mockResolvedValue(mockRedisClient as any);
407
414
  mockRedisGet.mockResolvedValue('invalid json {');
408
415
 
409
416
  const newService = new AgentService(mockDb, mockUserId);