From 768ee2bf230b0bfcd23babea19a359ec04ebd319 Mon Sep 17 00:00:00 2001 From: YuTengjing Date: Fri, 18 Jul 2025 02:41:27 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20use=20server=20env=20conf?= =?UTF-8?q?ig=20image=20models=20(#8478)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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' --- .cursor/rules/testing-guide/testing-guide.mdc | 173 +++++++++++++ .../environment-variables/model-provider.mdx | 25 ++ .../model-provider.zh-CN.mdx | 25 ++ .../faq/vercel-ai-image-timeout.mdx | 65 +++++ .../faq/vercel-ai-image-timeout.zh-CN.mdx | 63 +++++ docs/usage/providers/fal.mdx | 12 +- docs/usage/providers/fal.zh-CN.mdx | 12 +- .../openaiCompatibleFactory/index.test.ts | 2 +- .../utils/openaiCompatibleFactory/index.ts | 3 +- .../genServerAiProviderConfig.test.ts | 235 ++++++++++++++++++ .../globalConfig/genServerAiProviderConfig.ts | 19 +- src/store/aiInfra/slices/aiProvider/action.ts | 3 +- src/utils/getFallbackModelProperty.test.ts | 193 ++++++++++++++ src/utils/getFallbackModelProperty.ts | 36 +++ src/utils/parseModels.test.ts | 198 +++++++++++---- src/utils/parseModels.ts | 37 ++- 16 files changed, 1017 insertions(+), 84 deletions(-) create mode 100644 docs/self-hosting/faq/vercel-ai-image-timeout.mdx create mode 100644 docs/self-hosting/faq/vercel-ai-image-timeout.zh-CN.mdx create mode 100644 src/server/globalConfig/genServerAiProviderConfig.test.ts create mode 100644 src/utils/getFallbackModelProperty.test.ts create mode 100644 src/utils/getFallbackModelProperty.ts 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: + +![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) 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**: + +![开启 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) 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 -{'Using +{'Using [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. - {'Enter + {'Enter - 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. - {'Select + {'Select 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;