diff --git a/.cursor/rules/testing-guide/testing-guide.mdc b/.cursor/rules/testing-guide/testing-guide.mdc
index bd63bef840..df33456d8f 100644
--- a/.cursor/rules/testing-guide/testing-guide.mdc
+++ b/.cursor/rules/testing-guide/testing-guide.mdc
@@ -214,6 +214,176 @@ describe('', () => {
**修复方法**: 更新了测试文件中的 mock 数据结构,使其与最新的 API 响应格式保持一致。具体修改了 `user.test.ts` 中的 `mockUserData` 对象结构。
```
+## 🎯 测试编写最佳实践
+
+### Mock 数据策略:追求"低成本的真实性" 📋
+
+**核心原则**: 测试数据应默认追求真实性,只有在引入"高昂的测试成本"时才进行简化。
+
+#### 什么是"高昂的测试成本"?
+
+"高成本"指的是测试中引入了外部依赖,使测试变慢、不稳定或复杂:
+
+- **文件 I/O 操作**:读写硬盘文件
+- **网络请求**:HTTP 调用、数据库连接
+- **系统调用**:获取系统时间、环境变量等
+
+#### ✅ 推荐做法:Mock 依赖,保留真实数据
+
+```typescript
+// ✅ 好的做法:Mock I/O 操作,但使用真实的文件内容格式
+describe('parseContentType', () => {
+ beforeEach(() => {
+ // Mock 文件读取操作(避免真实 I/O)
+ vi.spyOn(fs, 'readFileSync').mockImplementation((path) => {
+ // 但返回真实的文件内容格式
+ if (path.includes('.pdf')) return '%PDF-1.4\n%âãÏÓ'; // 真实 PDF 文件头
+ if (path.includes('.png')) return '\x89PNG\r\n\x1a\n'; // 真实 PNG 文件头
+ return '';
+ });
+ });
+
+ it('should detect PDF content type correctly', () => {
+ const result = parseContentType('/path/to/file.pdf');
+ expect(result).toBe('application/pdf');
+ });
+});
+
+// ❌ 过度简化:使用不真实的数据
+describe('parseContentType', () => {
+ it('should detect PDF content type correctly', () => {
+ // 这种简化数据没有测试价值
+ const result = parseContentType('fake-pdf-content');
+ expect(result).toBe('application/pdf');
+ });
+});
+```
+
+#### 🎯 真实标识符的价值
+
+```typescript
+// ✅ 使用真实的提供商标识符
+it('should parse OpenAI model list correctly', () => {
+ const result = parseModelString('openai', '+gpt-4,+gpt-3.5-turbo');
+ expect(result.add).toHaveLength(2);
+ expect(result.add[0].id).toBe('gpt-4');
+});
+
+// ❌ 使用占位符标识符(价值较低)
+it('should parse model list correctly', () => {
+ const result = parseModelString('test-provider', '+model1,+model2');
+ expect(result.add).toHaveLength(2);
+ // 这种测试对理解真实场景帮助不大
+});
+```
+
+### 错误处理测试:测试"行为"而非"文本" ⚠️
+
+**核心原则**: 测试应该验证程序在错误发生时的行为是可预测的,而不是验证易变的错误信息文本。
+
+#### ✅ 推荐的错误测试方式
+
+```typescript
+// ✅ 测试是否抛出错误
+it('should throw error when invalid input provided', () => {
+ expect(() => processInput(null)).toThrow();
+});
+
+// ✅ 测试错误类型(最推荐)
+it('should throw ValidationError for invalid data', () => {
+ expect(() => validateUser({})).toThrow(ValidationError);
+});
+
+// ✅ 测试错误属性而非消息文本
+it('should throw error with correct error code', () => {
+ expect(() => processPayment({})).toThrow(
+ expect.objectContaining({
+ code: 'INVALID_PAYMENT_DATA',
+ statusCode: 400,
+ }),
+ );
+});
+```
+
+#### ❌ 应避免的做法
+
+```typescript
+// ❌ 过度依赖具体错误信息文本
+it('should throw specific error message', () => {
+ expect(() => processUser({})).toThrow('用户数据不能为空,请检查输入参数');
+ // 这种测试很脆弱,错误文案稍有修改就会失败
+});
+```
+
+#### 🎯 例外情况:何时可以测试错误信息
+
+```typescript
+// ✅ 测试标准 API 错误(这是契约的一部分)
+it('should return proper HTTP error for API', () => {
+ expect(response.statusCode).toBe(400);
+ expect(response.error).toBe('Bad Request');
+});
+
+// ✅ 测试错误信息的关键部分(使用正则)
+it('should include field name in validation error', () => {
+ expect(() => validateField('email', '')).toThrow(/email/i);
+});
+```
+
+### 疑难解答:警惕模块污染 🚨
+
+**识别信号**: 当你的测试出现以下"灵异"现象时,优先怀疑模块污染:
+
+- 单独运行某个测试通过,但和其他测试一起运行就失败
+- 测试的执行顺序影响结果
+- Mock 设置看起来正确,但实际使用的是旧的 Mock 版本
+
+#### 典型场景:动态 Mock 同一模块
+
+```typescript
+// ❌ 容易出现模块污染的写法
+describe('ConfigService', () => {
+ it('should work in development mode', async () => {
+ vi.doMock('./config', () => ({ isDev: true }));
+ const { getSettings } = await import('./configService'); // 第一次加载
+ expect(getSettings().debugMode).toBe(true);
+ });
+
+ it('should work in production mode', async () => {
+ vi.doMock('./config', () => ({ isDev: false }));
+ const { getSettings } = await import('./configService'); // 可能使用缓存的旧版本!
+ expect(getSettings().debugMode).toBe(false); // ❌ 可能失败
+ });
+});
+
+// ✅ 使用 resetModules 解决模块污染
+describe('ConfigService', () => {
+ beforeEach(() => {
+ vi.resetModules(); // 清除模块缓存,确保每个测试都是干净的环境
+ });
+
+ it('should work in development mode', async () => {
+ vi.doMock('./config', () => ({ isDev: true }));
+ const { getSettings } = await import('./configService');
+ expect(getSettings().debugMode).toBe(true);
+ });
+
+ it('should work in production mode', async () => {
+ vi.doMock('./config', () => ({ isDev: false }));
+ const { getSettings } = await import('./configService');
+ expect(getSettings().debugMode).toBe(false); // ✅ 测试通过
+ });
+});
+```
+
+#### 🔧 排查和解决步骤
+
+1. **识别问题**: 测试失败时,首先问自己:"是否有多个测试在 Mock 同一个模块?"
+2. **添加隔离**: 在 `beforeEach` 中添加 `vi.resetModules()`
+3. **验证修复**: 重新运行测试,确认问题解决
+
+**记住**: `vi.resetModules()` 是解决测试"灵异"失败的终极武器,当常规调试方法都无效时,它往往能一针见血地解决问题。
+
## 📂 测试文件组织
### 文件命名约定
@@ -320,4 +490,7 @@ git show HEAD -- path/to/component.ts | cat # 查看最新提交的修改
- **修复原则**: 失败1-2次后寻求帮助,测试命名关注行为而非实现细节
- **调试流程**: 复现 → 分析 → 假设 → 修复 → 验证 → 总结
- **文件组织**: 优先在现有 `describe` 块中添加测试,避免创建冗余顶级块
+- **数据策略**: 默认追求真实性,只有高成本(I/O、网络等)时才简化
+- **错误测试**: 测试错误类型和行为,避免依赖具体的错误信息文本
+- **模块污染**: 测试"灵异"失败时,优先怀疑模块污染,使用 `vi.resetModules()` 解决
- **安全要求**: Model 测试必须包含权限检查,并在双环境下验证通过
diff --git a/docs/self-hosting/environment-variables/model-provider.mdx b/docs/self-hosting/environment-variables/model-provider.mdx
index 72cab858d9..5d5592f19e 100644
--- a/docs/self-hosting/environment-variables/model-provider.mdx
+++ b/docs/self-hosting/environment-variables/model-provider.mdx
@@ -625,4 +625,29 @@ If you need to use Azure OpenAI to provide model services, you can refer to the
- Default: `-`
- Example: `-all,+qwq-32b,+deepseek-r1`
+## FAL
+
+### `ENABLED_FAL`
+
+- Type: Optional
+- Description: Enables FAL as a model provider by default. Set to `0` to disable the FAL service.
+- Default: `1`
+- Example: `0`
+
+### `FAL_API_KEY`
+
+- Type: Required
+- Description: This is the API key you applied for in the FAL service.
+- Default: -
+- Example: `fal-xxxxxx...xxxxxx`
+
+### `FAL_MODEL_LIST`
+
+- Type: Optional
+- Description: Used to control the FAL model list. Use `+` to add a model, `-` to hide a model, and `model_name=display_name` to customize the display name of a model. Separate multiple entries with commas. The definition syntax follows the same rules as other providers' model lists.
+- Default: `-`
+- Example: `-all,+fal-model-1,+fal-model-2=fal-special`
+
+The above example disables all models first, then enables `fal-model-1` and `fal-model-2` (displayed as `fal-special`).
+
[model-list]: /docs/self-hosting/advanced/model-list
diff --git a/docs/self-hosting/environment-variables/model-provider.zh-CN.mdx b/docs/self-hosting/environment-variables/model-provider.zh-CN.mdx
index e3d491b3b1..4d5d4f9d04 100644
--- a/docs/self-hosting/environment-variables/model-provider.zh-CN.mdx
+++ b/docs/self-hosting/environment-variables/model-provider.zh-CN.mdx
@@ -624,4 +624,29 @@ LobeChat 在部署时提供了丰富的模型服务商相关的环境变量,
- 默认值:`-`
- 示例:`-all,+qwq-32b,+deepseek-r1`
+## FAL
+
+### `ENABLED_FAL`
+
+- 类型:可选
+- 描述:默认启用 FAL 作为模型供应商,当设为 0 时关闭 FAL 服务
+- 默认值:`1`
+- 示例:`0`
+
+### `FAL_API_KEY`
+
+- 类型:必选
+- 描述:这是你在 FAL 服务中申请的 API 密钥
+- 默认值:-
+- 示例:`fal-xxxxxx...xxxxxx`
+
+### `FAL_MODEL_LIST`
+
+- 类型:可选
+- 描述:用来控制 FAL 模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。模型定义语法规则与其他 provider 保持一致。
+- 默认值:`-`
+- 示例:`-all,+fal-model-1,+fal-model-2=fal-special`
+
+上述示例表示先禁用所有模型,再启用 `fal-model-1` 和 `fal-model-2`(显示名为 `fal-special`)。
+
[model-list]: /zh/docs/self-hosting/advanced/model-list
diff --git a/docs/self-hosting/faq/vercel-ai-image-timeout.mdx b/docs/self-hosting/faq/vercel-ai-image-timeout.mdx
new file mode 100644
index 0000000000..414577ca2b
--- /dev/null
+++ b/docs/self-hosting/faq/vercel-ai-image-timeout.mdx
@@ -0,0 +1,65 @@
+---
+title: Resolving AI Image Generation Timeout on Vercel
+description: >-
+ Learn how to resolve timeout issues when using AI image generation models like gpt-image-1 on Vercel by enabling Fluid Compute for extended execution time.
+
+tags:
+ - Vercel
+ - AI Image Generation
+ - Timeout
+ - Fluid Compute
+ - gpt-image-1
+---
+
+# Resolving AI Image Generation Timeout on Vercel
+
+## Problem Description
+
+When using AI image generation models (such as `gpt-image-1`) on Vercel, you may encounter timeout errors. This occurs because AI image generation typically requires more than 1 minute to complete, which exceeds Vercel's default function execution time limit.
+
+Common error symptoms include:
+
+- Function timeout errors during image generation
+- Failed image generation requests after approximately 60 seconds
+- "Function execution timed out" messages
+
+### Typical Log Symptoms
+
+In your Vercel function logs, you may see entries like this:
+
+```plaintext
+JUL 16 18:39:09.51 POST 504 /trpc/async/image.createImage
+Provider runtime map found for provider: openai
+```
+
+The key indicators are:
+
+- **Status Code**: `504` (Gateway Timeout)
+- **Endpoint**: `/trpc/async/image.createImage` or similar image generation endpoints
+- **Timing**: Usually occurs around 60 seconds after the request starts
+
+## Solution: Enable Fluid Compute
+
+For projects created before Vercel's dashboard update, you can resolve this issue by enabling Fluid Compute, which extends the maximum execution duration to 300 seconds.
+
+### Steps to Enable Fluid Compute (Legacy Vercel Dashboard)
+
+1. Go to your project dashboard on Vercel
+2. Navigate to the **Settings** tab
+3. Find the **Functions** section
+4. Enable **Fluid Compute** as shown in the screenshot below:
+
+
+
+5. After enabling, the maximum execution duration will be extended to 300 seconds by default
+
+### Important Notes
+
+- **For new projects**: Newer Vercel projects have Fluid Compute enabled by default, so this issue primarily affects legacy projects
+
+## Additional Resources
+
+For more information about Vercel's function limitations and Fluid Compute:
+
+- [Vercel Fluid Compute Documentation](https://vercel.com/docs/fluid-compute)
+- [Vercel Functions Limitations](https://vercel.com/docs/functions/limitations#max-duration)
diff --git a/docs/self-hosting/faq/vercel-ai-image-timeout.zh-CN.mdx b/docs/self-hosting/faq/vercel-ai-image-timeout.zh-CN.mdx
new file mode 100644
index 0000000000..287c97a9d8
--- /dev/null
+++ b/docs/self-hosting/faq/vercel-ai-image-timeout.zh-CN.mdx
@@ -0,0 +1,63 @@
+---
+title: 解决 Vercel 上 AI 绘画生图超时问题
+description: 了解如何通过开启 Fluid Compute 来解决在 Vercel 上使用 gpt-image-1 等 AI 绘画模型时遇到的超时问题。
+tags:
+ - Vercel
+ - AI 绘画
+ - 超时问题
+ - Fluid Compute
+ - gpt-image-1
+---
+
+# 解决 Vercel 上 AI 绘画生图超时问题
+
+## 问题描述
+
+在 Vercel 上使用 AI 绘画模型(如 `gpt-image-1`)时,您可能会遇到超时错误。这是因为 AI 绘画生成通常需要超过 1 分钟的时间,超出了 Vercel 默认的函数执行时间限制。
+
+常见的错误症状包括:
+
+- 图像生成过程中出现函数超时错误
+- 图像生成请求在大约 60 秒后失败
+- 出现 "函数执行超时" 的错误消息
+
+### 典型的日志现象
+
+在您的 Vercel 函数日志中,您可能会看到类似这样的条目:
+
+```plaintext
+JUL 16 18:39:09.51 POST 504 /trpc/async/image.createImage
+Provider runtime map found for provider: openai
+```
+
+关键指标包括:
+
+- **状态码**: `504`(网关超时)
+- **端点**: `/trpc/async/image.createImage` 或类似的图像生成端点
+- **时间**: 通常在请求开始后约 60 秒出现
+
+## 解决方案:开启 Fluid Compute
+
+对于在 Vercel 控制台更新前创建的项目,您可以通过开启 Fluid Compute 来解决此问题,这将最大执行时长延长至 300 秒。
+
+### 开启 Fluid Compute 的步骤(旧版 Vercel 控制台)
+
+1. 前往您在 Vercel 上的项目控制台
+2. 进入 **Settings**(设置)选项卡
+3. 找到 **Functions**(函数)部分
+4. 按照下方截图所示开启 **Fluid Compute**:
+
+
+
+5. 开启后,最大执行时长将默认延长至 300 秒
+
+### 重要说明
+
+- **新项目**:较新的 Vercel 项目默认已启用 Fluid Compute,因此此问题主要影响旧版项目
+
+## 其他资源
+
+有关 Vercel 函数限制和 Fluid Compute 的更多信息:
+
+- [Vercel Fluid Compute 文档](https://vercel.com/docs/fluid-compute)
+- [Vercel 函数限制说明](https://vercel.com/docs/functions/limitations#max-duration)
diff --git a/docs/usage/providers/fal.mdx b/docs/usage/providers/fal.mdx
index 0f3489e1e1..e27ca4ba2c 100644
--- a/docs/usage/providers/fal.mdx
+++ b/docs/usage/providers/fal.mdx
@@ -13,7 +13,7 @@ tags:
# Using Fal in LobeChat
-
+
[Fal.ai](https://fal.ai/) is a lightning-fast inference platform specialized in AI media generation, hosting state-of-the-art models for image and video creation including FLUX, Kling, HiDream, and other cutting-edge generative models. This document will guide you on how to use Fal in LobeChat:
@@ -28,7 +28,7 @@ tags:
alt={'Open the creation window'}
inStep
src={
-'https://github.com/user-attachments/assets/a2203b3a-1657-485a-a060-b018e7b2faaa'
+'https://hub-apac-1.lobeobjects.space/docs/3f3676e7f9c04a55603bc1174b636b45.png'
}
/>
@@ -36,7 +36,7 @@ tags:
alt={'Create API Key'}
inStep
src={
-'https://github.com/user-attachments/assets/a216e326-6a51-4f3a-b8c1-23bb995ddac2'
+'https://hub-apac-1.lobeobjects.space/docs/214cc5019d9c0810951b33215349136e.png'
}
/>
@@ -44,7 +44,7 @@ tags:
alt={'Retrieve API Key'}
inStep
src={
-'https://github.com/user-attachments/assets/faee998d-4349-4c17-a5c4-07a7ff65f18e'
+'https://hub-apac-1.lobeobjects.space/docs/499a447e98dcc79407d56495d0305e2a.png'
}
/>
@@ -53,12 +53,12 @@ tags:
- Visit the `Settings` page in LobeChat.
- Under **AI Service Provider**, locate the **Fal** configuration section.
-
+
- Paste the API key you obtained.
- Choose a Fal model (e.g. `fal-ai/flux-pro`, `fal-ai/kling-video`, `fal-ai/hidream-i1-fast`) for image or video generation.
-
+
During usage, you may incur charges according to Fal's pricing policy. Please review Fal's
diff --git a/docs/usage/providers/fal.zh-CN.mdx b/docs/usage/providers/fal.zh-CN.mdx
index 0210c50e69..044f895a94 100644
--- a/docs/usage/providers/fal.zh-CN.mdx
+++ b/docs/usage/providers/fal.zh-CN.mdx
@@ -13,7 +13,7 @@ tags:
# 在 LobeChat 中使用 Fal
-
+
[Fal.ai](https://fal.ai/) 是一个专门从事 AI 媒体生成的快速推理平台,提供包括 FLUX、Kling、HiDream 等在内的最先进图像和视频生成模型。本文将指导你如何在 LobeChat 中使用 Fal:
@@ -28,7 +28,7 @@ tags:
alt={'打开创建窗口'}
inStep
src={
-'https://github.com/user-attachments/assets/a2203b3a-1657-485a-a060-b018e7b2faaa'
+'https://hub-apac-1.lobeobjects.space/docs/3f3676e7f9c04a55603bc1174b636b45.png'
}
/>
@@ -36,7 +36,7 @@ tags:
alt={'创建 API Key'}
inStep
src={
-'https://github.com/user-attachments/assets/a216e326-6a51-4f3a-b8c1-23bb995ddac2'
+'https://hub-apac-1.lobeobjects.space/docs/214cc5019d9c0810951b33215349136e.png'
}
/>
@@ -44,7 +44,7 @@ tags:
alt={'获取 API Key'}
inStep
src={
-'https://github.com/user-attachments/assets/faee998d-4349-4c17-a5c4-07a7ff65f18e'
+'https://hub-apac-1.lobeobjects.space/docs/499a447e98dcc79407d56495d0305e2a.png'
}
/>
@@ -53,12 +53,12 @@ tags:
- 访问 LobeChat 的 `设置` 页面;
- 在 `AI服务商` 下找到 `Fal` 的设置项;
-
+
- 粘贴获取到的 API Key;
- 选择一个 Fal 模型(如 `fal-ai/flux-pro`、`fal-ai/kling-video`、`fal-ai/hidream-i1-fast`)用于图像或视频生成。
-
+
在使用过程中,你可能需要向 Fal 支付相应费用,请在大量调用前查阅 Fal 的官方计费政策。
diff --git a/src/libs/model-runtime/utils/openaiCompatibleFactory/index.test.ts b/src/libs/model-runtime/utils/openaiCompatibleFactory/index.test.ts
index 9f372271eb..ded44db6d6 100644
--- a/src/libs/model-runtime/utils/openaiCompatibleFactory/index.test.ts
+++ b/src/libs/model-runtime/utils/openaiCompatibleFactory/index.test.ts
@@ -1392,7 +1392,7 @@ describe('LobeOpenAICompatibleFactory', () => {
{
id: 'gemini',
releasedAt: '2025-01-10',
- type: undefined,
+ type: 'chat',
},
]);
});
diff --git a/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts b/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts
index 842cdaedc4..01c0e8f3ee 100644
--- a/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts
+++ b/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts
@@ -7,6 +7,7 @@ import { Stream } from 'openai/streaming';
import { LOBE_DEFAULT_MODEL_LIST } from '@/config/aiModels';
import { RuntimeImageGenParamsValue } from '@/libs/standard-parameters/meta-schema';
import type { ChatModelCard } from '@/types/llm';
+import { getModelPropertyWithFallback } from '@/utils/getFallbackModelProperty';
import { LobeRuntimeAI } from '../../BaseAI';
import { AgentRuntimeErrorType, ILobeAgentRuntimeErrorType } from '../../error';
@@ -462,7 +463,7 @@ export const createOpenAICompatibleRuntime = = an
return resultModels.map((model) => {
return {
...model,
- type: model.type || LOBE_DEFAULT_MODEL_LIST.find((m) => m.id === model.id)?.type,
+ type: model.type || getModelPropertyWithFallback(model.id, 'type'),
};
}) as ChatModelCard[];
}
diff --git a/src/server/globalConfig/genServerAiProviderConfig.test.ts b/src/server/globalConfig/genServerAiProviderConfig.test.ts
new file mode 100644
index 0000000000..879a0411f1
--- /dev/null
+++ b/src/server/globalConfig/genServerAiProviderConfig.test.ts
@@ -0,0 +1,235 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ModelProvider } from '@/libs/model-runtime';
+import { AiFullModelCard } from '@/types/aiModel';
+
+import { genServerAiProvidersConfig } from './genServerAiProviderConfig';
+
+// Mock dependencies using importOriginal to preserve real provider data
+vi.mock('@/config/aiModels', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ // Keep the original exports but we can override specific ones if needed
+ };
+});
+
+vi.mock('@/config/llm', () => ({
+ getLLMConfig: vi.fn(() => ({
+ ENABLED_OPENAI: true,
+ ENABLED_ANTHROPIC: false,
+ ENABLED_AI21: false,
+ })),
+}));
+
+vi.mock('@/utils/parseModels', () => ({
+ extractEnabledModels: vi.fn((providerId: string, modelString?: string) => {
+ if (!modelString) return undefined;
+ return [`${providerId}-model-1`, `${providerId}-model-2`];
+ }),
+ transformToAiModelList: vi.fn((params) => {
+ return params.defaultModels;
+ }),
+}));
+
+describe('genServerAiProvidersConfig', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Clear environment variables
+ Object.keys(process.env).forEach((key) => {
+ if (key.includes('MODEL_LIST')) {
+ delete process.env[key];
+ }
+ });
+ });
+
+ it('should generate basic provider config with default settings', () => {
+ const result = genServerAiProvidersConfig({});
+
+ expect(result).toHaveProperty('openai');
+ expect(result).toHaveProperty('anthropic');
+
+ expect(result.openai).toEqual({
+ enabled: true,
+ enabledModels: undefined,
+ serverModelLists: expect.any(Array),
+ });
+
+ expect(result.anthropic).toEqual({
+ enabled: false,
+ enabledModels: undefined,
+ serverModelLists: expect.any(Array),
+ });
+ });
+
+ it('should use custom enabled settings from specificConfig', () => {
+ const specificConfig = {
+ openai: {
+ enabled: false,
+ },
+ anthropic: {
+ enabled: true,
+ },
+ };
+
+ const result = genServerAiProvidersConfig(specificConfig);
+
+ expect(result.openai.enabled).toBe(false);
+ expect(result.anthropic.enabled).toBe(true);
+ });
+
+ it('should use custom enabledKey from specificConfig', async () => {
+ const specificConfig = {
+ openai: {
+ enabledKey: 'CUSTOM_OPENAI_ENABLED',
+ },
+ };
+
+ // Mock the LLM config to include our custom key
+ const { getLLMConfig } = vi.mocked(await import('@/config/llm'));
+ getLLMConfig.mockReturnValue({
+ ENABLED_OPENAI: true,
+ ENABLED_ANTHROPIC: false,
+ CUSTOM_OPENAI_ENABLED: true,
+ } as any);
+
+ const result = genServerAiProvidersConfig(specificConfig);
+
+ expect(result.openai.enabled).toBe(true);
+ });
+
+ it('should use environment variables for model lists', async () => {
+ process.env.OPENAI_MODEL_LIST = '+gpt-4,+gpt-3.5-turbo';
+
+ const { extractEnabledModels } = vi.mocked(await import('@/utils/parseModels'));
+ extractEnabledModels.mockReturnValue(['gpt-4', 'gpt-3.5-turbo']);
+
+ const result = genServerAiProvidersConfig({});
+
+ expect(extractEnabledModels).toHaveBeenCalledWith('openai', '+gpt-4,+gpt-3.5-turbo', false);
+ expect(result.openai.enabledModels).toEqual(['gpt-4', 'gpt-3.5-turbo']);
+ });
+
+ it('should use custom modelListKey from specificConfig', async () => {
+ const specificConfig = {
+ openai: {
+ modelListKey: 'CUSTOM_OPENAI_MODELS',
+ },
+ };
+
+ process.env.CUSTOM_OPENAI_MODELS = '+custom-model';
+
+ const { extractEnabledModels } = vi.mocked(await import('@/utils/parseModels'));
+
+ genServerAiProvidersConfig(specificConfig);
+
+ expect(extractEnabledModels).toHaveBeenCalledWith('openai', '+custom-model', false);
+ });
+
+ it('should handle withDeploymentName option', async () => {
+ const specificConfig = {
+ openai: {
+ withDeploymentName: true,
+ },
+ };
+
+ process.env.OPENAI_MODEL_LIST = '+gpt-4->deployment1';
+
+ const { extractEnabledModels, transformToAiModelList } = vi.mocked(
+ await import('@/utils/parseModels'),
+ );
+
+ genServerAiProvidersConfig(specificConfig);
+
+ expect(extractEnabledModels).toHaveBeenCalledWith('openai', '+gpt-4->deployment1', true);
+ expect(transformToAiModelList).toHaveBeenCalledWith({
+ defaultModels: expect.any(Array),
+ modelString: '+gpt-4->deployment1',
+ providerId: 'openai',
+ withDeploymentName: true,
+ });
+ });
+
+ it('should include fetchOnClient when specified in config', () => {
+ const specificConfig = {
+ openai: {
+ fetchOnClient: true,
+ },
+ };
+
+ const result = genServerAiProvidersConfig(specificConfig);
+
+ expect(result.openai).toHaveProperty('fetchOnClient', true);
+ });
+
+ it('should not include fetchOnClient when not specified in config', () => {
+ const result = genServerAiProvidersConfig({});
+
+ expect(result.openai).not.toHaveProperty('fetchOnClient');
+ });
+
+ it('should handle all available providers', () => {
+ const result = genServerAiProvidersConfig({});
+
+ // Check that result includes some key providers
+ expect(result).toHaveProperty('openai');
+ expect(result).toHaveProperty('anthropic');
+
+ // Check structure for each provider
+ Object.keys(result).forEach((provider) => {
+ expect(result[provider]).toHaveProperty('enabled');
+ expect(result[provider]).toHaveProperty('serverModelLists');
+ // enabled can be boolean or undefined (when no config is provided)
+ expect(['boolean', 'undefined']).toContain(typeof result[provider].enabled);
+ expect(Array.isArray(result[provider].serverModelLists)).toBe(true);
+ });
+ });
+});
+
+describe('genServerAiProvidersConfig Error Handling', () => {
+ it('should throw error when a provider is not found in aiModels', async () => {
+ // Reset all mocks to create a clean test environment
+ vi.resetModules();
+
+ // Mock dependencies with a missing provider scenario
+ vi.doMock('@/config/aiModels', () => ({
+ // Explicitly set openai to undefined to simulate missing provider
+ openai: undefined,
+ anthropic: [
+ {
+ id: 'claude-3',
+ displayName: 'Claude 3',
+ type: 'chat',
+ enabled: true,
+ },
+ ],
+ }));
+
+ vi.doMock('@/config/llm', () => ({
+ getLLMConfig: vi.fn(() => ({})),
+ }));
+
+ vi.doMock('@/utils/parseModels', () => ({
+ extractEnabledModels: vi.fn(() => undefined),
+ transformToAiModelList: vi.fn(() => []),
+ }));
+
+ // Mock ModelProvider to include the missing provider
+ vi.doMock('@/libs/model-runtime', () => ({
+ ModelProvider: {
+ openai: 'openai', // This exists in enum
+ anthropic: 'anthropic', // This exists in both enum and aiModels
+ },
+ }));
+
+ // Import the function with the new mocks
+ const { genServerAiProvidersConfig } = await import(
+ './genServerAiProviderConfig?v=' + Date.now()
+ );
+
+ // This should throw because 'openai' is in ModelProvider but not in aiModels
+ expect(() => {
+ genServerAiProvidersConfig({});
+ }).toThrow();
+ });
+});
diff --git a/src/server/globalConfig/genServerAiProviderConfig.ts b/src/server/globalConfig/genServerAiProviderConfig.ts
index 1ae6f6744b..43ad5b8973 100644
--- a/src/server/globalConfig/genServerAiProviderConfig.ts
+++ b/src/server/globalConfig/genServerAiProviderConfig.ts
@@ -3,7 +3,7 @@ import { getLLMConfig } from '@/config/llm';
import { ModelProvider } from '@/libs/model-runtime';
import { AiFullModelCard } from '@/types/aiModel';
import { ProviderConfig } from '@/types/user/settings';
-import { extractEnabledModels, transformToAiChatModelList } from '@/utils/parseModels';
+import { extractEnabledModels, transformToAiModelList } from '@/utils/parseModels';
interface ProviderSpecificConfig {
enabled?: boolean;
@@ -19,19 +19,17 @@ export const genServerAiProvidersConfig = (specificConfig: Record {
const providerUpperCase = provider.toUpperCase();
- const providerCard = AiModels[provider] as AiFullModelCard[];
+ const aiModels = AiModels[provider] as AiFullModelCard[];
- if (!providerCard)
+ if (!aiModels)
throw new Error(
`Provider [${provider}] not found in aiModels, please make sure you have exported the provider in the \`aiModels/index.ts\``,
);
const providerConfig = specificConfig[provider as keyof typeof specificConfig] || {};
- const providerModelList =
+ const modelString =
process.env[providerConfig.modelListKey ?? `${providerUpperCase}_MODEL_LIST`];
- const defaultChatModels = providerCard.filter((c) => c.type === 'chat');
-
config[provider] = {
enabled:
typeof providerConfig.enabled !== 'undefined'
@@ -39,12 +37,13 @@ export const genServerAiProvidersConfig = (specificConfig: Record m.id === model.id)?.parameters,
+ getModelPropertyWithFallback(model.id, 'parameters'),
}),
}));
diff --git a/src/utils/getFallbackModelProperty.test.ts b/src/utils/getFallbackModelProperty.test.ts
new file mode 100644
index 0000000000..bf5d3adfb0
--- /dev/null
+++ b/src/utils/getFallbackModelProperty.test.ts
@@ -0,0 +1,193 @@
+import { vi } from 'vitest';
+
+import { getModelPropertyWithFallback } from './getFallbackModelProperty';
+
+// Mock LOBE_DEFAULT_MODEL_LIST for testing
+vi.mock('@/config/aiModels', () => ({
+ LOBE_DEFAULT_MODEL_LIST: [
+ {
+ id: 'gpt-4',
+ providerId: 'openai',
+ type: 'chat',
+ displayName: 'GPT-4',
+ contextWindowTokens: 8192,
+ enabled: true,
+ abilities: {
+ functionCall: true,
+ vision: true,
+ },
+ parameters: {
+ temperature: 0.7,
+ maxTokens: 4096,
+ },
+ },
+ {
+ id: 'gpt-4',
+ providerId: 'azure',
+ type: 'chat',
+ displayName: 'GPT-4 Azure',
+ contextWindowTokens: 8192,
+ enabled: true,
+ abilities: {
+ functionCall: true,
+ },
+ },
+ {
+ id: 'claude-3',
+ providerId: 'anthropic',
+ type: 'chat',
+ displayName: 'Claude 3',
+ contextWindowTokens: 200000,
+ enabled: false,
+ },
+ {
+ id: 'dall-e-3',
+ providerId: 'openai',
+ type: 'image',
+ displayName: 'DALL-E 3',
+ enabled: true,
+ parameters: {
+ size: '1024x1024',
+ quality: 'standard',
+ },
+ },
+ ],
+}));
+
+describe('getModelPropertyWithFallback', () => {
+ describe('when providerId is specified', () => {
+ it('should return exact match value when model exists with specified provider', () => {
+ const result = getModelPropertyWithFallback('gpt-4', 'displayName', 'openai');
+ expect(result).toBe('GPT-4');
+ });
+
+ it('should return exact match type when model exists with specified provider', () => {
+ const result = getModelPropertyWithFallback('gpt-4', 'type', 'openai');
+ expect(result).toBe('chat');
+ });
+
+ it('should return exact match contextWindowTokens when model exists with specified provider', () => {
+ const result = getModelPropertyWithFallback('gpt-4', 'contextWindowTokens', 'azure');
+ expect(result).toBe(8192);
+ });
+
+ it('should fall back to other provider when exact provider match not found', () => {
+ const result = getModelPropertyWithFallback('gpt-4', 'displayName', 'fake-provider');
+ expect(result).toBe('GPT-4'); // Falls back to openai provider
+ });
+
+ it('should return nested property like abilities', () => {
+ const result = getModelPropertyWithFallback('gpt-4', 'abilities', 'openai');
+ expect(result).toEqual({
+ functionCall: true,
+ vision: true,
+ });
+ });
+
+ it('should return parameters property correctly', () => {
+ const result = getModelPropertyWithFallback('dall-e-3', 'parameters', 'openai');
+ expect(result).toEqual({
+ size: '1024x1024',
+ quality: 'standard',
+ });
+ });
+ });
+
+ describe('when providerId is not specified', () => {
+ it('should return fallback match value when model exists', () => {
+ const result = getModelPropertyWithFallback('claude-3', 'displayName');
+ expect(result).toBe('Claude 3');
+ });
+
+ it('should return fallback match type when model exists', () => {
+ const result = getModelPropertyWithFallback('claude-3', 'type');
+ expect(result).toBe('chat');
+ });
+
+ it('should return fallback match enabled property', () => {
+ const result = getModelPropertyWithFallback('claude-3', 'enabled');
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('when model is not found', () => {
+ it('should return default value "chat" for type property', () => {
+ const result = getModelPropertyWithFallback('non-existent-model', 'type');
+ expect(result).toBe('chat');
+ });
+
+ it('should return default value "chat" for type property even with providerId', () => {
+ const result = getModelPropertyWithFallback('non-existent-model', 'type', 'fake-provider');
+ expect(result).toBe('chat');
+ });
+
+ it('should return undefined for non-type properties when model not found', () => {
+ const result = getModelPropertyWithFallback('non-existent-model', 'displayName');
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for contextWindowTokens when model not found', () => {
+ const result = getModelPropertyWithFallback('non-existent-model', 'contextWindowTokens');
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for enabled property when model not found', () => {
+ const result = getModelPropertyWithFallback('non-existent-model', 'enabled');
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('provider precedence logic', () => {
+ it('should prioritize exact provider match over general match', () => {
+ // gpt-4 exists in both openai and azure providers with different displayNames
+ const openaiResult = getModelPropertyWithFallback('gpt-4', 'displayName', 'openai');
+ const azureResult = getModelPropertyWithFallback('gpt-4', 'displayName', 'azure');
+
+ expect(openaiResult).toBe('GPT-4');
+ expect(azureResult).toBe('GPT-4 Azure');
+ });
+
+ it('should fall back to first match when specified provider not found', () => {
+ // When asking for 'fake-provider', should fall back to first match (openai)
+ const result = getModelPropertyWithFallback('gpt-4', 'displayName', 'fake-provider');
+ expect(result).toBe('GPT-4');
+ });
+ });
+
+ describe('property existence handling', () => {
+ it('should handle undefined properties gracefully', () => {
+ // claude-3 doesn't have abilities property defined
+ const result = getModelPropertyWithFallback('claude-3', 'abilities');
+ expect(result).toBeUndefined();
+ });
+
+ it('should handle properties that exist but have falsy values', () => {
+ // claude-3 has enabled: false
+ const result = getModelPropertyWithFallback('claude-3', 'enabled');
+ expect(result).toBe(false);
+ });
+
+ it('should distinguish between undefined and null values', () => {
+ // Testing that we check for undefined specifically, not just falsy values
+ const result = getModelPropertyWithFallback('claude-3', 'contextWindowTokens');
+ expect(result).toBe(200000); // Should find the defined value
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle empty string modelId', () => {
+ const result = getModelPropertyWithFallback('', 'type');
+ expect(result).toBe('chat'); // Should fall back to default
+ });
+
+ it('should handle empty string providerId', () => {
+ const result = getModelPropertyWithFallback('gpt-4', 'type', '');
+ expect(result).toBe('chat'); // Should still find the model via fallback
+ });
+
+ it('should handle case-sensitive modelId correctly', () => {
+ const result = getModelPropertyWithFallback('GPT-4', 'type'); // Wrong case
+ expect(result).toBe('chat'); // Should fall back to default since no match
+ });
+ });
+});
diff --git a/src/utils/getFallbackModelProperty.ts b/src/utils/getFallbackModelProperty.ts
new file mode 100644
index 0000000000..9de3621c62
--- /dev/null
+++ b/src/utils/getFallbackModelProperty.ts
@@ -0,0 +1,36 @@
+import { LOBE_DEFAULT_MODEL_LIST } from '@/config/aiModels';
+import { AiFullModelCard } from '@/types/aiModel';
+
+/**
+ * Get the model property value, first from the specified provider, and then from other providers as a fallback.
+ * @param modelId The ID of the model.
+ * @param propertyName The name of the property.
+ * @param providerId Optional provider ID for an exact match.
+ * @returns The property value or a default value.
+ */
+export const getModelPropertyWithFallback = (
+ modelId: string,
+ propertyName: keyof AiFullModelCard,
+ providerId?: string,
+): T => {
+ // Step 1: If providerId is provided, prioritize an exact match (same provider + same id)
+ if (providerId) {
+ const exactMatch = LOBE_DEFAULT_MODEL_LIST.find(
+ (m) => m.id === modelId && m.providerId === providerId,
+ );
+
+ if (exactMatch && exactMatch[propertyName] !== undefined) {
+ return exactMatch[propertyName] as T;
+ }
+ }
+
+ // Step 2: Fallback to a match ignoring the provider (match id only)
+ const fallbackMatch = LOBE_DEFAULT_MODEL_LIST.find((m) => m.id === modelId);
+
+ if (fallbackMatch && fallbackMatch[propertyName] !== undefined) {
+ return fallbackMatch[propertyName] as T;
+ }
+
+ // Step 3: Return a default value
+ return (propertyName === 'type' ? 'chat' : undefined) as T;
+};
diff --git a/src/utils/parseModels.test.ts b/src/utils/parseModels.test.ts
index 3e42a911b1..806bdd93eb 100644
--- a/src/utils/parseModels.test.ts
+++ b/src/utils/parseModels.test.ts
@@ -4,11 +4,12 @@ import { LOBE_DEFAULT_MODEL_LIST } from '@/config/aiModels';
import { openaiChatModels } from '@/config/aiModels/openai';
import { AiFullModelCard } from '@/types/aiModel';
-import { parseModelString, transformToAiChatModelList } from './parseModels';
+import { extractEnabledModels, parseModelString, transformToAiModelList } from './parseModels';
describe('parseModelString', () => {
it('custom deletion, addition, and renaming of models', () => {
const result = parseModelString(
+ 'test-provider',
'-all,+llama,+claude-2,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo,gpt-4-1106-preview=gpt-4-32k',
);
@@ -16,24 +17,30 @@ describe('parseModelString', () => {
});
it('duplicate naming model', () => {
- const result = parseModelString('gpt-4-1106-preview=gpt-4-turbo,gpt-4-1106-preview=gpt-4-32k');
+ const result = parseModelString(
+ 'test-provider',
+ 'gpt-4-1106-preview=gpt-4-turbo,gpt-4-1106-preview=gpt-4-32k',
+ );
expect(result).toMatchSnapshot();
});
it('only add the model', () => {
- const result = parseModelString('model1,model2,model3,model4');
+ const result = parseModelString('test-provider', 'model1,model2,model3,model4');
expect(result).toMatchSnapshot();
});
it('empty string model', () => {
- const result = parseModelString('gpt-4-1106-preview=gpt-4-turbo,, ,\n ,+claude-2');
+ const result = parseModelString(
+ 'test-provider',
+ 'gpt-4-1106-preview=gpt-4-turbo,, ,\n ,+claude-2',
+ );
expect(result).toMatchSnapshot();
});
describe('extension capabilities', () => {
it('with token', () => {
- const result = parseModelString('chatglm-6b=ChatGLM 6B<4096>');
+ const result = parseModelString('test-provider', 'chatglm-6b=ChatGLM 6B<4096>');
expect(result.add[0]).toEqual({
displayName: 'ChatGLM 6B',
@@ -45,7 +52,7 @@ describe('parseModelString', () => {
});
it('token and function calling', () => {
- const result = parseModelString('spark-v3.5=讯飞星火 v3.5<8192:fc>');
+ const result = parseModelString('test-provider', 'spark-v3.5=讯飞星火 v3.5<8192:fc>');
expect(result.add[0]).toEqual({
displayName: '讯飞星火 v3.5',
@@ -59,7 +66,7 @@ describe('parseModelString', () => {
});
it('token and reasoning', () => {
- const result = parseModelString('deepseek-r1=Deepseek R1<65536:reasoning>');
+ const result = parseModelString('test-provider', 'deepseek-r1=Deepseek R1<65536:reasoning>');
expect(result.add[0]).toEqual({
displayName: 'Deepseek R1',
@@ -73,7 +80,7 @@ describe('parseModelString', () => {
});
it('token and search', () => {
- const result = parseModelString('qwen-max-latest=Qwen Max<32768:search>');
+ const result = parseModelString('test-provider', 'qwen-max-latest=Qwen Max<32768:search>');
expect(result.add[0]).toEqual({
displayName: 'Qwen Max',
@@ -88,6 +95,7 @@ describe('parseModelString', () => {
it('token and image output', () => {
const result = parseModelString(
+ 'test-provider',
'gemini-2.0-flash-exp-image-generation=Gemini 2.0 Flash (Image Generation) Experimental<32768:imageOutput>',
);
@@ -104,6 +112,7 @@ describe('parseModelString', () => {
it('multi models', () => {
const result = parseModelString(
+ 'test-provider',
'gemini-1.5-flash-latest=Gemini 1.5 Flash<16000:vision>,gpt-4-all=ChatGPT Plus<128000:fc:vision:file>',
);
@@ -133,6 +142,7 @@ describe('parseModelString', () => {
it('should have file with builtin models like gpt-4-0125-preview', () => {
const result = parseModelString(
+ 'openai',
'-all,+gpt-4-0125-preview=ChatGPT-4<128000:fc:file>,+gpt-4-turbo-2024-04-09=ChatGPT-4 Vision<128000:fc:vision:file>',
);
expect(result.add).toEqual([
@@ -161,7 +171,7 @@ describe('parseModelString', () => {
});
it('should handle empty extension capability value', () => {
- const result = parseModelString('model1<1024:>');
+ const result = parseModelString('test-provider', 'model1<1024:>');
expect(result.add[0]).toEqual({
abilities: {},
type: 'chat',
@@ -171,7 +181,7 @@ describe('parseModelString', () => {
});
it('should handle empty extension capability name', () => {
- const result = parseModelString('model1<1024::file>');
+ const result = parseModelString('test-provider', 'model1<1024::file>');
expect(result.add[0]).toEqual({
id: 'model1',
contextWindowTokens: 1024,
@@ -183,7 +193,7 @@ describe('parseModelString', () => {
});
it('should handle duplicate extension capabilities', () => {
- const result = parseModelString('model1<1024:vision:vision>');
+ const result = parseModelString('test-provider', 'model1<1024:vision:vision>');
expect(result.add[0]).toEqual({
id: 'model1',
contextWindowTokens: 1024,
@@ -195,7 +205,7 @@ describe('parseModelString', () => {
});
it('should handle case-sensitive extension capability names', () => {
- const result = parseModelString('model1<1024:VISION:FC:file>');
+ const result = parseModelString('test-provider', 'model1<1024:VISION:FC:file>');
expect(result.add[0]).toEqual({
id: 'model1',
contextWindowTokens: 1024,
@@ -207,7 +217,7 @@ describe('parseModelString', () => {
});
it('should handle case-sensitive extension capability values', () => {
- const result = parseModelString('model1<1024:vision:Fc:File>');
+ const result = parseModelString('test-provider', 'model1<1024:vision:Fc:File>');
expect(result.add[0]).toEqual({
id: 'model1',
contextWindowTokens: 1024,
@@ -219,12 +229,12 @@ describe('parseModelString', () => {
});
it('should handle empty angle brackets', () => {
- const result = parseModelString('model1<>');
+ const result = parseModelString('test-provider', 'model1<>');
expect(result.add[0]).toEqual({ id: 'model1', abilities: {}, type: 'chat' });
});
it('should handle not close angle brackets', () => {
- const result = parseModelString('model1<,model2');
+ const result = parseModelString('test-provider', 'model1<,model2');
expect(result.add).toEqual([
{ id: 'model1', abilities: {}, type: 'chat' },
{ id: 'model2', abilities: {}, type: 'chat' },
@@ -232,7 +242,7 @@ describe('parseModelString', () => {
});
it('should handle multi close angle brackets', () => {
- const result = parseModelString('model1<>>,model2');
+ const result = parseModelString('test-provider', 'model1<>>,model2');
expect(result.add).toEqual([
{ id: 'model1', abilities: {}, type: 'chat' },
{ id: 'model2', abilities: {}, type: 'chat' },
@@ -240,22 +250,22 @@ describe('parseModelString', () => {
});
it('should handle only colon inside angle brackets', () => {
- const result = parseModelString('model1<:>');
+ const result = parseModelString('test-provider', 'model1<:>');
expect(result.add[0]).toEqual({ id: 'model1', abilities: {}, type: 'chat' });
});
it('should handle only non-digit characters inside angle brackets', () => {
- const result = parseModelString('model1');
+ const result = parseModelString('test-provider', 'model1');
expect(result.add[0]).toEqual({ id: 'model1', abilities: {}, type: 'chat' });
});
it('should handle non-digit characters followed by digits inside angle brackets', () => {
- const result = parseModelString('model1');
+ const result = parseModelString('test-provider', 'model1');
expect(result.add[0]).toEqual({ id: 'model1', abilities: {}, type: 'chat' });
});
it('should handle digits followed by non-colon characters inside angle brackets', () => {
- const result = parseModelString('model1<1024abc>');
+ const result = parseModelString('test-provider', 'model1<1024abc>');
expect(result.add[0]).toEqual({
id: 'model1',
contextWindowTokens: 1024,
@@ -265,7 +275,7 @@ describe('parseModelString', () => {
});
it('should handle digits followed by multiple colons inside angle brackets', () => {
- const result = parseModelString('model1<1024::>');
+ const result = parseModelString('test-provider', 'model1<1024::>');
expect(result.add[0]).toEqual({
id: 'model1',
contextWindowTokens: 1024,
@@ -275,7 +285,7 @@ describe('parseModelString', () => {
});
it('should handle digits followed by a colon and non-letter characters inside angle brackets', () => {
- const result = parseModelString('model1<1024:123>');
+ const result = parseModelString('test-provider', 'model1<1024:123>');
expect(result.add[0]).toEqual({
id: 'model1',
contextWindowTokens: 1024,
@@ -285,7 +295,7 @@ describe('parseModelString', () => {
});
it('should handle digits followed by a colon and spaces inside angle brackets', () => {
- const result = parseModelString('model1<1024: vision>');
+ const result = parseModelString('test-provider', 'model1<1024: vision>');
expect(result.add[0]).toEqual({
id: 'model1',
contextWindowTokens: 1024,
@@ -295,7 +305,7 @@ describe('parseModelString', () => {
});
it('should handle digits followed by multiple colons and spaces inside angle brackets', () => {
- const result = parseModelString('model1<1024: : vision>');
+ const result = parseModelString('test-provider', 'model1<1024: : vision>');
expect(result.add[0]).toEqual({
id: 'model1',
contextWindowTokens: 1024,
@@ -305,9 +315,64 @@ describe('parseModelString', () => {
});
});
+ describe('FAL image models', () => {
+ it('should correctly parse FAL image model ids with slash and custom display names', () => {
+ const result = parseModelString(
+ 'fal',
+ '-all,+flux-kontext/dev=KontextDev,+flux-pro/kontext=KontextPro,+flux/schnell=Schnell,+imagen4/preview=Imagen4',
+ );
+ expect(result.add).toEqual([
+ {
+ id: 'flux-kontext/dev',
+ displayName: 'KontextDev',
+ abilities: {},
+ type: 'image',
+ },
+ {
+ id: 'flux-pro/kontext',
+ displayName: 'KontextPro',
+ abilities: {},
+ type: 'image',
+ },
+ {
+ id: 'flux/schnell',
+ displayName: 'Schnell',
+ abilities: {},
+ type: 'image',
+ },
+ {
+ id: 'imagen4/preview',
+ displayName: 'Imagen4',
+ abilities: {},
+ type: 'image',
+ },
+ ]);
+ expect(result.removeAll).toBe(true);
+ expect(result.removed).toEqual(['all']);
+ });
+
+ it('should correctly parse FAL image model ids with slash (no displayName)', () => {
+ const result = parseModelString('fal', '-all,+flux-kontext/dev,+flux-pro/kontext');
+ expect(result.add).toEqual([
+ {
+ id: 'flux-kontext/dev',
+ abilities: {},
+ type: 'image',
+ },
+ {
+ id: 'flux-pro/kontext',
+ abilities: {},
+ type: 'image',
+ },
+ ]);
+ expect(result.removeAll).toBe(true);
+ expect(result.removed).toEqual(['all']);
+ });
+ });
+
describe('deployment name', () => {
it('should have no deployment name', () => {
- const result = parseModelString('model1=Model 1', true);
+ const result = parseModelString('test-provider', 'model1=Model 1', true);
expect(result.add[0]).toEqual({
id: 'model1',
displayName: 'Model 1',
@@ -317,7 +382,7 @@ describe('parseModelString', () => {
});
it('should have diff deployment name as id', () => {
- const result = parseModelString('gpt-35-turbo->my-deploy=GPT 3.5 Turbo', true);
+ const result = parseModelString('azure', 'gpt-35-turbo->my-deploy=GPT 3.5 Turbo', true);
expect(result.add[0]).toEqual({
id: 'gpt-35-turbo',
displayName: 'GPT 3.5 Turbo',
@@ -331,6 +396,7 @@ describe('parseModelString', () => {
it('should handle with multi deployName', () => {
const result = parseModelString(
+ 'azure',
'gpt-4o->id1=GPT-4o,gpt-4o-mini->id2=gpt-4o-mini,o1-mini->id3=O1 mini',
true,
);
@@ -361,6 +427,42 @@ describe('parseModelString', () => {
});
});
+describe('extractEnabledModels', () => {
+ it('should return undefined when no models are added', () => {
+ const result = extractEnabledModels('test-provider', '-all');
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined when modelString is empty', () => {
+ const result = extractEnabledModels('test-provider', '');
+ expect(result).toBeUndefined();
+ });
+
+ it('should return array of model IDs when models are added', () => {
+ const result = extractEnabledModels('test-provider', '+model1,+model2,+model3');
+ expect(result).toEqual(['model1', 'model2', 'model3']);
+ });
+
+ it('should handle mixed add/remove operations and return only added models', () => {
+ const result = extractEnabledModels('test-provider', '+model1,-model2,+model3');
+ expect(result).toEqual(['model1', 'model3']);
+ });
+
+ it('should handle deployment names when withDeploymentName is true', () => {
+ const result = extractEnabledModels(
+ 'azure',
+ '+gpt-4->deployment1,+gpt-35-turbo->deployment2',
+ true,
+ );
+ expect(result).toEqual(['gpt-4', 'gpt-35-turbo']);
+ });
+
+ it('should handle complex model strings with custom names', () => {
+ const result = extractEnabledModels('openai', '+gpt-4=Custom GPT-4,+claude-2=Custom Claude');
+ expect(result).toEqual(['gpt-4', 'claude-2']);
+ });
+});
+
describe('transformToChatModelCards', () => {
const defaultChatModels: AiFullModelCard[] = [
{ id: 'model1', displayName: 'Model 1', enabled: true, type: 'chat' },
@@ -368,27 +470,27 @@ describe('transformToChatModelCards', () => {
];
it('should return undefined when modelString is empty', () => {
- const result = transformToAiChatModelList({
+ const result = transformToAiModelList({
modelString: '',
- defaultChatModels,
+ defaultModels: defaultChatModels,
providerId: 'openai',
});
expect(result).toBeUndefined();
});
it('should remove all models when removeAll is true', () => {
- const result = transformToAiChatModelList({
+ const result = transformToAiModelList({
modelString: '-all',
- defaultChatModels,
+ defaultModels: defaultChatModels,
providerId: 'openai',
});
expect(result).toEqual([]);
});
it('should remove specified models', () => {
- const result = transformToAiChatModelList({
+ const result = transformToAiModelList({
modelString: '-model1',
- defaultChatModels,
+ defaultModels: defaultChatModels,
providerId: 'openai',
});
expect(result).toEqual([
@@ -398,9 +500,9 @@ describe('transformToChatModelCards', () => {
it('should add a new known model', () => {
const knownModel = LOBE_DEFAULT_MODEL_LIST.find((m) => m.providerId === 'ai21')!;
- const result = transformToAiChatModelList({
+ const result = transformToAiModelList({
modelString: `${knownModel.id}`,
- defaultChatModels,
+ defaultModels: defaultChatModels,
providerId: 'ai21',
});
@@ -413,9 +515,9 @@ describe('transformToChatModelCards', () => {
it('should update an existing known model', () => {
const knownModel = LOBE_DEFAULT_MODEL_LIST.find((m) => m.providerId === 'openai')!;
- const result = transformToAiChatModelList({
+ const result = transformToAiModelList({
modelString: `+${knownModel.id}=Updated Model`,
- defaultChatModels: [knownModel],
+ defaultModels: [knownModel],
providerId: 'openai',
});
@@ -427,9 +529,9 @@ describe('transformToChatModelCards', () => {
});
it('should add a new custom model', () => {
- const result = transformToAiChatModelList({
+ const result = transformToAiModelList({
modelString: '+custom_model=Custom Model',
- defaultChatModels,
+ defaultModels: defaultChatModels,
providerId: 'openai',
});
expect(result).toContainEqual({
@@ -442,10 +544,10 @@ describe('transformToChatModelCards', () => {
});
it('should have file with builtin models like gpt-4-0125-preview', () => {
- const result = transformToAiChatModelList({
+ const result = transformToAiModelList({
modelString:
'-all,+gpt-4-0125-preview=ChatGPT-4<128000:fc:file>,+gpt-4-turbo-2024-04-09=ChatGPT-4 Vision<128000:fc:vision:file>',
- defaultChatModels: openaiChatModels,
+ defaultModels: openaiChatModels,
providerId: 'openai',
});
@@ -457,9 +559,9 @@ describe('transformToChatModelCards', () => {
(m) => m.id === 'deepseek-r1' && m.providerId === 'volcengine',
);
const defaultChatModels: AiFullModelCard[] = [];
- const result = transformToAiChatModelList({
+ const result = transformToAiModelList({
modelString: '+deepseek-r1',
- defaultChatModels,
+ defaultModels: defaultChatModels,
providerId: 'volcengine',
withDeploymentName: true,
});
@@ -474,9 +576,9 @@ describe('transformToChatModelCards', () => {
const knownModel = LOBE_DEFAULT_MODEL_LIST.find(
(m) => m.id === 'deepseek-r1' && m.providerId === 'volcengine',
);
- const result = transformToAiChatModelList({
+ const result = transformToAiModelList({
modelString: `+deepseek-r1->my-custom-deploy`,
- defaultChatModels,
+ defaultModels: defaultChatModels,
providerId: 'volcengine',
withDeploymentName: true,
});
@@ -489,9 +591,9 @@ describe('transformToChatModelCards', () => {
it('should set both id and deploymentName to the full string when no -> is used and withDeploymentName is true', () => {
const defaultChatModels: AiFullModelCard[] = [];
- const result = transformToAiChatModelList({
+ const result = transformToAiModelList({
modelString: `+my_model`,
- defaultChatModels,
+ defaultModels: defaultChatModels,
providerId: 'volcengine',
withDeploymentName: true,
});
@@ -602,9 +704,9 @@ describe('transformToChatModelCards', () => {
const modelString =
'-all,gpt-4o->id1=GPT-4o,gpt-4o-mini->id2=GPT 4o Mini,o1-mini->id3=OpenAI o1-mini';
- const data = transformToAiChatModelList({
+ const data = transformToAiModelList({
modelString,
- defaultChatModels,
+ defaultModels: defaultChatModels,
providerId: 'azure',
withDeploymentName: true,
});
diff --git a/src/utils/parseModels.ts b/src/utils/parseModels.ts
index 1e9fbad34e..d765a91380 100644
--- a/src/utils/parseModels.ts
+++ b/src/utils/parseModels.ts
@@ -1,13 +1,18 @@
import { produce } from 'immer';
import { LOBE_DEFAULT_MODEL_LIST } from '@/config/aiModels';
-import { AiFullModelCard } from '@/types/aiModel';
+import { AiFullModelCard, AiModelType } from '@/types/aiModel';
+import { getModelPropertyWithFallback } from '@/utils/getFallbackModelProperty';
import { merge } from '@/utils/merge';
/**
* Parse model string to add or remove models.
*/
-export const parseModelString = (modelString: string = '', withDeploymentName = false) => {
+export const parseModelString = (
+ providerId: string,
+ modelString: string = '',
+ withDeploymentName = false,
+) => {
let models: AiFullModelCard[] = [];
let removeAll = false;
const removedModels: string[] = [];
@@ -46,12 +51,18 @@ export const parseModelString = (modelString: string = '', withDeploymentName =
models.splice(existingIndex, 1);
}
+ // Use new type lookup function, prioritizing same provider first, then fallback to other providers
+ const modelType: AiModelType = getModelPropertyWithFallback(
+ id,
+ 'type',
+ providerId,
+ );
+
const model: AiFullModelCard = {
abilities: {},
displayName: displayName || undefined,
id,
- // TODO: 临时写死为 chat ,后续基于元数据迭代成对应的类型
- type: 'chat',
+ type: modelType,
};
if (deploymentName) {
@@ -108,21 +119,21 @@ export const parseModelString = (modelString: string = '', withDeploymentName =
/**
* Extract a special method to process chatModels
*/
-export const transformToAiChatModelList = ({
+export const transformToAiModelList = ({
modelString = '',
- defaultChatModels,
+ defaultModels,
providerId,
withDeploymentName = false,
}: {
- defaultChatModels: AiFullModelCard[];
+ defaultModels: AiFullModelCard[];
modelString?: string;
providerId: string;
withDeploymentName?: boolean;
}): AiFullModelCard[] | undefined => {
if (!modelString) return undefined;
- const modelConfig = parseModelString(modelString, withDeploymentName);
- let chatModels = modelConfig.removeAll ? [] : defaultChatModels;
+ const modelConfig = parseModelString(providerId, modelString, withDeploymentName);
+ let chatModels = modelConfig.removeAll ? [] : defaultModels;
// 处理移除逻辑
if (!modelConfig.removeAll) {
@@ -182,8 +193,12 @@ export const transformToAiChatModelList = ({
});
};
-export const extractEnabledModels = (modelString: string = '', withDeploymentName = false) => {
- const modelConfig = parseModelString(modelString, withDeploymentName);
+export const extractEnabledModels = (
+ providerId: string,
+ modelString: string = '',
+ withDeploymentName = false,
+) => {
+ const modelConfig = parseModelString(providerId, modelString, withDeploymentName);
const list = modelConfig.add.map((m) => m.id);
if (list.length === 0) return;