@lobehub/chat 1.99.6 → 1.100.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.
Files changed (108) hide show
  1. package/.cursor/rules/testing-guide/testing-guide.mdc +173 -0
  2. package/.github/workflows/desktop-pr-build.yml +3 -3
  3. package/.github/workflows/release-desktop-beta.yml +3 -3
  4. package/CHANGELOG.md +50 -0
  5. package/apps/desktop/package.json +5 -2
  6. package/apps/desktop/src/main/controllers/AuthCtr.ts +310 -111
  7. package/apps/desktop/src/main/controllers/NetworkProxyCtr.ts +1 -1
  8. package/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts +50 -3
  9. package/apps/desktop/src/main/controllers/RemoteServerSyncCtr.ts +188 -23
  10. package/apps/desktop/src/main/controllers/__tests__/NetworkProxyCtr.test.ts +37 -18
  11. package/apps/desktop/src/main/types/store.ts +1 -0
  12. package/apps/desktop/src/preload/electronApi.ts +2 -1
  13. package/apps/desktop/src/preload/streamer.ts +58 -0
  14. package/changelog/v1.json +18 -0
  15. package/docs/development/database-schema.dbml +9 -0
  16. package/docs/self-hosting/environment-variables/model-provider.mdx +25 -0
  17. package/docs/self-hosting/environment-variables/model-provider.zh-CN.mdx +25 -0
  18. package/docs/self-hosting/faq/vercel-ai-image-timeout.mdx +65 -0
  19. package/docs/self-hosting/faq/vercel-ai-image-timeout.zh-CN.mdx +63 -0
  20. package/docs/usage/providers/fal.mdx +6 -6
  21. package/docs/usage/providers/fal.zh-CN.mdx +6 -6
  22. package/locales/ar/electron.json +3 -0
  23. package/locales/ar/oauth.json +8 -4
  24. package/locales/bg-BG/electron.json +3 -0
  25. package/locales/bg-BG/oauth.json +8 -4
  26. package/locales/de-DE/electron.json +3 -0
  27. package/locales/de-DE/oauth.json +9 -5
  28. package/locales/en-US/electron.json +3 -0
  29. package/locales/en-US/oauth.json +8 -4
  30. package/locales/es-ES/electron.json +3 -0
  31. package/locales/es-ES/oauth.json +9 -5
  32. package/locales/fa-IR/electron.json +3 -0
  33. package/locales/fa-IR/oauth.json +8 -4
  34. package/locales/fr-FR/electron.json +3 -0
  35. package/locales/fr-FR/oauth.json +8 -4
  36. package/locales/it-IT/electron.json +3 -0
  37. package/locales/it-IT/oauth.json +9 -5
  38. package/locales/ja-JP/electron.json +3 -0
  39. package/locales/ja-JP/oauth.json +8 -4
  40. package/locales/ko-KR/electron.json +3 -0
  41. package/locales/ko-KR/oauth.json +8 -4
  42. package/locales/nl-NL/electron.json +3 -0
  43. package/locales/nl-NL/oauth.json +9 -5
  44. package/locales/pl-PL/electron.json +3 -0
  45. package/locales/pl-PL/oauth.json +8 -4
  46. package/locales/pt-BR/electron.json +3 -0
  47. package/locales/pt-BR/oauth.json +8 -4
  48. package/locales/ru-RU/electron.json +3 -0
  49. package/locales/ru-RU/oauth.json +8 -4
  50. package/locales/tr-TR/electron.json +3 -0
  51. package/locales/tr-TR/oauth.json +8 -4
  52. package/locales/vi-VN/electron.json +3 -0
  53. package/locales/vi-VN/oauth.json +9 -5
  54. package/locales/zh-CN/electron.json +3 -0
  55. package/locales/zh-CN/oauth.json +8 -4
  56. package/locales/zh-TW/electron.json +3 -0
  57. package/locales/zh-TW/oauth.json +8 -4
  58. package/package.json +3 -3
  59. package/packages/electron-client-ipc/src/dispatch.ts +14 -2
  60. package/packages/electron-client-ipc/src/index.ts +1 -0
  61. package/packages/electron-client-ipc/src/streamInvoke.ts +62 -0
  62. package/packages/electron-client-ipc/src/types/proxyTRPCRequest.ts +5 -0
  63. package/packages/electron-client-ipc/src/utils/headers.ts +27 -0
  64. package/packages/electron-client-ipc/src/utils/request.ts +28 -0
  65. package/src/app/(backend)/oidc/callback/desktop/route.ts +58 -0
  66. package/src/app/(backend)/oidc/handoff/route.ts +46 -0
  67. package/src/app/[variants]/oauth/callback/error/page.tsx +55 -0
  68. package/src/app/[variants]/oauth/callback/layout.tsx +12 -0
  69. package/src/app/[variants]/oauth/callback/loading.tsx +3 -0
  70. package/src/app/[variants]/oauth/{consent/[uid] → callback}/success/page.tsx +10 -1
  71. package/src/app/[variants]/oauth/consent/[uid]/Consent.tsx +7 -1
  72. package/src/database/client/migrations.json +8 -0
  73. package/src/database/migrations/0028_oauth_handoffs.sql +8 -0
  74. package/src/database/migrations/meta/0028_snapshot.json +6055 -0
  75. package/src/database/migrations/meta/_journal.json +7 -0
  76. package/src/database/models/oauthHandoff.ts +94 -0
  77. package/src/database/repositories/tableViewer/index.test.ts +1 -1
  78. package/src/database/schemas/oidc.ts +46 -0
  79. package/src/features/ElectronTitlebar/Connection/Waiting.tsx +59 -115
  80. package/src/features/ElectronTitlebar/Connection/WaitingAnim.tsx +114 -0
  81. package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.test.ts +1 -1
  82. package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts +2 -1
  83. package/src/libs/oidc-provider/config.ts +16 -17
  84. package/src/libs/oidc-provider/jwt.ts +135 -0
  85. package/src/libs/oidc-provider/provider.ts +22 -38
  86. package/src/libs/trpc/client/async.ts +1 -2
  87. package/src/libs/trpc/client/edge.ts +1 -2
  88. package/src/libs/trpc/client/lambda.ts +1 -1
  89. package/src/libs/trpc/client/tools.ts +1 -2
  90. package/src/libs/trpc/lambda/context.ts +9 -16
  91. package/src/locales/default/electron.ts +3 -0
  92. package/src/locales/default/oauth.ts +8 -4
  93. package/src/middleware.ts +10 -4
  94. package/src/server/globalConfig/genServerAiProviderConfig.test.ts +235 -0
  95. package/src/server/globalConfig/genServerAiProviderConfig.ts +9 -10
  96. package/src/server/services/oidc/index.ts +0 -71
  97. package/src/services/chat.ts +5 -1
  98. package/src/services/electron/remoteServer.ts +0 -7
  99. package/src/store/aiInfra/slices/aiProvider/action.ts +2 -1
  100. package/src/{libs/trpc/client/helpers → utils/electron}/desktopRemoteRPCFetch.ts +22 -7
  101. package/src/utils/getFallbackModelProperty.test.ts +193 -0
  102. package/src/utils/getFallbackModelProperty.ts +36 -0
  103. package/src/utils/parseModels.test.ts +150 -48
  104. package/src/utils/parseModels.ts +26 -11
  105. package/src/utils/server/auth.ts +22 -0
  106. package/src/app/[variants]/oauth/consent/[uid]/failed/page.tsx +0 -36
  107. package/src/app/[variants]/oauth/handoff/Client.tsx +0 -98
  108. package/src/app/[variants]/oauth/handoff/page.tsx +0 -13
