🐛 fix: use server env config image models (#8478)

* docs: update fal provider invalid image links

* docs: add FAL model provider environment variables documentation

* 🐛 fix: update model type assignment in parseModels.ts to use dynamic lookup

* 📝 docs: add FAQ for resolving AI image generation timeout issues on Vercel

*  feat: implement getModelPropertyWithFallback utility for dynamic model property retrieval

* 📝 docs: expand testing guide with best practices for mock data strategies, error handling, and module pollution prevention

* 🐛 fix: update model type in LobeOpenAICompatibleFactory tests to 'chat'
This commit is contained in:
YuTengjing
2025-07-18 02:41:27 +08:00
committed by GitHub
parent d23d8f1c29
commit 768ee2bf23
16 changed files with 1017 additions and 84 deletions
@@ -214,6 +214,176 @@ describe('<UserProfile />', () => {
**修复方法**: 更新了测试文件中的 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 测试必须包含权限检查,并在双环境下验证通过
@@ -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
@@ -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
@@ -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:
![Enable Fluid Compute](https://hub-apac-1.lobeobjects.space/docs/2e2ff332c4b440b584efe1d7ba46aed5.png)
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)
@@ -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**
![开启 Fluid Compute](https://hub-apac-1.lobeobjects.space/docs/2e2ff332c4b440b584efe1d7ba46aed5.png)
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)
+6 -6
View File
@@ -13,7 +13,7 @@ tags:
# Using Fal in LobeChat
<Image alt={'Using Fal in LobeChat'} cover src={'https://github.com/user-attachments/assets/febb2ffb-8fe8-4f88-b8c9-8fa0985a2352'} />
<Image alt={'Using Fal in LobeChat'} cover src={'https://hub-apac-1.lobeobjects.space/docs/f253e749baaa2ccac498014178f93091.png'} />
[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.
<Image alt={'Enter API Key'} inStep src={'https://github.com/user-attachments/assets/7566f679-f935-4a5b-9c98-a8d99d9c7994'} />
<Image alt={'Enter API Key'} inStep src={'https://hub-apac-1.lobeobjects.space/docs/fa056feecba0133c76abe1ad12706c05.png'} />
- 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.
<Image alt={'Select Fal model for media generation'} inStep src={'https://github.com/user-attachments/assets/817167ff-4723-4f84-8528-c779c5c3a118'} />
<Image alt={'Select Fal model for media generation'} inStep src={'https://hub-apac-1.lobeobjects.space/docs/7560502f31b8500032922103fc22e69b.png'} />
<Callout type={'warning'}>
During usage, you may incur charges according to Fal's pricing policy. Please review Fal's
+6 -6
View File
@@ -13,7 +13,7 @@ tags:
# 在 LobeChat 中使用 Fal
<Image alt={'在 LobeChat 中使用 Fal'} cover src={'https://github.com/user-attachments/assets/febb2ffb-8fe8-4f88-b8c9-8fa0985a2352'} />
<Image alt={'在 LobeChat 中使用 Fal'} cover src={'https://hub-apac-1.lobeobjects.space/docs/f253e749baaa2ccac498014178f93091.png'} />
[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` 的设置项;
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/user-attachments/assets/7566f679-f935-4a5b-9c98-a8d99d9c7994'} />
<Image alt={'填入 API 密钥'} inStep src={'https://hub-apac-1.lobeobjects.space/docs/fa056feecba0133c76abe1ad12706c05.png'} />
- 粘贴获取到的 API Key
- 选择一个 Fal 模型(如 `fal-ai/flux-pro`、`fal-ai/kling-video`、`fal-ai/hidream-i1-fast`)用于图像或视频生成。
<Image alt={'选择 Fal 模型进行媒体生成'} inStep src={'https://github.com/user-attachments/assets/817167ff-4723-4f84-8528-c779c5c3a118'} />
<Image alt={'选择 Fal 模型进行媒体生成'} inStep src={'https://hub-apac-1.lobeobjects.space/docs/7560502f31b8500032922103fc22e69b.png'} />
<Callout type={'warning'}>
在使用过程中,你可能需要向 Fal 支付相应费用,请在大量调用前查阅 Fal 的官方计费政策。
@@ -1392,7 +1392,7 @@ describe('LobeOpenAICompatibleFactory', () => {
{
id: 'gemini',
releasedAt: '2025-01-10',
type: undefined,
type: 'chat',
},
]);
});
@@ -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 = <T extends Record<string, any> = 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[];
}
@@ -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<typeof import('@/config/aiModels')>();
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();
});
});
@@ -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<any, ProviderS
return Object.values(ModelProvider).reduce(
(config, provider) => {
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<any, ProviderS
: llmConfig[providerConfig.enabledKey || `ENABLED_${providerUpperCase}`],
enabledModels: extractEnabledModels(
providerModelList,
provider,
modelString,
providerConfig.withDeploymentName || false,
),
serverModelLists: transformToAiChatModelList({
defaultChatModels: defaultChatModels || [],
modelString: providerModelList,
serverModelLists: transformToAiModelList({
defaultModels: aiModels || [],
modelString,
providerId: provider,
withDeploymentName: providerConfig.withDeploymentName || false,
}),
@@ -19,6 +19,7 @@ import {
UpdateAiProviderConfigParams,
UpdateAiProviderParams,
} from '@/types/aiProvider';
import { getModelPropertyWithFallback } from '@/utils/getFallbackModelProperty';
enum AiProviderSwrKey {
fetchAiProviderItem = 'FETCH_AI_PROVIDER_ITEM',
@@ -216,7 +217,7 @@ export const createAiProviderSlice: StateCreator<
...(model.type === 'image' && {
parameters:
(model as AIImageModelCard).parameters ||
LOBE_DEFAULT_MODEL_LIST.find((m) => m.id === model.id)?.parameters,
getModelPropertyWithFallback(model.id, 'parameters'),
}),
}));
+193
View File
@@ -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
});
});
});
+36
View File
@@ -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 = <T>(
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;
};
+150 -48
View File
@@ -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-turbogpt-4-1106-preview=gpt-4-32k');
const result = parseModelString(
'test-provider',
'gpt-4-1106-preview=gpt-4-turbogpt-4-1106-preview=gpt-4-32k',
);
expect(result).toMatchSnapshot();
});
it('only add the model', () => {
const result = parseModelString('model1,model2,model3model4');
const result = parseModelString('test-provider', 'model1,model2,model3model4');
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<abc>');
const result = parseModelString('test-provider', 'model1<abc>');
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<abc123>');
const result = parseModelString('test-provider', 'model1<abc123>');
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,
});
+26 -11
View File
@@ -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<AiModelType>(
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;