@@ -3,7 +3,7 @@ import { getLLMConfig } from '@/config/llm';
3
3
  import { ModelProvider } from '@/libs/model-runtime';
4
4
  import { AiFullModelCard } from '@/types/aiModel';
5
5
  import { ProviderConfig } from '@/types/user/settings';
6
- import { extractEnabledModels, transformToAiChatModelList } from '@/utils/parseModels';
6
+ import { extractEnabledModels, transformToAiModelList } from '@/utils/parseModels';
7
7
 
8
8
  interface ProviderSpecificConfig {
9
9
  enabled?: boolean;
@@ -19,19 +19,17 @@ export const genServerAiProvidersConfig = (specificConfig: Record<any, ProviderS
19
19
  return Object.values(ModelProvider).reduce(
20
20
  (config, provider) => {
21
21
  const providerUpperCase = provider.toUpperCase();
22
- const providerCard = AiModels[provider] as AiFullModelCard[];
22
+ const aiModels = AiModels[provider] as AiFullModelCard[];
23
23
 
24
- if (!providerCard)
24
+ if (!aiModels)
25
25
  throw new Error(
26
26
  `Provider [${provider}] not found in aiModels, please make sure you have exported the provider in the \`aiModels/index.ts\``,
27
27
  );
28
28
 
29
29
  const providerConfig = specificConfig[provider as keyof typeof specificConfig] || {};
30
- const providerModelList =
30
+ const modelString =
31
31
  process.env[providerConfig.modelListKey ?? `${providerUpperCase}_MODEL_LIST`];
32
32
 
33
- const defaultChatModels = providerCard.filter((c) => c.type === 'chat');
34
-
35
33
  config[provider] = {
36
34
  enabled:
37
35
  typeof providerConfig.enabled !== 'undefined'
@@ -39,12 +37,13 @@ export const genServerAiProvidersConfig = (specificConfig: Record<any, ProviderS
39
37
  : llmConfig[providerConfig.enabledKey || `ENABLED_${providerUpperCase}`],
40
38
 
41
39
  enabledModels: extractEnabledModels(
42
- providerModelList,
40
+ provider,
41
+ modelString,
43
42
  providerConfig.withDeploymentName || false,
44
43
  ),
45
- serverModelLists: transformToAiChatModelList({
46
- defaultChatModels: defaultChatModels || [],
47
- modelString: providerModelList,
44
+ serverModelLists: transformToAiModelList({
45
+ defaultModels: aiModels || [],
46
+ modelString,
48
47
  providerId: provider,
49
48
  withDeploymentName: providerConfig.withDeploymentName || false,
50
49
  }),
@@ -1,4 +1,3 @@
1
- import { TRPCError } from '@trpc/server';
2
1
  import debug from 'debug';
3
2
 
4
3
  import { createContextForInteractionDetails } from '@/libs/oidc-provider/http-adapter';
@@ -20,76 +19,6 @@ export class OIDCService {
20
19
  return new OIDCService(provider);
21
20
  }
22
21
 
23
- /**
24
- * 验证 OIDC Bearer Token 并返回用户信息
25
- * 使用 oidc-provider 实例的 AccessToken.find 方法验证 token
26
- *
27
- * @param token - Bearer Token
28
- * @returns 包含用户ID和Token数据的对象
29
- * @throws 如果token无效或OIDC未启用则抛出 TRPCError
30
- */
31
- async validateToken(token: string) {
32
- try {
33
- log('Validating access token using AccessToken.find');
34
-
35
- // 使用 oidc-provider 的 AccessToken 查找和验证方法
36
- const accessToken = await this.provider.AccessToken.find(token);
37
-
38
- if (!accessToken) {
39
- log('Access token not found, expired, or consumed');
40
- throw new TRPCError({
41
- code: 'UNAUTHORIZED',
42
- message: 'Access token 无效、已过期或已被使用',
43
- });
44
- }
45
-
46
- // 从 accessToken 实例中获取必要的数据
47
- // 注意:accessToken 没有 payload() 方法,而是直接访问其属性
48
- const userId = accessToken.accountId; // 用户 ID 通常存储在 accountId 属性中
49
- const clientId = accessToken.clientId;
50
-
51
- // 如果需要更多的声明信息,可以从 accessToken 的其他属性中获取
52
- // 例如,scopes、claims、exp 等
53
- const tokenData = {
54
- client_id: clientId,
55
- exp: accessToken.exp,
56
- iat: accessToken.iat,
57
- jti: accessToken.jti,
58
- scope: accessToken.scope,
59
- // OIDC 标准中,sub 字段表示用户 ID
60
- sub: userId,
61
- };
62
-
63
- if (!userId) {
64
- log('Access token does not contain user ID (accountId)');
65
- throw new TRPCError({
66
- code: 'UNAUTHORIZED',
67
- message: 'Access token 中未包含用户 ID',
68
- });
69
- }
70
-
71
- log('Access token validated successfully for user: %s', userId);
72
- return {
73
- // 包含 token 原始数据,可用于获取更多信息
74
- accessToken,
75
- // 构建的 token 数据对象
76
- tokenData,
77
- // 用户 ID
78
- userId,
79
- };
80
- } catch (error) {
81
- if (error instanceof TRPCError) throw error;
82
-
83
- // AccessToken.find 可能抛出特定错误
84
- log('Error validating access token with AccessToken.find: %O', error);
85
- console.error('OIDC 令牌验证错误:', error);
86
- throw new TRPCError({
87
- code: 'UNAUTHORIZED',
88
- message: `OIDC 认证失败: ${(error as Error).message}`,
89
- });
90
- }
91
- }
92
-
93
22
  async getInteractionDetails(uid: string) {
94
23
  const { req, res } = await createContextForInteractionDetails(uid);
95
24
  return this.provider.interactionDetails(req, res);
@@ -40,6 +40,7 @@ import { ChatImageItem, ChatMessage, MessageToolCall } from '@/types/message';
40
40
  import type { ChatStreamPayload, OpenAIChatMessage } from '@/types/openai/chat';
41
41
  import { UserMessageContentPart } from '@/types/openai/chat';
42
42
  import { parsePlaceholderVariablesMessages } from '@/utils/client/parserPlaceholder';
43
+ import { fetchWithInvokeStream } from '@/utils/electron/desktopRemoteRPCFetch';
43
44
  import { createErrorResponse } from '@/utils/errorResponse';
44
45
  import {
45
46
  FetchSSEOptions,
@@ -361,7 +362,10 @@ class ChatService {
361
362
 
362
363
  let fetcher: typeof fetch | undefined = undefined;
363
364
 
364
- if (enableFetchOnClient) {
365
+ // Add desktop remote RPC fetch support
366
+ if (isDesktop) {
367
+ fetcher = fetchWithInvokeStream;
368
+ } else if (enableFetchOnClient) {
365
369
  /**
366
370
  * Notes:
367
371
  * 1. Browser agent runtime will skip auth check if a key and endpoint provided by
@@ -28,13 +28,6 @@ class RemoteServerService {
28
28
  requestAuthorization = async (config: DataSyncConfig) => {
29
29
  return dispatch('requestAuthorization', config);
30
30
  };
31
-
32
- /**
33
- * 刷新访问令牌
34
- */
35
- refreshAccessToken = async () => {
36
- return dispatch('refreshAccessToken');
37
- };
38
31
  }
39
32
 
40
33
  export const remoteServerService = new RemoteServerService();
@@ -19,6 +19,7 @@ import {
19
19
  UpdateAiProviderConfigParams,
20
20
  UpdateAiProviderParams,
21
21
  } from '@/types/aiProvider';
22
+ import { getModelPropertyWithFallback } from '@/utils/getFallbackModelProperty';
22
23
 
23
24
  enum AiProviderSwrKey {
24
25
  fetchAiProviderItem = 'FETCH_AI_PROVIDER_ITEM',
@@ -216,7 +217,7 @@ export const createAiProviderSlice: StateCreator<
216
217
  ...(model.type === 'image' && {
217
218
  parameters:
218
219
  (model as AIImageModelCard).parameters ||
219
- LOBE_DEFAULT_MODEL_LIST.find((m) => m.id === model.id)?.parameters,
220
+ getModelPropertyWithFallback(model.id, 'parameters'),
220
221
  }),
221
222
  }));
222
223
 
@@ -1,4 +1,4 @@
1
- import { ProxyTRPCRequestParams, dispatch } from '@lobechat/electron-client-ipc';
1
+ import { ProxyTRPCRequestParams, dispatch, streamInvoke } from '@lobechat/electron-client-ipc';
2
2
  import debug from 'debug';
3
3
 
4
4
  import { isDesktop } from '@/const/version';
@@ -6,7 +6,7 @@ import { getElectronStoreState } from '@/store/electron';
6
6
  import { electronSyncSelectors } from '@/store/electron/selectors';
7
7
  import { getRequestBody, headersToRecord } from '@/utils/fetch';
8
8
 
9
- const log = debug('lobe-lambda:desktopRemoteRPCFetch');
9
+ const log = debug('utils:desktopRemoteRPCFetch');
10
10
 
11
11
  // eslint-disable-next-line no-undef
12
12
  export const desktopRemoteRPCFetch = async (input: string, init?: RequestInit) => {
@@ -15,8 +15,8 @@ export const desktopRemoteRPCFetch = async (input: string, init?: RequestInit) =
15
15
 
16
16
  if (isSyncActive) {
17
17
  log('Using IPC proxy for tRPC request');
18
+ const url = input as string;
18
19
  try {
19
- const url = input as string;
20
20
  const parsedUrl = new URL(url, window.location.origin);
21
21
  const urlPath = parsedUrl.pathname + parsedUrl.search;
22
22
  const method = init?.method?.toUpperCase() || 'GET';
@@ -32,7 +32,7 @@ export const desktopRemoteRPCFetch = async (input: string, init?: RequestInit) =
32
32
 
33
33
  const ipcResult = await dispatch('proxyTRPCRequest', params);
34
34
 
35
- log('Received IPC proxy response:', { status: ipcResult.status });
35
+ log(`Received ${url} IPC proxy response:`, { status: ipcResult.status });
36
36
  const response = new Response(ipcResult.body, {
37
37
  headers: ipcResult.headers,
38
38
  status: ipcResult.status,
@@ -41,7 +41,7 @@ export const desktopRemoteRPCFetch = async (input: string, init?: RequestInit) =
41
41
 
42
42
  if (!response.ok) {
43
43
  console.warn(
44
- '[lambda] IPC proxy response indicates an error:',
44
+ `[lambda] ${url} IPC proxy response indicates an error:`,
45
45
  response.status,
46
46
  response.statusText,
47
47
  );
@@ -49,7 +49,7 @@ export const desktopRemoteRPCFetch = async (input: string, init?: RequestInit) =
49
49
 
50
50
  return response;
51
51
  } catch (error) {
52
- console.error('[lambda] Error during IPC proxy call:', error);
52
+ console.error(`[lambda] Error during ${url} IPC proxy call:`, error);
53
53
  return new Response(
54
54
  `IPC Proxy Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
55
55
  {
@@ -62,11 +62,26 @@ export const desktopRemoteRPCFetch = async (input: string, init?: RequestInit) =
62
62
  };
63
63
 
64
64
  // eslint-disable-next-line no-undef
65
- export const fetchWithDesktopRemoteRPC = async (input: string, init?: RequestInit) => {
65
+ export const fetchWithDesktopRemoteRPC = (async (input: RequestInfo | URL, init?: RequestInit) => {
66
66
  if (isDesktop) {
67
67
  const res = await desktopRemoteRPCFetch(input as string, init);
68
68
  if (res) return res;
69
69
  }
70
70
 
71
+ return fetch(input, init);
72
+ }) as typeof fetch;
73
+
74
+ // eslint-disable-next-line no-undef
75
+ export const fetchWithInvokeStream = async (input: RequestInfo | URL, init?: RequestInit) => {
76
+ if (isDesktop) {
77
+ const isSyncActive = electronSyncSelectors.isSyncActive(getElectronStoreState());
78
+ log('isSyncActive:', isSyncActive);
79
+ if (isSyncActive) {
80
+ log('Using IPC stream proxy for request to:', input);
81
+
82
+ return streamInvoke(input, init);
83
+ }
84
+ }
85
+
71
86
  return fetch(input, init);
72
87
  };
@@ -0,0 +1,193 @@
1
+ import { vi } from 'vitest';
2
+
3
+ import { getModelPropertyWithFallback } from './getFallbackModelProperty';
4
+
5
+ // Mock LOBE_DEFAULT_MODEL_LIST for testing
6
+ vi.mock('@/config/aiModels', () => ({
7
+ LOBE_DEFAULT_MODEL_LIST: [
8
+ {
9
+ id: 'gpt-4',
10
+ providerId: 'openai',
11
+ type: 'chat',
12
+ displayName: 'GPT-4',
13
+ contextWindowTokens: 8192,
14
+ enabled: true,
15
+ abilities: {
16
+ functionCall: true,
17
+ vision: true,
18
+ },
19
+ parameters: {
20
+ temperature: 0.7,
21
+ maxTokens: 4096,
22
+ },
23
+ },
24
+ {
25
+ id: 'gpt-4',
26
+ providerId: 'azure',
27
+ type: 'chat',
28
+ displayName: 'GPT-4 Azure',
29
+ contextWindowTokens: 8192,
30
+ enabled: true,
31
+ abilities: {
32
+ functionCall: true,
33
+ },
34
+ },
35
+ {
36
+ id: 'claude-3',
37
+ providerId: 'anthropic',
38
+ type: 'chat',
39
+ displayName: 'Claude 3',
40
+ contextWindowTokens: 200000,
41
+ enabled: false,
42
+ },
43
+ {
44
+ id: 'dall-e-3',
45
+ providerId: 'openai',
46
+ type: 'image',
47
+ displayName: 'DALL-E 3',
48
+ enabled: true,
49
+ parameters: {
50
+ size: '1024x1024',
51
+ quality: 'standard',
52
+ },
53
+ },
54
+ ],
55
+ }));
56
+
57
+ describe('getModelPropertyWithFallback', () => {
58
+ describe('when providerId is specified', () => {
59
+ it('should return exact match value when model exists with specified provider', () => {
60
+ const result = getModelPropertyWithFallback('gpt-4', 'displayName', 'openai');
61
+ expect(result).toBe('GPT-4');
62
+ });
63
+
64
+ it('should return exact match type when model exists with specified provider', () => {
65
+ const result = getModelPropertyWithFallback('gpt-4', 'type', 'openai');
66
+ expect(result).toBe('chat');
67
+ });
68
+
69
+ it('should return exact match contextWindowTokens when model exists with specified provider', () => {
70
+ const result = getModelPropertyWithFallback('gpt-4', 'contextWindowTokens', 'azure');
71
+ expect(result).toBe(8192);
72
+ });
73
+
74
+ it('should fall back to other provider when exact provider match not found', () => {
75
+ const result = getModelPropertyWithFallback('gpt-4', 'displayName', 'fake-provider');
76
+ expect(result).toBe('GPT-4'); // Falls back to openai provider
77
+ });
78
+
79
+ it('should return nested property like abilities', () => {
80
+ const result = getModelPropertyWithFallback('gpt-4', 'abilities', 'openai');
81
+ expect(result).toEqual({
82
+ functionCall: true,
83
+ vision: true,
84
+ });
85
+ });
86
+
87
+ it('should return parameters property correctly', () => {
88
+ const result = getModelPropertyWithFallback('dall-e-3', 'parameters', 'openai');
89
+ expect(result).toEqual({
90
+ size: '1024x1024',
91
+ quality: 'standard',
92
+ });
93
+ });
94
+ });
95
+
96
+ describe('when providerId is not specified', () => {
97
+ it('should return fallback match value when model exists', () => {
98
+ const result = getModelPropertyWithFallback('claude-3', 'displayName');
99
+ expect(result).toBe('Claude 3');
100
+ });
101
+
102
+ it('should return fallback match type when model exists', () => {
103
+ const result = getModelPropertyWithFallback('claude-3', 'type');
104
+ expect(result).toBe('chat');
105
+ });
106
+
107
+ it('should return fallback match enabled property', () => {
108
+ const result = getModelPropertyWithFallback('claude-3', 'enabled');
109
+ expect(result).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe('when model is not found', () => {
114
+ it('should return default value "chat" for type property', () => {
115
+ const result = getModelPropertyWithFallback('non-existent-model', 'type');
116
+ expect(result).toBe('chat');
117
+ });
118
+
119
+ it('should return default value "chat" for type property even with providerId', () => {
120
+ const result = getModelPropertyWithFallback('non-existent-model', 'type', 'fake-provider');
121
+ expect(result).toBe('chat');
122
+ });
123
+
124
+ it('should return undefined for non-type properties when model not found', () => {
125
+ const result = getModelPropertyWithFallback('non-existent-model', 'displayName');
126
+ expect(result).toBeUndefined();
127
+ });
128
+
129
+ it('should return undefined for contextWindowTokens when model not found', () => {
130
+ const result = getModelPropertyWithFallback('non-existent-model', 'contextWindowTokens');
131
+ expect(result).toBeUndefined();
132
+ });
133
+
134
+ it('should return undefined for enabled property when model not found', () => {
135
+ const result = getModelPropertyWithFallback('non-existent-model', 'enabled');
136
+ expect(result).toBeUndefined();
137
+ });
138
+ });
139
+
140
+ describe('provider precedence logic', () => {
141
+ it('should prioritize exact provider match over general match', () => {
142
+ // gpt-4 exists in both openai and azure providers with different displayNames
143
+ const openaiResult = getModelPropertyWithFallback('gpt-4', 'displayName', 'openai');
144
+ const azureResult = getModelPropertyWithFallback('gpt-4', 'displayName', 'azure');
145
+
146
+ expect(openaiResult).toBe('GPT-4');
147
+ expect(azureResult).toBe('GPT-4 Azure');
148
+ });
149
+
150
+ it('should fall back to first match when specified provider not found', () => {
151
+ // When asking for 'fake-provider', should fall back to first match (openai)
152
+ const result = getModelPropertyWithFallback('gpt-4', 'displayName', 'fake-provider');
153
+ expect(result).toBe('GPT-4');
154
+ });
155
+ });
156
+
157
+ describe('property existence handling', () => {
158
+ it('should handle undefined properties gracefully', () => {
159
+ // claude-3 doesn't have abilities property defined
160
+ const result = getModelPropertyWithFallback('claude-3', 'abilities');
161
+ expect(result).toBeUndefined();
162
+ });
163
+
164
+ it('should handle properties that exist but have falsy values', () => {
165
+ // claude-3 has enabled: false
166
+ const result = getModelPropertyWithFallback('claude-3', 'enabled');
167
+ expect(result).toBe(false);
168
+ });
169
+
170
+ it('should distinguish between undefined and null values', () => {
171
+ // Testing that we check for undefined specifically, not just falsy values
172
+ const result = getModelPropertyWithFallback('claude-3', 'contextWindowTokens');
173
+ expect(result).toBe(200000); // Should find the defined value
174
+ });
175
+ });
176
+
177
+ describe('edge cases', () => {
178
+ it('should handle empty string modelId', () => {
179
+ const result = getModelPropertyWithFallback('', 'type');
180
+ expect(result).toBe('chat'); // Should fall back to default
181
+ });
182
+
183
+ it('should handle empty string providerId', () => {
184
+ const result = getModelPropertyWithFallback('gpt-4', 'type', '');
185
+ expect(result).toBe('chat'); // Should still find the model via fallback
186
+ });
187
+
188
+ it('should handle case-sensitive modelId correctly', () => {
189
+ const result = getModelPropertyWithFallback('GPT-4', 'type'); // Wrong case
190
+ expect(result).toBe('chat'); // Should fall back to default since no match
191
+ });
192
+ });
193
+ });
@@ -0,0 +1,36 @@
1
+ import { LOBE_DEFAULT_MODEL_LIST } from '@/config/aiModels';
2
+ import { AiFullModelCard } from '@/types/aiModel';
3
+
4
+ /**
5
+ * Get the model property value, first from the specified provider, and then from other providers as a fallback.
6
+ * @param modelId The ID of the model.
7
+ * @param propertyName The name of the property.
8
+ * @param providerId Optional provider ID for an exact match.
9
+ * @returns The property value or a default value.
10
+ */
11
+ export const getModelPropertyWithFallback = <T>(
12
+ modelId: string,
13
+ propertyName: keyof AiFullModelCard,
14
+ providerId?: string,
15
+ ): T => {
16
+ // Step 1: If providerId is provided, prioritize an exact match (same provider + same id)
17
+ if (providerId) {
18
+ const exactMatch = LOBE_DEFAULT_MODEL_LIST.find(
19
+ (m) => m.id === modelId && m.providerId === providerId,
20
+ );
21
+
22
+ if (exactMatch && exactMatch[propertyName] !== undefined) {
23
+ return exactMatch[propertyName] as T;
24
+ }
25
+ }
26
+
27
+ // Step 2: Fallback to a match ignoring the provider (match id only)
28
+ const fallbackMatch = LOBE_DEFAULT_MODEL_LIST.find((m) => m.id === modelId);
29
+
30
+ if (fallbackMatch && fallbackMatch[propertyName] !== undefined) {
31
+ return fallbackMatch[propertyName] as T;
32
+ }
33
+
34
+ // Step 3: Return a default value
35
+ return (propertyName === 'type' ? 'chat' : undefined) as T;
36
+ };