diff --git a/.agents/design/core/ai/admin-model-template-installation.md b/.agents/design/core/ai/admin-model-template-installation.md new file mode 100644 index 000000000000..82076abde33b --- /dev/null +++ b/.agents/design/core/ai/admin-model-template-installation.md @@ -0,0 +1,227 @@ +# 管理员模型模板化与添加模型最终设计 + +本文记录系统管理员模型管理的最终设计,包括模板安装、空白创建、编辑、渠道关联、运行时目录及升级兼容。 + +关联设计: + +- [模型引用统一为 modelId](./model-id-reference-migration.md) +- [客户端模型目录与接口收敛](./client-model-catalog.md) +- [Figma「账号」设计稿](https://www.figma.com/design/KcAmeVQkIsXRuR9CsFTa1N/%E8%B4%A6%E5%8F%B7?node-id=1252-3345) + +## 1. 范围与依赖 + +- 本功能只管理系统模型,不引入团队安装模型、team scope 模型或成员渠道隔离。既有团队模型授权能力保持独立。 +- Plugin 模型只作为创建模板,不自动成为已安装模型。全新数据库的模型列表和默认模型配置允许为空。 +- 系统模型统一通过 AIProxy 网关调用,`AIPROXY_API_ENDPOINT` 和 `AIPROXY_API_TOKEN` 为必填配置。`OPENAI_BASE_URL`、`CHAT_API_KEY` 已弃用,不再作为系统调用回退配置。 +- Plugin Provider 元数据是启动硬依赖;模板列表按需实时拉取。启动完成后,模板请求失败不影响已安装模型目录的加载,也不能清空或修改已安装模型。 +- 模型级 `requestUrl`、`requestAuth` 保留存储、编辑和运行兼容,界面标注“即将弃用,请直接使用渠道”。用户自带 OpenAI Key 的既有调用能力保留。 + +## 2. 数据与身份 + +### 模型模板 + +- 模板是 Plugin 单次请求返回的创建参数,不落库,不建立 `templateId`、`templateVersion` 或模板外键。 +- `{ type, model }` 仅作为一次安装请求的匹配键。 +- 模板创建完成后与实例解绑;Plugin 变化不会修改实例,删除实例后不会自动重装。 +- 所有已安装模型使用相同编辑、启停和删除规则,不返回或派生 `isCustom`,不提供“恢复模板默认值”。 + +### 模型实例 + +- 系统模型存储在 `ai_models`,作用域为 `system`。 +- `modelId` 由 MongoDB `_id` 生成,是业务引用的稳定身份。 +- `model` 是 Provider 调用及 AIProxy 渠道绑定使用的标识;system scope 内跨类型唯一,由 `{ scope, model }` 唯一索引兜底。 +- 创建后 `model`、`type` 不可修改。编辑目标通过表单外部的 `modelId` 明确指定。 +- 创建状态、创建表单和创建请求不得携带 `modelId`,包括可选字段或 `undefined` 占位;创建 API 拒绝注入该字段。 +- 创建与编辑使用独立控制器、提交函数和请求 Schema。创建只能插入,更新目标不存在时报错,不执行 upsert 或退化创建。 +- 创建初值通过字段白名单构造;复用默认模型能力参数时不复制实例身份。新建不会覆盖原默认模型或改变其默认引用。 +- 新业务引用写入 `modelId`。旧 `model` 仅用于兼容读取;非空无效 `modelId` 不回退到旧名称。 + +### 模型与渠道 + +- 模型与渠道为多对多关系,AIProxy 的 `channel.models: string[]` 是关联事实源。 +- 渠道按模型的 `model` 建立关系;`ai_models` 不重复保存 `channelIds`。 +- 渠道数量及摘要由服务端从渠道快照反向计算,模型页和渠道页使用同一份关系。 +- 零渠道允许保存和启用。启用状态与连接配置独立,保存成功不保证实际调用成功。 + +## 3. 创建流程 + +### 从模板新建 + +1. 每次打开入口实时拉取模板并规范化,支持 Provider、模型类型、名称筛选和多选。 +2. 已安装的同名 `model` 从候选中排除,不因类型不同而允许重复。 +3. 未选择模板时不能进入下一步;选择后在同一 Modal 中切换到统一渠道配置。 +4. 所选渠道应用到全部待创建模型,允许零渠道,不逐个编辑模板参数;“上一步”保留选择。 +5. 确认时提交 `templateKeys` 和 `channelIds`,批量范围为 1~500 个模板。 +6. 服务端重新实时拉取模板。任一 `{ type, model }` 不存在则整批失败,且不开始渠道或模型写入;参数变化则采用提交时最新模板参数。 +7. 渠道全部绑定成功后,在单个 MongoDB 事务中创建整批模型,统一默认为 `isActive: false`。 + +模板只保留在本次弹窗状态中;不使用启动预加载、定时刷新、Redis、服务端全局缓存、浏览器持久化或版本协商。 + +### 从空白新建 + +- 先选择 LLM、Embedding、TTS、STT 或 Rerank,再打开配置弹窗。 +- Provider 初始为空,渠道初始为空,启用默认关闭。管理员可主动启用。 +- 模型 ID 为空时,进入配置弹窗自动聚焦该输入框。 +- 配置弹窗返回按钮为“上一步”,回到类型选择。 +- 确认后才创建模型并提交渠道绑定;退出不保存模型草稿或普通关联草稿。 + +### 表单规则 + +| 类型 | 必填项及默认规则 | +| --- | --- | +| 通用 | 模型 ID(`model`)、模型提供商;别名可空,保存时回退到模型 ID | +| LLM | 最大上下文、最大响应 Tokens;知识库最大引用为空时取最大上下文的 80%,向下取整 | +| Embedding | 并发数(默认 1)、默认分块长度、最大上下文 | +| TTS | 音色必须是非空数组,元素遵循音色 Schema | +| Rerank | 最大 Tokens | +| STT | 遵循通用必填项 | + +- Provider placeholder 为“请选择提供商”;初次进入不展示必填红色错误。 +- Tokens 相关标签统一使用复数 `Tokens`,必填标签通过 `FormLabel` 显示星号。 +- 同名模型使用枚举错误,客户端精确翻译为“模型 ID 已存在”。 + +## 4. 编辑与渠道交互 + +### 编辑模型 + +- 点击编辑立即打开 Modal,详情请求在弹窗内显示加载状态。 +- 详情一次返回完整模型配置、全部脱敏渠道摘要及各渠道的 `isAssociated`;不重复请求渠道列表。 +- 模型 ID 只读。参数与普通关联变化保存在本地草稿,确认后通过一次更新请求预检并提交。 +- 取消编辑不提交模型参数或普通渠道关联变化。 +- 新增或编辑流程前往渠道管理时,只要表单或关联集合有未保存变更,都提示确认离开;取消则保留当前草稿。 + +### 关联已有渠道 + +- 列表入口使用列表接口的渠道快照;编辑入口使用详情接口的渠道快照。 +- 勾选仅改变本地状态;关闭或返回不提交。 +- 从表格项直接打开时,确认调用渠道替换 API,成功提示“关联渠道成功”,然后刷新列表。 +- 从模型编辑内打开时,确认只回写父弹窗草稿,不显示持久化成功提示;保存父弹窗时统一提交。 +- 请求失败保留弹窗与选择,允许重试;写入期间禁止重复提交。 + +### 新增渠道 + +- 模型编辑、空白创建和模板批量创建均可新增渠道,并将创建响应中的精确渠道 ID 加入关联列表。 +- 新增渠道是独立资源,子弹窗确认成功即写入 AIProxy,不随父弹窗取消而回滚。 +- 已安装模型可在创建渠道时直接绑定;尚未创建的模型先建立空模型渠道,保存模型时再绑定。 +- 不通过刷新前后列表差集推测新渠道 ID。 +- 新建渠道拒绝同名渠道。渠道管理页支持编辑、启停、优先级及删除。 +- 渠道摘要按 `created_at DESC, id DESC` 排序,启停不导致列表跳位。 + +### 模型测试 + +- 已安装模型支持按 `modelId` 测试;新增和编辑弹窗统一测试当前完整草稿,尚未落库也能测试。 +- 测试只校验调用所需字段,计费字段不参与连通性测试。 +- 显式渠道通过 `Aiproxy-Channel` 指定,并仅在本次请求移除模型级 `requestUrl/requestAuth`。 +- 草稿模型尚未在目标渠道声明时,测试使用临时绑定并按临时绑定规则清理;不创建 MongoDB 模型。 +- 测试状态按渠道 ID 隔离,并发测试互不覆盖 Loading 或结果。 +- 结果提示包含模型标识、渠道名称及失败原因;模型 ID 为空时提示先填写。 +- 用户 Key 优先于模型级连接配置;显式渠道测试使用指定渠道;其余保留模型级连接配置兼容,系统默认连接只使用 AIProxy。 + +## 5. 列表与公共组件 + +- 管理员页面保留模型配置、模型渠道、调用日志和监控;删除“可用模型”Tab,旧 `modelTab=model` 链接回退模型配置。 +- 模型按 MongoDB `_id` 倒序展示,客户端不再按 Provider 或模型标识排序。 +- 表格包含独立选择列、模型 ID/名称、Plus 计费、模型渠道、启用状态及操作。类型作为模型标签展示。 +- 模型与模板列表使用固定行高虚拟滚动;固定列布局避免滚动时列宽变化,筛选变化回到顶部。 +- Provider 筛选来自已安装模型实际使用的 Provider;无效筛选值保留并显示“无效值”,不回退首项。名称搜索按普通文本处理。 +- 仅首次无数据请求展示整表 Loading;详情、测试和更新使用对应操作的 Loading。请求完成后才判断空状态。 +- 列表 API 一次返回模型及渠道摘要;Layout 在模型配置页不重复发起管理员列表请求。 +- 渠道数量 hover 展示名称、协议图标/名称和状态;离开 trigger 即关闭,点击进入关联。 +- 全选覆盖当前筛选结果;进入多选状态后,非交互区域的行点击切换选择。复选框、复制、渠道、启停和操作按钮不重复触发行选择。 +- 选择以稳定身份维护,复选框只打勾或反选,不产生缩放、动画或布局抖动。 +- 单项和批量启停共用 `updateStatus`;批量删除同时删除模型权限记录。任一目标不存在则 MongoDB 整批失败。 +- 写入成功后刷新一次列表并清空批量选择;刷新失败不反转或误报写入结果。 + +复用组件统一位于 `projects/app/src/pageComponents/model`: + +| 组件/模块 | 职责 | +| --- | --- | +| `AddModel` | 模板选择、类型选择和创建流程 | +| `ModelEditModal` / `useModelEditWorkflow` | 编辑身份、详情、草稿、测试与离开确认 | +| `ModelConfigForm` | 通用参数展示与校验,不决定创建或更新目标 | +| `ModelLinkedChannels` / `ModelChannelModal` | 关联展示及本地选择,不重复获取渠道 | +| `ModelChannelCount` | 渠道数量与摘要展示 | +| `Channel/EditChannelModal` | 复用渠道创建与编辑表单 | +| `DefaultModelModal` / `JsonModelConfigModal` / `ModelPriceTiersTable` | 默认模型、JSON 配置及计费编辑 | + +弹窗统一使用 v2 `MyModal`,按钮通过 `footer` 传入,正文内部滚动,标题和底部操作固定。模型入口的紧凑渠道新增态使用 `sm`,渠道管理完整编辑态使用 `lg`。业务名称直接显示,静态文案使用翻译 key。 + +## 6. 价格与 JSON 配置 + +- LLM 读取历史价格时兼容 `priceTiers`、`inputPrice/outputPrice`、`charsPointsPrice`。 +- 打开编辑器先转换为 `priceTiers`;提交只认当前阶梯价格,清空或双零表示免费,不重新回退历史价格。 +- LLM 保存时清除历史价格字段;非 LLM 继续使用 `charsPointsPrice`。不新增独立价格迁移任务。 +- 输入、输出独立计费;输入价为零不跳过输出计费。 +- JSON 导出保留原有价格字段及稳定 `modelId`,不转换、不回写数据库,剥离内部和未知字段。 +- JSON 配置导入会事务删除文件中缺失的系统模型及其权限,保留 AIProxy 渠道和模型关联;空数组删除全部系统模型,全部缺少 modelId 的旧配置仍忽略。 +- JSON 导入与编辑器共享 `normalizeModelPricingForRead`、`normalizeModelPricingForSave`;混合旧字段按历史读取规则解释,纯新格式空阶梯表示免费。 +- JSON 按本地 `modelId` 命中时保留数据库中的 `model`、`type`;外部 ID 按非空 `model` 安装或匹配。 +- 完整配置更新清除被省略的可清理字段,不能让旧请求配置或旧价格重新生效。 +- JSON 覆盖需要二次确认,确认按钮保持 Loading 至接口完成;一次失败只显示一次错误提示。 + +## 7. 写入一致性与失败边界 + +- 模型写入编排集中在服务模块,API 路由负责管理员鉴权、请求解析和响应校验。 +- 写入前完成模板、重复标识、参数、全部目标模型和渠道合法性校验。 +- 涉及渠道的写入顺序为:更新全部相关渠道,再提交 MongoDB 事务。零渠道创建跳过渠道步骤。 +- 删除先从全部渠道解绑,再事务删除模型和权限记录。 +- MongoDB 模型、默认配置变化与 `ai_default_models.catalogRevision` 自增同事务提交,使用 majority 写确认。 +- AIProxy 与 MongoDB 不做分布式事务或失败补偿。渠道部分成功时不写 MongoDB;渠道成功但 MongoDB 失败时允许保留冗余绑定,重试合并模型名时去重。 +- 数据库提交成功后的运行时预热或客户端刷新失败只记录、反馈刷新问题,不把已提交操作报告为写入失败。 +- AIProxy `/channels/all` 按全量协议使用。模型绑定和渠道普通编辑都必须保留完整 PUT 支持的其他可变字段,防止覆盖高级配置或凭证。 +- FastGPT 渠道读改写和管理代理共用自动续租 lease,锁内读取最新快照;不自动重试已经部分成功的写入。 +- AIProxy v0.6.5 不支持绑定差量更新或 CAS。对于 PUT 无法保留的非零 `balance_threshold`,拒绝修改绑定,避免静默重置。 +- 外部绕过 FastGPT 的写入、旧客户端完整对象覆盖及租约过期的迟到请求仍受上游无 CAS 限制。 + +## 8. 运行时模型目录 + +- 业务统一通过异步 `getModelHandle()` 获取模型读取能力;不读取模型全局变量、私有缓存或旧 getter。 +- 每个消费者可独立获取 handle,不依赖 HTTP 请求上下文。并发版本检查合并,顺序调用分别检查。 +- 权威 revision 来自数据库;模型、默认配置和 revision 在同一个 snapshot 事务中加载,并一次性发布完整快照。 +- 每个发布版本只构造一次 handle。快照复制并冻结,查找与默认槽位方法使用同步闭包;`findModelData` 返回可编辑副本,旧 handle 不受后续发布影响。 +- handle 收敛类型 getter、展示查找、默认槽位、默认 ID、全部模型和启用模型读取;optional 通过选项表达。知识库封装只转换模型引用。 +- `tryGetVlmModelData` 仅将严格 VLM getter 的 `UserError(ModelErrEnum.unExist)` 转为 `{ model: undefined, error }`,其他异常原样抛出,供图片任务显式降级。 +- 版本检查与快照加载合计最多等待 5 秒;失败或超时回退到最近成功发布的本地快照,包括合法空目录。首次无快照时仍报错。 +- 超时只结束等待,不取消共享加载;后台迟到成功仍可发布。失败不修改 revision、不清空快照,不另加熔断、退避或缓存系统。 +- 接受故障期间旧价格、默认模型和启停状态延迟更新;5 秒是单次获取等待上限,不是缓存陈旧上限或整个 HTTP 请求累计上限。 +- 鉴权、权限查询和业务写入失败不能由目录回退吞掉。 +- 模型与默认配置不注册 MongoDB Change Stream;启动加载、写后主动预热及读取时 revision 检查负责更新。Provider/协议元数据通过独立入口读取,模板获取不依赖模型目录。 + +## 9. API 与数据暴露 + +下列模型管理接口统一以 `/api/admin/settings/model` 为前缀,要求系统管理员权限: + +| 方法 | 路径 | 职责 | +| --- | --- | --- | +| GET | `/list` | 已安装模型、全部脱敏渠道摘要、Provider、默认模型 ID、协议元信息 | +| GET | `/detail` | 按 `modelId` 返回编辑配置和带 `isAssociated` 的渠道摘要 | +| GET | `/templates` | 实时模板候选,无版本协商或模板缓存 | +| POST | `/createFromTemplates` | 模板批量创建,统一渠道集合 | +| POST | `/create` | 空白创建,不接受 `modelId` | +| PUT | `/update` | 按 `modelId` 一次更新可编辑参数与渠道草稿 | +| PUT | `/updateStatus` | `modelIds + isActive`,单个及批量启停 | +| PUT | `/channel/replace` | 替换完整渠道集合,不接收新模型标识 | +| DELETE | `/delete` | body `modelIds` 批量删除,兼容 query 单个 `modelId` | +| GET / POST | `/test` | 已安装模型测试 / 当前草稿测试 | +| PUT | `/updateDefault` | 默认模型槽位配置 | +| GET | `/getConfigJson` | 导出配置 | +| PUT | `/updateWithJson` | JSON 配置导入 | + +- 不保留模板默认恢复或独立模型绑定查询接口。 +- API 请求用 `parseApiInput` 校验,业务响应用 Zod Schema 约束。 +- 管理员列表模型脱敏;完整编辑配置仅由管理员详情和配置导出接口提供。 +- 渠道摘要只返回 ID、名称、协议图标/名称、状态等展示字段,不暴露 API Key。 +- 模型列表或详情的渠道查询失败时整体报错,不伪装成零渠道结果。 +- 普通客户端模型目录、摘要、公开价格列表及知识库模型展示的接口边界见[客户端模型目录与接口收敛](./client-model-catalog.md)。 +- `/api/aiproxy/**` 转发前先鉴权,只使用服务端 AIProxy 管理员 Token;客户端 Authorization、Cookie、Host、Origin、Connection 不透传,合法业务请求头按规则保留。 + +## 10. 升级兼容 + +- 已有部署保留现有模型、稳定 ID、有效默认引用及模型级请求配置;停止自动预装不等于删除历史预装模型。 +- 迁移 Runner 依据注册表和 `system_migration_states` 判断是否执行,不能依据业务表是否为空。管理员清空模型后不会重新迁入旧表。 +- `system_models` 按 `model` 合并到 system scope 的 `ai_models`:同名保留新表 `_id`,配置由旧表覆盖;旧表独有模型沿用旧 `_id` 新增;新表独有模型保留。 +- 已有有效默认槽位优先,旧默认标记仅补齐缺失或失效槽位。 +- 模型新增、覆盖与默认配置合并处于同一事务,失败全部回滚;不删除旧表。 +- 旧表为空时不拉取 Plugin。历史损坏记录可用实时模板修复,但模板不能独立产生迁移或预装模型。 +- 迁移需支持全量重放幂等;正式发布后的迁移 ID、顺序和既有语义保持稳定,后续修复追加任务。 +- 发布迁移窗口内停止管理员及旧节点模型写入;不引入跨版本双写或写入栅栏,接受旧节点在迁移快照后写入 legacy 表的发布窗口风险。 diff --git a/.agents/design/core/ai/model-id-reference-migration.md b/.agents/design/core/ai/model-id-reference-migration.md index ae359d9305b3..5c2366e1cfcb 100644 --- a/.agents/design/core/ai/model-id-reference-migration.md +++ b/.agents/design/core/ai/model-id-reference-migration.md @@ -64,7 +64,6 @@ | `model` | provider 侧路由名称,例如 `gpt-4o` | 否;仅兼容旧引用和 provider 请求 | | `name` | 用户可见展示名 | 否 | | `scope` | 模型实例作用域 | 本轮固定为 `system`,后续可扩展 `team` | -| `isCustom` | 是否不在插件模板中 | 运行时根据模板匹配结果派生,不能表示所有权,不能参与身份判断 | 核心约束: @@ -107,7 +106,6 @@ type SystemModelDocument = { type RuntimeSystemModel = Omit & { modelId: string; avatar?: string; - isCustom?: boolean; }; ``` @@ -115,7 +113,7 @@ type RuntimeSystemModel = Omit & { - 顶层字段回答“这是谁、由谁提供、是否启用、如何连接、如何计费”。 - `config` 回答“这个类型的模型支持什么,以及调用时如何构造参数”。 -- `modelId`、`avatar`、`isCustom` 是派生字段,不在 MongoDB 中重复保存。 +- `modelId`、`avatar` 是派生字段,不在 MongoDB 中重复保存。 - `metadata` 只作为迁移期 legacy 字段保留,新写入不再产生 `metadata`。 不在 MongoDB 中重复保存 `modelId`;运行时和 API 返回时由 `_id` 映射得到。这样只有一个真实主键来源,避免 `_id` 与 `modelId` 不一致。 @@ -219,7 +217,7 @@ Mongoose 顶层字段应显式声明,`config` 可以使用 `Schema.Types.Mixed | `isActive/testMode`、默认标记、连接信息 | FastGPT 默认值或管理员配置 | 只读 DB,插件不参与 | | 价格字段 | 插件模板初始化 | DB 权威,插件更新不覆盖管理员价格 | | `config` | 插件能力配置初始化 | 只读 DB 完整快照;模板变化不隐式合并 | -| `avatar/isCustom` | 不持久化 | 分别由 provider 和是否命中插件模板派生 | +| `avatar` | 不持久化 | 由 provider 派生 | 加载时不做 `config` 合并。`defaultConfig`、`fieldMap`、`dbConfig`、`queryConfig`、数组以及 `false`、`0` 等值全部按数据库快照解释;配置不完整或非法时保留上一版运行时缓存并暴露配置错误,不能从插件或其他模型借默认值。 @@ -260,12 +258,12 @@ defineIndex(SystemModelSchema, { 不能只建立无 partial filter 的 `{ scope, model }` 唯一索引,否则所有团队模型仍会在全平台共享一组唯一空间,与未来的“团队内唯一”冲突。 -### 5.6 `scope` 与 `isCustom` +### 5.6 `scope` 与模板解绑 - 本轮 `ai_models` 中的所有模型都属于平台系统模型,包括管理员自行添加、未命中插件模板的模型;创建和迁移统一写入 `scope: system`。 - 本轮输入 Schema 将 `scope` 收紧为 literal `system`,不允许管理员 API 写入团队作用域。未来团队安装需求再扩展这一边界。 -- `isCustom = true` 的唯一含义是“该系统模型不在当前插件模板列表中”;命中模板则为 `false`。它不表示私有模型、创建者或所有权。 -- `isCustom` 只在运行时根据 `model` 是否命中插件模板派生,不持久化,也不得用于访问控制或唯一索引;命中后若 `type` 不一致,应作为模板/数据库配置错误单独报告,不能把它伪装成自定义模型。 +- 模型创建完成后即与 Plugin 模板解绑,所有已安装模型都按独立实例管理。 +- 因此不再提供 `isCustom`:当前模板是否存在是动态外部状态,既不能描述实例来源,也不能用于删除、编辑、访问控制或唯一索引。 ## 6. 模型加载、缓存与查找 @@ -610,16 +608,15 @@ export const GetMyModelsResponseSchema = PaginationResponseSchema(ClientModelIte ### 11.1 模型结构接管与资源迁移 -旧版 `cleanSystemModelConfigs` 不再提供。新版本不修改 `system_models`:阻塞升级任务在内存中把全部旧记录转换为 canonical 结构,并在单个事务中按 `{ scope: system, model }` 把缺失模型追加到 `ai_models`。同名目标记录保留现有 `_id`,其余字段由旧表 canonical 配置完整替换;旧表独有记录沿用旧 `_id` 新增;目标表独有记录不删除。system 默认配置以已有且仍有效的槽位为准,旧表 `isDefault*` 只补齐缺失或失效槽位。成功结果分别记录旧表原始记录数、新表最终 system 模型数,以及按名称去重后已在新表中落位的旧模型数;同名替换属于成功落位而不是新增。升级状态表是唯一执行标记。单模型新增/更新和 JSON 批量更新只接受 canonical 数据;JSON 中没有 `modelId` 的旧记录直接过滤。JSON 的未知 `modelId` 表示跨实例导入:目标端按 `model` 复用已有系统实例或创建新实例。 +旧版 `cleanSystemModelConfigs` 不再提供。新版本不修改 `system_models`:阻塞升级任务在单个事务中把旧记录转换为 canonical 结构并按 `model` 合并到 `ai_models`。同名目标保留现有 `_id`,其余配置由旧表覆盖;旧表独有模型沿用旧 `_id` 新增;新表独有模型保留。已有且有效的默认槽位优先,旧表 `isDefault*` 只补齐缺失或失效槽位。升级状态表仍是迁移 Runner 是否调度任务的唯一依据。单模型新增/更新和 JSON 批量更新只接受 canonical 数据;JSON 中没有 `modelId` 的旧记录直接过滤。JSON 的未知 `modelId` 表示跨实例导入:目标端按 `model` 复用已有系统实例或创建新实例。 -启动迁移、插件模板刷新、自动预装策略和数据库实例加载必须保持四个独立职责: +启动迁移、动态模板读取和数据库实例加载必须保持三个独立职责: -1. `bootstrapAIModelsFromLegacy` 只由阻塞升级任务调用;它不读取升级状态、不保存 checkpoint、不生成缓存,也不由模型加载、定时刷新或管理接口调用。它先读取并全量校验旧表与插件类型,再在单事务中读取目标模型和默认配置、解析身份冲突、批量插入旧表独有模型、按目标 `_id` 替换同名模型配置,最后合并默认槽位。目标表独有模型不更新或删除;任务因节点退出被 lease 接管后可以确定性重复调用,事务失败不会留下部分结果。 -2. `refreshModelTemplates` 只获取并校验插件模板候选快照;启动失败则阻止启动,热刷新失败则保留上一版模板和 active 缓存,不触发任何数据库变更。 -3. `syncPreinstalledSystemModels` 只负责本版本“插件模板缺失实例自动预装”的兼容策略,按 `{ scope, model }` 创建缺失实例,不更新或删除已有实例。PR2 改为模板显式安装时只替换这一层。 -4. `loadInstalledModels` 只读取并严格解析数据库实例与 system scope 默认配置,在局部构建 list/map/defaults 后原子发布;不 repair、不拉插件、不创建或删除模型。管理员提交和数据库 Change Stream 只触发这一层。 +1. `bootstrapAIModelsFromLegacy` 只由阻塞升级任务调用;它不读取升级状态、不保存 checkpoint、不生成缓存,也不由模型加载、定时刷新或管理接口调用。它先读取并全量校验旧表与插件类型,再在单事务中读取最新目标快照,按 `model` 更新同名实例、插入缺失实例并合并默认槽位。任务因节点退出被 lease 接管后可以确定性重复调用,事务失败不会留下部分结果。 +2. `refreshModelTemplates` 只在管理员打开模板列表和提交模板安装时实时获取并校验候选快照,不缓存、不触发数据库变更,也不参与启动加载。 +3. `loadInstalledModels` 只读取并严格解析数据库实例与 system scope 默认配置,在局部构建 list/map/defaults 后原子发布;不 repair、不拉插件、不创建或删除模型。管理员提交和数据库 Change Stream 只触发这一层。 -启动编排为:先执行正常的 `preload providers -> refresh templates -> sync preinstalled -> load installed`,让升级脚本可以使用模型缓存;随后升级 runner 获取 lease。模型迁移任务执行 `preload providers -> refresh templates -> bootstrap legacy -> sync preinstalled -> load installed`:`bootstrap legacy` 在事务中补充旧表独有模型,并保留同名目标 `_id`、用旧表 canonical 配置替换其余字段,避免启动阶段自动预装配置覆盖升级前设置;任务在返回成功前重新发布模型缓存。其他节点轮询到阻塞任务成功后即可进入 ready,无需为迁移额外重复加载一次模型,因为各节点启动阶段已经发布缓存,而模型数据库变更由既有同步机制传播。迁移失败时事务不留下部分数据,节点保持 not ready,owner 持续持有 lease 并等待修复后重启;运行期模板刷新为 `refresh templates -> sync preinstalled -> load installed`,管理员写入为 `validate/preflight -> transaction -> load installed`。 +启动只预加载 Provider 并读取已安装模型,不请求 Plugin 模板。模型迁移任务在旧表非空时读取 Plugin 模板用于兼容性修复,再执行 `bootstrap legacy -> load installed`;旧表为空时直接以空模板执行。`bootstrap legacy` 在事务内读取最新目标快照并完成覆盖、插入和默认配置合并。迁移失败时事务不留下部分数据。管理员模板安装为 `实时拉取模板 -> 校验/过滤 -> 先绑定渠道 -> MongoDB 事务批量创建 -> load installed`;跨 AIProxy 与 MongoDB 不做分布式补偿。 4163 资源回填拆成模型权限、Dataset、Evaluation 和 App 四个非阻塞升级任务,其中 App 任务包含 `apps/app_versions/app_templates` 三个阶段,并在最终结果中分别展示三个阶段的处理数量,不再用一个合计数掩盖各类数据规模。四个任务分别重新读取 `ai_models`,按 `_id` 的固定 `endId/lastId` 游标增量补充 ID sibling;Usage 历史记录不回填。无法解析的历史业务引用保留原值并跳过,不视为任务异常;CAS 冲突、文档解析或数据库写入等真实执行异常进入独立失败记录表并继续当前批次和后续任务,任务最终保持 `failed`,管理员修复后通过通用页面触发断点重试。 @@ -658,7 +655,7 @@ export const GetMyModelsResponseSchema = PaginationResponseSchema(ClientModelIte ### 11.3 迁移规则 -- `system_models -> ai_models + ai_default_models` 的执行资格只来自通用升级状态,不使用业务表作为初始化标记。任务执行一次旧表读取、一次事务、一次目标快照读取、一次缺失模型批量插入和一次默认配置 upsert,不分批也不保存 checkpoint;它不会清空或更新已有模型。 +- `system_models -> ai_models + ai_default_models` 的执行资格只来自通用升级状态,不使用业务表作为初始化标记。任务执行一次旧表读取、一次事务和一次目标快照读取;同名模型更新、缺失模型插入及默认配置 upsert 原子完成,不分批也不保存 checkpoint,且不会清空或删除新表独有模型。 - 模型结构归一化优先级为 `canonical config > 旧顶层字段 > metadata`;同层冲突记入报告。 - 根据模型 `type` 使用白名单把类型特有字段写入 `config`,未知字段不自动搬运。 - 新代码只写 `ai_models` 的 canonical 顶层字段和 `config`;不再写 `metadata`、默认标记或顶层类型特有字段。 diff --git a/.agents/issue/ssrf-vulnerability-fix.md b/.agents/issue/ssrf-vulnerability-fix.md deleted file mode 100644 index 42b869583dfd..000000000000 --- a/.agents/issue/ssrf-vulnerability-fix.md +++ /dev/null @@ -1,216 +0,0 @@ -# SSRF 漏洞修复设计文档 - -## 漏洞概述 - -**漏洞编号**: GHSA-6g6x-8hq5-9cw4 -**漏洞类型**: Server-Side Request Forgery (SSRF) - CWE-918 -**严重程度**: High -**影响版本**: <= 4.8.22 - -## 漏洞详情 - -### 1. 主要问题 - -FastGPT 的 HTTP Tool 连接器在处理用户控制的 URL 时缺乏 SSRF 保护: - -**受影响文件**: -- `packages/service/core/app/http.ts` (lines 127-166) - `runHTTPTool()` 函数 -- `projects/app/src/pages/api/core/app/httpTools/runTool.ts` - API 端点 - -**问题代码**: -```typescript -export const runHTTPTool = async ({ baseUrl, toolPath, method, ... }) => { - const { data } = await axios({ - method: method.toUpperCase(), - baseURL: baseUrl.startsWith('http') ? baseUrl : `https://${baseUrl}`, - url: toolPath, - // 没有任何 IP 验证! - }); -}; -``` - -### 2. 次要问题 - -`isInternalAddress()` 函数默认被禁用: - -**文件**: `packages/service/common/system/utils.ts` (line 142) - -```typescript -if (process.env.CHECK_INTERNAL_IP !== 'true') { - return false; // 默认允许内部地址! -} -``` - -这意味着 http468 工作流节点和 readFiles 也缺乏 SSRF 保护,除非显式设置 `CHECK_INTERNAL_IP=true`。 - -## 攻击场景 - -认证用户可以使用 HTTP Tool 进行以下攻击: - -1. **AWS 凭证窃取**: - - `baseUrl: http://169.254.169.254` - - `toolPath: /latest/meta-data/iam/security-credentials/` - -2. **Kubernetes 密钥泄露**: - - `baseUrl: http://kubernetes.default.svc` - - `toolPath: /api/v1/namespaces/default/secrets/` - -3. **内部网络扫描和服务利用** - -## 修复方案 - -### 方案 1: 在 runHTTPTool 中添加 SSRF 保护(推荐) - -**修改文件**: `packages/service/core/app/http.ts` - -在 `runHTTPTool` 函数中,在发起请求前添加 URL 验证: - -```typescript -export const runHTTPTool = async ({ - baseUrl, - toolPath, - method = 'POST', - params, - headerSecret, - customHeaders, - staticParams, - staticHeaders, - staticBody -}: RunHTTPToolParams): Promise => { - try { - // 构建完整 URL - const fullBaseUrl = baseUrl.startsWith('http://') || baseUrl.startsWith('https://') - ? baseUrl - : `https://${baseUrl}`; - - // SSRF 保护:验证 URL 是否指向内部地址 - const fullUrl = new URL(toolPath, fullBaseUrl).toString(); - if (await isInternalAddress(fullUrl)) { - return { errorMsg: 'Access to internal addresses is not allowed' }; - } - - const { headers, body, queryParams } = buildHttpRequest({ - method, - params, - headerSecret, - customHeaders, - staticParams, - staticHeaders, - staticBody - }); - - const { data } = await axios({ - method: method.toUpperCase(), - baseURL: fullBaseUrl, - url: toolPath, - headers, - data: body, - params: queryParams, - timeout: 300000 - }); - - return { data }; - } catch (error: any) { - return { errorMsg: getErrText(error) }; - } -}; -``` - -### 方案 2: 修改 CHECK_INTERNAL_IP 默认值 - -**修改文件**: `packages/service/common/system/utils.ts` - -将默认行为从"允许"改为"拒绝": - -```typescript -// 3. 如果未启用内部 IP 检查,则默认拒绝(安全优先) -if (process.env.CHECK_INTERNAL_IP === 'false') { - return false; // 显式禁用检查时才允许 -} - -// 默认启用内部 IP 检查 -``` - -**注意**: 这个改动可能影响向后兼容性,需要在文档中说明。 - -### 方案 3: 添加 DNS Rebinding 保护(可选增强) - -在 `isInternalAddress` 函数中,可以添加 DNS rebinding 保护: - -1. 解析域名获取 IP -2. 验证 IP 是否为内部地址 -3. 在实际请求时,固定使用已验证的 IP(而不是重新解析) - -这需要修改 axios 请求的方式,使用已解析的 IP 而不是域名。 - -## 实施步骤 - -### 第一阶段:核心修复(必须) - -1. ✅ 在 `runHTTPTool` 中添加 `isInternalAddress` 验证 -2. ✅ 修改 `CHECK_INTERNAL_IP` 默认行为为启用 -3. ✅ 添加单元测试验证修复 - -### 第二阶段:文档更新(必须) - -1. 更新部署文档,说明 `CHECK_INTERNAL_IP` 环境变量的变化 -2. 添加安全最佳实践文档 -3. 更新 CHANGELOG - -### 第三阶段:增强保护(可选) - -1. 实现 DNS rebinding 保护 -2. 添加请求日志和监控 -3. 实现 URL 白名单机制 - -## 测试计划 - -### 单元测试 - -创建测试文件: `test/cases/service/core/app/http.test.ts` - -测试用例: -1. ✅ 测试拒绝 AWS 元数据端点 (169.254.169.254) -2. ✅ 测试拒绝 Kubernetes 服务 (kubernetes.default.svc) -3. ✅ 测试拒绝私有 IP 范围 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) -4. ✅ 测试拒绝 localhost 和 127.0.0.1 -5. ✅ 测试允许合法的外部 URL -6. ✅ 测试 DNS rebinding 场景(域名解析到内部 IP) - -### 集成测试 - -1. 测试 HTTP Tool 在工作流中的行为 -2. 测试 API 端点 `/api/core/app/httpTools/runTool` -3. 验证错误消息的正确性 - -## 向后兼容性 - -### 破坏性变更 - -1. **CHECK_INTERNAL_IP 默认值变更**: - - 旧行为: 默认允许内部地址访问 - - 新行为: 默认拒绝内部地址访问 - -2. **影响范围**: - - 依赖访问内部服务的工作流将失败 - - 需要显式设置 `CHECK_INTERNAL_IP=false` 来恢复旧行为(不推荐) - -### 迁移指南 - -对于需要访问内部服务的合法用例: - -1. **推荐方案**: 使用代理服务或 API 网关 -2. **临时方案**: 设置 `CHECK_INTERNAL_IP=false`(不安全,仅用于开发环境) - -## 安全建议 - -1. **生产环境**: 始终保持 `CHECK_INTERNAL_IP=true`(默认) -2. **网络隔离**: 在网络层面限制 FastGPT 服务器的出站访问 -3. **监控**: 记录所有 HTTP Tool 请求,监控异常模式 -4. **最小权限**: 限制 FastGPT 服务账号的权限 - -## 参考资料 - -- [CWE-918: Server-Side Request Forgery (SSRF)](https://cwe.mitre.org/data/definitions/918.html) -- [OWASP SSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html) -- GitHub Security Advisory: GHSA-6g6x-8hq5-9cw4 diff --git a/document/content/openapi/app.en.mdx b/document/content/openapi/app.en.mdx index b40820076d8f..a4b923e2ca10 100644 --- a/document/content/openapi/app.en.mdx +++ b/document/content/openapi/app.en.mdx @@ -1,5 +1,6 @@ --- title: App API +sidebarTag: No longer maintained description: FastGPT OpenAPI App Interface --- diff --git a/document/content/openapi/app.mdx b/document/content/openapi/app.mdx index 0effdc590ce9..7d63e29573be 100644 --- a/document/content/openapi/app.mdx +++ b/document/content/openapi/app.mdx @@ -1,5 +1,6 @@ --- title: 应用接口 +sidebarTag: 不再维护 description: FastGPT OpenAPI 应用接口 --- diff --git a/document/content/openapi/chat.en.mdx b/document/content/openapi/chat.en.mdx index a400ce321135..0e5e3a82d366 100644 --- a/document/content/openapi/chat.en.mdx +++ b/document/content/openapi/chat.en.mdx @@ -1,5 +1,6 @@ --- title: Chat API +sidebarTag: No longer maintained description: FastGPT OpenAPI Chat Interface --- diff --git a/document/content/openapi/chat.mdx b/document/content/openapi/chat.mdx index 5761b4edf618..4a7e150ac4ba 100644 --- a/document/content/openapi/chat.mdx +++ b/document/content/openapi/chat.mdx @@ -1,5 +1,6 @@ --- title: 对话接口 +sidebarTag: 不再维护 description: FastGPT OpenAPI 对话接口 --- diff --git a/document/content/openapi/dataset.en.mdx b/document/content/openapi/dataset.en.mdx index 121db34df2ff..93de2a2b0bc8 100644 --- a/document/content/openapi/dataset.en.mdx +++ b/document/content/openapi/dataset.en.mdx @@ -1,11 +1,12 @@ --- title: Dataset API +sidebarTag: No longer maintained description: FastGPT OpenAPI Dataset API --- -| How to Get Dataset ID (datasetId) | How to Get Collection ID (collection_id) | -| --------------------------------------- | ---------------------------------------- | -| ![](../../public/imgs/getDatasetId.jpg) | ![](../../public/imgs/getfile_id.webp) | +| How to Get Dataset ID (datasetId) | How to Get Collection ID (collection_id) | +| --------------------------------------- | ------------------------------------------ | +| ![](../../public/imgs/getDatasetId.png) | ![](../../public/imgs/getCollectionId.png) | ## Dataset diff --git a/document/content/openapi/dataset.mdx b/document/content/openapi/dataset.mdx index 21ebbf674b87..a941df99d90a 100644 --- a/document/content/openapi/dataset.mdx +++ b/document/content/openapi/dataset.mdx @@ -1,11 +1,12 @@ --- title: 知识库接口 +sidebarTag: 不再维护 description: FastGPT OpenAPI 知识库接口 --- -| 如何获取知识库 ID(datasetId) | 如何获取文件集合 ID(collection_id) | -| --------------------------------------- | -------------------------------------- | -| ![](../../public/imgs/getDatasetId.jpg) | ![](../../public/imgs/getfile_id.webp) | +| 如何获取知识库 ID(datasetId) | 如何获取文件集合 ID(collection_id) | +| --------------------------------------- | ------------------------------------------ | +| ![](../../public/imgs/getDatasetId.png) | ![](../../public/imgs/getCollectionId.png) | ## 知识库 diff --git a/document/content/openapi/intro.en.mdx b/document/content/openapi/intro.en.mdx index cbf0ac80aa1a..2b770bdd8ad5 100644 --- a/document/content/openapi/intro.en.mdx +++ b/document/content/openapi/intro.en.mdx @@ -3,7 +3,7 @@ title: API Documentation Introduction description: Introduction to FastGPT API Documentation --- -Starting with `4.15.0`, FastGPT API documentation is generated automatically with `zod-openapi` (some legacy endpoints have not been migrated, so they are not shown). You can view the latest endpoint status by opening the API documentation URL. The manually edited endpoint descriptions in the left sidebar of this documentation are no longer updated. +Starting with `4.15.0`, FastGPT API documentation is generated automatically with `zod-openapi`. You can view the latest endpoint status by opening the API documentation URL. The manually edited endpoint descriptions in the left sidebar of this documentation are no longer updated. FastGPT API documentation is split into two sets: @@ -44,11 +44,25 @@ You can find API Keys in two places: An API Key acts as the current account's access credential within the current team. In other words, any resource the account can access in that team can also be operated through the API Key. -### How to Find the BaseURL +### API key usage -**Note: BaseURL is not an endpoint URL. It is the root URL for all endpoints, and requesting the BaseURL directly does nothing.** +- Authenticate with an API Key. When calling `chat/completions`, pass `appId` in the request body whenever possible. +- For OpenAI SDK compatibility, you can also use `Authorization: Bearer -`. In this case, you can omit `body.appId`. +- The `appId` precedence is: `body.appId` > the `appId` in `-` > the `appId` associated with the API Key (legacy compatibility). +- Some SDKs require adding `v1` to the `BaseURL`. If you get a 404 error, try adding `v1` and retry. +- To proxy a team member's identity through `authProxy`, the team owner must enable `authProxy` when creating or editing the key. The proxied member must still have permission to access the target app and session. (FastGPT >= v4.15.0) -![](../../public/imgs/fastgpt-api-baseurl.png) +### How to find the app ID (appId) + +Open the app details page and find `appId` in the URL in your browser's address bar. + +![](../../public/imgs/appid.png) + +### How to find Dataset and Collection IDs + +| Dataset ID (datasetId) | Collection ID (collectionId) | +| --------------------------------------- | ------------------------------------------ | +| ![](../../public/imgs/getDatasetId.png) | ![](../../public/imgs/getCollectionId.png) | ### Basic Configuration diff --git a/document/content/openapi/intro.mdx b/document/content/openapi/intro.mdx index dccbcb548194..0d50ca1eb998 100644 --- a/document/content/openapi/intro.mdx +++ b/document/content/openapi/intro.mdx @@ -3,7 +3,7 @@ title: API 文档介绍 description: FastGPT API 文档介绍 --- -从 `4.15.0` 开始,FastGPT API 文档均采用 `zod-openapi` 自动生成的方式(部分旧接口未改造,所以不显示)。可通过访问 API 文档地址查看最新的接口情况,**该文档里左侧手动编辑的接口说明不再更新。** +从 `4.15.0` 开始,FastGPT API 文档均采用 `zod-openapi` 自动生成的方式。可通过访问 API 文档地址查看最新的接口情况,**该文档里左侧手动编辑的接口说明不再更新。** FastGPT API 文档一共分成两套: @@ -44,11 +44,25 @@ FastGPT OpenAPI 接口允许你使用 API Key 进行鉴权,从而操作 FastGP API 密钥相当于当前账号,在当前团队下的访问凭证。也就是,在该团队下有权限的资源,都可以通过 API 密钥进行操作。 -### 如何查看 BaseURL +### 密钥使用规范 -**注意:BaseURL 不是接口地址,而是所有接口的根地址,直接请求 BaseURL 是没有用的。** +- 使用 API Key 鉴权。调用 `chat/completions` 时,推荐在请求体中传入 `appId`。 +- 为兼容 OpenAI SDK,也支持 `Authorization: Bearer -`,此时可以不传 `body.appId`。 +- `appId` 的优先级为:`body.appId` > `-` 中的 `appId` > API Key 关联的 `appId`(旧版兼容)。 +- 部分 SDK 需要在 `BaseURL` 后添加 `v1` 路径。如果出现 404,可补充 `v1` 后重试。 +- 如需通过 `authProxy` 代理团队成员身份,需要团队所有者在创建或编辑密钥时开启 `authProxy`;代理身份仍需具备目标应用和会话的访问权限。(FastGPT >= v4.15.0) -![](../../public/imgs/fastgpt-api-baseurl.png) +### 如何获取应用 ID(appId) + +打开应用详情,可在浏览器地址栏的 URL 中获取 `appId`。 + +![](../../public/imgs/appid.png) + +### 如何获取知识库 ID 和文件集合 ID + +| 知识库 ID(datasetId) | 文件集合 ID(collectionId) | +| --------------------------------------- | ------------------------------------------ | +| ![](../../public/imgs/getDatasetId.png) | ![](../../public/imgs/getCollectionId.png) | ### 基本配置 diff --git a/document/content/self-host/config/env.en.mdx b/document/content/self-host/config/env.en.mdx index b273abd6772f..f80fc5594b67 100644 --- a/document/content/self-host/config/env.en.mdx +++ b/document/content/self-host/config/env.en.mdx @@ -33,16 +33,16 @@ These variables are mainly validated by `packages/service/env.ts` and apply to ` ### Service URLs and Integrations +Starting with V4.17.0, AI Proxy is required. App and Admin services that share App/Service configuration must set `AIPROXY_API_ENDPOINT` and a nonempty `AIPROXY_API_TOKEN`. Startup validation fails if either is missing. Legacy OpenAI environment variables and per-model custom request URLs do not replace these settings. + | Variable | Default | Description | | ------------------------ | ----------------------------------- | --------------------------------------------------------------------------------------------------------- | | `PLUGIN_BASE_URL` | `http://localhost:3004` | FastGPT Plugin service URL. Deployment templates usually set this to the internal Plugin service URL. | | `PLUGIN_TOKEN` | `token` | Authentication token for calling the Plugin service. It must match the Plugin service configuration. | | `CODE_SANDBOX_URL` | `http://localhost:3002` | Code Sandbox service URL. Deployment templates usually set this to the internal Code Sandbox service URL. | | `CODE_SANDBOX_TOKEN` | `codesandbox` | Token used by App when calling Code Sandbox. It must match the sandbox service `SANDBOX_TOKEN`. | -| `AIPROXY_API_ENDPOINT` | Empty | AI Proxy service URL. When configured, model requests prefer AI Proxy. | -| `AIPROXY_API_TOKEN` | Empty | Token for calling AI Proxy. | -| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | Default OpenAI-compatible model endpoint when AI Proxy is not configured. | -| `CHAT_API_KEY` | Empty | Default OpenAI-compatible model API key when AI Proxy token is not configured. | +| `AIPROXY_API_ENDPOINT` | None, **required** | AI Proxy service root URL, such as `http://aiproxy:3000`. Do not append `/v1`. | +| `AIPROXY_API_TOKEN` | None, **required** | A nonempty AI Proxy administrator token matching the server configuration. | | `CRM_API_URL` | Empty | Lead attribution CRM API base URL (including `/api/v1`). Empty disables identity reporting. | | `CRM_API_KEY` | Empty | CRM admin API key used to bind a FastGPT user to `visitor_id` after registration or login. | | `MARKETPLACE_URL` | `https://v2.marketplace.fastgpt.cn` | Plugin marketplace API URL. | diff --git a/document/content/self-host/config/env.mdx b/document/content/self-host/config/env.mdx index 6060c75dd7e7..5a7f944477be 100644 --- a/document/content/self-host/config/env.mdx +++ b/document/content/self-host/config/env.mdx @@ -33,16 +33,16 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说 ### 服务地址与集成 +从 V4.17.0 起,AI Proxy 为必需依赖。App 和复用 App/Service 配置的 Admin 服务都必须设置 `AIPROXY_API_ENDPOINT` 和非空的 `AIPROXY_API_TOKEN`;缺少任一项会导致启动校验失败。旧 OpenAI 环境变量或单模型自定义请求地址不能替代这两个配置。 + | 变量 | 默认值 | 说明 | | ------------------------ | ----------------------------------- | ----------------------------------------------------------------------------- | | `PLUGIN_BASE_URL` | `http://localhost:3004` | FastGPT Plugin 服务地址;部署模板通常会配置为内部 Plugin 服务地址。 | | `PLUGIN_TOKEN` | `token` | 调用 Plugin 服务使用的认证 Token;需与 Plugin 服务配置一致。 | | `CODE_SANDBOX_URL` | `http://localhost:3002` | Code Sandbox 服务地址;部署模板通常会配置为内部 Code Sandbox 服务地址。 | | `CODE_SANDBOX_TOKEN` | `codesandbox` | App 调用 Code Sandbox 时使用的认证 Token,需与沙箱服务 `SANDBOX_TOKEN` 一致。 | -| `AIPROXY_API_ENDPOINT` | 空 | AI Proxy 服务地址;配置后模型请求会优先走 AI Proxy。 | -| `AIPROXY_API_TOKEN` | 空 | 调用 AI Proxy 使用的认证 Token。 | -| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | 未配置 AI Proxy 时,兼容 OpenAI 协议的默认模型接口地址。 | -| `CHAT_API_KEY` | 空 | 未配置 AI Proxy Token 时,兼容 OpenAI 协议的默认模型 API Key。 | +| `AIPROXY_API_ENDPOINT` | 无,**必填** | AI Proxy 服务根地址,例如 `http://aiproxy:3000`,不要附加 `/v1`。 | +| `AIPROXY_API_TOKEN` | 无,**必填** | AI Proxy 管理员 Token,必须与服务端配置一致且不能为空。 | | `CRM_API_URL` | 空 | 官网访客归因 CRM 的 API 基础地址(包含 `/api/v1`);为空时不进行身份上报。 | | `CRM_API_KEY` | 空 | CRM 管理 API Key,用于注册或登录成功后按 `visitor_id` 绑定 FastGPT 用户。 | | `MARKETPLACE_URL` | `https://v2.marketplace.fastgpt.cn` | 插件市场接口地址。 | diff --git a/document/content/self-host/custom-models/ollama.en.mdx b/document/content/self-host/custom-models/ollama.en.mdx index 2358b4b9805f..d17cdb55aa68 100644 --- a/document/content/self-host/custom-models/ollama.en.mdx +++ b/document/content/self-host/custom-models/ollama.en.mdx @@ -149,7 +149,7 @@ After adding the channel, click Token -> Add Token, fill in the name, and config ![](../../../public/imgs/Ollama-oneapi2.png) -Edit the FastGPT docker-compose.yml file: comment out AI Proxy, set OPENAI_BASE_URL to your OneAPI address (default http://address:port/v1 -- /v1 is required), and set KEY to your OneAPI token. +FastGPT routes model requests through AI Proxy. Keep `AIPROXY_API_ENDPOINT` and `AIPROXY_API_TOKEN` configured. In Admin, add an OpenAI-compatible model provider with your OneAPI endpoint (`http://address:port/v1`) and OneAPI token, then associate the models. ![](../../../public/imgs/Ollama-oneapi3.png) @@ -157,7 +157,7 @@ Then [jump to section 5](#5-model-addition-and-usage) to add and use models. ### 4. Direct Integration -If you don't want to use AI Proxy or OneAPI, you can connect directly. Edit the FastGPT docker-compose.yml: comment out AI Proxy code, set OPENAI_BASE_URL to your Ollama address (default http://address:port/v1 -- /v1 is required), and set KEY to any value (Ollama has no authentication by default; if you've enabled it, use the correct key). Everything else is the same as the OneAPI approach -- just add your model in FastGPT. This example uses Docker-deployed Ollama; for host-installed Ollama, use http://[host IP]:[port]. +To use Ollama without OneAPI, configure its OpenAI-compatible endpoint (`http://address:port/v1`) as a model provider in Admin. Ollama does not require authentication by default; if you enabled authentication, supply the corresponding key. FastGPT still routes requests through AI Proxy, so keep `AIPROXY_API_ENDPOINT` and `AIPROXY_API_TOKEN` configured. For a host deployment, use the host IP; for a container deployment, use an address reachable from AI Proxy. ![](../../../public/imgs/Ollama-direct1.png) diff --git a/document/content/self-host/custom-models/ollama.mdx b/document/content/self-host/custom-models/ollama.mdx index d4ae84fb7f7c..dffe88f33a0a 100644 --- a/document/content/self-host/custom-models/ollama.mdx +++ b/document/content/self-host/custom-models/ollama.mdx @@ -149,7 +149,7 @@ docker run -it --network [ FastGPT 网络 ] --name 容器名 intel/oneapi-hpckit ![](../../../public/imgs/Ollama-oneapi2.png) -修改部署 FastGPT 的 docker-compose.yml 文件,在其中将 AI Proxy 的使用注释,在 OPENAI_BASE_URL 中加入自己的 OneAPI 开放地址,默认是 http://地址:端口/v1,v1 必须填写。KEY 中填写自己在 OneAPI 的令牌。 +FastGPT 统一通过 AI Proxy 调用模型。保留 `AIPROXY_API_ENDPOINT` 和 `AIPROXY_API_TOKEN` 配置,在管理员的模型渠道中新增 OpenAI 协议渠道,将地址设为 OneAPI 开放地址(`http://地址:端口/v1`),密钥填写 OneAPI 令牌,再关联对应模型。 ![](../../../public/imgs/Ollama-oneapi3.png) @@ -157,7 +157,7 @@ docker run -it --network [ FastGPT 网络 ] --name 容器名 intel/oneapi-hpckit ### 4. 直接接入 -如果你既不想使用 AI Proxy,也不想使用 OneAPI,也可以选择直接接入,修改部署 FastGPT 的 docker-compose.yml 文件,在其中将 AI Proxy 的使用注释,采用和 OneAPI 的类似配置。注释掉 AIProxy 相关代码,在 OPENAI_BASE_URL 中加入自己的 Ollama 开放地址,默认是 http://地址:端口/v1,强调:v1 必须填写。在 KEY 中随便填入,因为 Ollama 默认没有鉴权,如果开启鉴权,请自行填写。其他操作和在 OneAPI 中加入 Ollama 一致,只需在 FastGPT 中加入自己的模型即可使用。此处演示采用的是 Docker 部署 Ollama 的效果,主机 Ollama 需要修改代理地址为 http://[主机 IP]:[端口] +无需 OneAPI 时,可在管理员的模型渠道中配置 Ollama 的 OpenAI 兼容接口,地址为 `http://地址:端口/v1`。Ollama 默认不需要鉴权;如果已开启鉴权,请填写对应密钥。FastGPT 仍通过 AI Proxy 调用该渠道,保留 `AIPROXY_API_ENDPOINT` 和 `AIPROXY_API_TOKEN` 配置。主机部署使用主机 IP,容器部署使用 AI Proxy 可访问的容器地址。 ![](../../../public/imgs/Ollama-direct1.png) diff --git a/document/content/self-host/deploy/docker.en.mdx b/document/content/self-host/deploy/docker.en.mdx index a60fdc5d1921..c1f8f73bb665 100644 --- a/document/content/self-host/deploy/docker.en.mdx +++ b/document/content/self-host/deploy/docker.en.mdx @@ -19,7 +19,7 @@ import { CurrentOriginCodeBlockUpdater } from '@/components/docs/CurrentOriginCo - MongoDB: Stores all data except vectors - PostgreSQL/Milvus/Oceanbase/SeekDB: Stores vector data -- AIProxy: Aggregates various AI APIs with multi-model support (for any model issues, test with OneAPI first) +- AI Proxy: Required for proxying model requests and managing providers in the Admin interface. @@ -376,10 +376,14 @@ rs.status() ### How to Change API Address and Key -By default, OneAPI connection address and key are configured. Modify the environment variables in the fastgpt container in `docker-compose.yml`: +Starting with V4.17.0, **you must deploy or connect to AI Proxy** and set these environment variables in the FastGPT container: -`OPENAI_BASE_URL` (API endpoint, must include /v1) -`CHAT_API_KEY` (API credentials) +- `AIPROXY_API_ENDPOINT`: The AI Proxy service root URL reachable from FastGPT, such as `http://aiproxy:3000`. Do not append `/v1`; FastGPT adds the path required by each API. +- `AIPROXY_API_TOKEN`: A nonempty AI Proxy administrator token that matches the administrator credentials configured on the AI Proxy server. + +Startup validation fails if either setting is missing. `OPENAI_BASE_URL` and `CHAT_API_KEY` do not replace these required settings. AI Proxy is still required when a model uses `requestUrl/requestAuth`. Configure model service endpoints and keys in the Admin provider settings. + +To update the AI Proxy connection, edit the FastGPT container's environment variables in `docker-compose.yml`. Commercial Admin services that share App/Service configuration also need both variables. After modifying, restart: diff --git a/document/content/self-host/deploy/docker.mdx b/document/content/self-host/deploy/docker.mdx index f54e0ef4904f..6bf1934659bf 100644 --- a/document/content/self-host/deploy/docker.mdx +++ b/document/content/self-host/deploy/docker.mdx @@ -19,7 +19,7 @@ import { CurrentOriginCodeBlockUpdater } from '@/components/docs/CurrentOriginCo - MongoDB:用于存储除了向量外的各类数据 - PostgreSQL/Milvus/Oceanbase/SeekDB:存储向量数据 -- AIProxy: 聚合各类 AI API,支持多模型调用(任何模型问题,先自行通过 OneAPI 测试校验) +- AI Proxy:必需的模型服务,负责模型请求代理和管理员渠道管理。 @@ -361,9 +361,14 @@ rs.status() ### 如何修改 API 地址和密钥 -默认是写了 OneAPi 的连接地址和密钥,可以通过修改 `docker-compose.yml` 中,fastgpt 容器的环境变量实现。 +从 V4.17.0 起,**必须部署或接入 AI Proxy**,并在 FastGPT 容器中配置以下环境变量: -`OPENAI_BASE_URL`(API 接口的地址,需要加/v1)`CHAT_API_KEY`(API 接口的凭证)。 +- `AIPROXY_API_ENDPOINT`:FastGPT 可访问的 AI Proxy 服务根地址,例如 `http://aiproxy:3000`,不要附加 `/v1`;FastGPT 会按接口补充路径。 +- `AIPROXY_API_TOKEN`:AI Proxy 管理员 Token,必须与 AI Proxy 服务端的管理员凭证一致,不能为空。 + +缺少上述任一配置会导致启动校验失败。`OPENAI_BASE_URL` 和 `CHAT_API_KEY` 不能替代这两个必填项;即使模型配置了 `requestUrl/requestAuth`,也不能省略 AI Proxy。模型服务商的地址和密钥在管理员渠道配置中维护。 + +可以通过修改 `docker-compose.yml` 中 FastGPT 容器的环境变量更新 AI Proxy 连接配置;商业版 Admin 服务若复用 App/Service 配置,也需要设置这两个变量。 修改完后重启: diff --git a/document/content/self-host/upgrading/4-15/4154.en.mdx b/document/content/self-host/upgrading/4-15/4154.en.mdx index 14933a98f8db..3ebc1f657299 100644 --- a/document/content/self-host/upgrading/4-15/4154.en.mdx +++ b/document/content/self-host/upgrading/4-15/4154.en.mdx @@ -1,12 +1,13 @@ --- title: 'V4.15.4' -description: 'FastGPT V4.15.4 Release Notes' +description: 'FastGPT V4.15.4 release notes' releaseTime: '2026-07-24' upgradeTags: + - IMPORTANT - CONFIG --- -## 📦 Upgrade Guide +## 📦 Upgrade guide ### Configure the required FE_DOMAIN @@ -18,7 +19,7 @@ production; local development can use `http://localhost:3000`. FE_DOMAIN=https://fastgpt.example.com ``` -### MongoDB Index Synchronization Changes +### MongoDB index synchronization changes Starting with V4.15.4, `SYNC_INDEX` is deprecated and replaced by `MONGO_DEPRECATE_INDEX`. The new variable controls whether indexes explicitly marked as deprecated by a schema are removed and defaults to `true`. Setting it to `false` skips only deprecated-index cleanup; missing current schema indexes are still created. @@ -36,20 +37,20 @@ This process does not call Mongoose's full `syncIndexes()` operation, so indexes To fully remove obsolete indexes before upgrading to V4.15.4: -1. Upgrade to and start V4.15.3 once. -2. Set `SYNC_INDEX=true`, restart the services, and wait for index synchronization to finish. +1. Set `SYNC_INDEX=true`, restart the services, and wait for index synchronization to finish. +2. Upgrade to and start V4.15.3 once. 3. After confirming that index synchronization succeeded, upgrade to V4.15.4. V4.15.3 removes every index that is not declared in its schemas, which may include custom indexes. Back up your database and review the existing indexes before following this procedure. If custom indexes must be preserved, record their definitions and recreate them after synchronization, or do not use V4.15.3 for full cleanup. Setting `MONGO_DEPRECATE_INDEX=false` skips deprecated-index cleanup that may be introduced in future releases, but does not skip creation of missing indexes. -### Image Changes +### Image changes - Update the `fastgpt-app` (FastGPT core service) image tag to `v4.15.4`. - Update the `fastgpt-pro` (FastGPT commercial edition) image tag to `v4.15.4`. -## 🚀 New Features +## 🚀 New features ## ⚙️ Improvements diff --git a/document/content/self-host/upgrading/4-15/4154.mdx b/document/content/self-host/upgrading/4-15/4154.mdx index ca3af4be196a..cf2c16b828fc 100644 --- a/document/content/self-host/upgrading/4-15/4154.mdx +++ b/document/content/self-host/upgrading/4-15/4154.mdx @@ -3,6 +3,7 @@ title: 'V4.15.4' description: 'FastGPT V4.15.4 更新说明' releaseTime: '2026-07-24' upgradeTags: + - 升级前必看 - 配置变更 --- @@ -32,10 +33,10 @@ FastGPT 启动时会自动执行安全的主动同步: > **旧索引清理说明:V4.15.4 不会把任何已有历史索引标记为废弃,因此升级到该版本时不会自动删除旧索引。后续版本会在确认安全后,通过 Schema 中的显式废弃标记逐步清理对应索引。** -如需在升级 V4.15.4 前完整删除历史过期索引,请按以下顺序操作: +如需在升级 V4.15.4 前请按以下顺序操作完整历史索引清理: -1. 先升级并启动一次 V4.15.3。 -2. 设置 `SYNC_INDEX=true`,重启服务并等待索引同步完成。 +1. 设置 `SYNC_INDEX=true`,重启服务并等待索引同步完成。 +2. 先升级并启动一次 V4.15.3。 3. 确认索引同步成功后,再升级至 V4.15.4。 V4.15.3 的索引同步会删除所有未在当时 Schema 中声明的索引,其中可能包含客户自建索引。执行上述步骤前,请先备份数据库并检查现有索引;如需保留自建索引,请记录其定义并在同步后重新创建,或不要使用 V4.15.3 进行全量清理。 diff --git a/document/content/self-host/upgrading/4-17/4170.en.mdx b/document/content/self-host/upgrading/4-17/4170.en.mdx index 0b8ee6853eab..e786c4b12c87 100644 --- a/document/content/self-host/upgrading/4-17/4170.en.mdx +++ b/document/content/self-host/upgrading/4-17/4170.en.mdx @@ -14,6 +14,17 @@ Starting with V4.17.0, tasks registered with the system upgrade manager run auto Migration tasks process `100` records per batch by default. For larger deployments, set `SYSTEM_MIGRATION_BATCH_SIZE` to a value from `50` to `1000`. +### AI Proxy (required) + +Starting with V4.17.0, you must deploy or connect to AI Proxy. Configure these variables for FastGPT App and any commercial Admin service that shares App/Service configuration: + +- `AIPROXY_API_ENDPOINT`: A reachable AI Proxy service root URL, such as `http://aiproxy:3000`. Do not append `/v1`. +- `AIPROXY_API_TOKEN`: A nonempty administrator token matching the AI Proxy server configuration. + +**As of V4.17.0, `OPENAI_BASE_URL` and `CHAT_API_KEY` are deprecated and removed. These settings no longer take effect.** Remove them from your deployment environment and use the AI Proxy settings above. Manage model provider endpoints and keys in Admin. The legacy `systemEnv.oneapiUrl/chatApiKey` overrides also no longer take effect. + +Startup validation fails if either AI Proxy setting is missing. Per-model `requestUrl/requestAuth` cannot replace these required settings. Before upgrading, verify that AI Proxy is reachable and that the administrator token can access its provider management APIs. Do not use a model service provider's API key as the AI Proxy administrator token. + ### Code Sandbox seccomp compatibility setting (optional) Code Sandbox continues to enable seccomp by default. If the host kernel does not support application-level seccomp, process pool initialization fails. If you accept the reduced syscall isolation, explicitly configure Code Sandbox with: diff --git a/document/content/self-host/upgrading/4-17/4170.mdx b/document/content/self-host/upgrading/4-17/4170.mdx index 249a0256c766..dc62d7a0a8b4 100644 --- a/document/content/self-host/upgrading/4-17/4170.mdx +++ b/document/content/self-host/upgrading/4-17/4170.mdx @@ -14,6 +14,17 @@ upgradeTags: 分批迁移任务默认每批处理 `100` 条记录。数据量较大时,可通过 `SYSTEM_MIGRATION_BATCH_SIZE` 调整为 `50`~`1000`。 +### AI Proxy(必需) + +从 V4.17.0 起,必须部署或接入 AI Proxy,并在 FastGPT App 及复用 App/Service 配置的商业版 Admin 服务中设置: + +- `AIPROXY_API_ENDPOINT`:可访问的 AI Proxy 服务根地址,例如 `http://aiproxy:3000`,不要附加 `/v1`。 +- `AIPROXY_API_TOKEN`:与 AI Proxy 服务端配置一致的非空管理员 Token。 + +**从 V4.17.0 起,`OPENAI_BASE_URL` 和 `CHAT_API_KEY` 已弃用并移除,配置后不再生效。** 请从部署环境中删除这两个变量,改用上述 AI Proxy 配置,并在管理员的模型渠道中维护模型服务商地址和密钥。旧的 `systemEnv.oneapiUrl/chatApiKey` 覆盖配置也不再生效。 + +缺少任一 AI Proxy 配置会导致启动校验失败,单模型的 `requestUrl/requestAuth` 不能替代这两个必填项。升级前请确认 AI Proxy 可访问,且管理员 Token 可以访问渠道管理接口;不要把模型服务商的 API Key 当作 AI Proxy 管理员 Token。 + ### Code Sandbox seccomp 兼容配置(可选) Code Sandbox 默认继续启用 seccomp。若宿主机内核不支持应用内 seccomp,进程池初始化会失败;确认可以接受系统调用隔离能力降低后,可为 Code Sandbox 显式设置: diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 79dfe7d05090..b8bd56f492f6 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -151,16 +151,16 @@ "content/guide/workspace/team/invitation_link.mdx": "2026-05-07T15:06:40+08:00", "content/guide/workspace/team/team_roles_permissions.en.mdx": "2026-08-15T23:40:17+08:00", "content/guide/workspace/team/team_roles_permissions.mdx": "2026-05-07T15:06:40+08:00", - "content/openapi/app.en.mdx": "2026-08-15T23:40:17+08:00", - "content/openapi/app.mdx": "2026-05-29T19:31:16+08:00", - "content/openapi/chat.en.mdx": "2026-08-15T23:40:17+08:00", - "content/openapi/chat.mdx": "2026-07-08T22:37:19+08:00", - "content/openapi/dataset.en.mdx": "2026-08-15T23:40:17+08:00", - "content/openapi/dataset.mdx": "2026-08-11T20:41:16+08:00", + "content/openapi/app.en.mdx": "2026-09-10T11:39:40+08:00", + "content/openapi/app.mdx": "2026-09-10T11:39:40+08:00", + "content/openapi/chat.en.mdx": "2026-09-10T11:39:40+08:00", + "content/openapi/chat.mdx": "2026-09-10T11:39:40+08:00", + "content/openapi/dataset.en.mdx": "2026-09-10T11:39:40+08:00", + "content/openapi/dataset.mdx": "2026-09-10T11:39:40+08:00", "content/openapi/index.en.mdx": "2026-04-26T21:08:47+08:00", "content/openapi/index.mdx": "2026-04-26T21:08:47+08:00", - "content/openapi/intro.en.mdx": "2026-08-15T23:40:17+08:00", - "content/openapi/intro.mdx": "2026-08-25T13:41:03+08:00", + "content/openapi/intro.en.mdx": "2026-09-10T11:39:40+08:00", + "content/openapi/intro.mdx": "2026-09-10T11:39:40+08:00", "content/plugin/index.en.mdx": "2026-06-04T16:10:15+08:00", "content/plugin/index.mdx": "2026-06-04T16:10:15+08:00", "content/plugin/intro.en.mdx": "2026-08-18T18:05:49+08:00", @@ -171,8 +171,8 @@ "content/plugin/system-tool-development.mdx": "2026-07-02T11:54:55+08:00", "content/plugin/team-installation.en.mdx": "2026-09-02T20:13:11+08:00", "content/plugin/team-installation.mdx": "2026-09-02T20:13:11+08:00", - "content/self-host/config/env.en.mdx": "2026-09-09T18:05:00+08:00", - "content/self-host/config/env.mdx": "2026-09-09T18:05:00+08:00", + "content/self-host/config/env.en.mdx": "2026-09-09T22:57:06+08:00", + "content/self-host/config/env.mdx": "2026-09-09T22:57:06+08:00", "content/self-host/config/model/intro.en.mdx": "2026-08-24T17:55:53+08:00", "content/self-host/config/model/intro.mdx": "2026-08-24T17:55:53+08:00", "content/self-host/config/model/minimax.en.mdx": "2026-08-15T23:40:17+08:00", @@ -201,12 +201,12 @@ "content/self-host/custom-models/marker.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/custom-models/mineru.en.mdx": "2026-08-15T23:40:17+08:00", "content/self-host/custom-models/mineru.mdx": "2026-04-26T21:08:47+08:00", - "content/self-host/custom-models/ollama.en.mdx": "2026-08-24T17:55:53+08:00", - "content/self-host/custom-models/ollama.mdx": "2026-08-24T17:55:53+08:00", + "content/self-host/custom-models/ollama.en.mdx": "2026-09-09T22:57:06+08:00", + "content/self-host/custom-models/ollama.mdx": "2026-09-09T22:57:06+08:00", "content/self-host/custom-models/xinference.en.mdx": "2026-08-15T23:40:17+08:00", "content/self-host/custom-models/xinference.mdx": "2026-04-26T21:08:47+08:00", - "content/self-host/deploy/docker.en.mdx": "2026-09-07T00:55:30+08:00", - "content/self-host/deploy/docker.mdx": "2026-09-07T00:55:30+08:00", + "content/self-host/deploy/docker.en.mdx": "2026-09-07T23:20:55+08:00", + "content/self-host/deploy/docker.mdx": "2026-09-07T23:20:55+08:00", "content/self-host/deploy/sealos.en.mdx": "2026-08-24T10:27:24+08:00", "content/self-host/deploy/sealos.mdx": "2026-08-24T10:27:24+08:00", "content/self-host/design/dataset.en.mdx": "2026-08-15T23:40:17+08:00", @@ -355,8 +355,8 @@ "content/self-host/upgrading/4-16/4161.mdx": "2026-08-24T10:06:28+08:00", "content/self-host/upgrading/4-16/4162.en.mdx": "2026-09-03T21:50:14+08:00", "content/self-host/upgrading/4-16/4162.mdx": "2026-09-03T21:50:14+08:00", - "content/self-host/upgrading/4-17/4170.en.mdx": "2026-09-09T18:05:00+08:00", - "content/self-host/upgrading/4-17/4170.mdx": "2026-09-09T18:05:00+08:00", + "content/self-host/upgrading/4-17/4170.en.mdx": "2026-09-09T22:57:06+08:00", + "content/self-host/upgrading/4-17/4170.mdx": "2026-09-09T22:57:06+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-08-16T23:16:43+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-08-16T23:16:43+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-08-16T23:16:43+08:00", diff --git a/document/public/imgs/datasetSetting1.png b/document/public/imgs/datasetSetting1.png index a3b0f9cd6a9a..c9634afa46a3 100644 Binary files a/document/public/imgs/datasetSetting1.png and b/document/public/imgs/datasetSetting1.png differ diff --git a/document/public/imgs/dataset_search_params1.png b/document/public/imgs/dataset_search_params1.png index f75c244b97b4..4ffcaff07778 100644 Binary files a/document/public/imgs/dataset_search_params1.png and b/document/public/imgs/dataset_search_params1.png differ diff --git a/document/public/imgs/dataset_search_params2.png b/document/public/imgs/dataset_search_params2.png index 9537dc02e956..221d9824f238 100644 Binary files a/document/public/imgs/dataset_search_params2.png and b/document/public/imgs/dataset_search_params2.png differ diff --git a/document/public/imgs/dataset_search_params3.png b/document/public/imgs/dataset_search_params3.png index 0aaa68b02d22..9f68f091eac0 100644 Binary files a/document/public/imgs/dataset_search_params3.png and b/document/public/imgs/dataset_search_params3.png differ diff --git a/document/public/imgs/dataset_search_process.png b/document/public/imgs/dataset_search_process.png index 6ac9cfdbb2ed..6e0acf6fd98a 100644 Binary files a/document/public/imgs/dataset_search_process.png and b/document/public/imgs/dataset_search_process.png differ diff --git a/document/public/imgs/dataset_tree.png b/document/public/imgs/dataset_tree.png index 66bf6d9aa650..56b3d91e9be5 100644 Binary files a/document/public/imgs/dataset_tree.png and b/document/public/imgs/dataset_tree.png differ diff --git a/document/public/imgs/fastgpt-api-baseurl.png b/document/public/imgs/fastgpt-api-baseurl.png deleted file mode 100644 index bd9645568e48..000000000000 Binary files a/document/public/imgs/fastgpt-api-baseurl.png and /dev/null differ diff --git a/document/public/imgs/getCollectionId.png b/document/public/imgs/getCollectionId.png new file mode 100644 index 000000000000..f1383e96606b Binary files /dev/null and b/document/public/imgs/getCollectionId.png differ diff --git a/document/public/imgs/getDatasetId.jpg b/document/public/imgs/getDatasetId.jpg deleted file mode 100644 index ce893d69f1d6..000000000000 Binary files a/document/public/imgs/getDatasetId.jpg and /dev/null differ diff --git a/document/public/imgs/getDatasetId.png b/document/public/imgs/getDatasetId.png new file mode 100644 index 000000000000..cdb863818f46 Binary files /dev/null and b/document/public/imgs/getDatasetId.png differ diff --git a/document/public/imgs/getfile_id.webp b/document/public/imgs/getfile_id.webp deleted file mode 100644 index 847eec7f8a90..000000000000 Binary files a/document/public/imgs/getfile_id.webp and /dev/null differ diff --git a/document/public/imgs/image-1.png b/document/public/imgs/image-1.png index e212205985dd..85cfb531306a 100644 Binary files a/document/public/imgs/image-1.png and b/document/public/imgs/image-1.png differ diff --git a/document/public/imgs/image-30.png b/document/public/imgs/image-30.png index 80b8bdfa8c77..7401460771c1 100644 Binary files a/document/public/imgs/image-30.png and b/document/public/imgs/image-30.png differ diff --git a/document/public/imgs/image-45.png b/document/public/imgs/image-45.png index 5517a74fc9d0..9eca968ab833 100644 Binary files a/document/public/imgs/image-45.png and b/document/public/imgs/image-45.png differ diff --git a/document/public/imgs/image-47.png b/document/public/imgs/image-47.png index 49795f36e67b..0d1c07b6cac4 100644 Binary files a/document/public/imgs/image-47.png and b/document/public/imgs/image-47.png differ diff --git a/document/public/imgs/image.png b/document/public/imgs/image.png index 4789efd415ce..ca521a2119fc 100644 Binary files a/document/public/imgs/image.png and b/document/public/imgs/image.png differ diff --git a/document/public/imgs/mineru5-1.png b/document/public/imgs/mineru5-1.png index cc8c22343557..7d998a3108b6 100644 Binary files a/document/public/imgs/mineru5-1.png and b/document/public/imgs/mineru5-1.png differ diff --git a/packages/global/common/error/code/model.ts b/packages/global/common/error/code/model.ts index 7af72a027144..68b498fd8e15 100644 --- a/packages/global/common/error/code/model.ts +++ b/packages/global/common/error/code/model.ts @@ -4,7 +4,8 @@ import { i18nT } from '../../i18n/utils'; /* model: 513000 */ export enum ModelErrEnum { unExist = 'modelUnExist', - unConfigured = 'modelUnConfigured' + unConfigured = 'modelUnConfigured', + alreadyExists = 'modelAlreadyExists' } const modelErrList = [ @@ -15,6 +16,10 @@ const modelErrList = [ { statusText: ModelErrEnum.unConfigured, message: i18nT('common:not_model_config') + }, + { + statusText: ModelErrEnum.alreadyExists, + message: i18nT('common:model_id_already_exists') } ]; diff --git a/packages/global/common/system/types/index.ts b/packages/global/common/system/types/index.ts index bce2098eb2cf..72fad943dcce 100644 --- a/packages/global/common/system/types/index.ts +++ b/packages/global/common/system/types/index.ts @@ -171,9 +171,6 @@ export type SystemEnvType = { hnswEfSearch: number; hnswMaxScanTuples: number; - oneapiUrl?: string; - chatApiKey?: string; - customPdfParse?: customPdfParseType; fileUrlWhitelist?: string[]; customDomain?: customDomainType; diff --git a/packages/global/common/zod/openapi.ts b/packages/global/common/zod/openapi.ts new file mode 100644 index 000000000000..f340fe392e2c --- /dev/null +++ b/packages/global/common/zod/openapi.ts @@ -0,0 +1,13 @@ +import type { ZodOpenApiMetadata } from 'zod-openapi'; + +/** 显式导出 refine 中的字段组合约束;顺序也是文档默认示例的选择顺序,不改变运行时校验。 */ +export const requiredAlternatives = ( + branches: string[][], + exclusive = false +): Pick => ({ + override: ({ jsonSchema }) => { + const choices = branches.map((required) => ({ required })); + if (exclusive) jsonSchema.oneOf = choices; + else jsonSchema.anyOf = choices; + } +}); diff --git a/packages/global/core/ai/constants.ts b/packages/global/core/ai/constants.ts index d150c0c8c7f5..a5b2365bc0df 100644 --- a/packages/global/core/ai/constants.ts +++ b/packages/global/core/ai/constants.ts @@ -36,7 +36,6 @@ export const defaultQAModels: LLMSystemModelDataType[] = [ name: 'gpt-5', modelId: '', scope: ModelScopeEnum.system, - isCustom: false, charsPointsPrice: 0, config: { maxContext: 16000, @@ -61,7 +60,6 @@ export const defaultVectorModels: EmbeddingSystemModelDataType[] = [ name: 'Embedding-2', modelId: '', scope: ModelScopeEnum.system, - isCustom: false, charsPointsPrice: 0, config: { defaultToken: 500, @@ -79,7 +77,6 @@ export const defaultSTTModels: STTSystemModelDataType[] = [ name: 'whisper-1', modelId: '', scope: ModelScopeEnum.system, - isCustom: false, charsPointsPrice: 0, config: {} } diff --git a/packages/global/core/ai/model.schema.ts b/packages/global/core/ai/model.schema.ts index 667ac37937f5..0081e78679ae 100644 --- a/packages/global/core/ai/model.schema.ts +++ b/packages/global/core/ai/model.schema.ts @@ -134,11 +134,10 @@ export const SystemModelDocumentDataSchema = z.discriminatedUnion('type', [ ]); export type SystemModelDocumentDataType = z.infer; -/** 运行时模型数据。avatar 与 isCustom 都由 provider/plugin 信息派生。 */ +/** 运行时模型数据。modelId 来自 MongoDB `_id`,avatar 由 provider 派生。 */ const RuntimeSystemModelFields = { modelId: z.string(), - avatar: z.string().optional(), - isCustom: z.boolean() + avatar: z.string().optional() }; export const LLMSystemModelDataSchema = diff --git a/packages/global/core/ai/pricing.ts b/packages/global/core/ai/pricing.ts index 920994f408b6..63fa21751a0a 100644 --- a/packages/global/core/ai/pricing.ts +++ b/packages/global/core/ai/pricing.ts @@ -1,4 +1,5 @@ -import type { ModelPriceTierType, PriceType } from './model.schema'; +import type { ModelPriceTierType, PriceType, SystemModelDocumentDataType } from './model.schema'; +import { ModelTypeEnum } from './constants'; const isValidNumber = (value: unknown): value is number => { return typeof value === 'number' && Number.isFinite(value); @@ -68,39 +69,72 @@ export const sanitizeModelPriceTiers = (tiers?: ModelPriceTierType[]): ModelPric // 计算模型价格梯度 export const getRuntimeResolvedPriceTiers = (config?: PriceType): ModelPriceTierType[] => { - // 格式化梯度 + /** 最终只剩一档双零价格时视为空计费,保存与运行时加载共用该语义。 */ + const removeEmptySingleTier = (tiers: ModelPriceTierType[]) => { + const [onlyTier] = tiers; + return tiers.length === 1 && onlyTier.inputPrice === 0 && onlyTier.outputPrice === 0 + ? [] + : tiers; + }; + + // 新梯度为空或只有一档双零时继续向旧字段回退,兼容迁移前后的混合数据。 if (Array.isArray(config?.priceTiers)) { - return sanitizeModelPriceTiers(config.priceTiers); + const priceTiers = removeEmptySingleTier(sanitizeModelPriceTiers(config.priceTiers)); + if (priceTiers.length > 0) return priceTiers; } // 旧版的价格计费字段 - const hasLegacyIOPrice = isValidNumber(config?.inputPrice) && config.inputPrice > 0; + const hasLegacyIOPrice = isValidNumber(config?.inputPrice) || isValidNumber(config?.outputPrice); if (hasLegacyIOPrice) { - return [ - { - minInputTokens: 0, - inputPrice: getSafePrice(config?.inputPrice), - outputPrice: getSafePrice(config?.outputPrice) - } - ]; + const legacyPriceTiers = removeEmptySingleTier( + sanitizeModelPriceTiers([ + { + minInputTokens: 0, + inputPrice: getSafePrice(config?.inputPrice), + outputPrice: getSafePrice(config?.outputPrice) + } + ]) + ); + if (legacyPriceTiers.length > 0) return legacyPriceTiers; } - if (isValidNumber(config?.charsPointsPrice) || config?.charsPointsPrice === undefined) { + if (isValidNumber(config?.charsPointsPrice)) { const comprehensivePrice = getSafePrice(config?.charsPointsPrice); - return [ - { - minInputTokens: 0, - inputPrice: comprehensivePrice, - outputPrice: comprehensivePrice - } - ]; + const comprehensivePriceTiers = removeEmptySingleTier( + sanitizeModelPriceTiers([ + { + minInputTokens: 0, + inputPrice: comprehensivePrice, + outputPrice: comprehensivePrice + } + ]) + ); + if (comprehensivePriceTiers.length > 0) return comprehensivePriceTiers; } return []; }; +/** 编辑打开和 JSON 导入共用:将历史 LLM 计费转换为阶梯并移除旧字段,非 LLM 保持不变。 */ +export const normalizeModelPricingForRead = ( + modelData: SystemModelDocumentDataType +): SystemModelDocumentDataType => { + if (modelData.type !== ModelTypeEnum.llm) return modelData; + const { inputPrice: _input, outputPrice: _output, charsPointsPrice: _chars, ...data } = modelData; + return { ...data, priceTiers: getRuntimeResolvedPriceTiers(modelData) }; +}; + +/** 编辑保存和导入落库共用:只认转换/编辑后的新阶梯,免费配置不能再次回退旧字段。 */ +export const normalizeModelPricingForSave = ( + modelData: SystemModelDocumentDataType +): SystemModelDocumentDataType => { + if (modelData.type !== ModelTypeEnum.llm) return modelData; + const { inputPrice: _input, outputPrice: _output, charsPointsPrice: _chars, ...data } = modelData; + return { ...data, priceTiers: getRuntimeResolvedPriceTiers({ priceTiers: data.priceTiers }) }; +}; + export const calculateModelPrice = ({ config, inputTokens = 0, diff --git a/packages/global/core/workflow/type/io.ts b/packages/global/core/workflow/type/io.ts index 91a085afe726..1d0a29e9463a 100644 --- a/packages/global/core/workflow/type/io.ts +++ b/packages/global/core/workflow/type/io.ts @@ -17,8 +17,11 @@ export const SelectedDatasetSchema = z.object({ description: '可选知识库名称' }), vectorModel: z.object({ + modelId: z.string().optional().meta({ + description: '知识库绑定的向量模型 ID' + }), model: z.string().meta({ - description: '知识库使用的向量模型' + description: '知识库使用的向量模型(兼容历史数据)' }) }), isDeleted: BoolSchema.optional() diff --git a/packages/global/openapi/admin/core/ai/model/api.ts b/packages/global/openapi/admin/core/ai/model/api.ts index 50bd7e79e52c..725afb66c498 100644 --- a/packages/global/openapi/admin/core/ai/model/api.ts +++ b/packages/global/openapi/admin/core/ai/model/api.ts @@ -18,6 +18,7 @@ import z from 'zod'; import { ModelProviderSchema } from '../../../../core/ai/model/api'; import { ModelDefaultIdsSchema } from '../../../../../core/ai/defaultModel'; import { ObjectIdSchema } from '../../../../../common/type/mongo'; +import { I18nStringSchema } from '../../../../../common/i18n/type'; const ModelIdSchema = ObjectIdSchema.meta({ example: '68ad85a7463006c963799a05', @@ -29,16 +30,103 @@ export const AdminSystemModelReferenceSchema = z.object({ }); export type AdminSystemModelReference = z.infer; +const ModelIdsSchema = z + .array(ModelIdSchema) + .min(1) + .max(500) + .superRefine((modelIds, ctx) => { + if (new Set(modelIds).size !== modelIds.length) { + ctx.addIssue({ code: 'custom', message: 'modelIds must be unique' }); + } + }) + .meta({ + example: ['68ad85a7463006c963799a05', '68ad85a7463006c963799a06'], + description: '待批量操作的系统模型 ID,最多 500 个且不可重复' + }); + +/* ============================================================================ + * API: 批量删除系统模型 + * Route: DELETE /api/admin/settings/model/delete + * Method: DELETE + * Description: 按 modelIds 批量删除系统模型;兼容旧版 modelId query + * Tags: ['系统模型管理', 'Delete'] + * ============================================================================ */ + +export const DeleteSystemModelsBodySchema = z.object({ + modelIds: ModelIdsSchema +}); +export type DeleteSystemModelsBody = z.infer; + /* ============================================================================ * API: 获取管理员系统模型列表 * Route: GET /api/admin/settings/model/list * Method: GET * Description: 获取全部系统作用域模型 - * Tags: ['管理员系统配置', 'Read'] + * Tags: ['系统模型管理', 'Read'] * ============================================================================ */ +export const AdminModelChannelSchema = z.object({ + id: IntSchema.positive().meta({ example: 1, description: 'AI Proxy 渠道 ID' }), + name: z.string().meta({ example: 'OpenAI 主渠道', description: '渠道名称' }), + protocol: z.object({ + name: I18nStringSchema.meta({ description: '渠道协议名称' }), + avatar: z.string().meta({ example: 'model/openai', description: '渠道协议图标' }) + }), + status: IntSchema.meta({ example: 1, description: 'AI Proxy 渠道状态' }) +}); +export type AdminModelChannel = z.infer; + +/* ============================================================================ + * API: 创建 AI Proxy 渠道 + * Route: POST /api/aiproxy/api/createChannel + * Method: POST + * Description: 创建单个命名渠道,兼容旧版 AI Proxy 并返回准确渠道 ID + * Tags: ['系统模型管理', 'Write'] + * ============================================================================ */ +export const CreateAdminAIProxyChannelBodySchema = z + .object({ + name: z.string().trim().min(1).meta({ description: '渠道名称', example: 'OpenAI' }), + type: IntSchema.positive().meta({ description: 'AI Proxy 协议类型', example: 1 }), + base_url: z.string().optional().meta({ description: '渠道模型服务地址' }), + key: z + .string() + .refine((key) => key.split('\n').filter((line) => line.trim()).length <= 1, { + message: 'Only one channel credential is supported' + }) + .optional() + .meta({ description: '单个渠道模型服务凭证;不支持换行分隔多个密钥' }), + models: z.array(z.string().trim().min(1)).optional().meta({ description: '支持的模型标识' }), + model_mapping: z.record(z.string(), z.unknown()).optional().meta({ description: '模型映射' }), + priority: IntSchema.positive().optional().meta({ description: '渠道优先级', example: 1 }) + }) + .passthrough(); +export type CreateAdminAIProxyChannelBody = z.infer; +/** 此代理保留第三方 envelope,而不是 FastGPT NextAPI 的 data 响应。 */ +export const CreateAdminAIProxyChannelResponseSchema = z.discriminatedUnion('success', [ + z.object({ + success: z.literal(true), + data: z.object({ + id: IntSchema.positive().meta({ description: '创建成功的准确渠道 ID', example: 1 }) + }) + }), + z.object({ success: z.literal(false), message: z.string().optional() }) +]); +export type CreateAdminAIProxyChannelResponse = z.infer< + typeof CreateAdminAIProxyChannelResponseSchema +>; + +export const AdminSystemModelListItemSchema = SystemModelDataSchema.and( + z.object({ + channels: z.array(AdminModelChannelSchema).meta({ description: '当前模型关联的渠道摘要' }) + }) +); +export type AdminSystemModelListItem = z.infer; + export const GetAdminSystemModelListResponseSchema = z.object({ - models: z.array(SystemModelDataSchema), + models: z.array(AdminSystemModelListItemSchema), + channels: z.array(AdminModelChannelSchema).meta({ + description: '全部渠道摘要,供新增、编辑和关联渠道交互复用' + }), providers: z.array(ModelProviderSchema), defaultModelIds: ModelDefaultIdsSchema, aiproxyChannels: z.array( @@ -56,33 +144,33 @@ export type GetAdminSystemModelListResponse = z.infer; + +export const GetAdminSystemModelDetailResponseSchema = z.object({ + model: SystemModelDataSchema.meta({ description: '完整模型参数' }), + channels: z.array(AdminSystemModelDetailChannelSchema).meta({ + description: '全部渠道展示信息及其与当前模型的关联状态' + }) +}); export type GetAdminSystemModelDetailResponse = z.infer< typeof GetAdminSystemModelDetailResponseSchema >; -/* ============================================================================ - * API: 获取系统模型模板默认配置 - * Route: GET /api/admin/settings/model/getDefaultConfig - * Method: GET - * Description: 按 modelId 获取插件模板中的默认配置 - * Tags: ['管理员系统配置', 'Read'] - * ============================================================================ */ - -export const GetAdminSystemModelDefaultConfigResponseSchema = SystemModelDocumentDataSchema; -export type GetAdminSystemModelDefaultConfigResponse = z.infer< - typeof GetAdminSystemModelDefaultConfigResponseSchema ->; - /* ============================================================================ * API: 测试系统模型配置 * Route: GET /api/admin/settings/model/test * Method: GET * Description: 按 modelId 测试系统模型调用 - * Tags: ['管理员系统配置', 'Read'] + * Tags: ['系统模型管理', 'Read'] * ============================================================================ */ export const TestAdminSystemModelQuerySchema = AdminSystemModelReferenceSchema.extend({ @@ -95,37 +183,238 @@ export type TestAdminSystemModelQuery = z.infer; +/* ============================================================================ + * API: 测试新增或编辑中的管理员系统模型草稿 + * Route: POST /api/admin/settings/model/test + * Method: POST + * Description: 使用当前模型表单草稿和指定 AI Proxy 渠道发起测试,不持久化模型 + * Tags: ['系统模型管理', 'Read'] + * ============================================================================ */ + +const TestModelPriceFields = { + charsPointsPrice: true, + priceTiers: true, + inputPrice: true, + outputPrice: true +} as const; + +const TestDraftSystemModelDataSchema = z + .discriminatedUnion('type', [ + LLMSystemModelDocumentSchema.omit(TestModelPriceFields), + EmbeddingSystemModelDocumentSchema.omit(TestModelPriceFields), + TTSSystemModelDocumentSchema.omit(TestModelPriceFields), + STTSystemModelDocumentSchema.omit(TestModelPriceFields), + RerankSystemModelDocumentSchema.omit(TestModelPriceFields) + ]) + .meta({ description: '仅包含实际模型调用所需字段的表单草稿;计费字段会被忽略' }); + +export const TestDraftAdminSystemModelBodySchema = z + .object({ + modelData: TestDraftSystemModelDataSchema.meta({ + description: '新增或编辑中的当前模型运行参数;计费字段不参与测试' + }), + channelId: IntSchema.positive().meta({ + example: 1, + description: '本次测试指定的 AI Proxy 渠道 ID' + }) + }) + .strict() + .superRefine(({ modelData }, ctx) => { + if (modelData.type === ModelTypeEnum.tts && modelData.config.voices.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.too_small, + minimum: 1, + origin: 'array', + inclusive: true, + path: ['modelData', 'config', 'voices'], + message: 'TTS model test requires at least one voice' + }); + } + }); +export type TestDraftAdminSystemModelBody = z.infer; + +const ModelTemplateReferenceSchema = z.object({ + type: z.nativeEnum(ModelTypeEnum).meta({ + example: ModelTypeEnum.llm, + description: '模板模型类型' + }), + model: z.string().trim().min(1).meta({ + example: 'gpt-5.4', + description: '模板模型标识' + }) +}); +export type ModelTemplateReference = z.infer; + +/* ============================================================================ + * API: 获取管理员模型模板列表 + * Route: GET /api/admin/settings/model/templates + * Method: GET + * Description: 实时读取 Plugin 模型模板,不使用服务端缓存 + * Tags: ['系统模型管理', 'Read'] + * ============================================================================ */ + +export const GetAdminModelTemplatesResponseSchema = z.object({ + models: z.array(SystemModelDocumentDataSchema).meta({ description: '当前 Plugin 模型模板' }), + providers: z.array(ModelProviderSchema).meta({ description: '模型提供商元数据' }) +}); +export type GetAdminModelTemplatesResponse = z.infer; + /* ============================================================================ * API: 创建自定义系统模型 * Route: POST /api/admin/settings/model/create * Method: POST * Description: 按最新持久化结构创建自定义系统模型 - * Tags: ['管理员系统配置', 'Write'] + * Tags: ['系统模型管理', 'Write'] * ============================================================================ */ -export const CreateSystemModelBodySchema = z.object({ - modelData: SystemModelDocumentDataSchema.meta({ description: '完整的系统模型配置' }) -}); +const CreateSystemModelDataSchema = z + .unknown() + .superRefine((value, ctx) => { + if ( + typeof value === 'object' && + value !== null && + Object.prototype.hasOwnProperty.call(value, 'modelId') + ) { + ctx.addIssue({ + code: 'custom', + path: ['modelId'], + message: 'modelId is not allowed when creating a model' + }); + } + }) + .pipe(SystemModelDocumentDataSchema) + .meta({ description: '不含 modelId 的完整系统模型配置' }); + +export const CreateSystemModelBodySchema = z + .object({ + modelData: CreateSystemModelDataSchema, + channelIds: z.array(IntSchema.positive()).default([]).meta({ + description: '创建前统一绑定的 AI Proxy 渠道;允许为空数组' + }) + }) + .strict(); export type CreateSystemModelBody = z.infer; export const CreateSystemModelResponseSchema = z.object({ modelId: ModelIdSchema }); export type CreateSystemModelResponse = z.infer; +/* ============================================================================ + * API: 从 Plugin 模板批量创建系统模型 + * Route: POST /api/admin/settings/model/createFromTemplates + * Method: POST + * Description: 重新拉取模板并先绑定渠道,再事务级创建尚未安装的模型 + * Tags: ['系统模型管理', 'Write'] + * ============================================================================ */ + +export const CreateSystemModelsFromTemplatesBodySchema = z + .object({ + templates: z + .array(ModelTemplateReferenceSchema) + .min(1) + .max(500) + .superRefine((templates, ctx) => { + const keys = new Set(); + templates.forEach((template, index) => { + const key = template.model; + if (keys.has(key)) { + ctx.addIssue({ + code: 'custom', + path: [index], + message: `Duplicate model template: ${template.model}` + }); + } + keys.add(key); + }); + }) + .meta({ description: '本次选择的模板临时键' }), + channelIds: z.array(IntSchema.positive()).meta({ + example: [1, 2], + description: '统一关联的 AI Proxy 渠道 ID;允许为空数组' + }) + }) + .strict(); +export type CreateSystemModelsFromTemplatesBody = z.infer< + typeof CreateSystemModelsFromTemplatesBodySchema +>; + +export const CreatedSystemModelSchema = ModelTemplateReferenceSchema.extend({ + modelId: ModelIdSchema +}); +export type CreatedSystemModel = z.infer; + +export const CreateSystemModelsFromTemplatesResponseSchema = z.object({ + models: z.array(CreatedSystemModelSchema).meta({ + description: '本次实际新建的模型;已安装的重复项不会再次创建' + }) +}); +export type CreateSystemModelsFromTemplatesResponse = z.infer< + typeof CreateSystemModelsFromTemplatesResponseSchema +>; + +/* ============================================================================ + * API: 替换模型渠道绑定 + * Route: PUT /api/admin/settings/model/channel/replace + * Method: PUT + * Tags: ['系统模型管理', 'Write'] + * ============================================================================ */ + +export const ReplaceSystemModelChannelsBodySchema = z + .object({ + modelId: ModelIdSchema, + channelIds: z.array(IntSchema.positive()).meta({ + description: '替换后的完整渠道 ID 集合;允许为空数组' + }) + }) + .strict(); +export type ReplaceSystemModelChannelsBody = z.infer; + /* ============================================================================ * API: 更新系统模型配置 * Route: PUT /api/admin/settings/model/update * Method: PUT - * Description: 只按 modelId 更新已有系统模型,不执行 upsert 或历史结构修复 - * Tags: ['管理员系统配置', 'Write'] + * Description: 只按 modelId 更新已有系统模型的可编辑参数,模型标识不可修改 + * Tags: ['系统模型管理', 'Write'] * ============================================================================ */ -export const UpdateSystemModelBodySchema = z.object({ - modelId: ModelIdSchema, - modelData: SystemModelDocumentDataSchema.meta({ description: '完整的系统模型配置' }) -}); +export const UpdateSystemModelDataSchema = z + .discriminatedUnion('type', [ + LLMSystemModelDocumentSchema.omit({ model: true }).strict(), + EmbeddingSystemModelDocumentSchema.omit({ model: true }).strict(), + TTSSystemModelDocumentSchema.omit({ model: true }).strict(), + STTSystemModelDocumentSchema.omit({ model: true }).strict(), + RerankSystemModelDocumentSchema.omit({ model: true }).strict() + ]) + .meta({ + description: '不含不可变模型标识的系统模型可编辑参数;type 仅用于分支校验,不参与更新' + }); +export type UpdateSystemModelData = z.infer; + +export const UpdateSystemModelBodySchema = z + .object({ + modelId: ModelIdSchema, + modelData: UpdateSystemModelDataSchema, + channelIds: z.array(IntSchema.positive()).optional().meta({ + description: '可选的完整渠道集合;编辑表单一并提交时,模型配置预检通过后才更新渠道' + }) + }) + .strict(); export type UpdateSystemModelBody = z.infer; +/* ============================================================================ + * API: 批量更新系统模型启停状态 + * Route: PUT /api/admin/settings/model/updateStatus + * Method: PUT + * Description: 按 modelIds 批量启用或停用系统模型 + * Tags: ['系统模型管理', 'Write'] + * ============================================================================ */ + +export const UpdateSystemModelStatusBodySchema = z.object({ + modelIds: ModelIdsSchema, + isActive: z.boolean().meta({ example: true, description: '目标启用状态' }) +}); +export type UpdateSystemModelStatusBody = z.infer; + // 配置 JSON 允许来自其他实例的 ID;导入逻辑只把本实例真实 ObjectId 用作 `_id`,其余按 model 对齐。 const ImportedModelIdField = { modelId: z.string().trim().min(1).meta({ @@ -173,8 +462,8 @@ const JsonSystemModelListSchema = z.string().transform((value, ctx) => { * API: 导入系统模型配置 * Route: PUT /api/admin/settings/model/updateWithJson * Method: PUT - * Description: 忽略无 modelId 的旧记录,按 modelId 更新或按 model 创建外部实例记录 - * Tags: ['管理员系统配置', 'Write'] + * Description: 忽略无 modelId 的旧记录;本实例 modelId 只更新可编辑参数并保留原 model,外部记录按 model 创建或更新 + * Tags: ['系统模型管理', 'Write'] * ============================================================================ */ export const UpdateSystemModelsWithJsonBodySchema = z.object({ @@ -192,7 +481,7 @@ export type ParsedSystemModelsWithJsonBody = z.output(shape: T) = ...ChatTargetInputShape, ...shape }) - .superRefine(refineRequiredChatTargetInput); + .superRefine(refineRequiredChatTargetInput) + .meta(requiredAlternatives([['appId'], ['skillId']], true)); /** * 构造允许缺省 chat target 的对外入参 schema。 @@ -393,7 +395,8 @@ export const createOutLinkChatTargetInputSchema = (shap ...OutLinkChatAuthInputShape, ...shape }) - .superRefine(refineRequiredChatTargetInput); + .superRefine(refineRequiredChatTargetInput) + .meta(requiredAlternatives([['appId'], ['skillId'], ['outLinkAuthData']], true)); /** 构造允许缺省 chat target、且包含外链鉴权字段的入参 schema。 */ export const createOptionalOutLinkChatTargetInputSchema = (shape: T) => diff --git a/packages/global/openapi/core/chat/completion/api.ts b/packages/global/openapi/core/chat/completion/api.ts index 2cdf4bfbcc1b..da7bb3d48259 100644 --- a/packages/global/openapi/core/chat/completion/api.ts +++ b/packages/global/openapi/core/chat/completion/api.ts @@ -1,3 +1,4 @@ +import { requiredAlternatives } from '../../../../common/zod/openapi'; import z from 'zod'; import { ObjectIdSchema } from '../../../../common/type/mongo'; import { ChatCompletionMessageParamSchema } from '../../../../core/ai/llm/type'; @@ -91,6 +92,7 @@ export const ChatCompletionAuthProxySchema = z message: 'authProxy.username or authProxy.tmbId is required' }) .meta({ + ...requiredAlternatives([['username'], ['tmbId']]), description: 'API Key 代理调用身份。仅开启 authProxy 的团队级 API Key 可用,username 与 tmbId 同时传入时必须指向同一团队成员' }); diff --git a/packages/global/openapi/core/chat/record/api.ts b/packages/global/openapi/core/chat/record/api.ts index a149c6a813fb..e9f1d875a54f 100644 --- a/packages/global/openapi/core/chat/record/api.ts +++ b/packages/global/openapi/core/chat/record/api.ts @@ -6,11 +6,7 @@ import { LinkedListResponseSchema, LinkedPaginationSchema, PaginationSchema } fr import { ChatItemMiniSchema } from '../../../../core/chat/type'; import { AppTTSConfigInputSchema } from '../../app/common/api'; import { ChatSourceTypeEnum, GetChatTypeEnum } from '../../../../core/chat/constants'; -import { - createOutLinkChatTargetInputSchema, - refineRequiredChatTargetInput, - transformChatAuthTargetInput -} from '../api'; +import { createOutLinkChatTargetInputSchema, transformChatAuthTargetInput } from '../api'; const GetRecordTypeSchema = z.enum([ GetChatTypeEnum.normal, @@ -169,9 +165,10 @@ const GetRecordPropsSchema = { description: '是否包含已删除的记录' }) }; -export const GetPaginationRecordsBodyRawSchema = PaginationSchema.extend( - createOutLinkChatTargetInputSchema(GetRecordPropsSchema).shape -).superRefine(refineRequiredChatTargetInput); +export const GetPaginationRecordsBodyRawSchema = createOutLinkChatTargetInputSchema({ + ...PaginationSchema.shape, + ...GetRecordPropsSchema +}); export const GetPaginationRecordsBodySchema = GetPaginationRecordsBodyRawSchema.transform( transformChatAuthTargetInput ); @@ -190,9 +187,10 @@ export type GetPaginationRecordsResponseType = z.infer !!data.text.trim() || data.queryImageUrls.length > 0, { message: 'text or queryImageUrls is required' + }) + .meta({ + override: { + anyOf: [ + { + required: ['text'], + properties: { text: { type: 'string', minLength: 1, example: 'FastGPT 是什么' } } + }, + { + required: ['queryImageUrls'], + properties: { + queryImageUrls: { type: 'array', minItems: 1, items: { type: 'string', minLength: 1 } } + } + } + ] + } }); export type SearchDatasetTestBody = z.infer; diff --git a/packages/global/openapi/core/dataset/collection/api.ts b/packages/global/openapi/core/dataset/collection/api.ts index 7345d3aac959..6605b18119e4 100644 --- a/packages/global/openapi/core/dataset/collection/api.ts +++ b/packages/global/openapi/core/dataset/collection/api.ts @@ -1,3 +1,4 @@ +import { requiredAlternatives } from '../../../../common/zod/openapi'; import { GetPathPropsSchema, ParentIdSchema, @@ -38,16 +39,18 @@ export const ScrollCollectionsBodySchema = z.object({ export type ScrollCollectionsBodyType = z.infer; // ============= Update Collection ============= -export const UpdateDatasetCollectionBodySchema = z.object({ - id: ObjectIdSchema.optional().describe('集合ID,与 datasetId+externalFileId 二选一'), - parentId: ParentIdSchema.describe('父级目录ID'), - name: z.string().optional().describe('集合名称'), - tags: z.array(z.string()).optional().describe('标签列表(标签名称,非ID)'), - forbid: z.boolean().optional().describe('是否禁用'), - createTime: z.coerce.date().optional().describe('创建时间'), - datasetId: z.string().optional().describe('数据集ID,配合 externalFileId 使用'), - externalFileId: z.string().optional().describe('外部文件ID,配合 datasetId 使用') -}); +export const UpdateDatasetCollectionBodySchema = z + .object({ + id: ObjectIdSchema.optional().describe('集合ID,与 datasetId+externalFileId 二选一'), + parentId: ParentIdSchema.describe('父级目录ID'), + name: z.string().optional().describe('集合名称'), + tags: z.array(z.string()).optional().describe('标签列表(标签名称,非ID)'), + forbid: z.boolean().optional().describe('是否禁用'), + createTime: z.coerce.date().optional().describe('创建时间'), + datasetId: z.string().optional().describe('数据集ID,配合 externalFileId 使用'), + externalFileId: z.string().optional().describe('外部文件ID,配合 datasetId 使用') + }) + .meta(requiredAlternatives([['id'], ['datasetId', 'externalFileId']])); export type UpdateDatasetCollectionBodyType = z.infer; // ============= Export Collection ============= @@ -82,7 +85,7 @@ const BasicExportSchema = z .meta({ description: '通过身份鉴权导出集合', example: { - collectionId: '1234567890' + collectionId: '68ad85a7463006c963799a05' } }); @@ -106,9 +109,10 @@ const ChatExportRawSchema = createOptionalOutLinkChatTargetInputSchema({ } }) .meta({ + ...requiredAlternatives([['appId'], ['skillId'], ['outLinkAuthData']], true), description: '对话中导出集合,可通过 chatId 等身份信息', example: { - collectionId: '1234567890', + collectionId: '68ad85a7463006c963799a05', chatId: '1234567890', chatItemDataId: '1234567890', chatTime: '2025-12-30T00:00:00.000Z', @@ -129,7 +133,10 @@ export type ExportCollectionRuntimeBodyType = z.infer; diff --git a/packages/global/openapi/core/dataset/collection/index.ts b/packages/global/openapi/core/dataset/collection/index.ts index 00ab9ecd4051..8f9081a5a614 100644 --- a/packages/global/openapi/core/dataset/collection/index.ts +++ b/packages/global/openapi/core/dataset/collection/index.ts @@ -21,6 +21,10 @@ export const DatasetCollectionPath: OpenAPIPath = { ...DatasetCollectionCreatePath, '/core/dataset/collection/delete': { post: { + 'x-required-parameter-alternatives': [ + [{ in: 'query', name: 'id' }], + [{ in: 'body', name: 'collectionIds' }] + ], summary: '删除集合', description: '删除一个或多个集合及其子集合,支持通过 query.id 或 body.collectionIds 指定', tags: [DevApiTagsMap.datasetCollection, SystemOpenApiTagMap.datasetCollection], diff --git a/packages/global/openapi/core/dataset/training/api.ts b/packages/global/openapi/core/dataset/training/api.ts index 97c631c3cf42..5767f74a50f9 100644 --- a/packages/global/openapi/core/dataset/training/api.ts +++ b/packages/global/openapi/core/dataset/training/api.ts @@ -51,6 +51,15 @@ export const UpdateTrainingDataBodySchema = z message: 'collectionId and datasetId cannot be used together without dataId' }); } + }) + .meta({ + override: { + anyOf: [ + { required: ['dataId'] }, + { required: ['datasetId'], not: { required: ['collectionId'] } }, + { required: ['collectionId'], not: { required: ['datasetId'] } } + ] + } }); export type UpdateTrainingDataBody = z.infer; diff --git a/packages/global/openapi/path.ts b/packages/global/openapi/path.ts index 6546c137ac1e..6b34c5ce3c55 100644 --- a/packages/global/openapi/path.ts +++ b/packages/global/openapi/path.ts @@ -170,7 +170,7 @@ export const adminOpenAPIPaths: NonNullable = { export const adminOpenAPITagGroups = [ { name: '管理员-系统接口', - tags: [DevApiTagsMap.adminSystemMigration] + tags: [DevApiTagsMap.adminSystemMigration, DevApiTagsMap.adminSystemModel] }, { name: '管理员-系统概览', diff --git a/packages/global/openapi/provider/devapi.ts b/packages/global/openapi/provider/devapi.ts index d788353240b2..dbbb84466df6 100644 --- a/packages/global/openapi/provider/devapi.ts +++ b/packages/global/openapi/provider/devapi.ts @@ -1,4 +1,5 @@ import { createDocument } from 'zod-openapi'; +import { setRequiredRequestExamples } from '../requiredExamples'; import { openAPIPaths, openAPITagGroups } from '../path'; import { SystemOpenApiTagMap } from '../tag'; import type { OpenAPIPath } from '../type'; @@ -35,14 +36,17 @@ const omitSystemOpenApiTags = (paths: DefinedOpenAPIPath) => { return filteredPaths; }; -export const openAPIDocument = createDocument({ - openapi: '3.1.0', - info: { - title: 'FastGPT Dev API', - version: '0.1.0', - description: 'FastGPT 所有 API 的文档' - }, - paths: omitSystemOpenApiTags(openAPIPaths), - servers: [{ url: '/api' }], - 'x-tagGroups': openAPITagGroups -}); +export const openAPIDocument = setRequiredRequestExamples( + createDocument({ + openapi: '3.1.0', + info: { + title: 'FastGPT Dev API', + version: '0.1.0', + description: 'FastGPT 所有 API 的文档' + }, + paths: omitSystemOpenApiTags(openAPIPaths), + servers: [{ url: '/api' }], + 'x-tagGroups': openAPITagGroups + }), + openAPIPaths +); diff --git a/packages/global/openapi/provider/systemopenapi.ts b/packages/global/openapi/provider/systemopenapi.ts index 05719ff3de10..30cc94f5dd7e 100644 --- a/packages/global/openapi/provider/systemopenapi.ts +++ b/packages/global/openapi/provider/systemopenapi.ts @@ -1,4 +1,5 @@ import { createDocument } from 'zod-openapi'; +import { setRequiredRequestExamples } from '../requiredExamples'; import { SystemOpenApiTagMap } from '../tag'; import { openAPIPaths } from '../path'; import type { OpenAPIPath } from '../type'; @@ -83,26 +84,29 @@ const pickApiKeyPathsByTags = (paths: DefinedOpenAPIPath) => { const apiKeyOpenAPIPaths = pickApiKeyPathsByTags(openAPIPaths); -export const apiDocOpenAPIDocument = createDocument({ - openapi: '3.1.0', - info: { - title: 'FastGPT System OpenAPI', - version: '0.1.0', - description: 'FastGPT 开放 API 文档,仅包含支持 API key 鉴权的接口。' - }, - paths: apiKeyOpenAPIPaths, - servers: [{ url: '/api' }], - components: { - securitySchemes: { - ApiKeyAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'API Key', - description: - '在 Authorization 请求头中传入 Bearer 。除 chat/completions 外,对话相关接口必须在 body/query 中显式传入 appId。chat/completions 推荐传 body.appId;为兼容 OpenAI SDK,也允许 Bearer -,该后缀仅作为传输兼容,不会写入数据库。' +export const apiDocOpenAPIDocument = setRequiredRequestExamples( + createDocument({ + openapi: '3.1.0', + info: { + title: 'FastGPT System OpenAPI', + version: '0.1.0', + description: 'FastGPT 开放 API 文档,仅包含支持 API key 鉴权的接口。' + }, + paths: apiKeyOpenAPIPaths, + servers: [{ url: '/api' }], + components: { + securitySchemes: { + ApiKeyAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'API Key', + description: + '在 Authorization 请求头中传入 Bearer 。除 chat/completions 外,对话相关接口必须在 body/query 中显式传入 appId。chat/completions 推荐传 body.appId;为兼容 OpenAI SDK,也允许 Bearer -,该后缀仅作为传输兼容,不会写入数据库。' + } } - } - }, - security: [{ ApiKeyAuth: [] }], - 'x-tagGroups': tagGroups -}); + }, + security: [{ ApiKeyAuth: [] }], + 'x-tagGroups': tagGroups + }), + apiKeyOpenAPIPaths +); diff --git a/packages/global/openapi/requiredExamples.ts b/packages/global/openapi/requiredExamples.ts new file mode 100644 index 000000000000..d74a629bdeb5 --- /dev/null +++ b/packages/global/openapi/requiredExamples.ts @@ -0,0 +1,164 @@ +import { createSchema, type oas31 } from 'zod-openapi'; +import type { OpenAPIPath } from './type'; +import type { z } from 'zod'; + +/** 生成最小请求体与参数示例;从原始参数 Schema 保留组合选择,完整约束和响应示例不变。 */ +export const setRequiredRequestExamples = ( + document: oas31.OpenAPIObject, + sourcePaths?: OpenAPIPath +) => { + /** 只解析文档内引用;循环引用在当前递归分支终止,避免生成无限嵌套的示例。 */ + const resolve = (value: T | oas31.ReferenceObject): T => { + if (!('$ref' in value)) return value as T; + if (!value.$ref.startsWith('#/')) return {} as T; + return value.$ref + .slice(2) + .split('/') + .reduce((current, key) => { + const decoded = key.replace(/~1/g, '/').replace(/~0/g, '~'); + return current && typeof current === 'object' ? Reflect.get(current, decoded) : undefined; + }, document) as T; + }; + + /** 先合并组合约束,再按 required 递归取值;oneOf/anyOf 仅使用第一个分支。 */ + const sample = ( + input: oas31.SchemaObject | oas31.ReferenceObject, + seed?: unknown, + ancestors = new Set() + ): unknown => { + const schema = resolve(input); + if (!schema || ancestors.has(schema)) return undefined; + const next = new Set(ancestors).add(schema); + const branches = + schema.allOf ?? (schema.oneOf?.[0] ? [schema.oneOf[0]] : schema.anyOf?.slice(0, 1)); + if (branches?.length) { + const { allOf, oneOf, anyOf, ...base } = schema; + const merged = branches.reduce((result, branch) => { + const item = resolve(branch); + return { + ...result, + ...item, + ...((result.properties || item?.properties) && { + properties: { ...result.properties, ...item?.properties } + }), + required: [...new Set([...(result.required ?? []), ...(item?.required ?? [])])] + }; + }, base); + return sample(merged, seed, next); + } + const value = seed ?? schema.example ?? schema.examples?.[0] ?? schema.default; + if (schema.type === 'object' || schema.properties) { + return Object.fromEntries( + (schema.required ?? []).flatMap((key) => { + const property = schema.properties?.[key]; + if (!property) return []; + const resolvedProperty = resolve(property); + // Zod preprocess 包装的默认值可能仍被导出为 required,调用时可省略这些字段。 + if (resolvedProperty?.readOnly || resolvedProperty?.default !== undefined) return []; + const child = sample( + property, + value && typeof value === 'object' ? Reflect.get(value, key) : undefined, + next + ); + return child === undefined ? [] : [[key, child]]; + }) + ); + } + if (schema.type === 'array') { + if (!schema.items) return []; + const values = Array.isArray(value) ? value : []; + return Array.from( + { length: Math.max(schema.minItems ?? 0, values.length || 1) }, + (_, index) => sample(schema.items!, values[index], next) + ).filter((item) => item !== undefined); + } + if (schema.const !== undefined) return schema.const; + if (schema.enum?.length) return schema.enum[0]; + if (value !== undefined) return value; + const type = Array.isArray(schema.type) ? schema.type[0] : schema.type; + if (type === 'null') return null; + if (type === 'boolean') return false; + if (type === 'integer' || type === 'number') return schema.minimum ?? 0; + return 'string'.padEnd(schema.minLength ?? 0, 'x'); + }; + + for (const [url, path] of Object.entries(document.paths ?? {})) { + if (!path) continue; + for (const method of [ + 'get', + 'post', + 'put', + 'patch', + 'delete', + 'options', + 'head', + 'trace' + ] as const) { + const operation = path[method]; + if (!operation) continue; + // OpenAPI parameters 是平铺字段,跨字段约束需从原始 Query/Header 对象保留。 + const source = sourcePaths?.[url]?.[method]; + const selected = new Set(); + for (const location of ['query', 'header', 'path', 'cookie'] as const) { + const input = source?.requestParams?.[location]; + if (!input || !('_zod' in input)) continue; + const generated = createSchema(input as z.ZodType, { io: 'input' }); + // 只取组合分支选中的字段;普通必填字段仍由 parameter.required 控制。 + const seen = new Set(); + const collect = (input?: oas31.SchemaObject | oas31.ReferenceObject) => { + if (!input || seen.has(input)) return; + seen.add(input); + if (typeof input.$ref === 'string') { + const key = input.$ref.split('/').at(-1)!.replace(/~1/g, '/').replace(/~0/g, '~'); + collect(generated.components[key]); + return; + } + const schema = input as oas31.SchemaObject; + for (const key of schema.required ?? []) selected.add(`${location}:${key}`); + for (const child of schema.allOf ?? []) collect(child); + collect(schema.oneOf?.[0]); + collect(schema.anyOf?.[0]); + }; + collect(generated.schema); + } + // Query/Header/Body 跨位置约束无法用单个 JSON Schema 表达,使用 operation 扩展描述。 + const alternatives = operation['x-required-parameter-alternatives'] as + | { in: string; name: string }[][] + | undefined; + for (const field of alternatives?.[0] ?? []) selected.add(`${field.in}:${field.name}`); + for (const input of [...(path.parameters ?? []), ...(operation.parameters ?? [])]) { + const parameter = resolve(input); + if (!parameter) continue; + const enabled = + parameter.required === true || selected.has(`${parameter.in}:${parameter.name}`); + const first = Object.values(parameter.examples ?? {})[0]; + const seed = first ? resolve(first)?.value : parameter.example; + parameter.examples = { + default: { + value: parameter.schema ? sample(parameter.schema, seed) : seed, + 'x-disabled': !enabled + } + }; + delete parameter.example; + } + if (!operation.requestBody) continue; + const body = resolve(operation.requestBody); + for (const media of Object.values(body?.content ?? {})) { + if (!media.schema) continue; + const firstExample = Object.values(media.examples ?? {})[0]; + const seed = firstExample + ? resolve(firstExample)?.value + : media.example; + const bodyFields = [...selected] + .filter((field) => field.startsWith('body:')) + .map((field) => field.slice(5)); + media.example = sample( + bodyFields.length ? { allOf: [media.schema, { required: bodyFields }] } : media.schema, + seed + ); + delete media.examples; + } + } + } + return document; +}; diff --git a/packages/global/openapi/support/mcpServer/api.ts b/packages/global/openapi/support/mcpServer/api.ts index cf276b15e208..d7b3ce487767 100644 --- a/packages/global/openapi/support/mcpServer/api.ts +++ b/packages/global/openapi/support/mcpServer/api.ts @@ -1,3 +1,4 @@ +import { requiredAlternatives } from '../../../common/zod/openapi'; import { z } from 'zod'; import { ObjectIdSchema } from '../../../common/type/mongo'; @@ -199,5 +200,6 @@ export const McpAuthProxySchema = z }) .refine(({ username, tmbId }) => !!username || !!tmbId, { message: 'authProxy.username or authProxy.tmbId is required' - }); + }) + .meta(requiredAlternatives([['username'], ['tmbId']])); export type McpAuthProxyType = z.infer; diff --git a/packages/global/openapi/support/openapi/api.ts b/packages/global/openapi/support/openapi/api.ts index a9a2fba37788..776827b84cc3 100644 --- a/packages/global/openapi/support/openapi/api.ts +++ b/packages/global/openapi/support/openapi/api.ts @@ -1,3 +1,4 @@ +import { requiredAlternatives } from '../../../common/zod/openapi'; import z from 'zod'; import { ObjectIdSchema } from '../../../common/type/mongo'; import { getErrorResponse } from '../../type'; @@ -156,7 +157,8 @@ export const UpdateApiKeyBodySchema = CreateApiKeyBodySchema.partial() { message: 'name, limit, authProxy or tags is required' } - ); + ) + .meta(requiredAlternatives([['name'], ['limit'], ['authProxy'], ['tags']])); export type UpdateApiKeyBodyType = z.infer; export const UpdateApiKeyResponseSchema = z.undefined().meta({ diff --git a/packages/global/openapi/support/openapi/tag.ts b/packages/global/openapi/support/openapi/tag.ts index ea63c728921e..493434053ad0 100644 --- a/packages/global/openapi/support/openapi/tag.ts +++ b/packages/global/openapi/support/openapi/tag.ts @@ -1,3 +1,4 @@ +import { requiredAlternatives } from '../../../common/zod/openapi'; import z from 'zod'; import { ObjectIdSchema } from '../../../common/type/mongo'; import { BoolSchema, IntSchema } from '../../../common/zod'; @@ -110,7 +111,8 @@ export const UpdateOpenApiTagBodySchema = z }) .refine(({ name, order }) => name !== undefined || order !== undefined, { message: 'name or order is required' - }); + }) + .meta(requiredAlternatives([['name'], ['order']])); export type UpdateOpenApiTagBodyType = z.infer; export const UpdateOpenApiTagResponseSchema = z.undefined().meta({ diff --git a/packages/global/openapi/support/user/team/collaborator/api.ts b/packages/global/openapi/support/user/team/collaborator/api.ts index 505a36fd53b4..459708c57303 100644 --- a/packages/global/openapi/support/user/team/collaborator/api.ts +++ b/packages/global/openapi/support/user/team/collaborator/api.ts @@ -1,3 +1,4 @@ +import { requiredAlternatives } from '../../../../../common/zod/openapi'; import z from 'zod'; import { CollaboratorListSchema, @@ -30,6 +31,7 @@ const TeamCollaboratorTargetDescription = * ============================================================================ */ export const DeleteTeamCollaboratorQuerySchema = CollaboratorTargetSchema.meta({ + ...requiredAlternatives([['tmbId'], ['groupId'], ['orgId']], true), description: TeamCollaboratorTargetDescription }); export type DeleteTeamCollaboratorQueryType = z.infer; @@ -74,6 +76,7 @@ export const UpdateTeamCollaboratorOneBodySchema = CollaboratorTargetSchema.safe description: '权限角色值' }) }).meta({ + ...requiredAlternatives([['tmbId'], ['groupId'], ['orgId']], true), description: TeamCollaboratorTargetDescription, example: { tmbId: '68ad85a7463006c963799a06', diff --git a/packages/global/openapi/tag.ts b/packages/global/openapi/tag.ts index 673c09971892..71ce88916f27 100644 --- a/packages/global/openapi/tag.ts +++ b/packages/global/openapi/tag.ts @@ -117,6 +117,7 @@ export const DevApiTagsMap = { adminSettings: '系统配置', adminLogs: '系统日志', adminSystemMigration: '升级脚本', + adminSystemModel: '系统模型管理', adminLicense: '许可证管理', adminTemplate: '模板管理', adminTemplateType: '模板类型管理', diff --git a/packages/global/support/permission/collaborator.schema.ts b/packages/global/support/permission/collaborator.schema.ts index 36702a41a459..fa8b1bdb4604 100644 --- a/packages/global/support/permission/collaborator.schema.ts +++ b/packages/global/support/permission/collaborator.schema.ts @@ -1,3 +1,4 @@ +import { requiredAlternatives } from '../../common/zod/openapi'; import z from 'zod'; import { ObjectIdSchema } from '../../common/type/mongo'; import { PermissionSchema } from './controller'; @@ -27,7 +28,10 @@ export const CollaboratorTargetSchema = z .refine(({ tmbId, groupId, orgId }) => [tmbId, groupId, orgId].filter(Boolean).length === 1, { message: 'tmbId, groupId or orgId is required, and only one can be provided' }) - .meta({ description: '协作者目标;团队成员、成员组和组织节点必须且只能指定一个' }); + .meta({ + ...requiredAlternatives([['tmbId'], ['groupId'], ['orgId']], true), + description: '协作者目标;团队成员、成员组和组织节点必须且只能指定一个' + }); export type CollaboratorTargetType = z.infer; export const CollaboratorItemSchema = CollaboratorTargetSchema.safeExtend({ @@ -36,6 +40,7 @@ export const CollaboratorItemSchema = CollaboratorTargetSchema.safeExtend({ description: '权限角色值' }) }).meta({ + ...requiredAlternatives([['tmbId'], ['groupId'], ['orgId']], true), description: '协作者权限配置' }) as z.ZodType; diff --git a/packages/global/support/tmpData/constants.ts b/packages/global/support/tmpData/constants.ts index cead9a152200..67ba77c5d6a2 100644 --- a/packages/global/support/tmpData/constants.ts +++ b/packages/global/support/tmpData/constants.ts @@ -37,6 +37,7 @@ type _TmpDataType = { tmbId: string; modelIds: string[]; version: string; + catalogRevision?: number; }; }; diff --git a/packages/global/test/core/ai/pricing.test.ts b/packages/global/test/core/ai/pricing.test.ts index 7309334881b0..1785c79228a8 100644 --- a/packages/global/test/core/ai/pricing.test.ts +++ b/packages/global/test/core/ai/pricing.test.ts @@ -28,7 +28,7 @@ describe('sanitizeModelPriceTiers', () => { ]); }); - it('should push first tier even without prices', () => { + it('should normalize a single tier without prices', () => { // @ts-ignore const result = sanitizeModelPriceTiers([{ maxInputTokens: 10 }]); expect(result).toEqual([ @@ -36,6 +36,20 @@ describe('sanitizeModelPriceTiers', () => { ]); }); + it('should normalize a single tier when both prices are zero', () => { + const result = sanitizeModelPriceTiers([{ maxInputTokens: 10, inputPrice: 0, outputPrice: 0 }]); + expect(result).toEqual([ + { minInputTokens: 0, maxInputTokens: 10, inputPrice: 0, outputPrice: 0 } + ]); + }); + + it('should preserve a single tier when either price is non-zero', () => { + const result = sanitizeModelPriceTiers([{ inputPrice: 0, outputPrice: 2 }]); + expect(result).toEqual([ + { minInputTokens: 0, maxInputTokens: undefined, inputPrice: 0, outputPrice: 2 } + ]); + }); + it('should drop incomplete trailing rows without prices', () => { const result = sanitizeModelPriceTiers([ { maxInputTokens: 30.8, inputPrice: 1, outputPrice: 2 }, @@ -163,6 +177,13 @@ describe('sanitizeModelPriceTiers', () => { }); describe('getRuntimeResolvedPriceTiers', () => { + it('should return no tier for a single configured tier with zero prices', () => { + const result = getRuntimeResolvedPriceTiers({ + priceTiers: [{ maxInputTokens: 10, inputPrice: 0, outputPrice: 0 }] + }); + expect(result).toEqual([]); + }); + it('should resolve ranges from configured tiers', () => { const result = getRuntimeResolvedPriceTiers({ priceTiers: [ @@ -193,6 +214,28 @@ describe('getRuntimeResolvedPriceTiers', () => { expect(result).toEqual([{ minInputTokens: 0, inputPrice: 2, outputPrice: 2 }]); }); + it('should progressively fall back from empty tiers and zero legacy IO to comprehensive price', () => { + const result = getRuntimeResolvedPriceTiers({ + priceTiers: [{ inputPrice: 0, outputPrice: 0 }], + inputPrice: 0, + outputPrice: 0, + charsPointsPrice: 2 + }); + + expect(result).toEqual([{ minInputTokens: 0, inputPrice: 2, outputPrice: 2 }]); + }); + + it('should progressively fall back when the configured tiers array is empty', () => { + const result = getRuntimeResolvedPriceTiers({ + priceTiers: [], + inputPrice: 1, + outputPrice: 3, + charsPointsPrice: 9 + }); + + expect(result).toEqual([{ minInputTokens: 0, inputPrice: 1, outputPrice: 3 }]); + }); + it('should prioritize priceTiers over legacy fields', () => { const result = getRuntimeResolvedPriceTiers({ charsPointsPrice: 10, @@ -221,34 +264,27 @@ describe('getRuntimeResolvedPriceTiers', () => { ]); }); - it('should return default tier for undefined config', () => { - // undefined config 会走 charsPointsPrice 逻辑,返回默认梯度 - expect(getRuntimeResolvedPriceTiers(undefined)).toEqual([ - { minInputTokens: 0, inputPrice: 0, outputPrice: 0 } - ]); + it('should return no tier for undefined config', () => { + expect(getRuntimeResolvedPriceTiers(undefined)).toEqual([]); }); - it('should return default tier for empty object config', () => { - // 空对象 config 会走 charsPointsPrice 逻辑,返回默认梯度 - expect(getRuntimeResolvedPriceTiers({})).toEqual([ - { minInputTokens: 0, inputPrice: 0, outputPrice: 0 } - ]); + it('should return no tier for empty object config', () => { + expect(getRuntimeResolvedPriceTiers({})).toEqual([]); }); - it('should handle inputPrice of 0 (not use legacy mode)', () => { + it('should preserve legacy pricing when only outputPrice is non-zero', () => { const result = getRuntimeResolvedPriceTiers({ inputPrice: 0, outputPrice: 5 }); - // inputPrice 为 0,不满足 hasLegacyIOPrice 条件,走 charsPointsPrice 逻辑 - expect(result).toEqual([{ minInputTokens: 0, inputPrice: 0, outputPrice: 0 }]); + expect(result).toEqual([{ minInputTokens: 0, inputPrice: 0, outputPrice: 5 }]); }); - it('should handle charsPointsPrice of 0', () => { + it('should drop charsPointsPrice of 0', () => { const result = getRuntimeResolvedPriceTiers({ charsPointsPrice: 0 }); - expect(result).toEqual([{ minInputTokens: 0, inputPrice: 0, outputPrice: 0 }]); + expect(result).toEqual([]); }); it('should handle invalid price types', () => { @@ -258,7 +294,7 @@ describe('getRuntimeResolvedPriceTiers', () => { // @ts-ignore outputPrice: NaN }); - expect(result).toEqual([{ minInputTokens: 0, inputPrice: 0, outputPrice: 0 }]); + expect(result).toEqual([]); }); it('should handle empty priceTiers array', () => { @@ -466,10 +502,9 @@ describe('calculateModelPrice', () => { inputTokens: 1000, outputTokens: 500 }); - // undefined config 会返回默认梯度 expect(totalPoints).toBe(0); - expect(matchedTier).toEqual({ minInputTokens: 0, inputPrice: 0, outputPrice: 0 }); - expect(tiers).toEqual([{ minInputTokens: 0, inputPrice: 0, outputPrice: 0 }]); + expect(matchedTier).toBeUndefined(); + expect(tiers).toEqual([]); }); it('should handle empty config', () => { @@ -479,8 +514,8 @@ describe('calculateModelPrice', () => { outputTokens: 500 }); expect(totalPoints).toBe(0); - expect(matchedTier).toBeDefined(); - expect(tiers.length).toBeGreaterThan(0); + expect(matchedTier).toBeUndefined(); + expect(tiers).toEqual([]); }); it('should handle negative tokens gracefully', () => { diff --git a/packages/global/test/core/dataset/training/utils.test.ts b/packages/global/test/core/dataset/training/utils.test.ts index 8e1cd9dcfdd3..42d16c8b6f05 100644 --- a/packages/global/test/core/dataset/training/utils.test.ts +++ b/packages/global/test/core/dataset/training/utils.test.ts @@ -37,7 +37,6 @@ const createMockLLMModel = ( name: 'Test Model', modelId: '68ad85a7463006c963799a01', scope: 'system' as const, - isCustom: false, config: { maxContext: 8000, maxResponse: 2000, @@ -58,7 +57,6 @@ const createMockEmbeddingModel = ( name: 'Test Embedding', modelId: '68ad85a7463006c963799a02', scope: 'system' as const, - isCustom: false, config: { defaultToken: 512, maxToken: 1024, diff --git a/packages/global/test/openapi/admin/common/system/migrations.test.ts b/packages/global/test/openapi/admin/common/system/migrations.test.ts index 1962f8726cbc..ee994301d54c 100644 --- a/packages/global/test/openapi/admin/common/system/migrations.test.ts +++ b/packages/global/test/openapi/admin/common/system/migrations.test.ts @@ -35,7 +35,7 @@ describe('system migration Admin OpenAPI contracts', () => { it('places the migration tag in its dedicated Admin group', () => { expect(adminOpenAPITagGroups).toContainEqual({ name: '管理员-系统接口', - tags: [DevApiTagsMap.adminSystemMigration] + tags: [DevApiTagsMap.adminSystemMigration, DevApiTagsMap.adminSystemModel] }); expect(openAPITagGroups).toContainEqual({ name: '管理员-系统接口', diff --git a/packages/global/test/openapi/admin/core/model.test.ts b/packages/global/test/openapi/admin/core/model.test.ts index 0dc70639179a..79f9d799a839 100644 --- a/packages/global/test/openapi/admin/core/model.test.ts +++ b/packages/global/test/openapi/admin/core/model.test.ts @@ -2,10 +2,18 @@ import { describe, expect, it } from 'vitest'; import { createDocument } from 'zod-openapi'; import { AdminSystemModelReferenceSchema, + CreateSystemModelBodySchema, + CreateSystemModelsFromTemplatesBodySchema, + DeleteSystemModelsBodySchema, ImportedSystemModelSchema, - TestAdminSystemModelQuerySchema + ReplaceSystemModelChannelsBodySchema, + TestAdminSystemModelQuerySchema, + UpdateSystemModelBodySchema, + UpdateSystemModelStatusBodySchema } from '../../../../openapi/admin/core/ai/model/api'; import { AdminSystemModelPath } from '../../../../openapi/admin/core/ai/model'; +import { adminOpenAPITagGroups, adminOpenAPIPaths } from '../../../../openapi/path'; +import { DevApiTagsMap } from '../../../../openapi/tag'; describe('admin system model API schemas', () => { it('only accepts modelId as a model reference', () => { @@ -31,6 +39,112 @@ describe('admin system model API schemas', () => { ).not.toThrow(); }); + it('validates unique model IDs for batch status and delete operations', () => { + const modelIds = ['68ad85a7463006c963799a05', '68ad85a7463006c963799a06']; + + expect(DeleteSystemModelsBodySchema.parse({ modelIds })).toEqual({ modelIds }); + expect(UpdateSystemModelStatusBodySchema.parse({ modelIds, isActive: false })).toEqual({ + modelIds, + isActive: false + }); + expect(() => + DeleteSystemModelsBodySchema.parse({ modelIds: [modelIds[0], modelIds[0]] }) + ).toThrow('modelIds must be unique'); + }); + + it('enforces the batch model ID boundaries', () => { + const modelIds = Array.from({ length: 501 }, (_, index) => + (index + 1).toString(16).padStart(24, '0') + ); + + expect( + DeleteSystemModelsBodySchema.parse({ modelIds: modelIds.slice(0, 500) }).modelIds + ).toHaveLength(500); + expect(() => DeleteSystemModelsBodySchema.parse({ modelIds })).toThrow(); + expect(() => + UpdateSystemModelStatusBodySchema.parse({ modelIds: [], isActive: true }) + ).toThrow(); + }); + + it('enforces the template creation batch boundary', () => { + const templates = Array.from({ length: 501 }, (_, index) => ({ + type: 'llm' as const, + model: `model-${index}` + })); + + expect( + CreateSystemModelsFromTemplatesBodySchema.parse({ + templates: templates.slice(0, 500), + channelIds: [] + }).templates + ).toHaveLength(500); + expect(() => + CreateSystemModelsFromTemplatesBodySchema.parse({ templates, channelIds: [] }) + ).toThrow(); + }); + + it('rejects generated model IDs and invalid channel IDs at write boundaries', () => { + const modelData = { + type: 'llm' as const, + provider: 'OpenAI', + model: 'gpt-new', + name: 'GPT New', + scope: 'system' as const, + isActive: false, + config: { maxContext: 16000, maxResponse: 8000, quoteMaxToken: 12000 } + }; + + expect(() => + CreateSystemModelBodySchema.parse({ + modelData: { ...modelData, modelId: '68ad85a7463006c963799a05' }, + channelIds: [] + }) + ).toThrow('modelId is not allowed when creating a model'); + expect(() => CreateSystemModelBodySchema.parse({ modelData, channelIds: [0] })).toThrow(); + expect(() => + ReplaceSystemModelChannelsBodySchema.parse({ + modelId: '68ad85a7463006c963799a05', + channelIds: [-1] + }) + ).toThrow(); + }); + + it('does not include the immutable model identifier in update data', () => { + const modelId = '68ad85a7463006c963799a05'; + const modelData = { + type: 'llm' as const, + provider: 'OpenAI', + name: 'GPT', + scope: 'system' as const, + config: { maxContext: 16000, maxResponse: 8000, quoteMaxToken: 12000 } + }; + + expect(UpdateSystemModelBodySchema.parse({ modelId, modelData })).toEqual({ + modelId, + modelData + }); + expect(() => + UpdateSystemModelBodySchema.parse({ + modelId, + modelData: { ...modelData, model: 'renamed-model' } + }) + ).toThrow(); + }); + + it('places every admin model route in the system model management group', () => { + expect(adminOpenAPITagGroups).toContainEqual({ + name: '管理员-系统接口', + tags: [DevApiTagsMap.adminSystemMigration, DevApiTagsMap.adminSystemModel] + }); + + for (const [path, operations] of Object.entries(AdminSystemModelPath)) { + expect(adminOpenAPIPaths[path]).toBe(operations); + for (const operation of Object.values(operations ?? {})) { + expect(operation?.tags).toEqual([DevApiTagsMap.adminSystemModel]); + } + } + }); + it('keeps type-specific config fields without strict-mode import failures', () => { const parsed = ImportedSystemModelSchema.parse({ modelId: '68ad85a7463006c963799a05', diff --git a/packages/global/test/openapi/common/system.test.ts b/packages/global/test/openapi/common/system.test.ts index 1e7e118b5272..2474081443cd 100644 --- a/packages/global/test/openapi/common/system.test.ts +++ b/packages/global/test/openapi/common/system.test.ts @@ -15,7 +15,6 @@ const desensitizedEmbeddingModel = { model: 'text-embedding-3-small', name: 'Embedding-2', scope: 'system' as const, - isCustom: false, config: { defaultToken: 500, maxToken: 3000 @@ -142,7 +141,6 @@ describe('system initialization OpenAPI contract', () => { model: 'gpt-5', name: 'GPT-5', scope: 'system', - isCustom: false, config: { maxContext: 128000, maxResponse: 16000, diff --git a/packages/global/test/openapi/requiredExamples.test.ts b/packages/global/test/openapi/requiredExamples.test.ts new file mode 100644 index 000000000000..6f93dfa185f2 --- /dev/null +++ b/packages/global/test/openapi/requiredExamples.test.ts @@ -0,0 +1,185 @@ +import { openAPIDocument } from '../../openapi/provider/devapi'; +import { CollaboratorItemSchema } from '../../support/permission/collaborator.schema'; +import { SearchDatasetTestBodySchema } from '../../openapi/core/dataset/api'; +import { UpdateTrainingDataBodySchema } from '../../openapi/core/dataset/training/api'; +import { + UpdateDatasetCollectionBodySchema, + ExportCollectionBodyRawSchema +} from '../../openapi/core/dataset/collection/api'; +import { UpdateApiKeyBodySchema } from '../../openapi/support/openapi/api'; +import { UpdateOpenApiTagBodySchema } from '../../openapi/support/openapi/tag'; +import { McpAuthProxySchema } from '../../openapi/support/mcpServer/api'; +import { ChatCompletionAuthProxySchema } from '../../openapi/core/chat/completion/api'; +import { + GetPaginationRecordsBodyRawSchema, + GetRecordsV2BodyRawSchema +} from '../../openapi/core/chat/record/api'; +import { describe, expect, it } from 'vitest'; +import { createDocument } from 'zod-openapi'; +import z from 'zod'; +import { setRequiredRequestExamples } from '../../openapi/requiredExamples'; +import { + createChatTargetInputSchema, + createOutLinkChatTargetInputSchema, + createOptionalChatTargetInputSchema +} from '../../openapi/core/chat/api'; + +/** 用真实 zod-openapi 输出验证示例处理,确保文档约束和响应不被修改。 */ +const build = (schema: z.ZodType) => { + const document = createDocument({ + openapi: '3.1.0', + info: { title: 'test', version: '1' }, + paths: { + '/test': { + post: { + requestBody: { content: { 'application/json': { schema } } }, + responses: { '200': { description: 'ok' } } + } + } + } + }); + const original = structuredClone(document); + setRequiredRequestExamples(document); + const body = document.paths!['/test']!.post!.requestBody; + if (!body || '$ref' in body) throw new Error('Missing request body'); + const media = body.content['application/json']; + expect(media.schema).toEqual( + (original.paths!['/test']!.post!.requestBody as typeof body).content['application/json'].schema + ); + expect(document.paths!['/test']!.post!.responses).toEqual( + original.paths!['/test']!.post!.responses + ); + return media.example; +}; + +describe('setRequiredRequestExamples', () => { + it.each([createChatTargetInputSchema, createOutLinkChatTargetInputSchema])( + 'generates only appId for a required mutually exclusive chat target', + (createSchema) => { + const schema = createSchema({ chatId: z.string().meta({ example: 'chat' }) }); + const example = build(schema); + expect(example).toEqual({ appId: '68ad85a7463006c963799a05', chatId: 'chat' }); + expect(schema.safeParse(example).success).toBe(true); + expect( + schema.safeParse({ ...schema.parse(example), skillId: '68ad85a7463006c963799a06' }).success + ).toBe(false); + } + ); + + it('does not select a target when the entire choice is optional', () => { + expect(build(createOptionalChatTargetInputSchema({}))).toEqual({}); + }); + it('omits defaults wrapped in preprocess even when exported as required', () => { + expect( + build( + z.object({ + id: z.string().meta({ example: 'id' }), + stream: z.preprocess((value) => value ?? undefined, z.boolean().default(false)) + }) + ) + ).toEqual({ id: 'id' }); + }); + it('keeps primitive union branches as primitive values', () => { + expect(build(z.object({ value: z.union([z.string(), z.array(z.string())]) }))).toEqual({ + value: 'string' + }); + }); + it('omits optional fields recursively even when explicitly included in examples', () => { + expect( + build( + z + .object({ + id: z.string().meta({ example: 'id' }), + optional: z.string().optional().meta({ example: 'omit' }), + nested: z.object({ required: z.boolean(), optional: z.string().optional() }), + rows: z.array(z.object({ id: z.number(), optional: z.string().optional() })) + }) + .meta({ + example: { + id: 'supplied', + optional: 'omit', + nested: { required: true, optional: 'omit' }, + rows: [{ id: 3, optional: 'omit' }] + } + }) + ) + ).toEqual({ id: 'supplied', nested: { required: true }, rows: [{ id: 3 }] }); + }); + + it('uses the first union branch and enum value', () => { + expect( + build( + z.union([ + z.object({ + kind: z.literal('first'), + value: z.enum(['a', 'b']), + optional: z.string().optional() + }), + z.object({ kind: z.literal('second'), other: z.string() }) + ]) + ) + ).toEqual({ kind: 'first', value: 'a' }); + }); + + it('merges required fields from intersections', () => { + expect( + build( + z.intersection( + z.object({ left: z.string().meta({ example: 'left' }), optional: z.string().optional() }), + z.object({ right: z.number().meta({ example: 1 }) }) + ) + ) + ).toEqual({ left: 'left', right: 1 }); + }); + + it('keeps an empty body when every field is optional', () => { + expect(build(z.object({ optional: z.string().default('omit') }))).toEqual({}); + }); +}); + +describe('conditional required API examples', () => { + it.each([ + ['collaborator', CollaboratorItemSchema, 'tmbId'], + ['search', SearchDatasetTestBodySchema, 'text'], + ['training', UpdateTrainingDataBodySchema, 'dataId'], + ['collection update', UpdateDatasetCollectionBodySchema, 'id'], + ['collection export', ExportCollectionBodyRawSchema, 'appId'], + ['API key update', UpdateApiKeyBodySchema, 'name'], + ['API key tag update', UpdateOpenApiTagBodySchema, 'name'], + ['MCP proxy', McpAuthProxySchema, 'username'], + ['chat proxy', ChatCompletionAuthProxySchema, 'username'], + ['pagination records', GetPaginationRecordsBodyRawSchema, 'appId'], + ['records v2', GetRecordsV2BodyRawSchema, 'appId'] + ] as const)('produces a valid minimal example for %s', (_, schema, field) => { + const example = build(schema); + expect(example).toHaveProperty(field); + expect(schema.safeParse(example).success).toBe(true); + }); + + it.each([ + ['/core/chat/history/clearHistories', 'delete', ['appId']], + ['/core/chat/history/delHistory', 'delete', ['chatId']], + ['/core/app/tool/getPreviewNode', 'get', ['appId', 'versionId']], + ['/proApi/support/user/team/collaborator/delete', 'delete', ['tmbId']], + ['/core/dataset/collection/delete', 'post', ['id']], + ['/core/ai/sandbox/verifyTicket', 'get', ['ticket', 'x-proxy-token']] + ] as const)('enables only the first parameter alternative for %s', (path, method, expected) => { + const operation = openAPIDocument.paths![path]![method]!; + const parameters = operation.parameters!.map((parameter) => { + if ('$ref' in parameter) throw new Error('Unexpected ref'); + return parameter; + }); + const enabled = parameters.filter((parameter) => { + const example = parameter.examples!.default; + if ('$ref' in example) throw new Error('Unexpected ref'); + return example['x-disabled'] === false; + }); + expect(enabled.map((parameter) => parameter.name).sort()).toEqual([...expected].sort()); + // A choice remains optional individually; only its example is selected. + if (path !== '/core/app/tool/getPreviewNode' && path !== '/core/chat/history/delHistory') { + expect(parameters.find((parameter) => parameter.name === expected[0])?.required).not.toBe( + true + ); + } + }); +}); diff --git a/packages/service/common/mongo/sessionRun.ts b/packages/service/common/mongo/sessionRun.ts index f2912e758fb2..3498b112b11b 100644 --- a/packages/service/common/mongo/sessionRun.ts +++ b/packages/service/common/mongo/sessionRun.ts @@ -23,7 +23,10 @@ export class MongoTransactionConflictError extends Error { * driver 会因 TransientTransactionError 重跑事务回调,并会单独处理 * UnknownTransactionCommitResult;ACL 写入冲突会用新 session 重试,业务错误保持原样抛出。 */ -export const mongoSessionRun = async (fn: (session: ClientSession) => Promise) => { +export const mongoSessionRun = async ( + fn: (session: ClientSession) => Promise, + options?: Parameters[1] +) => { let conflictRetries = 0; while (true) { @@ -31,7 +34,8 @@ export const mongoSessionRun = async (fn: (session: ClientSession) try { return await session.withTransaction(() => fn(session), { - maxCommitTimeMS: timeout + maxCommitTimeMS: timeout, + ...options }); } catch (error) { if (error instanceof MongoTransactionConflictError && conflictRetries < maxConflictRetries) { diff --git a/packages/service/core/ai/config.ts b/packages/service/core/ai/config.ts index ca3900a2a716..c103840ee12f 100644 --- a/packages/service/core/ai/config.ts +++ b/packages/service/core/ai/config.ts @@ -2,13 +2,8 @@ import OpenAI from '@fastgpt/global/core/ai'; import { type OpenaiAccountType } from '@fastgpt/global/support/user/team/type'; import { serviceEnv } from '../../env'; -const aiProxyBaseUrl = serviceEnv.AIPROXY_API_ENDPOINT - ? `${serviceEnv.AIPROXY_API_ENDPOINT}/v1` - : undefined; -export const openaiBaseUrl = aiProxyBaseUrl || serviceEnv.OPENAI_BASE_URL; -export const openaiBaseKey = aiProxyBaseUrl - ? serviceEnv.AIPROXY_API_TOKEN || serviceEnv.CHAT_API_KEY - : serviceEnv.CHAT_API_KEY; +export const openaiBaseUrl = `${serviceEnv.AIPROXY_API_ENDPOINT}/v1`; +export const openaiBaseKey = serviceEnv.AIPROXY_API_TOKEN; export const defaultUserOpenAIBaseUrl = 'https://api.openai.com/v1'; export type AIApiRequestMeta = { @@ -30,8 +25,8 @@ export const getAIApi = (props?: { userKey?: OpenaiAccountType; timeout?: number const { userKey, timeout } = props || {}; const userOpenAIAccount = getUserOpenAIAccount(userKey); - const baseUrl = userOpenAIAccount?.baseUrl || global?.systemEnv?.oneapiUrl || openaiBaseUrl; - const apiKey = userOpenAIAccount?.key || global?.systemEnv?.chatApiKey || openaiBaseKey; + const baseUrl = userOpenAIAccount?.baseUrl || openaiBaseUrl; + const apiKey = userOpenAIAccount?.key || openaiBaseKey; return { ai: new OpenAI({ @@ -51,8 +46,8 @@ export const getAxiosConfig = (props?: { userKey?: OpenaiAccountType }) => { const { userKey } = props || {}; const userOpenAIAccount = getUserOpenAIAccount(userKey); - const baseUrl = userOpenAIAccount?.baseUrl || global?.systemEnv?.oneapiUrl || openaiBaseUrl; - const apiKey = userOpenAIAccount?.key || global?.systemEnv?.chatApiKey || openaiBaseKey; + const baseUrl = userOpenAIAccount?.baseUrl || openaiBaseUrl; + const apiKey = userOpenAIAccount?.key || openaiBaseKey; return { baseUrl, diff --git a/packages/service/core/ai/config/entity.ts b/packages/service/core/ai/config/entity.ts new file mode 100644 index 000000000000..2830247ac10d --- /dev/null +++ b/packages/service/core/ai/config/entity.ts @@ -0,0 +1,52 @@ +import { ModelScopeEnum } from '@fastgpt/global/core/ai/constants'; +import type { ClientSession } from '../../../common/mongo'; +import { mongoSessionRun } from '../../../common/mongo/sessionRun'; +import { MongoAIDefaultModel } from '../defaultModel/schema'; +import { ModelDefaultIdsSchema } from '@fastgpt/global/core/ai/defaultModel'; +import { MongoAIModel } from './schema'; + +/** 目录修订号与模型写入使用同一事务,避免数据成功但失效通知丢失。外部 I/O 不得放入回调。 */ +export const runSystemModelTransaction = (write: (session: ClientSession) => Promise) => + mongoSessionRun( + async (session) => { + // 所有目录写入先竞争同一文档,事务重试后重新执行依赖当前模型状态的校验。 + await MongoAIDefaultModel.updateOne( + { scope: ModelScopeEnum.system }, + { $inc: { catalogRevision: 1 }, $setOnInsert: { defaultModelIds: {} } }, + { upsert: true, session } + ); + return write(session); + }, + { readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' } } + ); + +/** 主节点上的权威修订号;线性化读取失败时不能把旧进程缓存当成最新目录。 */ +export const readSystemModelRevision = async () => { + const record = await MongoAIDefaultModel.findOne({ scope: ModelScopeEnum.system }) + .select({ catalogRevision: 1 }) + .read('primary') + .readConcern('linearizable') + .maxTimeMS(10000) + .lean(); + return record?.catalogRevision ?? 0; +}; + +/** 模型、默认配置和修订号必须属于同一个快照,不能将新版本号标记到旧数据上。 */ +export const readSystemModelSnapshot = () => + mongoSessionRun( + async (session) => { + const defaults = await MongoAIDefaultModel.findOne({ scope: ModelScopeEnum.system }) + .session(session) + .lean(); + const models = await MongoAIModel.find({ scope: ModelScopeEnum.system }) + .sort({ _id: -1 }) + .session(session) + .lean(); + return { + models, + defaultModelIds: ModelDefaultIdsSchema.parse(defaults?.defaultModelIds ?? {}), + revision: defaults?.catalogRevision ?? 0 + }; + }, + { readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' } } + ); diff --git a/packages/service/core/ai/config/handle.ts b/packages/service/core/ai/config/handle.ts new file mode 100644 index 000000000000..1bb7c082f692 --- /dev/null +++ b/packages/service/core/ai/config/handle.ts @@ -0,0 +1,152 @@ +import { assertModelAvailable } from '../utils'; +import { cloneDeep } from 'lodash-es'; +import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import type { + EmbeddingSystemModelDataType, + LLMSystemModelDataType, + ModelReferenceType, + RerankSystemModelDataType, + STTSystemModelDataType, + SystemModelDataType, + TTSSystemModelDataType +} from '@fastgpt/global/core/ai/model.schema'; +import type { ModelDefaultIds } from '@fastgpt/global/core/ai/defaultModel'; +import type { SystemDefaultModelType } from '../type'; +import { ModelErrEnum } from '@fastgpt/global/common/error/code/model'; +import { UserError } from '@fastgpt/global/common/error/utils'; +import { getModelReferenceValue, isEmptyModelValue } from '@fastgpt/global/core/ai/modelReference'; + +type ModelSnapshot = { + models: SystemModelDataType[]; + defaultModels: DefaultModelsBySlot; + configuredDefaultModelIds: ModelDefaultIds; + revision: number; + version: string; +}; +type OptionalResult = O extends true ? T | undefined : T; +type ModelLookupResult = { model: T; error: undefined } | { model: undefined; error: UserError }; +type DefaultModelsBySlot = { + [S in keyof SystemDefaultModelType as `${S}`]: SystemDefaultModelType[S]; +}; +type DefaultSlot = keyof DefaultModelsBySlot; +type DefaultResult = S extends 'datasetImageLLM' | 'chatTitleLLM' + ? DefaultModelsBySlot[S] + : NonNullable; + +/** + * 仅在发布目录时构造 handle。复制并冻结整个快照,方法闭包不再访问全局状态; + * 更新缓存指针不会改变已发出 handle 的模型、默认值或版本。Map 留在闭包内,不能被调用方修改。 + */ +export const createModelHandle = (input: ModelSnapshot) => { + // 快照只包含已校验的 JSON 配置。递归冻结防止请求临时配置污染其他消费者。 + const freeze = (value: T): T => { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.values(value).forEach(freeze); + Object.freeze(value); + } + return value; + }; + const snapshot = freeze(cloneDeep(input)); + const modelsById = new Map(snapshot.models.map((model) => [model.modelId, model])); + const modelsByName = new Map(snapshot.models.map((model) => [model.model, model])); + const activeModels = freeze(snapshot.models.filter((model) => model.isActive)); + const defaultIds = freeze({ + llm: snapshot.defaultModels.llm?.modelId, + embedding: snapshot.defaultModels.embedding?.modelId, + rerank: snapshot.defaultModels.rerank?.modelId, + tts: snapshot.defaultModels.tts?.modelId, + stt: snapshot.defaultModels.stt?.modelId + }); + + /** modelId 一旦出现就禁止按旧名称回退;展示名不作为模型身份。 */ + const resolve = (reference: ModelReferenceType) => { + if (!isEmptyModelValue(reference.modelId)) return modelsById.get(reference.modelId!); + if (!isEmptyModelValue(reference.model)) return modelsByName.get(reference.model!); + }; + const typedGetter = + (type: T['type'], vision = false) => + ( + reference: ModelReferenceType, + options?: { optional?: O } + ): OptionalResult => { + if (isEmptyModelValue(getModelReferenceValue(reference))) { + if (options?.optional) return undefined as OptionalResult; + throw new UserError(ModelErrEnum.unConfigured); + } + const model = resolve(reference); + assertModelAvailable({ model, type, vision }); + return model as OptionalResult; + }; + + const getVlmModelData = typedGetter(ModelTypeEnum.llm, true); + + return Object.freeze({ + revision: snapshot.revision, + version: snapshot.version, + configuredDefaultModelIds: snapshot.configuredDefaultModelIds, + getLLMModelData: typedGetter(ModelTypeEnum.llm), + getEmbeddingModelData: typedGetter(ModelTypeEnum.embedding), + getRerankModelData: typedGetter(ModelTypeEnum.rerank), + getTTSModelData: typedGetter(ModelTypeEnum.tts), + getSTTModelData: typedGetter(ModelTypeEnum.stt), + getVlmModelData, + /** 仅将预期的模型不可用转为业务结果;复用严格 getter,程序异常仍交给外层处理。 */ + tryGetVlmModelData: ( + reference: ModelReferenceType + ): ModelLookupResult => { + try { + return { model: getVlmModelData(reference), error: undefined }; + } catch (error) { + if ( + error instanceof UserError && + (error.message === ModelErrEnum.unExist || error.message === ModelErrEnum.unConfigured) + ) { + return { model: undefined, error }; + } + throw error; + } + }, + /** 展示/编辑允许停用项;返回副本供草稿临时覆盖配置,不暴露共享对象。 */ + findModelData: ( + reference: ModelReferenceType, + options?: { type?: T; vision?: boolean } + ): Extract | undefined => { + const model = resolve(reference); + if ( + !model || + (options?.type && model.type !== options.type) || + (options?.vision && !(model.type === ModelTypeEnum.llm && model.config.vision)) + ) + return; + return cloneDeep(model) as Extract; + }, + /** 保留原缺省约定:图片与标题可缺省,其余默认槽位缺失时明确报错。 */ + getDefaultModelData: (slot: S): DefaultResult => { + const model = snapshot.defaultModels[slot]; + if (slot === 'datasetImageLLM' || slot === 'chatTitleLLM') { + if (!model?.isActive) return undefined as DefaultResult; + } + if (!model) throw new UserError(ModelErrEnum.unConfigured); + const expectedType = ['datasetTextLLM', 'datasetImageLLM', 'chatTitleLLM'].includes(slot) + ? ModelTypeEnum.llm + : (slot as ModelTypeEnum); + assertModelAvailable({ model, type: expectedType, vision: slot === 'datasetImageLLM' }); + return model as DefaultResult; + }, + getSystemDefaultModelIds: () => defaultIds, + getAllModels: () => snapshot.models, + getActiveModels: () => activeModels + }); +}; + +export type ModelHandle = ReturnType; + +let cachedHandle: ModelHandle | undefined; + +/** 缓存实现内部读取;业务只能通过异步 getModelHandle 获取。 */ +export const getCachedModelHandle = () => cachedHandle; + +/** 加载器在完整验证后原子发布;测试或独立运行环境可显式注入/清理快照。 */ +export const publishModelHandle = (handle: ModelHandle | undefined) => { + cachedHandle = handle; +}; diff --git a/packages/service/core/ai/config/service.ts b/packages/service/core/ai/config/service.ts new file mode 100644 index 000000000000..693f80ecddd0 --- /dev/null +++ b/packages/service/core/ai/config/service.ts @@ -0,0 +1,139 @@ +import type { SystemModelDocumentDataType } from '@fastgpt/global/core/ai/model.schema'; +import { ModelScopeEnum, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import { ModelErrEnum } from '@fastgpt/global/common/error/code/model'; +import { UserError } from '@fastgpt/global/common/error/utils'; +import type { ClientSession } from '../../../common/mongo'; +import { runSystemModelTransaction } from './entity'; +import { MongoAIModel } from './schema'; +import { updatedReloadSystemModel } from './utils'; +import type { SystemModelSchemaType } from '../type'; +import type { UpdateQuery } from 'mongoose'; + +type EditableSystemModelData = Omit; + +const optionalSystemModelConfigFields = [ + 'requestUrl', + 'requestAuth', + 'testMode', + 'charsPointsPrice', + 'priceTiers', + 'inputPrice', + 'outputPrice' +] as const; + +/** + * 生成模型配置的替换式更新表达式。 + * + * `model`、`type` 与 `scope` 都是实例身份的一部分,不参与 `$set`;已知可选字段缺失时 + * 使用 `$unset`,避免普通 `$set` 让旧价格或旧请求配置残留。 + */ +export const getSystemModelConfigUpdate = ( + modelData: EditableSystemModelData +): UpdateQuery => { + const mutableModelData = { ...modelData } as Record; + delete mutableModelData.type; + delete mutableModelData.scope; + + // LLM 保存以新阶梯价格为准;即使客户端仍传旧字段,也必须从数据库清除。 + // 非 LLM 的 charsPointsPrice 仍是当前计费字段,不能一并删除。 + if (modelData.type === ModelTypeEnum.llm) { + delete mutableModelData.inputPrice; + delete mutableModelData.outputPrice; + delete mutableModelData.charsPointsPrice; + } + + const fieldsToUnset = optionalSystemModelConfigFields.filter((field) => { + const value = mutableModelData[field]; + const isEmptyRequestConfig = + (field === 'requestUrl' || field === 'requestAuth') && + typeof value === 'string' && + value.trim().length === 0; + + if (!(field in mutableModelData) || value === undefined || isEmptyRequestConfig) { + delete mutableModelData[field]; + return true; + } + return false; + }); + + return { + $set: mutableModelData, + ...(fieldsToUnset.length > 0 + ? { + $unset: Object.fromEntries(fieldsToUnset.map((field) => [field, 1 as const])) as Record< + string, + 1 + > + } + : {}) + } as UpdateQuery; +}; + +/** + * 更新一组已存在的系统模型,并保证目标集合完整命中。 + * + * 该内部入口集中 system scope 和“不允许部分命中”的业务规则;是否开启事务由上层操作决定。 + */ +const updateExistingSystemModels = async ({ + modelIds, + update, + session +}: { + modelIds: string[]; + update: EditableSystemModelData | Pick; + session?: ClientSession; +}) => { + const result = await MongoAIModel.updateMany( + { _id: { $in: modelIds }, scope: ModelScopeEnum.system }, + { $set: update }, + { session } + ); + + if (result.matchedCount !== modelIds.length) { + return Promise.reject(ModelErrEnum.unExist); + } +}; + +/** 按稳定 modelId 更新单个系统模型的可编辑配置,并刷新运行时模型快照。 */ +export const updateSystemModelConfig = async ({ + modelId, + modelData +}: { + modelId: string; + modelData: EditableSystemModelData; +}) => { + await runSystemModelTransaction(async (session) => { + const existingModel = await MongoAIModel.findOne( + { _id: modelId, scope: ModelScopeEnum.system }, + { type: 1 } + ) + .session(session) + .lean(); + if (!existingModel) throw ModelErrEnum.unExist; + if (existingModel.type !== modelData.type) { + throw new UserError('System model type cannot be changed'); + } + + const result = await MongoAIModel.updateOne( + { _id: modelId, scope: ModelScopeEnum.system, type: existingModel.type }, + getSystemModelConfigUpdate(modelData), + { session } + ); + if (result.matchedCount !== 1) throw ModelErrEnum.unExist; + }); + await updatedReloadSystemModel(); +}; + +/** 在单个 MongoDB 事务中批量更新系统模型启停状态,并刷新运行时模型快照。 */ +export const updateSystemModelStatus = async ({ + modelIds, + isActive +}: { + modelIds: string[]; + isActive: boolean; +}) => { + await runSystemModelTransaction((session) => + updateExistingSystemModels({ modelIds, update: { isActive }, session }) + ); + await updatedReloadSystemModel(); +}; diff --git a/packages/service/core/ai/config/utils.ts b/packages/service/core/ai/config/utils.ts index 64b7a65d5c70..d8639b28c895 100644 --- a/packages/service/core/ai/config/utils.ts +++ b/packages/service/core/ai/config/utils.ts @@ -1,6 +1,6 @@ +import { getModelProviderMetadata } from '../../app/provider/controller'; import type { SystemDefaultModelType } from '../type'; import { ModelScopeEnum, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; -import { MongoAIModel } from './schema'; import { type EmbeddingSystemModelDataType, type LLMSystemModelDataType, @@ -12,21 +12,17 @@ import { type SystemModelDataType, type SystemModelDocumentDataType } from '@fastgpt/global/core/ai/model.schema'; -import { debounce } from 'lodash-es'; import { getModelProvider } from '../../../core/app/provider/controller'; -import { findModelData } from '../model'; -import { delay, retryFn } from '@fastgpt/global/common/system/utils'; import { pluginClient } from '../../../thirdProvider/fastgptPlugin'; -import { setCron } from '../../../common/system/cron'; import { preloadModelProviders } from '../../../core/app/provider/controller'; import { getLogger, LogCategories } from '../../../common/logger'; import { getRuntimeResolvedPriceTiers } from '@fastgpt/global/core/ai/pricing'; -import { ModelErrEnum } from '@fastgpt/global/common/error/code/model'; import { UserError } from '@fastgpt/global/common/error/utils'; import { clearAllMyModelsCache } from '../../../support/permission/model/controller'; import { hashStr } from '@fastgpt/global/common/string/tools'; -import { findSystemDefaultModelIds } from '../defaultModel/entity'; -import { MongoAIDefaultModel } from '../defaultModel/schema'; +import { readSystemModelSnapshot, readSystemModelRevision } from './entity'; +import { withTimeout } from '@fastgpt/global/common/system/utils'; +import { createModelHandle, getCachedModelHandle, publishModelHandle } from './handle'; /** * 插件模型协议为了便于声明,将不同模型类型的能力字段平铺在顶层;数据库 canonical @@ -150,11 +146,8 @@ export const desensitizeSystemDefaultModels = (defaultModels: SystemDefaultModel export const getPluginSystemModelDocuments = async (): Promise => pluginClient.listModels().then((models) => models.map((model) => flatModelToDocumentData(model))); -let modelTemplateSnapshot: SystemModelDocumentDataType[] | undefined; - /** - * 拉取并校验完整插件模型模板,返回候选快照但不立即发布。 - * 候选模板会在数据库实例也成功加载后,与 active 模型缓存一起原子发布。 + * 实时拉取并校验完整插件模型模板。模板只服务于管理员主动创建模型,不进入运行时缓存。 */ export const refreshModelTemplates = async (): Promise => { return getPluginSystemModelDocuments(); @@ -181,57 +174,25 @@ export const assertSystemModelTypesMatchPluginTemplates = ({ } }; -/** - * 当前版本的自动预装兼容策略:只物化插件中存在但数据库缺失的系统模型。 - * 模板消失不会在这里删除或停用实例;PR2 可将本函数替换为显式模板安装。 - */ -export const syncPreinstalledSystemModels = async ({ - pluginDocuments -}: { - pluginDocuments: SystemModelDocumentDataType[]; -}) => { - if (pluginDocuments.length === 0) return; - - await retryFn( - () => - MongoAIModel.bulkWrite( - pluginDocuments.map((document) => ({ - updateOne: { - filter: { scope: ModelScopeEnum.system, model: document.model }, - update: { $setOnInsert: document }, - upsert: true - } - })), - { ordered: false } - ), - 3 - ); -}; - /** * 只读取数据库安装实例并原子发布运行时模型快照,不执行插件请求、历史迁移或自动预装。 */ -export const loadInstalledModels = async ({ - pluginDocuments = modelTemplateSnapshot, +const publishInstalledModels = async ({ language = 'en', skipPermissionCacheInvalidation = false }: { - pluginDocuments?: SystemModelDocumentDataType[]; language?: string; /** 启动阶段只发布初始快照,避免重启时删除仍然有效的成员目录缓存。 */ skipPermissionCacheInvalidation?: boolean; } = {}) => { - if (!pluginDocuments) { - return Promise.reject(new Error('Model template snapshot is not initialized')); - } - const getPermissionCacheSignature = (models: SystemModelDataType[]) => models .map((model) => `${model.modelId}:${model.model}`) .sort() .join('\n'); - const previousPermissionCacheSignature = global.systemActiveModelList - ? getPermissionCacheSignature(global.systemActiveModelList) + const previousHandle = getCachedModelHandle(); + const previousPermissionCacheSignature = previousHandle + ? getPermissionCacheSignature(previousHandle.getActiveModels()) : undefined; const _systemModelList: SystemModelDataType[] = []; @@ -243,37 +204,29 @@ export const loadInstalledModels = async ({ _systemModelMap.set(`id:${modelData.modelId}`, modelData); _systemModelMap.set(`model:${modelData.model}`, modelData); - if (modelData.isActive) { - if (modelData.type === ModelTypeEnum.llm) { - modelData.priceTiers = getRuntimeResolvedPriceTiers(modelData); - } + // 管理列表包含停用模型,统一解析价格可避免旧字段或单档双零在列表中显示错误。 + if (modelData.type === ModelTypeEnum.llm) { + modelData.priceTiers = getRuntimeResolvedPriceTiers(modelData); } }; try { - const [dbModels, configuredDefaultModelIds] = await Promise.all([ - MongoAIModel.find({ scope: ModelScopeEnum.system }).lean(), - findSystemDefaultModelIds() - ]); + const { + models: dbModels, + defaultModelIds: configuredDefaultModelIds, + revision + } = await readSystemModelSnapshot(); const dbDocuments = dbModels.map((dbModel) => SystemModelDocumentDataSchema.parse(dbModel)); - assertSystemModelTypesMatchPluginTemplates({ models: dbDocuments, pluginDocuments }); - const getPluginModelKey = (model: Pick) => - `${model.type}:${model.model}`; - const pluginDocumentMap = new Map( - pluginDocuments.map((model) => [getPluginModelKey(model), model]) - ); dbModels.forEach((dbModel, index) => { const dbDocument = dbDocuments[index]; - const pluginDocument = pluginDocumentMap.get(getPluginModelKey(dbDocument)); const provider = getModelProvider(dbDocument.provider, language); const runtimeModel = SystemModelDataSchema.parse({ ...dbDocument, modelId: String(dbModel._id), provider: provider.id, - avatar: provider.avatar, - isCustom: !pluginDocument + avatar: provider.avatar }); pushModel(runtimeModel); @@ -321,19 +274,7 @@ export const loadInstalledModels = async ({ (model): model is RerankSystemModelDataType => model.type === ModelTypeEnum.rerank ); - // Plugin 数组是内置模型展示顺序的唯一来源;MongoDB 自然顺序不具备业务语义。 - const pluginModelOrder = new Map( - pluginDocuments.map((model, index) => [getPluginModelKey(model), index]) - ); - _systemModelList.sort((a, b) => { - const orderA = pluginModelOrder.get(getPluginModelKey(a)); - const orderB = pluginModelOrder.get(getPluginModelKey(b)); - if (orderA !== undefined && orderB !== undefined) return orderA - orderB; - if (orderA !== undefined) return -1; - if (orderB !== undefined) return 1; - return a.modelId.localeCompare(b.modelId); - }); - // Active 列表从已排序的全量缓存派生,避免管理员与成员目录维护两套顺序语义。 + // Active 列表沿用 MongoDB 的新建时间倒序;后续可由持久化 order 字段接管排序。 const _systemActiveModelList = _systemModelList.filter((model) => model.isActive); // Default model check @@ -384,23 +325,26 @@ export const loadInstalledModels = async ({ await clearAllMyModelsCache(); } - // Set global value + // 完整目录与内容版本一起发布,不暴露多次赋值的半成品。 { - modelTemplateSnapshot = pluginDocuments; - global.systemModelList = _systemModelList; - global.systemActiveModelList = _systemActiveModelList; - global.systemModelMap = _systemModelMap; - global.systemDefaultModel = _systemDefaultModel; - global.systemConfiguredDefaultModelIds = configuredDefaultModelIds; - global.systemModelCatalogVersion = hashStr( + const version = hashStr( JSON.stringify({ schemaVersion: 1, - // 模型顺序属于目录内容;plugin 调整顺序后必须触发客户端缓存更新。 + // 模型顺序属于目录内容;安装实例变化后必须触发客户端缓存更新。 models: _systemActiveModelList.map(desensitizeSystemModel), - providers: global.ModelProviderRawCache, + providers: getModelProviderMetadata().providers, defaultModelIds: configuredDefaultModelIds }) ); + publishModelHandle( + createModelHandle({ + models: _systemModelList, + defaultModels: _systemDefaultModel, + configuredDefaultModelIds, + revision, + version + }) + ); } const logger = getLogger(LogCategories.MODULE.AI.CONFIG); @@ -416,30 +360,65 @@ export const loadInstalledModels = async ({ } }; +let modelReload: Promise | undefined; + +/** 同一进程只允许一个加载器发布目录,避免慢的旧加载覆盖较新的快照。 */ +export const loadInstalledModels = (options?: Parameters[0]) => { + if (!modelReload) { + modelReload = publishInstalledModels(options).finally(() => { + modelReload = undefined; + }); + } + return modelReload; +}; + +let modelRefresh: Promise | undefined; + /** - * 编排模型启动或模板热刷新。旧表迁移由阻塞系统升级任务负责;本函数只负责插件模板、 - * 自动预装和运行时快照,不会以 ai_models 是否为空推断迁移状态。 - * 任一步失败都会向启动链路抛错并终止进程。 + * 尽力读取最新目录,版本检查与快照加载总共最多等待 5 秒。 + * 失败或超时沿用已成功发布的本地快照(包括空目录);首次加载没有快照时仍报错。 + * race 不取消底层加载,迟到的完整快照仍可正常发布,不能伪造修订号或清空旧缓存。 + */ +export const refreshModelHandle = async () => { + const refresh = async () => { + const requiredRevision = await readSystemModelRevision(); + while (!getCachedModelHandle() || getCachedModelHandle()!.revision < requiredRevision) { + await loadInstalledModels(); + } + }; + + try { + modelRefresh ??= refresh().finally(() => { + modelRefresh = undefined; + }); + await withTimeout(modelRefresh, 5000, 'Model catalog refresh timed out'); + } catch (error) { + const handle = getCachedModelHandle(); + if (!handle) throw error; + getLogger(LogCategories.MODULE.AI.CONFIG).warn( + 'Using local model catalog after refresh failure', + { + error, + revision: handle.revision + } + ); + } +}; + +/** + * 启动依赖 Plugin Provider 元数据;模板 listModels 不参与已安装实例的加载和运行。 + * 历史模型迁移由阻塞升级任务负责。 */ export const loadSystemModels = async (refresh = false, language = 'en') => { - if (!refresh && global.systemModelList) return; + if (!refresh && getCachedModelHandle()) return; try { - const isInitialLoad = !global.systemModelList; + const isInitialLoad = !getCachedModelHandle(); await preloadModelProviders(); - const pluginDocuments = await refreshModelTemplates(); - if (isInitialLoad) { - await syncPreinstalledSystemModels({ pluginDocuments }); - await loadInstalledModels({ - pluginDocuments, - language, - skipPermissionCacheInvalidation: isInitialLoad - }); - return; - } - - await syncPreinstalledSystemModels({ pluginDocuments }); - await loadInstalledModels({ pluginDocuments, language }); + await loadInstalledModels({ + language, + skipPermissionCacheInvalidation: isInitialLoad + }); } catch (error) { getLogger(LogCategories.MODULE.AI.CONFIG).error('System models orchestration failed', { error @@ -448,70 +427,12 @@ export const loadSystemModels = async (refresh = false, language = 'en') => { } }; -/** 根据稳定模型 ID 恢复内置插件模板;自定义模型没有可恢复的模板。 */ -export const getSystemModelConfig = async ( - modelId: string -): Promise => { - const modelData = findModelData({ modelId }); - if (!modelData) return Promise.reject(ModelErrEnum.unExist); - if (modelData.isCustom) return Promise.reject('Custom model not data'); - - // Read file - const modelDefaultConfig = await getPluginSystemModelDocuments().then((models) => - models.find((item) => item.model === modelData.model && item.type === modelData.type) - ); - if (!modelDefaultConfig) return Promise.reject(ModelErrEnum.unExist); - - return { - ...modelDefaultConfig, - provider: modelData.provider - }; -}; - -export const watchSystemModelUpdate = () => { - const changeStream = MongoAIModel.watch(); - - return changeStream.on( - 'change', - debounce(async () => { - try { - // 数据库事件只重建安装实例快照,不触发插件请求、repair 或自动预装。 - await loadInstalledModels(); - } catch {} - }, 500) - ); -}; - -/** 默认模型配置变化时只重建模型目录,不推进 getInitData 版本。 */ -export const watchSystemDefaultModelUpdate = () => { - const changeStream = MongoAIDefaultModel.watch(); - - return changeStream.on( - 'change', - debounce(async () => { - try { - await loadInstalledModels(); - } catch {} - }, 500) - ); -}; - -// 更新完模型后,需要重载缓存 -export const updatedReloadSystemModel = async ({ - pluginDocuments -}: { - pluginDocuments?: SystemModelDocumentDataType[]; -} = {}) => { - const templates = pluginDocuments ?? (await refreshModelTemplates()); - // 管理员写入后只重建安装实例快照,不隐式执行全量预装。 - await loadInstalledModels({ pluginDocuments: templates }); - // 模型目录拥有独立版本,不能污染 getInitData.bufferId。 - // 延迟1秒,等待其他节点通过 change stream 刷新。 - await delay(1000); -}; -export const cronRefreshModels = async () => { - setCron('*/30 * * * *', async () => { - // 模板刷新成功后才执行自动预装和运行时快照发布;失败时保留旧快照。 - await loadSystemModels(true); +/** 写入已提交后尽力刷新本节点,失败保留诊断;后续模型读屏障负责重试,不能误报写入失败。 */ +export const updatedReloadSystemModel = async () => { + await refreshModelHandle().catch((error) => { + getLogger(LogCategories.MODULE.AI.CONFIG).warn( + 'Model write committed; catalog refresh pending', + { error } + ); }); }; diff --git a/packages/service/core/ai/defaultModel/schema.ts b/packages/service/core/ai/defaultModel/schema.ts index 02a6f168a00e..35ce6a7d50ad 100644 --- a/packages/service/core/ai/defaultModel/schema.ts +++ b/packages/service/core/ai/defaultModel/schema.ts @@ -11,6 +11,8 @@ export type AIDefaultModelSchemaType = { scope: ModelScopeEnum; teamId?: string; defaultModelIds: ModelDefaultIds; + /** 与模型写入事务共同提交的目录修订号;历史数据按 0 处理。 */ + catalogRevision?: number; }; const DefaultModelIdsSchema = new Schema( @@ -28,6 +30,7 @@ const DefaultModelIdsSchema = new Schema( ); const AIDefaultModelSchema = new Schema({ + catalogRevision: { type: Number, default: 0 }, scope: { type: String, enum: Object.values(ModelScopeEnum), diff --git a/packages/service/core/ai/model.ts b/packages/service/core/ai/model.ts index ab52a15151cf..09298c5e878a 100644 --- a/packages/service/core/ai/model.ts +++ b/packages/service/core/ai/model.ts @@ -1,162 +1,20 @@ -import { cloneDeep } from 'lodash-es'; -import type { - EmbeddingSystemModelDataType, - LLMSystemModelDataType, - ModelReferenceType, - RerankSystemModelDataType, - STTSystemModelDataType, - SystemModelDataType, - TTSSystemModelDataType -} from '@fastgpt/global/core/ai/model.schema'; -import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import type { EmbeddingSystemModelDataType } from '@fastgpt/global/core/ai/model.schema'; +import { getCachedModelHandle, type ModelHandle } from './config/handle'; import { ModelErrEnum } from '@fastgpt/global/common/error/code/model'; import { UserError } from '@fastgpt/global/common/error/utils'; -import { getModelReferenceValue, isEmptyModelValue } from '@fastgpt/global/core/ai/modelReference'; - -const modelNotFound = () => new UserError(ModelErrEnum.unExist); - -/** - * 校验模型状态、类型及可选视觉能力。保留 modelUnExist 机器码兼容队列降级, - * 通过独立展示消息区分停用与类型错误;名称来自实际模型记录,不使用用户输入猜测。 - */ -export const assertModelAvailable = ({ - model, - type, - vision = false -}: { - model?: Pick; - type: ModelTypeEnum; - vision?: boolean; -}) => { - if (!model) throw modelNotFound(); - const name = model.name || model.model; - if (model.type !== type) { - throw new UserError(ModelErrEnum.unExist, `Model type mismatch: ${name}`); - } - if (!model.isActive) { - throw new UserError(ModelErrEnum.unExist, `Model is disabled: ${name}`); - } - if ( - vision && - !(model.type === ModelTypeEnum.llm && 'vision' in model.config && model.config.vision) - ) { - throw new UserError(ModelErrEnum.unExist, `Model type mismatch: ${name}`); - } -}; - -/** - * 按稳定 ID 或旧 model 标识解析模型。非空 modelId 禁止降级到 model,避免错误 ID - * 静默命中另一个模型;不兼容裸字符串或展示名称 name。 - */ -const resolveModelReference = (reference: ModelReferenceType): SystemModelDataType | undefined => { - if (!isEmptyModelValue(reference.modelId)) { - return global.systemModelMap?.get(`id:${reference.modelId}`); - } - if (!isEmptyModelValue(reference.model)) { - return global.systemModelMap?.get(`model:${reference.model}`); - } -}; - -const getTypedModelData = ( - reference: ModelReferenceType, - type: T['type'] -): T => { - if (isEmptyModelValue(getModelReferenceValue(reference))) { - throw new UserError(ModelErrEnum.unConfigured); - } - const model = resolveModelReference(reference); - assertModelAvailable({ model, type }); - return model as T; -}; - -/** - * 解析允许缺省的模型引用。modelId 与旧 model 同时为空时返回 undefined;一旦提供 - * 任一引用,仍交给严格 getter 校验模型是否存在、启用且类型正确。 - */ -const getOptionalModelData = ( - reference: ModelReferenceType, - getter: (reference: ModelReferenceType) => T -): T | undefined => { - if (isEmptyModelValue(getModelReferenceValue(reference))) return; - return getter(reference); -}; - -export const getLLMModelData = (reference: ModelReferenceType): LLMSystemModelDataType => - getTypedModelData(reference, ModelTypeEnum.llm); -export const getEmbeddingModelData = ( - reference: ModelReferenceType -): EmbeddingSystemModelDataType => getTypedModelData(reference, ModelTypeEnum.embedding); -export const getRerankModelData = (reference: ModelReferenceType): RerankSystemModelDataType => - getTypedModelData(reference, ModelTypeEnum.rerank); -export const getTTSModelData = (reference: ModelReferenceType): TTSSystemModelDataType => - getTypedModelData(reference, ModelTypeEnum.tts); -export const getSTTModelData = (reference: ModelReferenceType): STTSystemModelDataType => - getTypedModelData(reference, ModelTypeEnum.stt); - -/** 缺省引用返回 undefined;非空引用仍按 LLM 的严格规则解析。 */ -export const getOptionalLLMModelData = ( - reference: ModelReferenceType -): LLMSystemModelDataType | undefined => getOptionalModelData(reference, getLLMModelData); -/** 缺省引用返回 undefined;非空引用仍按 Embedding 模型的严格规则解析。 */ -export const getOptionalEmbeddingModelData = ( - reference: ModelReferenceType -): EmbeddingSystemModelDataType | undefined => - getOptionalModelData(reference, getEmbeddingModelData); - -const getDefaultModelData = ( - model: SystemModelDataType | undefined, - type: T['type'] -): T => { - if (!model) throw new UserError(ModelErrEnum.unConfigured); - assertModelAvailable({ model, type }); - return model as T; -}; - -export const getDefaultLLMModelData = (): LLMSystemModelDataType => - getDefaultModelData(global.systemDefaultModel.llm, ModelTypeEnum.llm); -export const getDefaultEmbeddingModelData = (): EmbeddingSystemModelDataType => - getDefaultModelData(global.systemDefaultModel.embedding, ModelTypeEnum.embedding); -export const getDefaultRerankModelData = (): RerankSystemModelDataType => - getDefaultModelData(global.systemDefaultModel.rerank, ModelTypeEnum.rerank); -export const getDefaultTTSModelData = (): TTSSystemModelDataType => - getDefaultModelData(global.systemDefaultModel.tts, ModelTypeEnum.tts); -export const getDefaultSTTModelData = (): STTSystemModelDataType => - getDefaultModelData(global.systemDefaultModel.stt, ModelTypeEnum.stt); - -export const getDefaultVLMModelData = () => global.systemDefaultModel.datasetImageLLM; /** - * 返回按模型类型索引的有效系统默认模型 ID,供 Workflow 写入边界执行默认优先回退。 - * 默认模型已在模型配置加载阶段校验并按同类型 active 模型兜底;调用方仍需结合自己的候选列表复核。 + * 模型读取的唯一异步入口。并发检查由目录加载器合并,最多等待 5 秒; + * 超时或失败复用成功发布过的 handle。每个版本只构造一次 handle,调用方无需传递请求上下文。 */ -export const getSystemDefaultModelIds = (): Partial> => ({ - [ModelTypeEnum.llm]: global.systemDefaultModel.llm?.modelId, - [ModelTypeEnum.embedding]: global.systemDefaultModel.embedding?.modelId, - [ModelTypeEnum.tts]: global.systemDefaultModel.tts?.modelId, - [ModelTypeEnum.stt]: global.systemDefaultModel.stt?.modelId, - [ModelTypeEnum.rerank]: global.systemDefaultModel.rerank?.modelId -}); - -/** 解析可用于视觉请求的规范化 LLM 配置。 */ -export const getVlmModelData = (reference: ModelReferenceType): LLMSystemModelDataType => { - const result = getLLMModelData(reference); - assertModelAvailable({ model: result, type: ModelTypeEnum.llm, vision: true }); - return result; -}; - -/** 缺省引用返回 undefined;非空引用仍校验模型已启用且支持视觉。 */ -export const getOptionalVlmModelData = ( - reference: ModelReferenceType -): LLMSystemModelDataType | undefined => getOptionalModelData(reference, getVlmModelData); - -export const getDefaultChatTitleModelData = (): LLMSystemModelDataType | undefined => { - const model = global?.systemDefaultModel.chatTitleLLM; - return model?.isActive ? model : undefined; +export const getModelHandle = async (): Promise => { + const { refreshModelHandle } = await import('./config/utils'); + await refreshModelHandle(); + const handle = getCachedModelHandle(); + if (!handle) throw new UserError(ModelErrEnum.unExist); + return handle; }; +/** 仅判断传入模型的图片能力,不读取目录。 */ export const isImageEmbeddingModel = (model?: EmbeddingSystemModelDataType) => !!model?.config.vision; - -/** 查找并复制规范化模型数据,供需要临时覆盖请求参数的调用方使用。 */ -export const findModelData = (reference: ModelReferenceType) => - cloneDeep(resolveModelReference(reference)); diff --git a/packages/service/core/ai/rerank/index.ts b/packages/service/core/ai/rerank/index.ts index 2a62d596d806..3313af3fec31 100644 --- a/packages/service/core/ai/rerank/index.ts +++ b/packages/service/core/ai/rerank/index.ts @@ -1,5 +1,6 @@ +import { getModelHandle } from '../model'; import { axiosWithoutSSRF } from '../../../common/api/axios'; -import { getDefaultRerankModelData } from '../model'; + import { getAxiosConfig } from '../config'; import { type RerankSystemModelDataType } from '@fastgpt/global/core/ai/model.schema'; import { countPromptTokens } from '../../../common/string/tiktoken'; @@ -29,7 +30,7 @@ type ReRankCallResult = { }; export async function reRankRecall({ - model = getDefaultRerankModelData(), + model: inputModel, query, documents, headers @@ -39,6 +40,8 @@ export async function reRankRecall({ documents: { id: string; text: string }[]; headers?: Record; }): Promise { + const model = inputModel ?? (await getModelHandle()).getDefaultModelData('rerank'); + if (!model) { return Promise.reject(new UserError(ModelErrEnum.unExist)); } diff --git a/packages/service/core/ai/skill/debugChat/handler.ts b/packages/service/core/ai/skill/debugChat/handler.ts index 653f1ce98614..3c51a62eace3 100644 --- a/packages/service/core/ai/skill/debugChat/handler.ts +++ b/packages/service/core/ai/skill/debugChat/handler.ts @@ -1,3 +1,4 @@ +import { getModelHandle } from '../../model'; import type { NodeApiRequest, NodeApiResponse } from '../../../../types/http'; import { DispatchNodeResponseKeyEnum, @@ -31,7 +32,7 @@ import { getLocale } from '../../../../common/middle/i18n'; import { getLogger, LogCategories } from '../../../../common/logger'; import { getRunningUserInfoByTmbId } from '../../../../support/user/team/utils'; import { formatModelChars2Points } from '../../../../support/wallet/usage/utils'; -import { getLLMModelData, findModelData } from '../../model'; + import { getRunningSkillEditSandbox } from '../../sandbox/interface/skillEdit'; import { dispatchWorkFlow } from '../../../workflow/dispatch'; import { prepareWorkflowFileQuery } from '../../../workflow/utils/fileLimits'; @@ -118,7 +119,8 @@ export async function handleSkillDebugChat( skillId, per: WritePermissionVal }); - const modelData = getLLMModelData({ modelId }); + const modelHandle = await getModelHandle(); + const modelData = modelHandle.getLLMModelData({ modelId }); if (!(await teamFrequencyLimit({ teamId, type: LimitTypeEnum.chat, res }))) { return ChatWorkflowSseResponseSchema.parse(''); @@ -257,7 +259,7 @@ export async function handleSkillDebugChat( if (item.model && (item.inputTokens !== undefined || item.outputTokens !== undefined)) { try { - const usageModel = findModelData({ model: item.model }); + const usageModel = modelHandle.findModelData({ model: item.model }); if (!usageModel) return item; const { totalPoints } = formatModelChars2Points({ model: usageModel, diff --git a/packages/service/core/ai/type.d.ts b/packages/service/core/ai/type.d.ts index 7f00d986b3b4..ac7e12b72c23 100644 --- a/packages/service/core/ai/type.d.ts +++ b/packages/service/core/ai/type.d.ts @@ -4,7 +4,6 @@ import type { LLMSystemModelDataType, RerankSystemModelDataType, STTSystemModelDataType, - SystemModelDataType, SystemModelDocumentDataType, TTSSystemModelDataType } from '@fastgpt/global/core/ai/model.schema'; @@ -13,7 +12,6 @@ import type { AiproxyMapProviderItemType } from '@fastgpt/global/sdk/fastgpt-plugin'; import type { langType, ModelProviderItemType } from '@fastgpt/global/core/ai/provider'; -import type { ModelDefaultIds } from '@fastgpt/global/core/ai/defaultModel'; export type SystemModelSchemaType = SystemModelDocumentDataType & { _id: string; @@ -36,16 +34,6 @@ declare global { var ModelProviderListCache: Record; var ModelProviderMapCache: Record>; var aiproxyChannelsCache: AiproxyMapProviderItemType[]; - - var systemModelList: SystemModelDataType[]; - /** 标准模型索引,只允许 `id:` 与 `model:` 两种 key。 */ - var systemModelMap: Map; - var systemActiveModelList: SystemModelDataType[]; - var systemDefaultModel: SystemDefaultModelType; - /** 管理员配置的原始默认模型 ID,不包含成员权限回退。 */ - var systemConfiguredDefaultModelIds: ModelDefaultIds; - /** 脱敏 active 模型、Provider 与系统默认配置共同决定的目录内容版本。 */ - var systemModelCatalogVersion: string; } export {}; diff --git a/packages/service/core/ai/utils.ts b/packages/service/core/ai/utils.ts index a01e16a0e4e2..32b2d5d93c1e 100644 --- a/packages/service/core/ai/utils.ts +++ b/packages/service/core/ai/utils.ts @@ -1,6 +1,11 @@ -import type { LLMSystemModelDataType } from '@fastgpt/global/core/ai/model.schema'; +import { ModelErrEnum } from '@fastgpt/global/common/error/code/model'; +import { UserError } from '@fastgpt/global/common/error/utils'; +import type { + LLMSystemModelDataType, + SystemModelDataType +} from '@fastgpt/global/core/ai/model.schema'; import type { CompletionFinishReason, CompletionUsage } from '@fastgpt/global/core/ai/llm/type'; -import { getLLMDefaultUsage } from '@fastgpt/global/core/ai/constants'; +import { getLLMDefaultUsage, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; import { removeDatasetCiteText } from '@fastgpt/global/core/ai/llm/utils'; import json5 from 'json5'; import { sliceJsonStr } from '@fastgpt/global/common/string/tools'; @@ -375,3 +380,32 @@ export const parseJsonArgs = >(str: string) => { return; } }; + +/** + * 校验模型状态、类型及可选视觉能力。保留 modelUnExist 机器码兼容队列降级, + * 通过独立展示消息区分停用与类型错误;名称来自实际模型记录,不使用用户输入猜测。 + */ +export const assertModelAvailable = ({ + model, + type, + vision = false +}: { + model?: Pick; + type: ModelTypeEnum; + vision?: boolean; +}) => { + if (!model) throw new UserError(ModelErrEnum.unExist); + const name = model.name || model.model; + if (model.type !== type) { + throw new UserError(ModelErrEnum.unExist, `Model type mismatch: ${name}`); + } + if (!model.isActive) { + throw new UserError(ModelErrEnum.unExist, `Model is disabled: ${name}`); + } + if ( + vision && + !(model.type === ModelTypeEnum.llm && 'vision' in model.config && model.config.vision) + ) { + throw new UserError(ModelErrEnum.unExist, `Model type mismatch: ${name}`); + } +}; diff --git a/packages/service/core/app/provider/controller.ts b/packages/service/core/app/provider/controller.ts index e726c86e1664..5fda86f78269 100644 --- a/packages/service/core/app/provider/controller.ts +++ b/packages/service/core/app/provider/controller.ts @@ -20,6 +20,12 @@ export async function preloadModelProviders(): Promise { export const getModelProviders = (language = 'en') => { return getModelProviderListFromCache(global.ModelProviderListCache, language); }; + +/** Provider 与协议是独立元数据;读取不触发模型目录加载,模板和修复入口不依赖坏目录。 */ +export const getModelProviderMetadata = () => ({ + providers: global.ModelProviderRawCache, + aiproxyChannels: global.aiproxyChannelsCache +}); export const getModelProvider = (provider?: string, language = 'en') => { return getModelProviderFromCache({ cache: global.ModelProviderMapCache, diff --git a/packages/service/core/app/utils.ts b/packages/service/core/app/utils.ts index acb129ce18af..aaa36096f128 100644 --- a/packages/service/core/app/utils.ts +++ b/packages/service/core/app/utils.ts @@ -1,7 +1,6 @@ +import { getDatasetModelReference } from '../dataset/model'; import { MongoDataset } from '../dataset/schema'; -import { getDefaultEmbeddingModelData } from '../ai/model'; -import { desensitizeSystemModel } from '../ai/config/utils'; -import { getDatasetEmbeddingModel } from '../dataset/model'; + import { DatasetTypeEnum, DatasetTypeMap } from '@fastgpt/global/core/dataset/constants'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; @@ -16,6 +15,7 @@ import { } from '@fastgpt/global/core/app/formEdit/utils'; import { getClientToolPreviewNode } from './tool/utils/client'; import { authAppByTmbId } from '../../support/permission/app/auth'; +import { authDatasetByTmbId } from '../../support/permission/dataset/auth'; import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { @@ -169,9 +169,14 @@ export async function rewriteAppWorkflowToDetail({ value: hasSavedValue ? savedInput.value : normalizedInput.value }); }; + type DatasetLoadResult = { + datasets: SelectedDatasetType[]; + errors: string[]; + }; + const formatSelectedDatasetValue = async ( value?: SelectedDatasetSnapshot[] | SelectedDatasetSnapshot - ): Promise => { + ): Promise => { const loadDatasetInfo = async ( snapshot: SelectedDatasetSnapshot ): Promise => { @@ -180,13 +185,24 @@ export async function rewriteAppWorkflowToDetail({ _id: datasetId, ...(!isRoot && teamId && { teamId }) }).lean(); - if (dataset && !dataset.deleteTime) { + const { dataset: accessibleDataset } = await authDatasetByTmbId({ + tmbId: ownerTmbId, + datasetId, + per: ReadPermissionVal, + isRoot + }); + const modelReference = getDatasetModelReference(accessibleDataset, 'embedding'); + return { - datasetId: String(dataset._id), - avatar: dataset.avatar, - name: dataset.name, - vectorModel: getDatasetEmbeddingModel(dataset), + datasetId: String(accessibleDataset._id), + avatar: accessibleDataset.avatar, + name: accessibleDataset.name, + // 详情接口只返回知识库绑定的模型引用,不因模型停用或下架阻断应用详情。 + vectorModel: { + modelId: modelReference.modelId ?? undefined, + model: modelReference.model ?? '' + }, isDeleted: false }; } @@ -196,14 +212,20 @@ export async function rewriteAppWorkflowToDetail({ datasetId, avatar: defaultDeletedDatasetAvatar, name: snapshot.name || '', - vectorModel: snapshot.vectorModel || desensitizeSystemModel(getDefaultEmbeddingModelData()), + vectorModel: snapshot.vectorModel || { model: '' }, isDeleted: true }; }; if (!value) return; const datasets = Array.isArray(value) ? value : [value]; - return Promise.all(datasets.map(loadDatasetInfo)); + const results = await Promise.allSettled(datasets.map(loadDatasetInfo)); + return { + datasets: results.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : [])), + errors: results.flatMap((result) => + result.status === 'rejected' ? [getErrText(result.reason, '', lang)] : [] + ) + }; }; await Promise.all( @@ -403,31 +425,44 @@ export async function rewriteAppWorkflowToDetail({ node.flowNodeType === FlowNodeTypeEnum.datasetSearchNode || node.flowNodeType === FlowNodeTypeEnum.agent ) { + const datasetErrors: string[] = []; await Promise.all( node.inputs.map(async (input) => { if (nodeInputIsReference(input)) return; - // Agent - if (input.key === NodeInputKeyEnum.datasetSelectList) { - const datasets = await formatSelectedDatasetValue(input.value); - if (datasets) { - input.value = datasets; + try { + // Agent + if (input.key === NodeInputKeyEnum.datasetSelectList) { + const result = await formatSelectedDatasetValue(input.value); + if (result) { + input.value = result.datasets; + datasetErrors.push(...result.errors); + } } - } - // workflow - if (input.key === NodeInputKeyEnum.datasetParams) { - const datasetParams = input.value as AppFormEditFormType['dataset'] | undefined; - if (datasetParams?.datasets) { - const datasets = await formatSelectedDatasetValue(datasetParams.datasets); - if (!datasets) return; + // workflow + if (input.key === NodeInputKeyEnum.datasetParams) { + const datasetParams = input.value as AppFormEditFormType['dataset'] | undefined; + if (datasetParams?.datasets) { + const result = await formatSelectedDatasetValue(datasetParams.datasets); + if (!result) return; - input.value = { - ...datasetParams, - datasets - }; + input.value = { + ...datasetParams, + datasets: result.datasets + }; + datasetErrors.push(...result.errors); + } } + } catch (error) { + datasetErrors.push(getErrText(error, '', lang)); } }) ); + if (datasetErrors.length > 0) { + node.pluginData = { + ...node.pluginData, + error: [node.pluginData?.error, ...datasetErrors].filter(Boolean).join('\n') + }; + } } }) ); diff --git a/packages/service/core/chat/title.ts b/packages/service/core/chat/title.ts index aa7612d14f44..6902e929d51a 100644 --- a/packages/service/core/chat/title.ts +++ b/packages/service/core/chat/title.ts @@ -1,3 +1,4 @@ +import { getModelHandle } from '../ai/model'; import type { UserChatItemType } from '@fastgpt/global/core/chat/type'; import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; import { chatValue2RuntimePrompt } from '@fastgpt/global/core/chat/adapt'; @@ -7,7 +8,7 @@ import type { WorkflowTypedSseEvent } from '@fastgpt/global/core/workflow/runtim import { withTimeout } from '@fastgpt/global/common/system/utils'; import { getLogger, LogCategories } from '../../common/logger'; import { createLLMResponse } from '../ai/llm/request'; -import { getDefaultChatTitleModelData } from '../ai/model'; + import { MongoChat } from './chatSchema'; import { buildChatSourceQuery, type ChatSourceParams } from './source'; import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; @@ -96,7 +97,8 @@ const generateChatTitleFromQuestion = async ({ question: string; teamId: string; }): Promise => { - const titleModel = getDefaultChatTitleModelData(); + const modelHandle = await getModelHandle(); + const titleModel = modelHandle.getDefaultModelData('chatTitleLLM'); if (!titleModel?.model) return question.slice(0, FALLBACK_CHAT_TITLE_MAX_LENGTH); const questionForTitle = question.slice(0, CHAT_TITLE_QUESTION_MAX_LENGTH); const userPrompt = `Generate a title for the following source text. Do not answer it. diff --git a/packages/service/core/dataset/collection/controller.ts b/packages/service/core/dataset/collection/controller.ts index 576327238cee..3c67054f2857 100644 --- a/packages/service/core/dataset/collection/controller.ts +++ b/packages/service/core/dataset/collection/controller.ts @@ -1,3 +1,5 @@ +import { getModelHandle } from '../../ai/model'; +import { getDatasetModelReference } from '../model'; import { DatasetCollectionDataProcessModeEnum, DatasetCollectionTypeEnum @@ -19,7 +21,7 @@ import { predictDataLimitLength } from '../../../../global/core/dataset/utils'; import { mongoSessionRun } from '../../../common/mongo/sessionRun'; import { createTrainingUsage } from '../../../support/wallet/usage/controller'; import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; -import { getDatasetAgentModel, getDatasetEmbeddingModel, getDatasetVlmModel } from '../model'; + import { pushDataListToTrainingQueue, pushDatasetToParseQueue } from '../training/controller'; import { hashStr } from '@fastgpt/global/common/string/tools'; import { getFullTextStore } from '../data/textStore'; @@ -57,9 +59,14 @@ export const createCollectionAndInsertData = async ({ billId?: string; session?: ClientSession; }): Promise => { - const agentModelData = getDatasetAgentModel(dataset); - const embeddingModelData = getDatasetEmbeddingModel(dataset); - const vlmModelData = getDatasetVlmModel(dataset); + const modelHandle = await getModelHandle(); + const agentModelData = modelHandle.getLLMModelData(getDatasetModelReference(dataset, 'agent')); + const embeddingModelData = modelHandle.getEmbeddingModelData( + getDatasetModelReference(dataset, 'embedding') + ); + const vlmModelData = modelHandle.getVlmModelData(getDatasetModelReference(dataset, 'vlm'), { + optional: true + }); // Adapter 4.9.0 if (createCollectionParams.trainingType === DatasetCollectionDataProcessModeEnum.auto) { diff --git a/packages/service/core/dataset/model.ts b/packages/service/core/dataset/model.ts index eb2520385fd9..c1a613dd0779 100644 --- a/packages/service/core/dataset/model.ts +++ b/packages/service/core/dataset/model.ts @@ -1,67 +1,25 @@ import type { DatasetSchemaType } from '@fastgpt/global/core/dataset/type'; -import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; -import { - findModelData, - getEmbeddingModelData, - getLLMModelData, - getOptionalVlmModelData -} from '../ai/model'; +import type { ModelReferenceType } from '@fastgpt/global/core/ai/model.schema'; type DatasetModelFields = Pick< DatasetSchemaType, 'vectorModelId' | 'vectorModel' | 'agentModelId' | 'agentModel' | 'vlmModelId' | 'vlmModel' >; -const normalizeModelId = (modelId: unknown) => - modelId === undefined || modelId === null ? undefined : String(modelId); - -/** 使用新 ID 字段优先解析知识库向量模型,并兼容历史 model 字段。 */ -export const getDatasetEmbeddingModel = (dataset: Partial) => - getEmbeddingModelData({ - modelId: normalizeModelId(dataset.vectorModelId), - model: dataset.vectorModel - }); - -/** 使用新 ID 字段优先解析知识库处理模型,并兼容历史 model 字段。 */ -export const getDatasetAgentModel = (dataset: Partial) => - getLLMModelData({ - modelId: normalizeModelId(dataset.agentModelId), - model: dataset.agentModel - }); - -/** VLM 允许不配置;一旦配置但无法解析,统一抛出“模型不存在”。 */ -export const getDatasetVlmModel = (dataset: Partial) => - getOptionalVlmModelData({ - modelId: normalizeModelId(dataset.vlmModelId), - model: dataset.vlmModel - }); - -/** - * 解析知识库向量模型的展示数据。展示态允许返回已停用模型,模型缺失或类型不匹配时返回 undefined; - * 训练和检索链路仍必须使用 getDatasetEmbeddingModel 做严格校验。 - */ -export const findDatasetEmbeddingModel = (dataset: Partial) => { - const model = findModelData({ - modelId: normalizeModelId(dataset.vectorModelId), - model: dataset.vectorModel - }); - return model?.type === ModelTypeEnum.embedding ? model : undefined; -}; - -/** 展示态解析知识库处理模型;允许展示已停用模型,但不接受错误模型类型。 */ -export const findDatasetAgentModel = (dataset: Partial) => { - const model = findModelData({ - modelId: normalizeModelId(dataset.agentModelId), - model: dataset.agentModel - }); - return model?.type === ModelTypeEnum.llm ? model : undefined; -}; - -/** 展示态解析知识库图片理解模型;已停用模型可展示,非视觉模型按不可用处理。 */ -export const findDatasetVlmModel = (dataset: Partial) => { - const model = findModelData({ - modelId: normalizeModelId(dataset.vlmModelId), - model: dataset.vlmModel - }); - return model?.type === ModelTypeEnum.llm && model.config.vision ? model : undefined; +/** 纯引用转换:稳定 ID 优先,缺少 ID 才保留历史名称;不读取缓存,也不执行模型校验。 */ +export const getDatasetModelReference = ( + dataset: Partial, + slot: 'embedding' | 'agent' | 'vlm' +): ModelReferenceType => { + const keys = { + embedding: ['vectorModelId', 'vectorModel'], + agent: ['agentModelId', 'agentModel'], + vlm: ['vlmModelId', 'vlmModel'] + } as const; + const [idKey, nameKey] = keys[slot]; + const id = dataset[idKey]; + return { + modelId: id === undefined || id === null ? undefined : String(id), + model: dataset[nameKey] + }; }; diff --git a/packages/service/core/dataset/search/auxiliaryModels.ts b/packages/service/core/dataset/search/auxiliaryModels.ts index 69577243a01a..f8923d0e0dcd 100644 --- a/packages/service/core/dataset/search/auxiliaryModels.ts +++ b/packages/service/core/dataset/search/auxiliaryModels.ts @@ -1,33 +1,31 @@ import type { AppDatasetSearchParamsType } from '@fastgpt/global/core/app/type'; import { isModelConfigError } from '@fastgpt/global/common/error/model'; -import { - getDefaultLLMModelData, - getDefaultRerankModelData, - getLLMModelData, - getRerankModelData -} from '../../ai/model'; +import type { ModelHandle } from '../../ai/config/handle'; /** * 搜索辅助模型优先使用用户配置,未填写、停用、缺失或类型不符时回退对应系统默认。 * 默认也不可用则不启用该增强;不处理向量模型,也不吞掉非模型配置异常。 * 返回实际使用的模型对象,供搜索、计费和节点详情使用同一份配置。 */ -export const getDatasetSearchAuxiliaryModels = ({ - usingReRank, - rerankModelId, - rerankModel, - datasetSearchUsingExtensionQuery, - datasetSearchExtensionModelId, - datasetSearchExtensionModel -}: Pick< - AppDatasetSearchParamsType, - | 'usingReRank' - | 'rerankModelId' - | 'rerankModel' - | 'datasetSearchUsingExtensionQuery' - | 'datasetSearchExtensionModelId' - | 'datasetSearchExtensionModel' ->) => { +export const getDatasetSearchAuxiliaryModels = ( + { + usingReRank, + rerankModelId, + rerankModel, + datasetSearchUsingExtensionQuery, + datasetSearchExtensionModelId, + datasetSearchExtensionModel + }: Pick< + AppDatasetSearchParamsType, + | 'usingReRank' + | 'rerankModelId' + | 'rerankModel' + | 'datasetSearchUsingExtensionQuery' + | 'datasetSearchExtensionModelId' + | 'datasetSearchExtensionModel' + >, + modelHandle: ModelHandle +) => { /** 只降级模型配置错误;程序或基础设施异常仍交由外层处理。 */ const resolve = ({ enabled, @@ -53,17 +51,18 @@ export const getDatasetSearchAuxiliaryModels = ({ return { rerankModelData: resolve({ enabled: usingReRank, - getSelected: () => getRerankModelData({ modelId: rerankModelId, model: rerankModel }), - getDefault: getDefaultRerankModelData + getSelected: () => + modelHandle.getRerankModelData({ modelId: rerankModelId, model: rerankModel }), + getDefault: () => modelHandle.getDefaultModelData('rerank') }), extensionModelData: resolve({ enabled: datasetSearchUsingExtensionQuery, getSelected: () => - getLLMModelData({ + modelHandle.getLLMModelData({ modelId: datasetSearchExtensionModelId, model: datasetSearchExtensionModel }), - getDefault: getDefaultLLMModelData + getDefault: () => modelHandle.getDefaultModelData('llm') }) }; }; diff --git a/packages/service/core/dataset/search/vlm.ts b/packages/service/core/dataset/search/vlm.ts index d7756a441d95..5c9777e3b598 100644 --- a/packages/service/core/dataset/search/vlm.ts +++ b/packages/service/core/dataset/search/vlm.ts @@ -1,15 +1,21 @@ import { MongoDataset } from '../schema'; -import { findDatasetVlmModel } from '../model'; +import { getDatasetModelReference } from '../model'; +import { getModelHandle } from '../../ai/model'; +import type { ModelHandle } from '../../ai/config/handle'; /** * 搜索的视觉能力是可选增强:按知识库顺序选择第一个启用且支持视觉的模型。 * 缺失、停用、类型不符和空配置均跳过,全部不可用时返回 undefined;不影响训练的严格校验。 */ export const findFirstDatasetSearchVlmModel = ( - datasets: Parameters[0][] + datasets: Parameters[0][], + modelHandle: ModelHandle ) => { for (const dataset of datasets) { - const model = findDatasetVlmModel(dataset); + const model = modelHandle.findModelData(getDatasetModelReference(dataset, 'vlm'), { + type: 'llm', + vision: true + }); if (model?.isActive) return model; } }; @@ -19,10 +25,12 @@ export const findFirstDatasetSearchVlmModel = ( */ export const getDatasetSearchVlmModel = async ({ teamId, - datasetIds + datasetIds, + modelHandle }: { teamId: string; datasetIds: string[]; + modelHandle?: ModelHandle; }) => { if (datasetIds.length === 0) return; const datasets = await MongoDataset.find( @@ -34,6 +42,7 @@ export const getDatasetSearchVlmModel = async ({ datasetIds.flatMap((id) => { const dataset = datasetMap.get(id); return dataset ? [dataset] : []; - }) + }), + modelHandle ?? (await getModelHandle()) ); }; diff --git a/packages/service/core/workflow/dispatch/abandoned/queryExternsion.ts b/packages/service/core/workflow/dispatch/abandoned/queryExternsion.ts index a9c8711fac8e..c9b345003940 100644 --- a/packages/service/core/workflow/dispatch/abandoned/queryExternsion.ts +++ b/packages/service/core/workflow/dispatch/abandoned/queryExternsion.ts @@ -1,3 +1,4 @@ +import { getModelHandle } from '../../../ai/model'; /* Abandoned */ import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type'; @@ -5,7 +6,7 @@ import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type'; import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; -import { getDefaultEmbeddingModelData, getLLMModelData } from '../../../../core/ai/model'; + import { formatModelChars2Points } from '../../../../support/wallet/usage/utils'; import { queryExtension } from '../../../../core/ai/functions/queryExtension'; import { getHistories } from '../utils'; @@ -35,9 +36,9 @@ export const dispatchQueryExtension = async ({ if (!userChatInput) { return Promise.reject('Question is empty'); } - - const queryExtensionModel = getLLMModelData({ modelId, model }); - const embeddingModel = getDefaultEmbeddingModelData(); + const modelHandle = await getModelHandle(); + const queryExtensionModel = modelHandle.getLLMModelData({ modelId, model }); + const embeddingModel = modelHandle.getDefaultModelData('embedding'); const chatHistories = getHistories(history, histories); const { extensionQueries, inputTokens, outputTokens, embeddingTokens } = await queryExtension({ diff --git a/packages/service/core/workflow/dispatch/ai/agent/index.ts b/packages/service/core/workflow/dispatch/ai/agent/index.ts index 631961f082f4..80654c0bebfe 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/index.ts @@ -1,3 +1,4 @@ +import { getModelHandle } from '../../../../ai/model'; import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import type { DispatchNodeResultType, ModuleDispatchProps } from '../../../types/runtime'; @@ -14,7 +15,7 @@ import { getAgentDatasetParams, getSubapps } from './sub/utils'; import { useUserContext } from './adapter/userContext'; import type { AppFormEditFormType } from '@fastgpt/global/core/app/formEdit/type'; import { getLogger, LogCategories } from '../../../../../common/logger'; -import { getLLMModelData } from '../../../../ai/model'; + import { createWorkflowAgentLoopRuntime } from './adapter/runtime'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { createAgentSubAppLookup, getWorkflowAgentLoopProvider } from './utils'; @@ -110,7 +111,6 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise lang, histories, query, - chatConfig, lastInteractive, runningAppInfo, runningUserInfo, @@ -136,7 +136,8 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise } } = props; const datasetParams = getAgentDatasetParams(props.params); - const agentModel = getLLMModelData({ modelId, model }); + const modelHandle = await getModelHandle(); + const agentModel = modelHandle.getLLMModelData({ modelId, model }); // 旧 params.model 仅保留给兼容读取;Agent 请求链使用规范化 modelData。 props.params.model = agentModel.model; props.params.aiChatVision = !!(props.params.aiChatVision && agentModel.config.vision); diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/dataset/index.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/dataset/index.ts index adc9dce9d48f..05e8394c11aa 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/dataset/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/dataset/index.ts @@ -1,7 +1,7 @@ +import { getModelHandle } from '../../../../../../ai/model'; import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; import type { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; -import { getEmbeddingModelData } from '../../../../../../ai/model'; import { createLLMResponse } from '../../../../../../ai/llm/request'; import { countPromptTokens } from '../../../../../../../common/string/tiktoken/index'; import { calculateCompressionThresholds } from '../../../../../../ai/llm/compress/constants'; @@ -220,12 +220,16 @@ export const dispatchAgentDatasetSearch = async ({ datasetIds[0], 'vectorModelId vectorModel vlmModelId vlmModel' ).lean(); - const vectorModel = getEmbeddingModelData({ + const modelHandle = await getModelHandle(); + const vectorModel = modelHandle.getEmbeddingModelData({ modelId: dataset?.vectorModelId, model: dataset?.vectorModel }); - const vlmModelData = await getDatasetSearchVlmModel({ teamId, datasetIds }); - const { rerankModelData, extensionModelData } = getDatasetSearchAuxiliaryModels(datasetParams); + const vlmModelData = await getDatasetSearchVlmModel({ teamId, datasetIds, modelHandle }); + const { rerankModelData, extensionModelData } = getDatasetSearchAuxiliaryModels( + datasetParams, + modelHandle + ); const searchData: DefaultSearchDatasetDataProps = { histories: [], diff --git a/packages/service/core/workflow/dispatch/ai/chat/dispatchChatCompletion.ts b/packages/service/core/workflow/dispatch/ai/chat/dispatchChatCompletion.ts index bf7ef0603285..baeb9f632aa8 100644 --- a/packages/service/core/workflow/dispatch/ai/chat/dispatchChatCompletion.ts +++ b/packages/service/core/workflow/dispatch/ai/chat/dispatchChatCompletion.ts @@ -1,10 +1,11 @@ +import { getModelHandle } from '../../../../ai/model'; import { i18nT } from '@fastgpt/global/common/i18n/utils'; import { getQuoteTemplate } from '@fastgpt/global/core/ai/prompt/AIChat'; import { GPTMessages2Chats } from '@fastgpt/global/core/chat/adapt'; import { getHistoryPreview } from '@fastgpt/global/core/chat/utils'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { workflowSseEvent } from '@fastgpt/global/core/workflow/runtime/sse'; -import { getLLMModelData } from '../../../../ai/model'; + import { createLLMResponse } from '../../../../ai/llm/request'; import { computedMaxToken } from '../../../../ai/utils'; import { postTextCensor } from '../../../../chat/postTextCensor'; @@ -65,8 +66,8 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise { if (!content) { return getNodeErrResponse({ error: 'Input is empty' }); } - - const extractModel = getLLMModelData({ modelId, model }); + const modelHandle = await getModelHandle(); + const extractModel = modelHandle.getLLMModelData({ modelId, model }); const chatHistories = getHistories(history, histories); const memoryKey = getWorkflowSourceNodeKey({ runningAppInfo, nodeId }); diff --git a/packages/service/core/workflow/dispatch/ai/toolcall/index.ts b/packages/service/core/workflow/dispatch/ai/toolcall/index.ts index d36717c78cd6..6b602d899a1b 100644 --- a/packages/service/core/workflow/dispatch/ai/toolcall/index.ts +++ b/packages/service/core/workflow/dispatch/ai/toolcall/index.ts @@ -1,7 +1,8 @@ +import { getModelHandle } from '../../../../ai/model'; import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import type { DispatchNodeResultType } from '../../../types/runtime'; -import { getLLMModelData } from '../../../../ai/model'; + import { getAgentLoopHistories, getNodeErrResponse } from '../../utils'; import { runToolCall } from './toolCall'; import { type DispatchToolModuleProps } from './type'; @@ -41,7 +42,6 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< histories, chatConfig, lastInteractive, - runningUserInfo, runningAppInfo, externalProvider, responseChatItemId, @@ -76,7 +76,8 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< const useSandbox = isAppChat ? appSandboxAvailability?.available === true : !!useAgentSandbox; try { - const toolModel = getLLMModelData({ modelId, model }); + const modelHandle = await getModelHandle(); + const toolModel = modelHandle.getLLMModelData({ modelId, model }); const useVision = aiChatVision && toolModel.config.vision; const useAudio = aiChatAudio && toolModel.config.audio; const useVideo = aiChatVideo && toolModel.config.video; diff --git a/packages/service/core/workflow/dispatch/dataset/search.ts b/packages/service/core/workflow/dispatch/dataset/search.ts index 41fd0f02b098..2f644c971532 100644 --- a/packages/service/core/workflow/dispatch/dataset/search.ts +++ b/packages/service/core/workflow/dispatch/dataset/search.ts @@ -1,8 +1,8 @@ +import { getModelHandle } from '../../../ai/model'; import { formatModelChars2Points } from '../../../../support/wallet/usage/utils'; import type { SelectedDatasetType } from '@fastgpt/global/core/workflow/type/io'; import type { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type'; import type { DispatchNodeResultType, ModuleDispatchProps } from '../../types/runtime'; -import { getEmbeddingModelData, getLLMModelData } from '../../../ai/model'; import { deepRagSearch, defaultSearchDatasetData } from '../../../dataset/search'; import type { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; @@ -139,21 +139,28 @@ export async function dispatchDatasetSearch( datasets[0].datasetId, 'vectorModelId vectorModel vlmModelId vlmModel' ).lean(); - const vectorModel = getEmbeddingModelData({ + const modelHandle = await getModelHandle(); + const vectorModel = modelHandle.getEmbeddingModelData({ modelId: dataset?.vectorModelId, model: dataset?.vectorModel }); - const vlmModel = await getDatasetSearchVlmModel({ teamId, datasetIds }); - const { rerankModelData, extensionModelData } = getDatasetSearchAuxiliaryModels({ - usingReRank, - rerankModelId, - rerankModel, - datasetSearchUsingExtensionQuery, - datasetSearchExtensionModelId, - datasetSearchExtensionModel - }); + const vlmModel = await getDatasetSearchVlmModel({ teamId, datasetIds, modelHandle }); + const { rerankModelData, extensionModelData } = getDatasetSearchAuxiliaryModels( + { + usingReRank, + rerankModelId, + rerankModel, + datasetSearchUsingExtensionQuery, + datasetSearchExtensionModelId, + datasetSearchExtensionModel + }, + modelHandle + ); const deepSearchModelData = datasetDeepSearch - ? getLLMModelData({ modelId: datasetDeepSearchModelId, model: datasetDeepSearchModel }) + ? modelHandle.getLLMModelData({ + modelId: datasetDeepSearchModelId, + model: datasetDeepSearchModel + }) : undefined; // start search diff --git a/packages/service/env.ts b/packages/service/env.ts index 07eb1ad4fea9..3a55fedac36c 100644 --- a/packages/service/env.ts +++ b/packages/service/env.ts @@ -55,10 +55,8 @@ export const serviceEnv = createEnv({ CODE_SANDBOX_TOKEN: z.string().default('codesandbox'), // AI Proxy - AIPROXY_API_ENDPOINT: UrlSchema.optional(), - AIPROXY_API_TOKEN: z.string().optional(), - OPENAI_BASE_URL: UrlSchema.default('https://api.openai.com/v1'), - CHAT_API_KEY: z.string().optional(), + AIPROXY_API_ENDPOINT: UrlSchema, + AIPROXY_API_TOKEN: z.string().trim().min(1), PRO_URL: UrlSchema.optional(), PRO_TOKEN: z.string().min(32, 'PRO_TOKEN must be at least 32 characters').optional(), diff --git a/packages/service/env.util.ts b/packages/service/env.util.ts index 41cead96ec18..96ce8a6e286e 100644 --- a/packages/service/env.util.ts +++ b/packages/service/env.util.ts @@ -8,11 +8,21 @@ const TEST_INVOKE_TOKEN_SECRET = 'fastgpt_test_invoke_token_secret_32'; const TEST_PRO_TOKEN = 'fastgpt_test_pro_token_32_chars_min'; /** * 测试套件会在多个 workspace(包含 pro/admin 子模块)里直接导入 serviceEnv。 - * INVOKE_TOKEN_SECRET 生产启动仍要求显式配置;PRO_TOKEN 仅在启用 Pro 内部调用时配置。 + * AI Proxy 和 INVOKE_TOKEN_SECRET 生产启动仍要求显式配置;PRO_TOKEN 仅在启用 Pro 内部调用时配置。 * 仅 Vitest/测试环境允许注入稳定测试密钥,避免每个测试项目都重复维护同一组运行时密钥。 */ export const getRuntimeEnv = (): NodeJS.ProcessEnv => ({ ...process.env, + AIPROXY_API_ENDPOINT: + process.env.AIPROXY_API_ENDPOINT ?? + (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' + ? 'http://127.0.0.1:3000' + : undefined), + AIPROXY_API_TOKEN: + process.env.AIPROXY_API_TOKEN ?? + (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' + ? 'test-aiproxy-token' + : undefined), INVOKE_TOKEN_SECRET: process.env.INVOKE_TOKEN_SECRET ?? (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' diff --git a/packages/service/support/permission/model/controller.ts b/packages/service/support/permission/model/controller.ts index dd7df9292e74..091e61a53f4e 100644 --- a/packages/service/support/permission/model/controller.ts +++ b/packages/service/support/permission/model/controller.ts @@ -8,12 +8,13 @@ import { findResourceKeysByCollaboratorsPermission, getResourcePermissionsByTeam } from '../resourcePermissionService'; -import { isProVersion } from '../../../common/system/constants'; import { getTmpData, setTmpData } from '../../tmpData/controller'; import { TmpDataEnum } from '@fastgpt/global/support/tmpData/constants'; import { MongoTmpData } from '../../tmpData/schema'; import type { ClientSession } from '../../../common/mongo'; import { hashStr } from '@fastgpt/global/common/string/tools'; +import type { SystemModelDataType } from '@fastgpt/global/core/ai/model.schema'; +import { getModelHandle } from '../../../core/ai/model'; const myModelsCacheFilter = { dataId: { $regex: new RegExp(`^${TmpDataEnum.MyModels}--`) } @@ -45,6 +46,7 @@ export const getMemberModelCatalogPermission = async ({ teamId, tmbId, isTeamOwner, + catalogSnapshot, includeInactive = false }: { teamId: string; @@ -52,8 +54,19 @@ export const getMemberModelCatalogPermission = async ({ isTeamOwner: boolean; /** 仅供目录展示停用状态;执行权限调用仍保持 active 模型范围。 */ includeInactive?: boolean; + /** 调用方传入同一快照,避免权限计算期间混用目录版本。 */ + catalogSnapshot?: { models: SystemModelDataType[]; revision: number }; }) => { - const catalogModels = includeInactive ? global.systemModelList : global.systemActiveModelList; + const snapshot = + catalogSnapshot ?? + (await (async () => { + const handle = await getModelHandle(); + return { models: handle.getAllModels(), revision: handle.revision }; + })()); + const catalogModels = includeInactive + ? snapshot.models + : snapshot.models.filter((model) => model.isActive); + const catalogRevision = snapshot.revision; if (isTeamOwner) { const modelIds = catalogModels.map((model) => model.modelId); return { modelIds, version: hashStr([...modelIds].sort().join('\n')) }; @@ -66,7 +79,7 @@ export const getMemberModelCatalogPermission = async ({ type: TmpDataEnum.MyModels, metadata: cacheMetadata }); - if (cachedModels) { + if (cachedModels && (cachedModels.data.catalogRevision ?? 0) === catalogRevision) { return { modelIds: cachedModels.data.modelIds, version: cachedModels.data.version @@ -125,7 +138,8 @@ export const getMemberModelCatalogPermission = async ({ teamId, tmbId, modelIds, - version + version, + catalogRevision } }).catch(() => {}); diff --git a/packages/service/support/wallet/usage/controller.ts b/packages/service/support/wallet/usage/controller.ts index 866b6088f613..f6c3b7d8456b 100644 --- a/packages/service/support/wallet/usage/controller.ts +++ b/packages/service/support/wallet/usage/controller.ts @@ -1,3 +1,4 @@ +import { getModelHandle } from '../../../core/ai/model'; import { UsageItemTypeEnum, UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; import { MongoUsage } from './schema'; import { type ClientSession } from '../../../common/mongo'; @@ -13,7 +14,6 @@ import type { SystemModelDataType } from '@fastgpt/global/core/ai/model.schema'; import { mongoSessionRun } from '../../../common/mongo/sessionRun'; import { MongoUsageItem } from './usageItemSchema'; import { getLogger, LogCategories } from '../../../common/logger'; -import { getDefaultSTTModelData } from '../../../core/ai/model'; const logger = getLogger(LogCategories.MODULE.WALLET.USAGE); @@ -170,7 +170,7 @@ export const pushChatItemUsage = ({ }; /** 记录 STT 音频用量;source 由调用方显式指定,区分 API 与各 outLink 渠道。 */ -export const pushWhisperUsage = ({ +export const pushWhisperUsage = async ({ teamId, tmbId, duration, @@ -181,7 +181,8 @@ export const pushWhisperUsage = ({ duration: number; source: UsageSourceEnum; }) => { - const whisperModel = getDefaultSTTModelData(); + const modelHandle = await getModelHandle(); + const whisperModel = modelHandle.getDefaultModelData('stt'); const { totalPoints, modelId } = formatModelChars2Points({ model: whisperModel, diff --git a/packages/service/test/core/ai/catalog.test.ts b/packages/service/test/core/ai/catalog.test.ts index dc78f2bde3a8..15a1a6824bc0 100644 --- a/packages/service/test/core/ai/catalog.test.ts +++ b/packages/service/test/core/ai/catalog.test.ts @@ -11,7 +11,6 @@ const llm = (modelId: string, vision = false): SystemModelDataType => ({ type: ModelTypeEnum.llm, scope: 'system', isActive: true, - isCustom: false, config: { maxContext: 4096, maxResponse: 1024, quoteMaxToken: 1024, vision } }); @@ -23,7 +22,6 @@ const embedding = (modelId: string): SystemModelDataType => ({ type: ModelTypeEnum.embedding, scope: 'system', isActive: true, - isCustom: false, config: { defaultToken: 512, maxToken: 8192, weight: 100 } }); diff --git a/packages/service/test/core/ai/config.test.ts b/packages/service/test/core/ai/config.test.ts index 2863c0773d5d..77e500bb1893 100644 --- a/packages/service/test/core/ai/config.test.ts +++ b/packages/service/test/core/ai/config.test.ts @@ -1,54 +1,62 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const originalEnv = { + NODE_ENV: process.env.NODE_ENV, + VITEST: process.env.VITEST, AIPROXY_API_ENDPOINT: process.env.AIPROXY_API_ENDPOINT, AIPROXY_API_TOKEN: process.env.AIPROXY_API_TOKEN, OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, CHAT_API_KEY: process.env.CHAT_API_KEY }; +/** 重新加载模块,确保每个场景校验当前环境配置而非模块缓存。 */ const importConfig = async () => { vi.resetModules(); return import('@fastgpt/service/core/ai/config'); }; -describe('AI config defaults', () => { - afterEach(() => { - vi.stubEnv('AIPROXY_API_ENDPOINT', originalEnv.AIPROXY_API_ENDPOINT); - vi.stubEnv('AIPROXY_API_TOKEN', originalEnv.AIPROXY_API_TOKEN); - vi.stubEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL); - vi.stubEnv('CHAT_API_KEY', originalEnv.CHAT_API_KEY); - }); +beforeEach(() => { + vi.stubEnv('AIPROXY_API_ENDPOINT', 'http://aiproxy:3000'); + vi.stubEnv('AIPROXY_API_TOKEN', 'aiproxy-token'); + vi.stubEnv('OPENAI_BASE_URL', 'https://example.com/v1'); + vi.stubEnv('CHAT_API_KEY', 'sk-chat'); +}); - it('falls back to OpenAI config when AI Proxy is not explicitly configured', async () => { - vi.stubEnv('AIPROXY_API_ENDPOINT', ''); - vi.stubEnv('AIPROXY_API_TOKEN', ''); - vi.stubEnv('OPENAI_BASE_URL', 'https://example.com/v1'); - vi.stubEnv('CHAT_API_KEY', 'sk-chat'); +afterEach(() => { + vi.stubEnv('NODE_ENV', originalEnv.NODE_ENV); + vi.stubEnv('VITEST', originalEnv.VITEST); + vi.stubEnv('AIPROXY_API_ENDPOINT', originalEnv.AIPROXY_API_ENDPOINT); + vi.stubEnv('AIPROXY_API_TOKEN', originalEnv.AIPROXY_API_TOKEN); + vi.stubEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL); + vi.stubEnv('CHAT_API_KEY', originalEnv.CHAT_API_KEY); +}); +describe('AI config defaults', () => { + it('uses the required AI Proxy config instead of the legacy OpenAI environment defaults', async () => { const { openaiBaseUrl, openaiBaseKey } = await importConfig(); - expect(openaiBaseUrl).toBe('https://example.com/v1'); - expect(openaiBaseKey).toBe('sk-chat'); + expect(openaiBaseUrl).toBe('http://aiproxy:3000/v1'); + expect(openaiBaseKey).toBe('aiproxy-token'); }); - it('uses AI Proxy only when the endpoint is explicitly configured', async () => { - vi.stubEnv('AIPROXY_API_ENDPOINT', 'http://aiproxy:3000'); - vi.stubEnv('AIPROXY_API_TOKEN', 'aiproxy-token'); - vi.stubEnv('OPENAI_BASE_URL', 'https://example.com/v1'); - vi.stubEnv('CHAT_API_KEY', 'sk-chat'); + it('ignores invalid legacy environment values when AIProxy is configured', async () => { + vi.stubEnv('OPENAI_BASE_URL', 'not-a-url'); + vi.stubEnv('CHAT_API_KEY', ''); - const { openaiBaseUrl, openaiBaseKey } = await importConfig(); - - expect(openaiBaseUrl).toBe('http://aiproxy:3000/v1'); - expect(openaiBaseKey).toBe('aiproxy-token'); + const { getAIApi, getAxiosConfig } = await importConfig(); + const { ai, requestMeta } = getAIApi(); + + expect(ai.baseURL).toBe('http://aiproxy:3000/v1'); + expect(ai.apiKey).toBe('aiproxy-token'); + expect(requestMeta.usedUserOpenAIKey).toBe(false); + expect(getAxiosConfig()).toEqual({ + baseUrl: 'http://aiproxy:3000/v1', + authorization: 'Bearer aiproxy-token' + }); }); it('normalizes trailing slashes from AI Proxy endpoint', async () => { vi.stubEnv('AIPROXY_API_ENDPOINT', 'http://aiproxy:3000///'); - vi.stubEnv('AIPROXY_API_TOKEN', 'aiproxy-token'); - vi.stubEnv('OPENAI_BASE_URL', 'https://example.com/v1'); - vi.stubEnv('CHAT_API_KEY', 'sk-chat'); const { openaiBaseUrl, openaiBaseKey } = await importConfig(); @@ -56,29 +64,34 @@ describe('AI config defaults', () => { expect(openaiBaseKey).toBe('aiproxy-token'); }); - it('falls back to chat API key when AI Proxy endpoint has no token', async () => { - vi.stubEnv('AIPROXY_API_ENDPOINT', 'http://aiproxy:3000'); - vi.stubEnv('AIPROXY_API_TOKEN', undefined); - vi.stubEnv('OPENAI_BASE_URL', 'https://example.com/v1'); - vi.stubEnv('CHAT_API_KEY', 'sk-chat'); + it('trims the required AI Proxy token', async () => { + vi.stubEnv('AIPROXY_API_TOKEN', ' aiproxy-token '); - const { openaiBaseUrl, openaiBaseKey } = await importConfig(); - - expect(openaiBaseUrl).toBe('http://aiproxy:3000/v1'); - expect(openaiBaseKey).toBe('sk-chat'); + const { openaiBaseKey } = await importConfig(); + expect(openaiBaseKey).toBe('aiproxy-token'); }); - it('ignores AI Proxy token when endpoint is not configured', async () => { - vi.stubEnv('AIPROXY_API_ENDPOINT', ''); - vi.stubEnv('AIPROXY_API_TOKEN', 'aiproxy-token'); - vi.stubEnv('OPENAI_BASE_URL', 'https://example.com/v1'); - vi.stubEnv('CHAT_API_KEY', 'sk-chat'); - - const { openaiBaseUrl, openaiBaseKey } = await importConfig(); - - expect(openaiBaseUrl).toBe('https://example.com/v1'); - expect(openaiBaseKey).toBe('sk-chat'); - }); + it.each([undefined, '', ' ', 'not-a-url'])( + 'rejects an invalid AI Proxy endpoint (%s) without falling back to OPENAI_BASE_URL', + async (endpoint) => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('VITEST', undefined); + vi.stubEnv('AIPROXY_API_ENDPOINT', endpoint); + + await expect(importConfig()).rejects.toThrow('AIPROXY_API_ENDPOINT'); + } + ); + + it.each([undefined, '', ' '])( + 'rejects a missing or blank AI Proxy token (%s) without falling back to CHAT_API_KEY', + async (token) => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('VITEST', undefined); + vi.stubEnv('AIPROXY_API_TOKEN', token); + + await expect(importConfig()).rejects.toThrow('AIPROXY_API_TOKEN'); + } + ); }); describe('AI config user OpenAI account', () => { @@ -96,7 +109,7 @@ describe('AI config user OpenAI account', () => { global.systemEnv = originalSystemEnv; }); - it('should ignore user baseUrl when user key is missing', async () => { + it('uses AIProxy when the user key is missing, ignoring legacy system overrides', async () => { const { getAIApi, getAxiosConfig } = await importConfig(); expect( @@ -106,8 +119,8 @@ describe('AI config user OpenAI account', () => { } as any }) ).toEqual({ - baseUrl: 'https://system.example.com/v1', - authorization: 'Bearer system-key' + baseUrl: 'http://aiproxy:3000/v1', + authorization: 'Bearer aiproxy-token' }); expect( getAIApi({ @@ -117,7 +130,7 @@ describe('AI config user OpenAI account', () => { }).requestMeta ).toEqual({ usedUserOpenAIKey: false, - baseUrl: 'https://system.example.com/v1' + baseUrl: 'http://aiproxy:3000/v1' }); }); diff --git a/packages/service/test/core/ai/config/entity.test.ts b/packages/service/test/core/ai/config/entity.test.ts new file mode 100644 index 000000000000..04f328a735d8 --- /dev/null +++ b/packages/service/test/core/ai/config/entity.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ModelScopeEnum, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; + +// 使用测试环境的真实 MongoDB replica set,验证提交/回滚而非全局无事务 mock。 +vi.unmock(import('@fastgpt/service/common/mongo/sessionRun')); + +import { MongoAIModel } from '@fastgpt/service/core/ai/config/schema'; +import { MongoAIDefaultModel } from '@fastgpt/service/core/ai/defaultModel/schema'; +import { + readSystemModelRevision, + readSystemModelSnapshot, + runSystemModelTransaction +} from '@fastgpt/service/core/ai/config/entity'; + +const modelData = { + scope: ModelScopeEnum.system, + type: ModelTypeEnum.llm, + provider: 'OpenAI', + model: 'transaction-model', + name: 'Transaction model', + isActive: true, + config: { maxContext: 16000, maxResponse: 8000, quoteMaxToken: 12000 } +}; + +beforeEach(async () => { + await Promise.all([MongoAIModel.deleteMany({}), MongoAIDefaultModel.deleteMany({})]); +}); + +describe('runSystemModelTransaction', () => { + it('commits model data, defaults and their revision together and returns the callback result', async () => { + const modelId = await runSystemModelTransaction(async (session) => { + expect(session.inTransaction()).toBe(true); + const [model] = await MongoAIModel.create([modelData], { session }); + await MongoAIDefaultModel.updateOne( + { scope: ModelScopeEnum.system }, + { $set: { defaultModelIds: { llm: String(model._id) } } }, + { session } + ); + return String(model._id); + }); + + await expect(readSystemModelRevision()).resolves.toBe(1); + await expect(readSystemModelSnapshot()).resolves.toMatchObject({ + models: [{ model: modelData.model }], + defaultModelIds: { llm: modelId }, + revision: 1 + }); + }); + + it('rolls back both the first revision document and model writes if creation fails', async () => { + const failure = new Error('abort initial catalog write'); + + await expect( + runSystemModelTransaction(async (session) => { + await MongoAIModel.create([modelData], { session }); + throw failure; + }) + ).rejects.toBe(failure); + + await expect(MongoAIModel.countDocuments({})).resolves.toBe(0); + await expect(MongoAIDefaultModel.countDocuments({})).resolves.toBe(0); + await expect(readSystemModelRevision()).resolves.toBe(0); + }); + + it('rolls back an existing revision, model and defaults after a business failure', async () => { + const model = await MongoAIModel.create(modelData); + await MongoAIDefaultModel.create({ + scope: ModelScopeEnum.system, + catalogRevision: 7, + defaultModelIds: { llm: String(model._id) } + }); + const failure = new Error('abort catalog update'); + + await expect( + runSystemModelTransaction(async (session) => { + await MongoAIModel.updateOne( + { _id: model._id }, + { $set: { name: 'Changed' } }, + { session } + ); + await MongoAIDefaultModel.updateOne( + { scope: ModelScopeEnum.system }, + { $set: { defaultModelIds: {} } }, + { session } + ); + throw failure; + }) + ).rejects.toBe(failure); + + await expect(readSystemModelSnapshot()).resolves.toMatchObject({ + models: [{ model: modelData.model, name: modelData.name }], + defaultModelIds: { llm: String(model._id) }, + revision: 7 + }); + }); + + it('serializes concurrent commits without losing revisions or model updates', async () => { + // 预建单例,将用例聚焦在目录写冲突重试,而非集合/索引初始化竞态。 + await MongoAIDefaultModel.create({ scope: ModelScopeEnum.system, catalogRevision: 0 }); + + await Promise.all( + ['model-a', 'model-b', 'model-c'].map((model) => + runSystemModelTransaction(async (session) => { + await MongoAIModel.create([{ ...modelData, model }], { session }); + }) + ) + ); + + await expect(readSystemModelRevision()).resolves.toBe(3); + const snapshot = await readSystemModelSnapshot(); + expect(snapshot.revision).toBe(3); + expect(snapshot.models.map(({ model }) => model).sort()).toEqual([ + 'model-a', + 'model-b', + 'model-c' + ]); + }); +}); + +describe('readSystemModelRevision', () => { + it('uses revision zero for an empty catalog and historical records without a revision', async () => { + await expect(readSystemModelRevision()).resolves.toBe(0); + await MongoAIDefaultModel.collection.insertOne({ + scope: ModelScopeEnum.system, + defaultModelIds: {} + }); + + await expect(readSystemModelRevision()).resolves.toBe(0); + await runSystemModelTransaction(async () => 'migrated'); + await expect(readSystemModelRevision()).resolves.toBe(1); + }); +}); + +describe('readSystemModelSnapshot', () => { + it('returns empty defaults and revision zero when no catalog exists', async () => { + await expect(readSystemModelSnapshot()).resolves.toEqual({ + models: [], + defaultModelIds: {}, + revision: 0 + }); + }); + + it('excludes team models and orders system models newest first', async () => { + await MongoAIModel.create([ + { ...modelData, model: 'first' }, + { ...modelData, model: 'second' }, + { ...modelData, scope: ModelScopeEnum.team, model: 'team-only' } + ]); + + const snapshot = await readSystemModelSnapshot(); + + expect(snapshot.models.map(({ model }) => model)).toEqual(['second', 'first']); + expect(snapshot.defaultModelIds).toEqual({}); + expect(snapshot.revision).toBe(0); + }); + + it('does not expose in-flight data with the uncommitted revision', async () => { + const model = await MongoAIModel.create(modelData); + await MongoAIDefaultModel.create({ scope: ModelScopeEnum.system, catalogRevision: 4 }); + + await runSystemModelTransaction(async (session) => { + await MongoAIModel.updateOne( + { _id: model._id }, + { $set: { name: 'Committed name' } }, + { session } + ); + await MongoAIDefaultModel.updateOne( + { scope: ModelScopeEnum.system }, + { $set: { defaultModelIds: { llm: String(model._id) } } }, + { session } + ); + + // 独立读事务在写事务提交前只能看到完整的旧目录。 + await expect(readSystemModelSnapshot()).resolves.toMatchObject({ + models: [{ name: modelData.name }], + defaultModelIds: {}, + revision: 4 + }); + }); + + await expect(readSystemModelSnapshot()).resolves.toMatchObject({ + models: [{ name: 'Committed name' }], + defaultModelIds: { llm: String(model._id) }, + revision: 5 + }); + }); + + it('rejects invalid persisted default identifiers instead of publishing a partial snapshot', async () => { + await MongoAIDefaultModel.collection.insertOne({ + scope: ModelScopeEnum.system, + catalogRevision: 1, + defaultModelIds: { llm: 123 } + }); + + await expect(readSystemModelSnapshot()).rejects.toThrow(); + }); +}); diff --git a/packages/service/test/core/ai/config/handle.test.ts b/packages/service/test/core/ai/config/handle.test.ts new file mode 100644 index 000000000000..db31600b41dd --- /dev/null +++ b/packages/service/test/core/ai/config/handle.test.ts @@ -0,0 +1,146 @@ +import { assertModelAvailable } from '@fastgpt/service/core/ai/utils'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import type { + LLMSystemModelDataType, + ModelReferenceType, + SystemModelDataType +} from '@fastgpt/global/core/ai/model.schema'; +import { ModelErrEnum } from '@fastgpt/global/common/error/code/model'; +import { UserError, getErrText } from '@fastgpt/global/common/error/utils'; +import { createModelHandle } from '../../../../core/ai/config/handle'; + +const vlm: LLMSystemModelDataType = { + modelId: '68ee0bd23d17260b7829b137', + type: ModelTypeEnum.llm, + scope: 'system' as const, + provider: 'OpenAI', + model: 'test-vlm', + name: 'Visual model', + isActive: true, + config: { maxContext: 16000, maxResponse: 8000, quoteMaxToken: 12000, vision: true } +}; + +const createHandle = (models: SystemModelDataType[] = [vlm]) => + createModelHandle({ + models, + defaultModels: {}, + configuredDefaultModelIds: {}, + revision: 0, + version: 'test' + }); + +describe('tryGetVlmModelData', () => { + it('returns the same snapshot model as the strict getter and narrows the result after destructuring', () => { + const handle = createHandle(); + const { error, model } = handle.tryGetVlmModelData({ modelId: vlm.modelId }); + expect(error).toBeUndefined(); + if (error) throw error; + expectTypeOf(model).toEqualTypeOf(); + expect(model).toBe(handle.getVlmModelData({ modelId: vlm.modelId })); + expect(Object.isFrozen(model)).toBe(true); + }); + + it('supports legacy names only when the stable ID is absent', () => { + expect(createHandle().tryGetVlmModelData({ model: vlm.model }).model?.modelId).toBe( + vlm.modelId + ); + }); + + it.each([ + { modelId: 'missing' }, + { model: 'missing' }, + { modelId: 'missing', model: vlm.model }, + { model: vlm.name } + ])('returns a model-unavailable result for an invalid reference: %j', (reference) => { + const handle = createHandle(); + const { error, model } = handle.tryGetVlmModelData(reference); + expect(model).toBeUndefined(); + expect(error).toBeInstanceOf(UserError); + expect(error?.message).toBe(ModelErrEnum.unExist); + expect(() => handle.getVlmModelData(reference)).toThrow(ModelErrEnum.unExist); + }); + + it.each([ + { ...vlm, isActive: false }, + { ...vlm, config: { ...vlm.config, vision: false } }, + { + ...vlm, + type: ModelTypeEnum.embedding, + config: { defaultToken: 1, maxToken: 100, weight: 0, vision: true } + } + ])('returns unavailable for disabled, nonvisual or wrong-type models: $type', (model) => { + const result = createHandle([model]).tryGetVlmModelData({ modelId: vlm.modelId }); + expect(result.model).toBeUndefined(); + expect(result.error?.message).toBe(ModelErrEnum.unExist); + }); + + it.each([ + new TypeError('unexpected failure'), + new Error(ModelErrEnum.unExist), + new UserError('unrelated user error') + ])('rethrows unexpected failures unchanged: %s', (error) => { + // 使用真实输入访问异常,不 mock 严格 getter,证明结果包装不会吞掉非预期错误。 + const reference: ModelReferenceType = { + get modelId(): string { + throw error; + } + }; + expect(() => createHandle().tryGetVlmModelData(reference)).toThrow(error); + }); +}); + +describe('upstream model selection semantics', () => { + it.each([undefined, null, '', ' '])('treats empty references consistently (%s)', (modelId) => { + const handle = createHandle(); + expect(() => handle.getLLMModelData({ modelId })).toThrow(ModelErrEnum.unConfigured); + expect(handle.getLLMModelData({ modelId }, { optional: true })).toBeUndefined(); + expect(handle.getVlmModelData({ modelId }, { optional: true })).toBeUndefined(); + expect(handle.getLLMModelData({ modelId, model: vlm.model }).modelId).toBe(vlm.modelId); + expect(handle.tryGetVlmModelData({ modelId }).error?.message).toBe(ModelErrEnum.unConfigured); + }); + + it('reports disabled and wrong-type models by actual display name', () => { + for (const [model, type, vision, message] of [ + [{ ...vlm, isActive: false }, ModelTypeEnum.llm, false, 'Model is disabled: Visual model'], + [vlm, ModelTypeEnum.embedding, false, 'Model type mismatch: Visual model'], + [ + { ...vlm, config: { ...vlm.config, vision: false } }, + ModelTypeEnum.llm, + true, + 'Model type mismatch: Visual model' + ], + [ + { ...vlm, name: '', isActive: false }, + ModelTypeEnum.llm, + false, + 'Model is disabled: test-vlm' + ] + ] as const) { + expect(() => assertModelAvailable({ model, type, vision })).toThrow(ModelErrEnum.unExist); + try { + assertModelAvailable({ model, type, vision }); + } catch (error) { + expect(getErrText(error)).toBe(message); + } + } + expect(() => assertModelAvailable({ type: ModelTypeEnum.llm })).toThrow(ModelErrEnum.unExist); + }); + + it('distinguishes absent defaults from disabled defaults', () => { + expect(() => createHandle().getDefaultModelData('llm')).toThrow(ModelErrEnum.unConfigured); + const handle = createModelHandle({ + models: [vlm], + defaultModels: { llm: { ...vlm, isActive: false } }, + configuredDefaultModelIds: {}, + revision: 0, + version: 'disabled' + }); + expect(() => handle.getDefaultModelData('llm')).toThrow(ModelErrEnum.unExist); + try { + handle.getDefaultModelData('llm'); + } catch (error) { + expect(getErrText(error)).toBe('Model is disabled: Visual model'); + } + }); +}); diff --git a/packages/service/test/core/ai/config/load.test.ts b/packages/service/test/core/ai/config/load.test.ts index 7b5a992978b3..b8ec17c120e7 100644 --- a/packages/service/test/core/ai/config/load.test.ts +++ b/packages/service/test/core/ai/config/load.test.ts @@ -1,13 +1,16 @@ +import { getCachedModelHandle, publishModelHandle } from '@fastgpt/service/core/ai/config/handle'; +import { setModelTestSnapshot, getModelTestDefaults } from '@test/modelCache'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +vi.unmock(import('@fastgpt/service/common/mongo/sessionRun')); + const pluginMocks = vi.hoisted(() => ({ listModels: vi.fn() })); const reloadMocks = vi.hoisted(() => ({ clearAllMyModelsCache: vi.fn(), updateFastGPTConfigBuffer: vi.fn(), delay: vi.fn() })); -const cronMocks = vi.hoisted(() => ({ setCron: vi.fn() })); vi.mock('@fastgpt/service/core/app/provider/controller', async (importOriginal) => { const actual = @@ -37,18 +40,15 @@ vi.mock('@fastgpt/global/common/system/utils', async (importOriginal) => ({ ...(await importOriginal()), delay: reloadMocks.delay })); -vi.mock('@fastgpt/service/common/system/cron', () => ({ - setCron: cronMocks.setCron -})); - import { MongoAIModel } from '@fastgpt/service/core/ai/config/schema'; import { MongoAIDefaultModel } from '@fastgpt/service/core/ai/defaultModel/schema'; +import * as modelEntity from '@fastgpt/service/core/ai/config/entity'; +import { preloadModelProviders } from '@fastgpt/service/core/app/provider/controller'; import { LegacySystemModelCollectionName } from '@fastgpt/service/core/ai/config/constants'; import { - cronRefreshModels, loadInstalledModels, loadSystemModels, - syncPreinstalledSystemModels, + refreshModelHandle, updatedReloadSystemModel } from '@fastgpt/service/core/ai/config/utils'; @@ -78,36 +78,22 @@ describe('loadSystemModels', () => { beforeEach(async () => { pluginMocks.listModels.mockReset().mockResolvedValue([]); + vi.mocked(preloadModelProviders).mockReset().mockResolvedValue(undefined); reloadMocks.clearAllMyModelsCache.mockReset().mockResolvedValue(undefined); reloadMocks.updateFastGPTConfigBuffer.mockReset().mockResolvedValue(undefined); reloadMocks.delay.mockReset().mockResolvedValue(undefined); - cronMocks.setCron.mockReset(); await Promise.all([ MongoAIModel.deleteMany({}), MongoAIDefaultModel.deleteMany({}), legacyCollection.deleteMany({}) ]); - global.systemModelList = undefined as never; - global.systemActiveModelList = undefined as never; - global.systemModelMap = undefined as never; - global.systemDefaultModel = undefined as never; + publishModelHandle(undefined); }); afterEach(() => { vi.restoreAllMocks(); }); - it('refreshes plugin model templates every thirty minutes', async () => { - cronRefreshModels(); - - expect(cronMocks.setCron).toHaveBeenCalledWith('*/30 * * * *', expect.any(Function)); - - const refresh = cronMocks.setCron.mock.calls[0]?.[1]; - expect(refresh).toBeTypeOf('function'); - await refresh?.(); - expect(pluginMocks.listModels).toHaveBeenCalledOnce(); - }); - it('does not run the legacy migration during startup model loading', async () => { const legacy = await legacyCollection.insertOne({ model: 'legacy-llm', @@ -124,7 +110,7 @@ describe('loadSystemModels', () => { await loadSystemModels(); - expect(global.systemModelList).toEqual([]); + expect(getCachedModelHandle()?.getAllModels()).toEqual([]); await expect(MongoAIModel.findById(legacy.insertedId).lean()).resolves.toBeNull(); await expect(legacyCollection.findOne({ _id: legacy.insertedId })).resolves.not.toBeNull(); }); @@ -147,29 +133,41 @@ describe('loadSystemModels', () => { ]); await expect(loadSystemModels()).resolves.toBeUndefined(); - await expect(MongoAIModel.countDocuments()).resolves.toBe(1); - await expect(MongoAIModel.findOne({ model: pluginLlm.model })).resolves.not.toBeNull(); + await expect(MongoAIModel.countDocuments()).resolves.toBe(0); await expect(legacyCollection.countDocuments()).resolves.toBe(2); - expect(global.systemModelList).toMatchObject([{ model: pluginLlm.model }]); + expect(getCachedModelHandle()?.getAllModels()).toEqual([]); expect(reloadMocks.updateFastGPTConfigBuffer).not.toHaveBeenCalled(); }); - it('blocks startup before publishing a cache when the plugin request fails', async () => { - pluginMocks.listModels.mockRejectedValue(new Error('plugin unavailable')); + it('does not request model templates while reloading installed models', async () => { + await expect(loadSystemModels()).resolves.toBeUndefined(); + expect(pluginMocks.listModels).not.toHaveBeenCalled(); + expect(getCachedModelHandle()?.getAllModels()).toEqual([]); + }); + + it('rejects startup when required Plugin Provider metadata cannot be loaded', async () => { + const failure = new Error('Plugin Provider unavailable'); + await MongoAIModel.create(pluginLlmDocument); + vi.mocked(preloadModelProviders).mockRejectedValueOnce(failure); + + await expect(loadSystemModels()).rejects.toBe(failure); - await expect(loadSystemModels()).rejects.toThrow('plugin unavailable'); - expect(global.systemModelList).toBeUndefined(); + expect(preloadModelProviders).toHaveBeenCalledOnce(); + expect(pluginMocks.listModels).not.toHaveBeenCalled(); + expect(getCachedModelHandle()?.revision).toBeUndefined(); + expect(getCachedModelHandle()?.getAllModels()).toBeUndefined(); }); - it('preinstalls templates during initial model loading', async () => { + it('does not preinstall templates during initial model loading', async () => { pluginMocks.listModels.mockResolvedValue([pluginLlm]); await expect(legacyCollection.countDocuments()).resolves.toBe(0); await loadSystemModels(); - await expect(MongoAIModel.findOne({ model: 'plugin-llm' }).lean()).resolves.toBeTruthy(); - expect(global.systemModelList).toMatchObject([{ model: 'plugin-llm' }]); + await expect(MongoAIModel.findOne({ model: 'plugin-llm' }).lean()).resolves.toBeNull(); + expect(pluginMocks.listModels).not.toHaveBeenCalled(); + expect(getCachedModelHandle()?.getAllModels()).toEqual([]); }); it('does not invalidate member model caches during initial startup', async () => { @@ -180,27 +178,6 @@ describe('loadSystemModels', () => { expect(reloadMocks.clearAllMyModelsCache).not.toHaveBeenCalled(); }); - it('retries after another instance wins a preinstall duplicate-key race', async () => { - await MongoAIModel.create(pluginLlmDocument); - const bulkWrite = vi.spyOn(MongoAIModel, 'bulkWrite').mockRejectedValueOnce({ code: 11000 }); - - await expect( - syncPreinstalledSystemModels({ pluginDocuments: [pluginLlmDocument] }) - ).resolves.toBeUndefined(); - expect(bulkWrite).toHaveBeenCalledTimes(2); - await expect(MongoAIModel.countDocuments({ model: pluginLlmDocument.model })).resolves.toBe(1); - }); - - it('rethrows the last write error after three retries still fail', async () => { - const writeError = new Error('write failed'); - const bulkWrite = vi.spyOn(MongoAIModel, 'bulkWrite').mockRejectedValue(writeError); - - await expect( - syncPreinstalledSystemModels({ pluginDocuments: [pluginLlmDocument] }) - ).rejects.toBe(writeError); - expect(bulkWrite).toHaveBeenCalledTimes(4); - }); - it('leaves legacy records untouched when loading existing ai_models data', async () => { await MongoAIModel.create({ type: ModelTypeEnum.llm, @@ -226,7 +203,7 @@ describe('loadSystemModels', () => { await expect(MongoAIModel.findOne({ model: 'legacy-llm' })).resolves.toBeNull(); await expect(legacyCollection.countDocuments()).resolves.toBe(1); - expect(global.systemModelList).toMatchObject([{ model: 'installed-llm' }]); + expect(getCachedModelHandle()?.getAllModels()).toMatchObject([{ model: 'installed-llm' }]); }); it('does not inspect legacy data during a template hot refresh', async () => { @@ -258,19 +235,15 @@ describe('loadSystemModels', () => { expect(reloadMocks.clearAllMyModelsCache).not.toHaveBeenCalled(); }); - it('invalidates member caches when a hot refresh adds an active model', async () => { - pluginMocks.listModels.mockResolvedValue([pluginLlm]); + it('invalidates member caches when a hot refresh sees a newly installed active model', async () => { await loadSystemModels(); reloadMocks.clearAllMyModelsCache.mockClear(); - pluginMocks.listModels.mockResolvedValue([ - pluginLlm, - { - ...pluginLlm, - model: 'plugin-llm-2', - name: 'Plugin LLM 2' - } - ]); + await MongoAIModel.create({ + ...pluginLlmDocument, + model: 'installed-llm-2', + name: 'Installed LLM 2' + }); await loadSystemModels(true); @@ -288,53 +261,60 @@ describe('loadSystemModels', () => { config: { maxContext: 32000, maxResponse: 16000, quoteMaxToken: 24000 } }); - await loadInstalledModels({ pluginDocuments: [] }); + await loadInstalledModels(); expect(pluginMocks.listModels).not.toHaveBeenCalled(); - expect(global.systemModelList).toMatchObject([ - { modelId: String(model._id), model: 'installed-llm', isCustom: true } + expect(getCachedModelHandle()?.getAllModels()).toMatchObject([ + { modelId: String(model._id), model: 'installed-llm' } ]); + expect(getCachedModelHandle()?.getAllModels()?.[0]).not.toHaveProperty('isCustom'); }); - it('rejects a database model whose type conflicts with a same-name plugin template', async () => { - await MongoAIModel.create({ - type: ModelTypeEnum.embedding, + it('does not synthesize an empty price tier for legacy active models during startup', async () => { + await MongoAIModel.collection.insertOne({ + type: ModelTypeEnum.llm, provider: 'OpenAI', - model: pluginLlmDocument.model, - name: 'Conflicting embedding', + model: 'legacy-zero-price-llm', + name: 'Legacy zero price LLM', scope: 'system', isActive: true, - config: { defaultToken: 512, maxToken: 8192, weight: 100 } + inputPrice: 0, + outputPrice: 0, + config: { maxContext: 32000, maxResponse: 16000, quoteMaxToken: 24000 } }); - await expect(loadInstalledModels({ pluginDocuments: [pluginLlmDocument] })).rejects.toThrow( - 'System model type does not match plugin template' - ); + await loadInstalledModels(); + + expect(getCachedModelHandle()?.getAllModels()).toHaveLength(1); + expect(getCachedModelHandle()?.getAllModels()[0].priceTiers).toEqual([]); }); - it('accepts a database model when one of multiple same-name templates matches its type', async () => { - await MongoAIModel.create(pluginLlmDocument); + it('resolves inactive legacy model pricing with progressive fallback for admin display', async () => { + await MongoAIModel.collection.insertOne({ + type: ModelTypeEnum.llm, + provider: 'OpenAI', + model: 'inactive-legacy-priced-llm', + name: 'Inactive legacy priced LLM', + scope: 'system', + isActive: false, + priceTiers: [], + inputPrice: 0, + outputPrice: 0, + charsPointsPrice: 2, + config: { maxContext: 32000, maxResponse: 16000, quoteMaxToken: 24000 } + }); - await expect( - loadInstalledModels({ - pluginDocuments: [ - pluginLlmDocument, - { - type: ModelTypeEnum.embedding, - provider: 'OpenAI', - model: pluginLlmDocument.model, - name: 'Same-name embedding', - scope: 'system', - isActive: true, - config: { defaultToken: 512, maxToken: 8192, weight: 100 } - } - ] - }) - ).resolves.toBeUndefined(); + await loadInstalledModels(); + + expect(getCachedModelHandle()?.getAllModels()).toHaveLength(1); + expect(getCachedModelHandle()?.getAllModels()[0].priceTiers).toEqual([ + { minInputTokens: 0, inputPrice: 2, outputPrice: 2 } + ]); + expect(getCachedModelHandle()?.getActiveModels()).toEqual([]); }); - it('orders all cached models by the plugin array and derives the active list', async () => { - const pluginModels = [ + it('keeps the MongoDB newest-first order and derives the active list', async () => { + const installedModels = [ { type: ModelTypeEnum.llm, provider: 'OpenAI', @@ -361,30 +341,29 @@ describe('loadSystemModels', () => { } ]; await MongoAIModel.create([ - { ...pluginModels[2], scope: 'system' }, - { ...pluginModels[1], scope: 'system' }, - { ...pluginModels[0], scope: 'system' }, + { ...installedModels[0], scope: 'system' }, + { ...installedModels[1], scope: 'system' }, + { ...installedModels[2], scope: 'system' }, { - ...pluginModels[0], + ...installedModels[0], model: 'custom-model', name: 'Custom model', scope: 'system' } ]); - await loadInstalledModels({ pluginDocuments: pluginModels }); - - expect(global.systemModelList.map((model) => model.model)).toEqual([ - 'plugin-first', - 'plugin-second', - 'plugin-third', - 'custom-model' - ]); - expect(global.systemActiveModelList.map((model) => model.model)).toEqual([ - 'plugin-first', - 'plugin-third', - 'custom-model' - ]); + await loadInstalledModels(); + + expect( + getCachedModelHandle() + ?.getAllModels() + .map((model) => model.model) + ).toEqual(['custom-model', 'plugin-third', 'plugin-second', 'plugin-first']); + expect( + getCachedModelHandle() + ?.getActiveModels() + .map((model) => model.model) + ).toEqual(['custom-model', 'plugin-third', 'plugin-first']); }); it('loads configured system defaults from ai_default_models', async () => { @@ -402,16 +381,201 @@ describe('loadSystemModels', () => { defaultModelIds: { llm: String(model._id) } }); - await loadInstalledModels({ pluginDocuments: [] }); + await loadInstalledModels(); - expect(global.systemConfiguredDefaultModelIds).toEqual({ llm: String(model._id) }); - expect(global.systemDefaultModel.llm?.modelId).toBe(String(model._id)); + expect(getCachedModelHandle()?.configuredDefaultModelIds).toEqual({ llm: String(model._id) }); + expect(getModelTestDefaults().llm?.modelId).toBe(String(model._id)); }); it('reloads the model catalog without changing the system init buffer', async () => { - await updatedReloadSystemModel({ pluginDocuments: [] }); + await updatedReloadSystemModel(); expect(reloadMocks.updateFastGPTConfigBuffer).not.toHaveBeenCalled(); - expect(reloadMocks.delay).toHaveBeenCalledWith(1000); + expect(reloadMocks.delay).not.toHaveBeenCalled(); + expect(getCachedModelHandle()?.revision).toBe(0); + }); +}); + +describe('refreshModelHandle', () => { + beforeEach(async () => { + await Promise.all([MongoAIModel.deleteMany({}), MongoAIDefaultModel.deleteMany({})]); + publishModelHandle(undefined); + reloadMocks.clearAllMyModelsCache.mockReset().mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('loads the committed revision and its model configuration before resolving', async () => { + await MongoAIModel.create(pluginLlmDocument); + await MongoAIDefaultModel.create({ scope: 'system', catalogRevision: 2 }); + setModelTestSnapshot({ revision: 1 }); + + await refreshModelHandle(); + + expect(getCachedModelHandle()?.revision).toBe(2); + expect(getCachedModelHandle()?.getAllModels()).toMatchObject([{ model: 'plugin-llm' }]); + }); + + it('keeps the current snapshot when its revision is already current', async () => { + await MongoAIModel.create(pluginLlmDocument); + await MongoAIDefaultModel.create({ scope: 'system', catalogRevision: 2 }); + await loadInstalledModels(); + const snapshot = getCachedModelHandle()?.getAllModels(); + + await refreshModelHandle(); + + expect(getCachedModelHandle()?.getAllModels()).toBe(snapshot); + expect(getCachedModelHandle()?.revision).toBe(2); + }); + + it('reloads again when an in-flight snapshot predates the revision required by the read barrier', async () => { + await MongoAIModel.create(pluginLlmDocument); + await MongoAIDefaultModel.create({ scope: 'system', catalogRevision: 1 }); + const oldSnapshot = await modelEntity.readSystemModelSnapshot(); + let releaseOldSnapshot = () => {}; + const oldSnapshotGate = new Promise((resolve) => { + releaseOldSnapshot = resolve; + }); + const snapshotReader = vi + .spyOn(modelEntity, 'readSystemModelSnapshot') + .mockImplementationOnce(async () => { + await oldSnapshotGate; + return oldSnapshot; + }); + const inFlightLoad = loadInstalledModels(); + + await modelEntity.runSystemModelTransaction(async (session) => { + await MongoAIModel.updateOne( + { model: 'plugin-llm' }, + { $set: { name: 'Revision two model' } }, + { session } + ); + }); + + const revisionReader = vi.spyOn(modelEntity, 'readSystemModelRevision'); + let barrierFinished = false; + const barrier = refreshModelHandle().then(() => { + barrierFinished = true; + }); + + try { + // 读屏障先于测试 await 注册 continuation;权威读取完成后它已加入旧的在途加载。 + await expect(revisionReader.mock.results[0].value).resolves.toBe(2); + expect(snapshotReader).toHaveBeenCalledOnce(); + expect(barrierFinished).toBe(false); + } finally { + releaseOldSnapshot(); + await Promise.all([inFlightLoad, barrier]); + } + + expect(snapshotReader).toHaveBeenCalledTimes(2); + expect(getCachedModelHandle()?.revision).toBe(2); + expect(getCachedModelHandle()?.getAllModels()).toMatchObject([ + { model: 'plugin-llm', name: 'Revision two model' } + ]); + }); + + it('uses the local snapshot after a reload failure without publishing a new revision', async () => { + await MongoAIModel.create(pluginLlmDocument); + await MongoAIDefaultModel.create({ scope: 'system', catalogRevision: 1 }); + await loadInstalledModels(); + const snapshot = getCachedModelHandle()?.getAllModels(); + await MongoAIDefaultModel.updateOne({ scope: 'system' }, { $inc: { catalogRevision: 1 } }); + // 持久化的不合法类型使真实目录解析失败,而不是伪造加载器的行为。 + await MongoAIModel.updateOne({ model: 'plugin-llm' }, { $set: { type: 'invalid' } }); + + await expect(refreshModelHandle()).resolves.toBeUndefined(); + + expect(getCachedModelHandle()?.revision).toBe(1); + expect(getCachedModelHandle()?.getAllModels()).toBe(snapshot); + }); + + it('immediately falls back on revision read failure, including a valid empty catalog', async () => { + await loadInstalledModels(); + const snapshot = getCachedModelHandle()?.getAllModels(); + vi.spyOn(modelEntity, 'readSystemModelRevision').mockRejectedValue(new Error('DB unavailable')); + + await expect(refreshModelHandle()).resolves.toBeUndefined(); + expect(getCachedModelHandle()?.getAllModels()).toBe(snapshot); + expect(snapshot).toEqual([]); + expect(getCachedModelHandle()?.revision).toBe(0); + }); + + it('still rejects a failed initial read without a previously published snapshot', async () => { + const error = new Error('DB unavailable'); + vi.spyOn(modelEntity, 'readSystemModelRevision').mockRejectedValue(error); + await expect(refreshModelHandle()).rejects.toBe(error); + expect(getCachedModelHandle()?.revision).toBeUndefined(); + }); + + it('bounds the combined revision read and shared reload wait to five seconds', async () => { + await loadInstalledModels(); + const localSnapshot = getCachedModelHandle()?.getAllModels(); + const nextSnapshot = { models: [], defaultModelIds: {}, revision: 1 }; + let completeReload = () => {}; + const gate = new Promise((resolve) => { + completeReload = () => resolve(nextSnapshot); + }); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + vi.spyOn(modelEntity, 'readSystemModelRevision').mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(1), 3000)) + ); + const reader = vi.spyOn(modelEntity, 'readSystemModelSnapshot').mockReturnValue(gate); + let done = false; + const requests = Promise.all([refreshModelHandle(), refreshModelHandle()]).then(() => { + done = true; + }); + try { + await vi.advanceTimersByTimeAsync(4999); + expect(done).toBe(false); + expect(reader).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1); + await requests; + expect(getCachedModelHandle()?.getAllModels()).toBe(localSnapshot); + expect(getCachedModelHandle()?.revision).toBe(0); + } finally { + // race 不取消共享加载;释放后正常发布完整的新版本,避免测试遗留挂起的 single-flight。 + completeReload(); + await loadInstalledModels(); + } + expect(getCachedModelHandle()?.revision).toBe(1); + expect(reader).toHaveBeenCalledOnce(); + }); + + it('times out a hung revision read without manufacturing an initial snapshot', async () => { + let releaseRevision = () => {}; + const gate = new Promise((resolve) => { + releaseRevision = () => resolve(0); + }); + vi.spyOn(modelEntity, 'readSystemModelRevision').mockReturnValue(gate); + vi.spyOn(modelEntity, 'readSystemModelSnapshot').mockResolvedValue({ + models: [], + defaultModelIds: {}, + revision: 0 + }); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const request = expect(refreshModelHandle()).rejects.toThrow('Model catalog refresh timed out'); + await vi.advanceTimersByTimeAsync(5000); + await request; + expect(getCachedModelHandle()?.revision).toBeUndefined(); + releaseRevision(); + await loadInstalledModels(); + }); + + it('does not fail an already committed write and retries at the next read barrier', async () => { + await MongoAIDefaultModel.create({ scope: 'system', catalogRevision: 1 }); + await MongoAIModel.create({ ...pluginLlmDocument, type: 'invalid' }); + + await expect(updatedReloadSystemModel()).resolves.toBeUndefined(); + expect(getCachedModelHandle()?.revision).toBeUndefined(); + + await MongoAIModel.updateOne({ model: 'plugin-llm' }, { $set: { type: ModelTypeEnum.llm } }); + await refreshModelHandle(); + + expect(getCachedModelHandle()?.revision).toBe(1); + expect(getCachedModelHandle()?.getAllModels()).toMatchObject([{ model: 'plugin-llm' }]); }); }); diff --git a/packages/service/test/core/ai/config/service.test.ts b/packages/service/test/core/ai/config/service.test.ts new file mode 100644 index 000000000000..3fe6cd30dbb2 --- /dev/null +++ b/packages/service/test/core/ai/config/service.test.ts @@ -0,0 +1,167 @@ +import { ModelScopeEnum, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + findOne: vi.fn(), + updateOne: vi.fn(), + updateMany: vi.fn(), + querySession: vi.fn(), + updatedReloadSystemModel: vi.fn(), + session: { id: 'model-update-session' } +})); + +vi.mock('../../../../core/ai/config/schema', () => ({ + MongoAIModel: { + findOne: mocks.findOne, + updateOne: mocks.updateOne, + updateMany: mocks.updateMany + } +})); +vi.mock('../../../../core/ai/config/utils', () => ({ + updatedReloadSystemModel: mocks.updatedReloadSystemModel +})); +vi.mock('../../../../core/ai/config/entity', () => ({ + runSystemModelTransaction: vi.fn((callback: (session: unknown) => Promise) => + callback(mocks.session) + ) +})); + +import { + getSystemModelConfigUpdate, + updateSystemModelConfig, + updateSystemModelStatus +} from '../../../../core/ai/config/service'; + +const modelData = { + type: ModelTypeEnum.llm, + provider: 'OpenAI', + name: 'GPT test', + scope: ModelScopeEnum.system, + isActive: false, + config: { maxContext: 16000, maxResponse: 8000, quoteMaxToken: 12000 } +}; + +describe('system model update service', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.querySession.mockReturnValue({ + lean: vi.fn().mockResolvedValue({ type: ModelTypeEnum.llm }) + }); + mocks.findOne.mockReturnValue({ session: mocks.querySession }); + mocks.updateOne.mockResolvedValue({ matchedCount: 1 }); + mocks.updateMany.mockResolvedValue({ matchedCount: 1 }); + }); + + it('updates one existing model configuration and reloads the runtime snapshot', async () => { + await updateSystemModelConfig({ modelId: 'model-1', modelData }); + + expect(mocks.findOne).toHaveBeenCalledWith( + { _id: 'model-1', scope: ModelScopeEnum.system }, + { type: 1 } + ); + expect(mocks.updateOne).toHaveBeenCalledWith( + { _id: 'model-1', scope: ModelScopeEnum.system, type: ModelTypeEnum.llm }, + { + $set: { + provider: modelData.provider, + name: modelData.name, + isActive: modelData.isActive, + config: modelData.config + }, + $unset: { + requestUrl: 1, + requestAuth: 1, + testMode: 1, + charsPointsPrice: 1, + priceTiers: 1, + inputPrice: 1, + outputPrice: 1 + } + }, + { session: mocks.session } + ); + expect(mocks.querySession).toHaveBeenCalledWith(mocks.session); + expect(mocks.updatedReloadSystemModel).toHaveBeenCalledOnce(); + }); + + it('rejects a missing configuration target without reloading the runtime snapshot', async () => { + mocks.querySession.mockReturnValueOnce({ lean: vi.fn().mockResolvedValue(null) }); + + await expect(updateSystemModelConfig({ modelId: 'missing-model', modelData })).rejects.toBe( + 'modelUnExist' + ); + expect(mocks.updateOne).not.toHaveBeenCalled(); + expect(mocks.updatedReloadSystemModel).not.toHaveBeenCalled(); + }); + + it('rejects attempts to change the persisted model type', async () => { + mocks.querySession.mockReturnValueOnce({ + lean: vi.fn().mockResolvedValue({ type: ModelTypeEnum.embedding }) + }); + + await expect(updateSystemModelConfig({ modelId: 'model-1', modelData })).rejects.toThrow( + 'System model type cannot be changed' + ); + expect(mocks.updateOne).not.toHaveBeenCalled(); + expect(mocks.updatedReloadSystemModel).not.toHaveBeenCalled(); + }); + + it('rejects a configuration update that no longer matches without reloading', async () => { + mocks.updateOne.mockResolvedValueOnce({ matchedCount: 0 }); + + await expect(updateSystemModelConfig({ modelId: 'model-1', modelData })).rejects.toBe( + 'modelUnExist' + ); + expect(mocks.updatedReloadSystemModel).not.toHaveBeenCalled(); + }); + + it('updates every requested status inside one transaction and reloads once', async () => { + mocks.updateMany.mockResolvedValueOnce({ matchedCount: 2 }); + + await updateSystemModelStatus({ modelIds: ['model-1', 'model-2'], isActive: true }); + + expect(mocks.updateMany).toHaveBeenCalledWith( + { _id: { $in: ['model-1', 'model-2'] }, scope: ModelScopeEnum.system }, + { $set: { isActive: true } }, + { session: mocks.session } + ); + expect(mocks.updatedReloadSystemModel).toHaveBeenCalledOnce(); + }); + + it('rejects a partially matched status update without reloading the runtime snapshot', async () => { + mocks.updateMany.mockResolvedValueOnce({ matchedCount: 1 }); + + await expect( + updateSystemModelStatus({ modelIds: ['model-1', 'missing-model'], isActive: false }) + ).rejects.toBe('modelUnExist'); + expect(mocks.updatedReloadSystemModel).not.toHaveBeenCalled(); + }); +}); + +describe('getSystemModelConfigUpdate', () => { + it('unsets all legacy LLM prices even when the caller supplies them', () => { + const result = getSystemModelConfigUpdate({ + ...modelData, + inputPrice: 2, + outputPrice: 3, + charsPointsPrice: 4, + priceTiers: [] + }); + expect(result.$set).toMatchObject({ priceTiers: [] }); + expect(result.$set).not.toHaveProperty('inputPrice'); + expect(result.$set).not.toHaveProperty('outputPrice'); + expect(result.$set).not.toHaveProperty('charsPointsPrice'); + expect(result.$unset).toMatchObject({ inputPrice: 1, outputPrice: 1, charsPointsPrice: 1 }); + }); + + it('preserves the active non-LLM comprehensive price field', () => { + const result = getSystemModelConfigUpdate({ + ...modelData, + type: ModelTypeEnum.stt, + config: {}, + charsPointsPrice: 4 + }); + expect(result.$set).toMatchObject({ charsPointsPrice: 4 }); + expect(result.$unset).not.toHaveProperty('charsPointsPrice'); + }); +}); diff --git a/packages/service/test/core/ai/config/utils.test.ts b/packages/service/test/core/ai/config/utils.test.ts index 409c42dd341c..9ac30ef3cda0 100644 --- a/packages/service/test/core/ai/config/utils.test.ts +++ b/packages/service/test/core/ai/config/utils.test.ts @@ -85,7 +85,6 @@ describe('system model response filtering', () => { model: 'gpt-test', name: 'GPT test', scope: 'system' as const, - isCustom: false, requestUrl: 'https://provider.example/v1', requestAuth: 'model-secret', config: { @@ -122,7 +121,6 @@ describe('system model response filtering', () => { model: 'configured-model', name: 'Configured model', scope: 'system' as const, - isCustom: false, requestUrl: 'https://provider.example/v1', requestAuth: 'configured-secret', config: { diff --git a/packages/service/test/core/ai/embedding/index.test.ts b/packages/service/test/core/ai/embedding/index.test.ts index 816c6cbe057e..065ec2a5edc4 100644 --- a/packages/service/test/core/ai/embedding/index.test.ts +++ b/packages/service/test/core/ai/embedding/index.test.ts @@ -419,7 +419,6 @@ describe('getVectors function test', () => { type: ModelTypeEnum.embedding, scope: 'system' as const, isActive: true, - isCustom: false, ...commonOverrides, config: { defaultToken, diff --git a/packages/service/test/core/ai/functions/createQuestionGuide.test.ts b/packages/service/test/core/ai/functions/createQuestionGuide.test.ts index 914df13a29a3..351053841d7d 100644 --- a/packages/service/test/core/ai/functions/createQuestionGuide.test.ts +++ b/packages/service/test/core/ai/functions/createQuestionGuide.test.ts @@ -21,7 +21,6 @@ describe('createQuestionGuide', () => { type: ModelTypeEnum.llm, scope: 'system' as const, isActive: true, - isCustom: false, config: { maxContext: 128000, maxResponse: 4096, diff --git a/packages/service/test/core/ai/functions/queryExtension.test.ts b/packages/service/test/core/ai/functions/queryExtension.test.ts index 967d8ff13f7c..0a3717e5db32 100644 --- a/packages/service/test/core/ai/functions/queryExtension.test.ts +++ b/packages/service/test/core/ai/functions/queryExtension.test.ts @@ -37,7 +37,6 @@ const llmModel = { type: ModelTypeEnum.llm, scope: 'system' as const, isActive: true, - isCustom: false, config: { maxContext: 4000, maxResponse: 1000, @@ -53,7 +52,6 @@ const embeddingModel = { type: ModelTypeEnum.embedding, scope: 'system' as const, isActive: true, - isCustom: false, config: { defaultToken: 512, maxToken: 8192, diff --git a/packages/service/test/core/ai/hooks/useTextCosine.test.ts b/packages/service/test/core/ai/hooks/useTextCosine.test.ts index 353b6415eb97..8556ed347693 100644 --- a/packages/service/test/core/ai/hooks/useTextCosine.test.ts +++ b/packages/service/test/core/ai/hooks/useTextCosine.test.ts @@ -23,7 +23,6 @@ const embeddingModel = { type: 'embedding' as const, scope: 'system' as const, isActive: true, - isCustom: false, config: { defaultToken: 100, maxToken: 100, weight: 0 } }; diff --git a/packages/service/test/core/ai/llm/agentLoop/baseLoop.test.ts b/packages/service/test/core/ai/llm/agentLoop/baseLoop.test.ts index 0f1e22fc2810..73b71a3b0279 100644 --- a/packages/service/test/core/ai/llm/agentLoop/baseLoop.test.ts +++ b/packages/service/test/core/ai/llm/agentLoop/baseLoop.test.ts @@ -53,7 +53,6 @@ const modelData = { name: 'GPT-4', isActive: true, scope: 'system' as const, - isCustom: false, config: { maxContext: 128000, maxResponse: 4096, diff --git a/packages/service/test/core/ai/llm/agentLoop/piAgentModelBridge.test.ts b/packages/service/test/core/ai/llm/agentLoop/piAgentModelBridge.test.ts index 7b15d26a233b..0b2300611c37 100644 --- a/packages/service/test/core/ai/llm/agentLoop/piAgentModelBridge.test.ts +++ b/packages/service/test/core/ai/llm/agentLoop/piAgentModelBridge.test.ts @@ -13,7 +13,6 @@ const createLlmModel = (overrides = {}) => ({ name: 'Plain Model', isActive: true, scope: 'system' as const, - isCustom: false, requestUrl: 'https://api.example.com/v1/chat/completions', requestAuth: 'model-key', ...overrides, diff --git a/packages/service/test/core/ai/llm/compress/index.test.ts b/packages/service/test/core/ai/llm/compress/index.test.ts index f12556f9dfc0..a2a31214ff10 100644 --- a/packages/service/test/core/ai/llm/compress/index.test.ts +++ b/packages/service/test/core/ai/llm/compress/index.test.ts @@ -90,7 +90,6 @@ const model: LLMSystemModelDataType = { name: 'GPT-4', scope: 'system' as const, isActive: true, - isCustom: false, config: { maxContext: 4000, maxResponse: 1024, diff --git a/packages/service/test/core/ai/llm/request/createLLMResponse.test.ts b/packages/service/test/core/ai/llm/request/createLLMResponse.test.ts index c2d1bf86cbe5..f74d58ee6cb5 100644 --- a/packages/service/test/core/ai/llm/request/createLLMResponse.test.ts +++ b/packages/service/test/core/ai/llm/request/createLLMResponse.test.ts @@ -133,7 +133,6 @@ const createMockModelData = ( name: 'GPT-4', isActive: true, scope: 'system' as const, - isCustom: false, ...overrides, config: { maxContext: 128000, @@ -251,10 +250,6 @@ describe('createLLMResponse', () => { }); it('should use user key and default OpenAI baseUrl when only user key is provided', async () => { - const modelData = createMockModelData({ - requestUrl: 'https://model.example.com/v1/chat/completions', - requestAuth: 'model-key' - }); const createMock = vi.fn().mockResolvedValue(mockTextResponse); mockGetAIApi.mockReturnValue( createMockAIApiResult( diff --git a/packages/service/test/core/ai/llm/request/requestBody.test.ts b/packages/service/test/core/ai/llm/request/requestBody.test.ts index d34f637bd6ee..dddccf76ed27 100644 --- a/packages/service/test/core/ai/llm/request/requestBody.test.ts +++ b/packages/service/test/core/ai/llm/request/requestBody.test.ts @@ -21,7 +21,6 @@ const createModel = (overrides: Record = {}) => modelId: '68ad85a7463006c963799a05', isActive: true, scope: 'system' as const, - isCustom: false, ...overrides, config: { maxContext: 128000, diff --git a/packages/service/test/core/ai/model.test.ts b/packages/service/test/core/ai/model.test.ts index 990527d011ad..dc54da58e418 100644 --- a/packages/service/test/core/ai/model.test.ts +++ b/packages/service/test/core/ai/model.test.ts @@ -1,158 +1,160 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ModelScopeEnum, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; -import type { LLMSystemModelDataType } from '@fastgpt/global/core/ai/model.schema'; -import { - getLLMModelData, - getOptionalLLMModelData, - getOptionalVlmModelData, - getSystemDefaultModelIds, - getEmbeddingModelData, - getDefaultLLMModelData, - assertModelAvailable -} from '../../../core/ai/model'; -import { ModelErrEnum } from '@fastgpt/global/common/error/code/model'; -import { getErrText, UserError } from '@fastgpt/global/common/error/utils'; - -// 本文件验证真实模型校验,不能使用全局测试环境中绕过校验的 embedding stub。 +import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import type { SystemModelDataType } from '@fastgpt/global/core/ai/model.schema'; +import { createModelHandle, publishModelHandle } from '../../../core/ai/config/handle'; +import * as entity from '../../../core/ai/config/entity'; vi.unmock('@fastgpt/service/core/ai/model'); +import { getModelHandle, isImageEmbeddingModel } from '../../../core/ai/model'; -const modelId = '68ee0bd23d17260b7829b137'; -const modelData: LLMSystemModelDataType = { - modelId, +const model: SystemModelDataType = { + modelId: '68ee0bd23d17260b7829b137', type: ModelTypeEnum.llm, provider: 'OpenAI', - model: 'gpt-test', - name: 'GPT test display name', - scope: ModelScopeEnum.system, + model: 'test-llm', + name: 'Display name', + scope: 'system' as const, isActive: true, - isCustom: false, - config: { - maxContext: 128000, - maxResponse: 8192, - quoteMaxToken: 100000 - } + config: { maxContext: 128000, maxResponse: 8192, quoteMaxToken: 100000, vision: true } }; +const build = (models: SystemModelDataType[] = [model], revision = 0) => + createModelHandle({ + models, + defaultModels: { llm: models[0] as typeof model }, + configuredDefaultModelIds: { llm: model.modelId }, + revision, + version: 'v' + revision + }); -describe('getLLMModelData', () => { - const originalMap = global.systemModelMap; - const originalDefaults = global.systemDefaultModel; - +describe('getModelHandle', () => { beforeEach(() => { - global.systemModelMap = new Map([ - [`id:${modelId}`, modelData], - [`model:${modelData.model}`, modelData] - ]); - global.systemDefaultModel = { llm: modelData }; + publishModelHandle(build()); + vi.spyOn(entity, 'readSystemModelRevision').mockResolvedValue(0); }); + afterEach(() => vi.restoreAllMocks()); - afterEach(() => { - global.systemModelMap = originalMap; - global.systemDefaultModel = originalDefaults; + it('reuses the same handle without rebuilding an unchanged snapshot', async () => { + const first = await getModelHandle(); + const second = await getModelHandle(); + expect(second).toBe(first); + expect(first.getLLMModelData({ modelId: model.modelId }).config.maxContext).toBe(128000); }); - it('resolves modelId and returns canonical modelData', () => { - const result = getLLMModelData({ modelId }); - - expect(result.config.maxContext).toBe(128000); - expect(result).not.toHaveProperty('maxContext'); + it('keeps issued handles bound to their original model and version', async () => { + const first = await getModelHandle(); + publishModelHandle(build([{ ...model, name: 'New alias' }], 1)); + const next = await getModelHandle(); + expect(next).not.toBe(first); + expect(first.getLLMModelData({ modelId: model.modelId }).name).toBe('Display name'); + expect(first.revision).toBe(0); + expect(next.getLLMModelData({ modelId: model.modelId }).name).toBe('New alias'); }); - it('uses deprecated model only when modelId is absent', () => { - expect(getLLMModelData({ model: 'gpt-test' }).model).toBe('gpt-test'); - expect(() => - getLLMModelData({ modelId: '68ee0bd23d17260b7829b138', model: 'gpt-test' }) - ).toThrow(ModelErrEnum.unExist); - expect(getLLMModelData({ modelId: '', model: 'gpt-test' }).model).toBe('gpt-test'); + it('merges concurrent version checks and does not retry an unchanged directory', async () => { + const read = vi.mocked(entity.readSystemModelRevision); + await Promise.all([getModelHandle(), getModelHandle(), getModelHandle()]); + expect(read).toHaveBeenCalledOnce(); }); - it('does not resolve display names or missing model identifiers', () => { - expect(() => getLLMModelData({})).toThrow(ModelErrEnum.unConfigured); - expect(() => getLLMModelData({ model: 'GPT test display name' })).toThrow(ModelErrEnum.unExist); - expect(() => getLLMModelData({ model: 'missing-model' })).toThrow(ModelErrEnum.unExist); + it('falls back to the local handle on failed version reads', async () => { + const first = await getModelHandle(); + vi.mocked(entity.readSystemModelRevision).mockRejectedValue(new Error('offline')); + expect(await getModelHandle()).toBe(first); }); - it('returns undefined only when an optional model reference is empty', () => { - expect(getOptionalLLMModelData({})).toBeUndefined(); - expect(getOptionalVlmModelData({ modelId: undefined, model: undefined })).toBeUndefined(); - expect(getOptionalLLMModelData({ modelId: '', model: 'gpt-test' })?.model).toBe('gpt-test'); - expect(() => getOptionalLLMModelData({ model: 'missing-model' })).toThrow(ModelErrEnum.unExist); - expect(() => - getOptionalLLMModelData({ modelId: '68ee0bd23d17260b7829b138', model: 'gpt-test' }) - ).toThrow(ModelErrEnum.unExist); + it('rejects initial reads when no snapshot is available', async () => { + publishModelHandle(undefined); + vi.mocked(entity.readSystemModelRevision).mockRejectedValue(new Error('offline')); + await expect(getModelHandle()).rejects.toThrow('offline'); }); +}); - it('rejects disabled models for execution', () => { - const disabledModel = { ...modelData, isActive: false }; - global.systemModelMap.set(`id:${modelId}`, disabledModel); - global.systemModelMap.set(`model:${modelData.model}`, disabledModel); - expect(() => getLLMModelData({ modelId })).toThrow(ModelErrEnum.unExist); - expect(() => getLLMModelData({ model: modelData.model })).toThrow(ModelErrEnum.unExist); - try { - getLLMModelData({ modelId }); - } catch (error) { - expect(getErrText(error)).toBe('Model is disabled: GPT test display name'); +describe('model handle operations', () => { + it('does not fall back from invalid IDs or search display aliases', () => { + const handle = build(); + expect(handle.getLLMModelData({ model: model.model }).modelId).toBe(model.modelId); + expect(handle.getLLMModelData({ modelId: '', model: model.model }).modelId).toBe(model.modelId); + expect(() => handle.getLLMModelData({})).toThrow('modelUnConfigured'); + for (const ref of [{ modelId: 'missing', model: model.model }, { model: model.name }]) { + expect(() => handle.getLLMModelData(ref)).toThrow('modelUnExist'); } }); - it('reports type mismatch and unsupported vision with the actual model name', () => { - for (const run of [ - () => getEmbeddingModelData({ modelId }), - () => getOptionalVlmModelData({ modelId }) - ]) { - expect(run).toThrow(ModelErrEnum.unExist); - try { - run(); - } catch (error) { - expect(error).toBeInstanceOf(UserError); - expect(getErrText(error)).toBe('Model type mismatch: GPT test display name'); - } - } + it('optional only accepts missing references, not invalid or inactive ones', () => { + const handle = build([{ ...model, isActive: false }]); + expect(handle.getLLMModelData({}, { optional: true })).toBeUndefined(); + expect(() => handle.getLLMModelData({ modelId: model.modelId }, { optional: true })).toThrow(); + expect(handle.findModelData({ modelId: model.modelId })).toMatchObject({ isActive: false }); + expect(handle.getActiveModels()).toEqual([]); }); - it('keeps missing references distinct from delisted models and validates default state', () => { - expect(() => getLLMModelData({})).toThrow(ModelErrEnum.unConfigured); - expect(() => getLLMModelData({ modelId: 'deleted' })).toThrow(ModelErrEnum.unExist); - global.systemDefaultModel = {}; - expect(() => getDefaultLLMModelData()).toThrow(ModelErrEnum.unConfigured); - global.systemDefaultModel = { llm: { ...modelData, isActive: false } }; - try { - getDefaultLLMModelData(); - } catch (error) { - expect(getErrText(error)).toBe('Model is disabled: GPT test display name'); - } - global.systemDefaultModel = { llm: modelData }; - expect(getDefaultLLMModelData()).toBe(modelData); + it('keeps type and vision validation for runtime and display lookup', () => { + const handle = build([{ ...model, config: { ...model.config, vision: false } }]); + expect(() => handle.getVlmModelData({ modelId: model.modelId })).toThrow(); + expect(() => handle.getEmbeddingModelData({ modelId: model.modelId })).toThrow(); + expect(handle.findModelData({ modelId: model.modelId }, { type: 'embedding' })).toBeUndefined(); + expect( + handle.findModelData({ modelId: model.modelId }, { type: 'llm', vision: true }) + ).toBeUndefined(); + expect(handle.findModelData({ modelId: 'missing' })).toBeUndefined(); }); - it('accepts vision models and uses the model identifier when its display name is empty', () => { - expect(() => - assertModelAvailable({ - model: { ...modelData, config: { ...modelData.config, vision: true } }, - type: ModelTypeEnum.llm, - vision: true - }) - ).not.toThrow(); - expect(() => assertModelAvailable({ type: ModelTypeEnum.llm })).toThrow(ModelErrEnum.unExist); - try { - assertModelAvailable({ - model: { ...modelData, name: '', isActive: false }, - type: ModelTypeEnum.llm - }); - } catch (error) { - expect(getErrText(error)).toBe('Model is disabled: gpt-test'); - } + it('protects shared configuration and returns editable lookup copies', () => { + const input = structuredClone(model); + const handle = build([input]); + input.name = 'Changed input'; + const resolved = handle.getLLMModelData({ modelId: model.modelId }); + expect(resolved.name).toBe('Display name'); + expect(() => { + resolved.name = 'Wrong'; + }).toThrow(); + expect(() => { + resolved.config.maxContext = 1; + }).toThrow(); + expect(() => handle.getAllModels().pop()).toThrow(); + const copy = handle.findModelData({ modelId: model.modelId })!; + copy.name = 'Draft'; + expect(resolved.name).toBe('Display name'); + }); + + it('preserves strict defaults and optional image/title slots', () => { + const handle = build(); + expect(handle.getDefaultModelData('llm').modelId).toBe(model.modelId); + expect(handle.getSystemDefaultModelIds().llm).toBe(model.modelId); + expect(handle.getDefaultModelData('datasetImageLLM')).toBeUndefined(); + expect(handle.getDefaultModelData('chatTitleLLM')).toBeUndefined(); + expect(() => handle.getDefaultModelData('embedding')).toThrow(); }); - it.each([undefined, null, '', ' '])('treats empty references consistently (%s)', (modelId) => { - expect(() => getLLMModelData({ modelId })).toThrow(ModelErrEnum.unConfigured); - expect(getOptionalLLMModelData({ modelId })).toBeUndefined(); - expect(getOptionalVlmModelData({ modelId })).toBeUndefined(); - expect(getLLMModelData({ modelId, model: 'gpt-test' }).model).toBe('gpt-test'); + it('recognizes image embeddings without reading any cache', () => { + expect(isImageEmbeddingModel()).toBe(false); + expect(isImageEmbeddingModel({ config: { vision: true } } as never)).toBe(true); }); - it('returns effective system default model ids by model type', () => { - expect(getSystemDefaultModelIds()).toMatchObject({ - [ModelTypeEnum.llm]: modelId + it('rejects malformed default types and accepts configured visual/title defaults', () => { + const valid = createModelHandle({ + models: [model], + defaultModels: { datasetImageLLM: model, chatTitleLLM: model }, + configuredDefaultModelIds: {}, + revision: 0, + version: 'defaults' + }); + expect(valid.getDefaultModelData('datasetImageLLM')?.modelId).toBe(model.modelId); + expect(valid.getDefaultModelData('chatTitleLLM')?.modelId).toBe(model.modelId); + const invalid = createModelHandle({ + models: [model], + defaultModels: { embedding: model as never }, + configuredDefaultModelIds: {}, + revision: 0, + version: 'invalid' + }); + expect(() => invalid.getDefaultModelData('embedding')).toThrow('modelUnExist'); + const noVision = createModelHandle({ + models: [model], + defaultModels: { datasetImageLLM: { ...model, config: { ...model.config, vision: false } } }, + configuredDefaultModelIds: {}, + revision: 0, + version: 'no-vision' }); + expect(() => noVision.getDefaultModelData('datasetImageLLM')).toThrow('modelUnExist'); }); }); diff --git a/packages/service/test/core/ai/modelAccess.test.ts b/packages/service/test/core/ai/modelAccess.test.ts new file mode 100644 index 000000000000..9552045cad64 --- /dev/null +++ b/packages/service/test/core/ai/modelAccess.test.ts @@ -0,0 +1,131 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, extname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..'); + +/** 开源 CI 不检出私有 Pro 子模块;主仓库始终检查,Pro 源码存在时才追加检查范围。 */ +const getSourceScopes = (hasProSources: boolean) => [ + 'packages/service', + 'projects/app/src', + ...(hasProSources ? ['pro/admin/src'] : []) +]; +const legacyFields = new Set([ + 'systemModelList', + 'systemActiveModelList', + 'systemModelMap', + 'systemDefaultModel', + 'systemConfiguredDefaultModelIds', + 'systemModelCatalogVersion', + 'systemModelRevision', + 'llmModelMap' +]); + +describe('model access architecture', () => { + it('keeps main sources mandatory and only adds the optional Pro checkout when available', () => { + expect(getSourceScopes(false)).toEqual(['packages/service', 'projects/app/src']); + expect(getSourceScopes(true)).toEqual([ + 'packages/service', + 'projects/app/src', + 'pro/admin/src' + ]); + }); + + it('keeps production model reads behind getModelHandle without globals or internal cache access', () => { + const files: string[] = []; + const collect = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (['node_modules', '.git', '.next', 'test', 'tests', 'dist'].includes(entry.name)) + continue; + const path = resolve(dir, entry.name); + if (entry.isDirectory()) collect(path); + else if (['.ts', '.tsx'].includes(extname(path)) && !/\.(test|spec)\./.test(path)) + files.push(path); + } + }; + for (const scope of getSourceScopes(existsSync(resolve(root, 'pro/admin/src')))) + collect(resolve(root, scope)); + const violations: string[] = []; + const allowedInternals = new Set([ + 'packages/service/core/ai/model.ts', + 'packages/service/core/ai/config/utils.ts' + ]); + for (const file of files) { + const source = ts.createSourceFile( + file, + readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + true + ); + const name = relative(root, file); + const report = (node: ts.Node, reason: string) => + violations.push( + `${name}:${source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1}: ${reason}` + ); + const visit = (node: ts.Node) => { + if ( + (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && + ts.isIdentifier(node.expression) && + ['global', 'globalThis'].includes(node.expression.text) + ) { + const key = ts.isPropertyAccessExpression(node) + ? node.name.text + : ts.isStringLiteral(node.argumentExpression) + ? node.argumentExpression.text + : undefined; + if (key && legacyFields.has(key)) report(node, 'legacy model global'); + } + if ( + ts.isVariableDeclaration(node) && + node.initializer && + ['global', 'globalThis'].includes(node.initializer.getText(source)) && + ts.isObjectBindingPattern(node.name) + ) { + if ( + node.name.elements.some((entry) => + legacyFields.has((entry.propertyName ?? entry.name).getText(source)) + ) + ) + report(node, 'destructured model global'); + } + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + const spec = node.moduleSpecifier.text; + const target = spec.startsWith('@fastgpt/') + ? resolve(root, 'packages', spec.slice(9) + '.ts') + : resolve(dirname(file), spec + '.ts'); + const bindings = node.importClause?.namedBindings; + if (target === resolve(root, 'packages/service/core/ai/model.ts') && bindings) { + if ( + !ts.isNamedImports(bindings) || + bindings.elements.some( + (entry) => + !['getModelHandle', 'isImageEmbeddingModel'].includes( + (entry.propertyName ?? entry.name).text + ) + ) + ) + report(node, 'standalone model getter import'); + } + if ( + target === resolve(root, 'packages/service/core/ai/config/handle.ts') && + !allowedInternals.has(name) && + bindings && + ts.isNamedImports(bindings) + ) { + if ( + !node.importClause?.isTypeOnly && + bindings.elements.some((entry) => !entry.isTypeOnly) + ) + report(node, 'private snapshot access'); + } + if (spec.endsWith('/ai/config/runtime')) report(node, 'removed model read barrier'); + } + ts.forEachChild(node, visit); + }; + visit(source); + } + expect(violations).toEqual([]); + }); +}); diff --git a/packages/service/test/core/ai/rerank/index.test.ts b/packages/service/test/core/ai/rerank/index.test.ts index ac556a9e590d..7f1b4bae2254 100644 --- a/packages/service/test/core/ai/rerank/index.test.ts +++ b/packages/service/test/core/ai/rerank/index.test.ts @@ -52,7 +52,6 @@ const buildModel = ( type: ModelTypeEnum.rerank, scope: 'system' as const, isActive: true, - isCustom: false, ...commonOverrides, config: { maxToken, defaultConfig } }; diff --git a/packages/service/test/core/ai/utils.test.ts b/packages/service/test/core/ai/utils.test.ts index 5cce6298850c..173907a7571b 100644 --- a/packages/service/test/core/ai/utils.test.ts +++ b/packages/service/test/core/ai/utils.test.ts @@ -19,7 +19,6 @@ const mockModel = (maxResponse: number, maxTemperature?: number) => type: ModelTypeEnum.llm, scope: 'system' as const, isActive: true, - isCustom: false, config: { maxContext: 8192, maxResponse, diff --git a/packages/service/test/core/chat/title.test.ts b/packages/service/test/core/chat/title.test.ts index 72fa7f95d0e9..446969ab575e 100644 --- a/packages/service/test/core/chat/title.test.ts +++ b/packages/service/test/core/chat/title.test.ts @@ -22,7 +22,12 @@ vi.mock('@fastgpt/service/core/ai/llm/request', () => ({ })); vi.mock('@fastgpt/service/core/ai/model', () => ({ - getDefaultChatTitleModelData: getDefaultChatTitleModelDataMock + getModelHandle: async () => ({ + getDefaultModelData: (slot: string) => + (({ chatTitleLLM: getDefaultChatTitleModelDataMock }) as Record unknown>)[ + slot + ]?.() + }) })); const base = { diff --git a/packages/service/test/core/dataset/model.test.ts b/packages/service/test/core/dataset/model.test.ts index 2f29823650dc..b943f142c256 100644 --- a/packages/service/test/core/dataset/model.test.ts +++ b/packages/service/test/core/dataset/model.test.ts @@ -1,85 +1,20 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; -import { - findDatasetAgentModel, - findDatasetEmbeddingModel, - findDatasetVlmModel, - getDatasetEmbeddingModel -} from '../../../core/dataset/model'; - -const getEmbeddingModelDataMock = vi.hoisted(() => vi.fn()); -const findModelDataMock = vi.hoisted(() => vi.fn()); - -vi.mock('../../../core/ai/model', () => ({ - findModelData: findModelDataMock, - getEmbeddingModelData: getEmbeddingModelDataMock, - getLLMModelData: vi.fn(), - getOptionalVlmModelData: vi.fn() -})); - -describe('dataset model resolution', () => { - beforeEach(() => { - getEmbeddingModelDataMock.mockReset(); - findModelDataMock.mockReset(); - }); - - it('uses the legacy model only when vectorModelId is absent', () => { - getDatasetEmbeddingModel({ vectorModel: 'text-embedding-3-small' }); - - expect(getEmbeddingModelDataMock).toHaveBeenCalledWith({ +import { describe, expect, it } from 'vitest'; +import { getDatasetModelReference } from '../../../core/dataset/model'; + +describe('getDatasetModelReference', () => { + it.each([ + ['embedding', 'vectorModelId', 'vectorModel'], + ['agent', 'agentModelId', 'agentModel'], + ['vlm', 'vlmModelId', 'vlmModel'] + ] as const)('extracts the %s reference without resolving a model', (slot, idKey, nameKey) => { + expect( + getDatasetModelReference({ [idKey]: 'stable-id', [nameKey]: 'legacy-name' }, slot) + ).toEqual({ modelId: 'stable-id', model: 'legacy-name' }); + expect(getDatasetModelReference({ [nameKey]: 'legacy-name' }, slot)).toEqual({ modelId: undefined, - model: 'text-embedding-3-small' - }); - }); - - it('does not normalize an empty vectorModelId into an absent field', () => { - getDatasetEmbeddingModel({ vectorModelId: '', vectorModel: 'text-embedding-3-small' }); - - expect(getEmbeddingModelDataMock).toHaveBeenCalledWith({ - modelId: '', - model: 'text-embedding-3-small' - }); - }); - - it('returns inactive embedding models for display without weakening execution lookup', () => { - const inactiveModel = { - type: ModelTypeEnum.embedding, - isActive: false - }; - findModelDataMock.mockReturnValue(inactiveModel); - - expect(findDatasetEmbeddingModel({ vectorModelId: 'embedding-id' })).toBe(inactiveModel); - expect(findModelDataMock).toHaveBeenCalledWith({ - modelId: 'embedding-id', - model: undefined - }); - expect(getEmbeddingModelDataMock).not.toHaveBeenCalled(); - }); - - it('treats deleted and wrong-type models as unavailable display data', () => { - findModelDataMock.mockReturnValueOnce(undefined).mockReturnValueOnce({ - type: ModelTypeEnum.llm - }); - - expect(findDatasetEmbeddingModel({ vectorModelId: 'deleted-id' })).toBeUndefined(); - expect(findDatasetEmbeddingModel({ vectorModelId: 'wrong-type-id' })).toBeUndefined(); - }); - - it('returns only LLM and vision-capable models for dataset display fields', () => { - const llmModel = { - type: ModelTypeEnum.llm, - config: { vision: true }, - isActive: false - }; - findModelDataMock.mockReturnValue(llmModel); - - expect(findDatasetAgentModel({ agentModelId: 'llm-id' })).toBe(llmModel); - expect(findDatasetVlmModel({ vlmModelId: 'vlm-id' })).toBe(llmModel); - - findModelDataMock.mockReturnValue({ - type: ModelTypeEnum.llm, - config: { vision: false } + model: 'legacy-name' }); - expect(findDatasetVlmModel({ vlmModelId: 'text-only-id' })).toBeUndefined(); + expect(getDatasetModelReference({ [idKey]: '' }, slot).modelId).toBe(''); + expect(getDatasetModelReference({}, slot)).toEqual({ modelId: undefined, model: undefined }); }); }); diff --git a/packages/service/test/core/dataset/search/auxiliaryModels.test.ts b/packages/service/test/core/dataset/search/auxiliaryModels.test.ts index 2a863b9243d7..77c35dbe4878 100644 --- a/packages/service/test/core/dataset/search/auxiliaryModels.test.ts +++ b/packages/service/test/core/dataset/search/auxiliaryModels.test.ts @@ -1,3 +1,10 @@ +import { getCachedModelHandle } from '@fastgpt/service/core/ai/config/handle'; +import { + getModelTestMap, + getModelTestDefaults, + setModelTestMap, + setModelTestSnapshot +} from '@test/modelCache'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ModelScopeEnum, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; import type { @@ -5,8 +12,11 @@ import type { RerankSystemModelDataType } from '@fastgpt/global/core/ai/model.schema'; import { UserError } from '@fastgpt/global/common/error/utils'; -import * as modelGetters from '../../../../core/ai/model'; -import { getDatasetSearchAuxiliaryModels } from '../../../../core/dataset/search/auxiliaryModels'; + +import { getDatasetSearchAuxiliaryModels as resolveModels } from '../../../../core/dataset/search/auxiliaryModels'; + +const getDatasetSearchAuxiliaryModels = (input: Parameters[0]) => + resolveModels(input, getCachedModelHandle()!); describe('getDatasetSearchAuxiliaryModels', () => { const llm: LLMSystemModelDataType = { @@ -31,12 +41,12 @@ describe('getDatasetSearchAuxiliaryModels', () => { isCustom: false, config: {} }; - let previousMap: typeof global.systemModelMap; - let previousDefaults: typeof global.systemDefaultModel; + let previousMap: ReturnType; + let previousDefaults: ReturnType; beforeEach(() => { - previousMap = global.systemModelMap; - previousDefaults = global.systemDefaultModel; + previousMap = getModelTestMap(); + previousDefaults = getModelTestDefaults(); const models = [ llm, rerank, @@ -45,18 +55,20 @@ describe('getDatasetSearchAuxiliaryModels', () => { { ...llm, modelId: 'disabled-llm', model: 'disabled-llm', isActive: false }, { ...rerank, modelId: 'disabled-rerank', model: 'disabled-rerank', isActive: false } ]; - global.systemModelMap = new Map( - models.flatMap((model) => [ - [`id:${model.modelId}`, model], - [`model:${model.model}`, model] - ]) + setModelTestMap( + new Map( + models.flatMap((model) => [ + [`id:${model.modelId}`, model], + [`model:${model.model}`, model] + ]) + ) ); - global.systemDefaultModel = { llm, rerank }; + setModelTestSnapshot({ defaultModels: { llm, rerank } }); }); afterEach(() => { - global.systemModelMap = previousMap; - global.systemDefaultModel = previousDefaults; + setModelTestMap(previousMap); + setModelTestSnapshot({ defaultModels: previousDefaults }); vi.restoreAllMocks(); }); @@ -93,25 +105,28 @@ describe('getDatasetSearchAuxiliaryModels', () => { ); it('does not resolve defaults for disabled features', () => { - const llmSpy = vi.spyOn(modelGetters, 'getDefaultLLMModelData'); - const rerankSpy = vi.spyOn(modelGetters, 'getDefaultRerankModelData'); + const getDefaultModelData = vi.fn(); expect( - getDatasetSearchAuxiliaryModels({ - usingReRank: false, - datasetSearchUsingExtensionQuery: false - }) + resolveModels( + { + usingReRank: false, + datasetSearchUsingExtensionQuery: false + }, + { ...getCachedModelHandle()!, getDefaultModelData } + ) ).toEqual({ rerankModelData: undefined, extensionModelData: undefined }); - expect(llmSpy).not.toHaveBeenCalled(); - expect(rerankSpy).not.toHaveBeenCalled(); + expect(getDefaultModelData).not.toHaveBeenCalled(); }); it.each(['missing', 'disabled'])( 'skips the optional enhancement when default models are also %s', (state) => { - global.systemDefaultModel = - state === 'missing' - ? {} - : { llm: { ...llm, isActive: false }, rerank: { ...rerank, isActive: false } }; + setModelTestSnapshot({ + defaultModels: + state === 'missing' + ? {} + : { llm: { ...llm, isActive: false }, rerank: { ...rerank, isActive: false } } + }); expect( getDatasetSearchAuxiliaryModels({ usingReRank: true, @@ -124,11 +139,16 @@ describe('getDatasetSearchAuxiliaryModels', () => { it.each([new Error('unexpected failure'), new UserError('unAuth')])( 'does not swallow unrelated errors (%s)', (error) => { - vi.spyOn(modelGetters, 'getLLMModelData').mockImplementationOnce(() => { - throw error; - }); expect(() => - getDatasetSearchAuxiliaryModels({ datasetSearchUsingExtensionQuery: true }) + resolveModels( + { datasetSearchUsingExtensionQuery: true }, + { + ...getCachedModelHandle()!, + getLLMModelData: () => { + throw error; + } + } + ) ).toThrow(error); } ); diff --git a/packages/service/test/core/dataset/search/defaultRecall.test.ts b/packages/service/test/core/dataset/search/defaultRecall.test.ts index ea3615ace1e4..bc771cad87ec 100644 --- a/packages/service/test/core/dataset/search/defaultRecall.test.ts +++ b/packages/service/test/core/dataset/search/defaultRecall.test.ts @@ -36,7 +36,6 @@ const embeddingModel = { type: ModelTypeEnum.embedding, scope: 'system' as const, isActive: true, - isCustom: false, config: { defaultToken: 100, maxToken: 100, @@ -52,7 +51,6 @@ const vlmModel = { type: ModelTypeEnum.llm, scope: 'system' as const, isActive: true, - isCustom: false, config: { maxContext: 128000, maxResponse: 4096, diff --git a/packages/service/test/core/dataset/search/index.test.ts b/packages/service/test/core/dataset/search/index.test.ts index 60c610f50043..1609ccd38a2d 100644 --- a/packages/service/test/core/dataset/search/index.test.ts +++ b/packages/service/test/core/dataset/search/index.test.ts @@ -24,7 +24,6 @@ const embeddingModel: EmbeddingSystemModelDataType = { name: 'Embedding model', modelId: '68ad85a7463006c963799a01', scope: 'system' as const, - isCustom: false, type: ModelTypeEnum.embedding, config: { defaultToken: 512, @@ -38,7 +37,6 @@ const extensionModel: LLMSystemModelDataType = { name: 'Query extension model', modelId: '68ad85a7463006c963799a02', scope: 'system' as const, - isCustom: false, type: ModelTypeEnum.llm, config: { maxContext: 32000, diff --git a/packages/service/test/core/dataset/search/vlm.test.ts b/packages/service/test/core/dataset/search/vlm.test.ts index 271a891d07a1..c790c8a546b7 100644 --- a/packages/service/test/core/dataset/search/vlm.test.ts +++ b/packages/service/test/core/dataset/search/vlm.test.ts @@ -1,9 +1,11 @@ +import { getCachedModelHandle } from '@fastgpt/service/core/ai/config/handle'; +import { getModelTestMap, setModelTestMap } from '@test/modelCache'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SystemModelDataType } from '@fastgpt/global/core/ai/model.schema'; import { ModelScopeEnum, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; import { getDatasetSearchVlmModel, - findFirstDatasetSearchVlmModel + findFirstDatasetSearchVlmModel as resolveModels } from '../../../../core/dataset/search/vlm'; const findMock = vi.hoisted(() => vi.fn()); @@ -11,8 +13,11 @@ vi.mock('@fastgpt/service/core/dataset/schema', () => ({ MongoDataset: { find: findMock } })); +const findFirstDatasetSearchVlmModel = (input: Parameters[0]) => + resolveModels(input, getCachedModelHandle()!); + describe('dataset search VLM selection', () => { - let originalMap: typeof global.systemModelMap; + let originalMap: ReturnType; const activeModel: SystemModelDataType = { modelId: 'active-id', model: 'active-vision', @@ -26,7 +31,7 @@ describe('dataset search VLM selection', () => { }; beforeEach(() => { - originalMap = global.systemModelMap; + originalMap = getModelTestMap(); const models = [ activeModel, { ...activeModel, modelId: 'second-id', model: 'second-vision' }, @@ -38,17 +43,19 @@ describe('dataset search VLM selection', () => { config: { ...activeModel.config, vision: false } } ]; - global.systemModelMap = new Map( - models.flatMap((model) => [ - [`id:${model.modelId}`, model], - [`model:${model.model}`, model] - ]) + setModelTestMap( + new Map( + models.flatMap((model) => [ + [`id:${model.modelId}`, model], + [`model:${model.model}`, model] + ]) + ) ); findMock.mockReset(); }); afterEach(() => { - global.systemModelMap = originalMap; + setModelTestMap(originalMap); }); it('skips empty, deleted, disabled and non-vision models, then stops at the first usable one', () => { diff --git a/packages/service/test/core/dataset/training/controller.test.ts b/packages/service/test/core/dataset/training/controller.test.ts index 57c023face81..08f151775e1d 100644 --- a/packages/service/test/core/dataset/training/controller.test.ts +++ b/packages/service/test/core/dataset/training/controller.test.ts @@ -1,3 +1,4 @@ +import { getModelTestDefaults } from '@test/modelCache'; import { describe, expect, it } from 'vitest'; import { i18nT } from '@fastgpt/global/common/i18n/utils'; import { MongoDatasetTraining } from '@fastgpt/service/core/dataset/training/schema'; @@ -24,7 +25,7 @@ describe('dataset training controller', () => { tmbId: root.tmbId, datasetId: '507f1f77bcf86cd799439011', collectionId: '507f1f77bcf86cd799439012', - vectorModel: global.systemDefaultModel.embedding!, + vectorModel: getModelTestDefaults().embedding!, vlmModelConfigured: true, billId: 'test', mode, @@ -42,7 +43,7 @@ describe('dataset training controller', () => { tmbId: root.tmbId, datasetId: '507f1f77bcf86cd799439011', collectionId: '507f1f77bcf86cd799439012', - vectorModel: global.systemDefaultModel.embedding!, + vectorModel: getModelTestDefaults().embedding!, mode: TrainingModeEnum.imageParse, billId: 'test', data: [{ q: 'source text' }] diff --git a/packages/service/test/core/dataset/utils.test.ts b/packages/service/test/core/dataset/utils.test.ts index 1d9e6c054be6..af57b23fe52d 100644 --- a/packages/service/test/core/dataset/utils.test.ts +++ b/packages/service/test/core/dataset/utils.test.ts @@ -640,7 +640,6 @@ describe('getDatasetImageIndexCapability', () => { type: ModelTypeEnum.embedding, scope: 'system' as const, isActive: true, - isCustom: false, config: { defaultToken: 512, maxToken: 8192, @@ -656,7 +655,6 @@ describe('getDatasetImageIndexCapability', () => { type: ModelTypeEnum.llm, scope: 'system' as const, isActive: true, - isCustom: false, config: { maxContext: 32000, maxResponse: 4000, diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/index.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/index.test.ts index 8862e89dcc2b..80bee5ac4164 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agent/index.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/agent/index.test.ts @@ -75,7 +75,12 @@ const { vi.mock('@fastgpt/service/core/ai/model', async (importOriginal) => ({ ...(await importOriginal()), - getLLMModelData: getLLMModelDataMock + getModelHandle: async () => ({ + ...(await ( + await importOriginal() + ).getModelHandle()), + getLLMModelData: getLLMModelDataMock + }) })); vi.mock('@fastgpt/service/env', () => ({ diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/sub/dataset.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/sub/dataset.test.ts index 155458a78541..b0348b1e0934 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agent/sub/dataset.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/agent/sub/dataset.test.ts @@ -40,35 +40,37 @@ vi.mock('@fastgpt/service/core/dataset/utils', () => ({ })); vi.mock('@fastgpt/service/core/ai/model', () => ({ - getDefaultLLMModelData: vi.fn(), - getDefaultRerankModelData: vi.fn(), - getEmbeddingModelData: vi.fn(() => ({ - model: 'embedding-model', - name: 'Embedding Model', - config: {} - })), - getLLMModelData: vi.fn(({ model }: { model: string }) => ({ - modelId: '68ad85a7463006c963799a43', - model, - name: `${model} name`, - config: { maxContext: 1000 } - })), - getRerankModelData: vi.fn(() => undefined), - getVlmModelData: vi.fn(({ model }: { model: string }) => ({ - model, - name: `${model} name`, - config: { vision: true } - })), - getOptionalVlmModelData: vi.fn(({ modelId, model }: { modelId?: string; model?: string }) => - modelId || model - ? { - modelId, - model, - name: `${model ?? modelId} name`, - config: { vision: true } - } - : undefined - ) + getModelHandle: async () => ({ + getDefaultLLMModelData: vi.fn(), + getDefaultRerankModelData: vi.fn(), + getEmbeddingModelData: vi.fn(() => ({ + model: 'embedding-model', + name: 'Embedding Model', + config: {} + })), + getLLMModelData: vi.fn(({ model }: { model: string }) => ({ + modelId: '68ad85a7463006c963799a43', + model, + name: `${model} name`, + config: { maxContext: 1000 } + })), + getRerankModelData: vi.fn(() => undefined), + getVlmModelData: vi.fn(({ model }: { model: string }) => ({ + model, + name: `${model} name`, + config: { vision: true } + })), + getOptionalVlmModelData: vi.fn(({ modelId, model }: { modelId?: string; model?: string }) => + modelId || model + ? { + modelId, + model, + name: `${model ?? modelId} name`, + config: { vision: true } + } + : undefined + ) + }) })); vi.mock('@fastgpt/service/core/ai/llm/request', () => ({ diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/toolProvider/createWorkflowAgentToolProvider.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/toolProvider/createWorkflowAgentToolProvider.test.ts index ac3e49988fce..1fe625aee9ff 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agent/toolProvider/createWorkflowAgentToolProvider.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/agent/toolProvider/createWorkflowAgentToolProvider.test.ts @@ -20,7 +20,12 @@ const { dispatchWorkflowReadFilesMock, dispatchAgentDatasetSearchMock, getLLMMod vi.mock('@fastgpt/service/core/ai/model', async (importOriginal) => ({ ...(await importOriginal()), - getLLMModelData: getLLMModelDataMock + getModelHandle: async () => ({ + ...(await ( + await importOriginal() + ).getModelHandle()), + getLLMModelData: getLLMModelDataMock + }) })); vi.mock('@fastgpt/service/core/workflow/dispatch/ai/readFiles', () => ({ diff --git a/packages/service/test/core/workflow/dispatch/ai/extract.test.ts b/packages/service/test/core/workflow/dispatch/ai/extract.test.ts index e404c0976ca1..e3f9f4c2ed7f 100644 --- a/packages/service/test/core/workflow/dispatch/ai/extract.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/extract.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; -import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; const { createLLMResponseMock, getLLMModelMock, formatModelChars2PointsMock } = vi.hoisted(() => ({ @@ -14,14 +14,16 @@ vi.mock('@fastgpt/service/core/ai/llm/request', () => ({ })); vi.mock('@fastgpt/service/core/ai/model', () => ({ - getLLMModelData: (...args: unknown[]) => { - const model = getLLMModelMock(...args); - return { - ...model, - modelId: '68ad85a7463006c963799a65', - config: model.config ?? model - }; - } + getModelHandle: async () => ({ + getLLMModelData: (...args: unknown[]) => { + const model = getLLMModelMock(...args); + return { + ...model, + modelId: '68ad85a7463006c963799a65', + config: model.config ?? model + }; + } + }) })); vi.mock('@fastgpt/service/support/wallet/usage/utils', () => ({ diff --git a/packages/service/test/core/workflow/dispatch/ai/toolcall/index.test.ts b/packages/service/test/core/workflow/dispatch/ai/toolcall/index.test.ts index c8f0bb38eb50..af41d392580d 100644 --- a/packages/service/test/core/workflow/dispatch/ai/toolcall/index.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/toolcall/index.test.ts @@ -26,7 +26,7 @@ const { })); vi.mock('@fastgpt/service/core/ai/model', () => ({ - getLLMModelData: getLLMModelDataMock + getModelHandle: async () => ({ getLLMModelData: getLLMModelDataMock }) })); vi.mock('@fastgpt/service/core/workflow/dispatch/ai/toolcall/toolCall', () => ({ diff --git a/packages/service/test/core/workflow/dispatch/dataset/search.test.ts b/packages/service/test/core/workflow/dispatch/dataset/search.test.ts index 80a66f2c5d41..838944b3295a 100644 --- a/packages/service/test/core/workflow/dispatch/dataset/search.test.ts +++ b/packages/service/test/core/workflow/dispatch/dataset/search.test.ts @@ -42,43 +42,45 @@ vi.mock('@fastgpt/service/core/dataset/utils', () => ({ filterDatasetsByTmbId: vi.fn() })); -vi.mock('@fastgpt/service/core/ai/model', () => ({ - getDefaultLLMModelData: vi.fn(), - getDefaultRerankModelData: vi.fn(), - getEmbeddingModelData: vi.fn(() => ({ - modelId: '68ad85a7463006c963799a01', - model: 'embedding-model', - name: 'Embedding Model', - type: 'embedding', - config: {} - })), - getLLMModelData: vi.fn(() => ({ - modelId: '68ad85a7463006c963799a02', - model: 'gpt-query', - name: 'gpt-query name', - type: 'llm', - config: {} - })), - getRerankModelData: vi.fn(() => undefined), - getVlmModelData: vi.fn(() => ({ - modelId: '68ad85a7463006c963799a03', - model: 'vision-model', - name: 'gpt-vision name', - type: 'llm', - config: { vision: true } - })), - getOptionalVlmModelData: vi.fn(({ modelId, model }) => - modelId || model - ? { - modelId: '68ad85a7463006c963799a03', - model: 'vision-model', - name: 'gpt-vision name', - type: 'llm', - config: { vision: true } - } - : undefined - ) -})); +vi.mock('@fastgpt/service/core/ai/model', () => { + const handle = { + getDefaultModelData: vi.fn(), + getEmbeddingModelData: vi.fn(() => ({ + modelId: '68ad85a7463006c963799a01', + model: 'embedding-model', + name: 'Embedding Model', + type: 'embedding', + config: {} + })), + getLLMModelData: vi.fn(() => ({ + modelId: '68ad85a7463006c963799a02', + model: 'gpt-query', + name: 'gpt-query name', + type: 'llm', + config: {} + })), + getRerankModelData: vi.fn(() => undefined), + getVlmModelData: vi.fn(() => ({ + modelId: '68ad85a7463006c963799a03', + model: 'vision-model', + name: 'gpt-vision name', + type: 'llm', + config: { vision: true } + })), + getOptionalVlmModelData: vi.fn(({ modelId, model }) => + modelId || model + ? { + modelId: '68ad85a7463006c963799a03', + model: 'vision-model', + name: 'gpt-vision name', + type: 'llm', + config: { vision: true } + } + : undefined + ) + }; + return { getModelHandle: async () => handle }; +}); vi.mock('@fastgpt/service/support/wallet/usage/utils', () => ({ formatModelChars2Points: formatModelChars2PointsMock @@ -99,26 +101,24 @@ describe('dispatchDatasetSearch', () => { } as any); it('executes and bills the fallback auxiliary models when configured models are unavailable', async () => { - vi.mocked(modelGetters.getLLMModelData).mockImplementationOnce(() => { + vi.mocked((await modelGetters.getModelHandle()).getLLMModelData).mockImplementationOnce(() => { throw new UserError(ModelErrEnum.unExist); }); - vi.mocked(modelGetters.getRerankModelData).mockImplementationOnce(() => { - throw new UserError(ModelErrEnum.unConfigured); - }); - vi.mocked(modelGetters.getDefaultLLMModelData).mockReturnValueOnce({ - modelId: 'fallback-llm', - model: 'fallback-llm', - name: 'Fallback LLM', - type: 'llm', - config: {} - } as any); - vi.mocked(modelGetters.getDefaultRerankModelData).mockReturnValueOnce({ - modelId: 'fallback-rerank', - model: 'fallback-rerank', - name: 'Fallback Rerank', - type: 'rerank', - config: {} - } as any); + vi.mocked((await modelGetters.getModelHandle()).getRerankModelData).mockImplementationOnce( + () => { + throw new UserError(ModelErrEnum.unConfigured); + } + ); + vi.mocked((await modelGetters.getModelHandle()).getDefaultModelData).mockImplementation( + (slot) => + ({ + modelId: `fallback-${slot}`, + model: `fallback-${slot}`, + name: `Fallback ${slot}`, + type: slot, + config: {} + }) as any + ); defaultSearchDatasetDataMock.mockResolvedValue({ searchRes: [], embeddingTokens: 0, @@ -154,13 +154,15 @@ describe('dispatchDatasetSearch', () => { }); it('still fails immediately when the embedding model is unavailable', async () => { - vi.mocked(modelGetters.getEmbeddingModelData).mockImplementationOnce(() => { - throw new UserError(ModelErrEnum.unExist); - }); + vi.mocked((await modelGetters.getModelHandle()).getEmbeddingModelData).mockImplementationOnce( + () => { + throw new UserError(ModelErrEnum.unExist); + } + ); const result = await runSearch({ usingReRank: true, datasetSearchUsingExtensionQuery: true }); expect(result.error).toBeDefined(); expect(defaultSearchDatasetDataMock).not.toHaveBeenCalled(); - expect(modelGetters.getDefaultLLMModelData).not.toHaveBeenCalled(); + expect((await modelGetters.getModelHandle()).getDefaultModelData).not.toHaveBeenCalled(); }); beforeEach(() => { vi.clearAllMocks(); @@ -215,7 +217,8 @@ describe('dispatchDatasetSearch', () => { } as any); expect(getDatasetSearchVlmModelMock).toHaveBeenCalledWith({ teamId: 'team_1', - datasetIds: ['first', 'second'] + datasetIds: ['first', 'second'], + modelHandle: await modelGetters.getModelHandle() }); expect(defaultSearchDatasetDataMock).toHaveBeenCalledWith( expect.objectContaining({ vlmModel: undefined }) diff --git a/packages/service/test/env.util.test.ts b/packages/service/test/env.util.test.ts index 9b6902580cbd..2a975fe46c5e 100644 --- a/packages/service/test/env.util.test.ts +++ b/packages/service/test/env.util.test.ts @@ -1,12 +1,48 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + getRuntimeEnv, getAgentSandboxMissingRequiredEnvKeys, validateAgentSandboxPreviewProxyEnv, validateAgentSandboxProxyEnv, validateS3Env } from '@fastgpt/service/env.util'; +describe('getRuntimeEnv AI Proxy test defaults', () => { + afterEach(() => vi.unstubAllEnvs()); + + it.each(['vitest', 'test'])('injects isolated defaults only for %s', (mode) => { + vi.stubEnv('AIPROXY_API_ENDPOINT', undefined); + vi.stubEnv('AIPROXY_API_TOKEN', undefined); + vi.stubEnv('VITEST', mode === 'vitest' ? 'true' : undefined); + vi.stubEnv('NODE_ENV', mode === 'test' ? 'test' : 'development'); + expect(getRuntimeEnv()).toMatchObject({ + AIPROXY_API_ENDPOINT: 'http://127.0.0.1:3000', + AIPROXY_API_TOKEN: 'test-aiproxy-token' + }); + expect(process.env.AIPROXY_API_TOKEN).toBeUndefined(); + }); + + it('does not supply AI Proxy configuration in production', () => { + vi.stubEnv('AIPROXY_API_ENDPOINT', undefined); + vi.stubEnv('AIPROXY_API_TOKEN', undefined); + vi.stubEnv('VITEST', undefined); + vi.stubEnv('NODE_ENV', 'production'); + expect(getRuntimeEnv().AIPROXY_API_ENDPOINT).toBeUndefined(); + expect(getRuntimeEnv().AIPROXY_API_TOKEN).toBeUndefined(); + }); + + it('preserves explicitly configured and blank values for schema validation', () => { + vi.stubEnv('AIPROXY_API_ENDPOINT', 'https://proxy.example.com'); + vi.stubEnv('AIPROXY_API_TOKEN', ''); + vi.stubEnv('VITEST', 'true'); + expect(getRuntimeEnv()).toMatchObject({ + AIPROXY_API_ENDPOINT: 'https://proxy.example.com', + AIPROXY_API_TOKEN: '' + }); + }); +}); + describe('validateS3Env', () => { const baseEnv = { STORAGE_VENDOR: 'minio' as const, diff --git a/packages/service/test/support/permission/model/controller.test.ts b/packages/service/test/support/permission/model/controller.test.ts index b0771866b556..8d62e4bf0431 100644 --- a/packages/service/test/support/permission/model/controller.test.ts +++ b/packages/service/test/support/permission/model/controller.test.ts @@ -1,3 +1,5 @@ +import { getCachedModelHandle } from '@fastgpt/service/core/ai/config/handle'; +import { setModelTestSnapshot } from '@test/modelCache'; import { beforeEach, describe, expect, it } from 'vitest'; import { Types } from '@fastgpt/service/common/mongo'; import { TmpDataEnum } from '@fastgpt/global/support/tmpData/constants'; @@ -25,12 +27,18 @@ describe('model permission cache', () => { const activeId = new Types.ObjectId().toString(); const inactiveId = new Types.ObjectId().toString(); const hiddenId = new Types.ObjectId(); - global.systemActiveModelList = [{ modelId: activeId }] as typeof global.systemActiveModelList; - global.systemModelList = [ - { modelId: activeId }, - { modelId: inactiveId }, - { modelId: String(hiddenId) } - ] as typeof global.systemModelList; + setModelTestSnapshot({ + models: [{ modelId: activeId }] as ReturnType< + NonNullable>['getActiveModels'] + > + }); + setModelTestSnapshot({ + models: [ + { modelId: activeId }, + { modelId: inactiveId, isActive: false }, + { modelId: String(hiddenId), isActive: false } + ] as ReturnType>['getAllModels']> + }); await MongoResourcePermission.collection.insertOne({ teamId: new Types.ObjectId(teamId), resourceType: PerResourceTypeEnum.model, @@ -57,7 +65,7 @@ describe('model permission cache', () => { MongoOrgMemberModel.deleteMany({}) ]); global.feConfigs = { isPlus: true } as typeof global.feConfigs; - global.systemActiveModelList = []; + setModelTestSnapshot({ models: [] }); }); it('caches calculated model IDs for one hour and ignores expired records', async () => { @@ -66,9 +74,11 @@ describe('model permission cache', () => { const firstModelId = new Types.ObjectId().toString(); const secondModelId = new Types.ObjectId().toString(); - global.systemActiveModelList = [ - { modelId: firstModelId, model: 'first-model' } - ] as typeof global.systemActiveModelList; + setModelTestSnapshot({ + models: [{ modelId: firstModelId, model: 'first-model' }] as ReturnType< + NonNullable>['getActiveModels'] + > + }); await expect(getMemberModelIds({ teamId, tmbId, isTeamOwner: false })).resolves.toEqual([ firstModelId @@ -84,9 +94,11 @@ describe('model permission cache', () => { }); expect(cached?.expireAt.getTime()).toBeGreaterThan(Date.now() + 59 * 60 * 1000); - global.systemActiveModelList = [ - { modelId: secondModelId, model: 'second-model' } - ] as typeof global.systemActiveModelList; + setModelTestSnapshot({ + models: [{ modelId: secondModelId, model: 'second-model' }] as ReturnType< + NonNullable>['getActiveModels'] + > + }); await expect(getMemberModelIds({ teamId, tmbId, isTeamOwner: false })).resolves.toEqual([ firstModelId ]); @@ -104,13 +116,15 @@ describe('model permission cache', () => { const teamId = new Types.ObjectId().toString(); const tmbId = new Types.ObjectId().toString(); const modelIds = [new Types.ObjectId().toString(), new Types.ObjectId().toString()]; - global.systemActiveModelList = modelIds.map((modelId) => ({ - modelId, - model: modelId - })) as typeof global.systemActiveModelList; + setModelTestSnapshot({ + models: modelIds.map((modelId) => ({ + modelId, + model: modelId + })) as ReturnType>['getActiveModels']> + }); const first = await getMemberModelCatalogPermission({ teamId, tmbId, isTeamOwner: true }); - global.systemActiveModelList.reverse(); + setModelTestSnapshot({ models: [...getCachedModelHandle()!.getActiveModels()].reverse() }); const second = await getMemberModelCatalogPermission({ teamId, tmbId, isTeamOwner: true }); expect(first.version).toBe(second.version); @@ -121,9 +135,11 @@ describe('model permission cache', () => { const currentTmbId = new Types.ObjectId().toString(); const otherTmbId = new Types.ObjectId().toString(); const modelId = new Types.ObjectId().toString(); - global.systemActiveModelList = [ - { modelId, model: 'legacy-name' } - ] as typeof global.systemActiveModelList; + setModelTestSnapshot({ + models: [{ modelId, model: 'legacy-name' }] as ReturnType< + NonNullable>['getActiveModels'] + > + }); await MongoResourcePermission.create({ teamId, diff --git a/packages/service/test/support/wallet/usage/utils.test.ts b/packages/service/test/support/wallet/usage/utils.test.ts index 30875838a900..88fca5fbfb82 100644 --- a/packages/service/test/support/wallet/usage/utils.test.ts +++ b/packages/service/test/support/wallet/usage/utils.test.ts @@ -14,7 +14,6 @@ const createModel = ( provider: 'test', scope: 'system' as const, isActive: true, - isCustom: false, config: { maxContext: 1000, maxResponse: 100, quoteMaxToken: 500 } }); @@ -45,12 +44,14 @@ const mockModels: Record = { }; vi.mock('@fastgpt/service/core/ai/model', () => ({ - findModelData: (reference: { modelId?: string; model?: string }) => { - if (reference.modelId) { - return Object.values(mockModels).find((model) => model.modelId === reference.modelId); + getModelHandle: async () => ({ + findModelData: (reference: { modelId?: string; model?: string }) => { + if (reference.modelId) { + return Object.values(mockModels).find((model) => model.modelId === reference.modelId); + } + return reference.model ? mockModels[reference.model] : undefined; } - return reference.model ? mockModels[reference.model] : undefined; - } + }) })); describe('formatModelChars2Points', () => { diff --git a/packages/service/test/thirdProvider/aiproxy/channel.test.ts b/packages/service/test/thirdProvider/aiproxy/channel.test.ts new file mode 100644 index 000000000000..017a50b6d6bd --- /dev/null +++ b/packages/service/test/thirdProvider/aiproxy/channel.test.ts @@ -0,0 +1,438 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + get: vi.fn(), + put: vi.fn(), + getAIProxyAdminConfig: vi.fn() +})); + +vi.mock('../../../common/api/axios', () => ({ + axiosWithoutSSRF: { get: mocks.get, put: mocks.put } +})); +vi.mock('../../../thirdProvider/aiproxy/config', () => ({ + getAIProxyAdminConfig: mocks.getAIProxyAdminConfig +})); + +import { + appendModelsToAIProxyChannels, + getAdminAIProxyChannelItems, + getAIProxyChannelList, + removeModelsFromAIProxyChannels, + replaceModelInAIProxyChannels, + withTemporaryModelChannelBinding +} from '../../../thirdProvider/aiproxy/channel'; + +const channels = [ + { + id: 1, + type: 1, + name: 'channel-1', + base_url: 'https://example.com/v1', + key: 'secret', + status: 1, + priority: 2, + model_mapping: {}, + proxy_url: 'https://proxy.example.com', + configs: { region: 'us-west' }, + sets: ['default'], + enabled_auto_balance_check: true, + balance_threshold: 0, + skip_tls_verify: true, + enabled_no_permission_ban: true, + warn_error_rate: 0.2, + max_error_rate: 0.5, + models: ['existing-model'] + }, + { + id: 2, + type: 1, + name: 'channel-2', + models: [] + } +]; + +describe('appendModelsToAIProxyChannels', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getAIProxyAdminConfig.mockReturnValue({ + baseUrl: 'https://aiproxy.example.com', + token: 'admin-token' + }); + mocks.get.mockResolvedValue({ data: { success: true, data: channels } }); + mocks.put.mockResolvedValue({ data: { success: true } }); + global.aiproxyChannelsCache = [ + { + channelId: 1, + name: { en: 'OpenAI', 'zh-CN': 'OpenAI', 'zh-Hant': 'OpenAI' }, + avatar: 'model/openai' + } + ]; + }); + + it('returns the validated channel snapshot for server-side aggregation', async () => { + await expect(getAIProxyChannelList()).resolves.toEqual(channels); + expect(mocks.get).toHaveBeenCalledWith('https://aiproxy.example.com/api/channels/all', { + headers: { Authorization: 'Bearer admin-token' } + }); + }); + + it('formats and sorts channels for administrator model views', async () => { + mocks.get.mockResolvedValue({ + data: { + success: true, + data: [ + { ...channels[0], id: 3, status: 2, priority: 10, created_at: 100 }, + { ...channels[0], id: 2, status: 1, priority: 1, created_at: 300 }, + { ...channels[0], id: 1, type: 9, status: 1, priority: 5, created_at: 200 } + ] + } + }); + + const result = await getAdminAIProxyChannelItems(); + + expect(result.map((item) => item.summary.id)).toEqual([2, 1, 3]); + expect(result[0].summary.protocol).toEqual({ + name: { en: 'OpenAI', 'zh-CN': 'OpenAI', 'zh-Hant': 'OpenAI' }, + avatar: 'model/openai' + }); + expect(result[1].summary.protocol).toEqual({ + name: { en: '9', 'zh-CN': '9', 'zh-Hant': '9' }, + avatar: '' + }); + }); + + it('replaces the complete association set for an immutable model identifier', async () => { + await replaceModelInAIProxyChannels({ + model: 'existing-model', + channelIds: [2] + }); + + expect(mocks.put).toHaveBeenNthCalledWith( + 1, + 'https://aiproxy.example.com/api/channel/1', + expect.objectContaining({ models: [] }), + expect.objectContaining({ + headers: { Authorization: 'Bearer admin-token' }, + signal: expect.any(AbortSignal), + timeout: 30000 + }) + ); + expect(mocks.put).toHaveBeenNthCalledWith( + 2, + 'https://aiproxy.example.com/api/channel/2', + expect.objectContaining({ models: ['existing-model'] }), + expect.objectContaining({ + headers: { Authorization: 'Bearer admin-token' }, + signal: expect.any(AbortSignal), + timeout: 30000 + }) + ); + }); + + it('validates every selected channel before replacing any association', async () => { + await expect( + replaceModelInAIProxyChannels({ + model: 'existing-model', + channelIds: [1, 9] + }) + ).rejects.toThrow('AI Proxy channel does not exist: 9'); + + expect(mocks.put).not.toHaveBeenCalled(); + }); + + it('does not rewrite channels when the submitted association set is unchanged', async () => { + await replaceModelInAIProxyChannels({ + model: 'existing-model', + channelIds: [1] + }); + + expect(mocks.put).not.toHaveBeenCalled(); + }); + + it('accepts the null model mapping returned by AI Proxy', async () => { + mocks.get.mockResolvedValue({ + data: { + success: true, + data: [{ ...channels[0], model_mapping: null }] + } + }); + + await replaceModelInAIProxyChannels({ + model: 'existing-model', + channelIds: [1] + }); + + expect(mocks.put).not.toHaveBeenCalled(); + }); + + it('deduplicates models and updates requested channels in order', async () => { + await appendModelsToAIProxyChannels({ + channelIds: [1, 1, 2], + models: ['new-model', 'new-model'] + }); + + expect(mocks.get).toHaveBeenCalledWith('https://aiproxy.example.com/api/channels/all', { + headers: { Authorization: 'Bearer admin-token' } + }); + expect(mocks.put).toHaveBeenNthCalledWith( + 1, + 'https://aiproxy.example.com/api/channel/1', + expect.objectContaining({ models: ['existing-model', 'new-model'] }), + expect.objectContaining({ + headers: { Authorization: 'Bearer admin-token' }, + signal: expect.any(AbortSignal), + timeout: 30000 + }) + ); + expect(mocks.put).toHaveBeenNthCalledWith( + 2, + 'https://aiproxy.example.com/api/channel/2', + expect.objectContaining({ models: ['new-model'] }), + expect.objectContaining({ + headers: { Authorization: 'Bearer admin-token' }, + signal: expect.any(AbortSignal), + timeout: 30000 + }) + ); + }); + + it('preserves every mutable channel setting supported by the full update endpoint', async () => { + await appendModelsToAIProxyChannels({ channelIds: [1], models: ['new-model'] }); + + expect(mocks.put).toHaveBeenCalledWith( + 'https://aiproxy.example.com/api/channel/1', + { + type: 1, + name: 'channel-1', + base_url: 'https://example.com/v1', + proxy_url: 'https://proxy.example.com', + model_mapping: {}, + configs: { region: 'us-west' }, + key: 'secret', + status: 1, + priority: 2, + sets: ['default'], + enabled_auto_balance_check: true, + skip_tls_verify: true, + enabled_no_permission_ban: true, + warn_error_rate: 0.2, + max_error_rate: 0.5, + models: ['existing-model', 'new-model'] + }, + expect.objectContaining({ + headers: { Authorization: 'Bearer admin-token' }, + signal: expect.any(AbortSignal), + timeout: 30000 + }) + ); + }); + + it('rejects an update that would silently reset a non-zero balance threshold', async () => { + mocks.get.mockResolvedValue({ + data: { + success: true, + data: [channels[0], { ...channels[1], balance_threshold: 10 }] + } + }); + + await expect( + appendModelsToAIProxyChannels({ channelIds: [1, 2], models: ['new-model'] }) + ).rejects.toThrow('cannot preserve balance_threshold for channel: 2'); + expect(mocks.put).not.toHaveBeenCalled(); + }); + + it('does not require AI Proxy configuration when no association is requested', async () => { + await appendModelsToAIProxyChannels({ channelIds: [], models: ['new-model'] }); + + expect(mocks.getAIProxyAdminConfig).not.toHaveBeenCalled(); + expect(mocks.get).not.toHaveBeenCalled(); + }); + + it('validates every requested channel before writing any channel', async () => { + await expect( + appendModelsToAIProxyChannels({ channelIds: [1, 9], models: ['new-model'] }) + ).rejects.toThrow('AI Proxy channel does not exist: 9'); + + expect(mocks.put).not.toHaveBeenCalled(); + }); + + it('stops after the first failed channel update without compensating it', async () => { + mocks.put.mockRejectedValueOnce(new Error('update failed')); + + await expect( + appendModelsToAIProxyChannels({ channelIds: [1, 2], models: ['new-model'] }) + ).rejects.toThrow('update failed'); + + expect(mocks.put).toHaveBeenCalledTimes(1); + }); +}); + +describe('removeModelsFromAIProxyChannels', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getAIProxyAdminConfig.mockReturnValue({ + baseUrl: 'https://aiproxy.example.com', + token: 'admin-token' + }); + mocks.get.mockResolvedValue({ + data: { + success: true, + data: [ + { ...channels[0], models: ['delete-a', 'keep', 'delete-b', 'delete-a'] }, + { ...channels[1], models: ['keep'] } + ] + } + }); + mocks.put.mockResolvedValue({ data: { success: true } }); + }); + + it('removes every requested model occurrence and only updates affected channels', async () => { + await removeModelsFromAIProxyChannels({ models: ['delete-a', 'delete-b', 'delete-a'] }); + + expect(mocks.put).toHaveBeenCalledOnce(); + expect(mocks.put).toHaveBeenCalledWith( + 'https://aiproxy.example.com/api/channel/1', + expect.objectContaining({ models: ['keep'] }), + expect.objectContaining({ + headers: { Authorization: 'Bearer admin-token' }, + signal: expect.any(AbortSignal), + timeout: 30000 + }) + ); + }); + + it('does not require AI Proxy configuration for an empty model set', async () => { + await removeModelsFromAIProxyChannels({ models: [] }); + + expect(mocks.getAIProxyAdminConfig).not.toHaveBeenCalled(); + expect(mocks.get).not.toHaveBeenCalled(); + expect(mocks.put).not.toHaveBeenCalled(); + }); + + it('stops on the first failed channel update without compensation', async () => { + mocks.get.mockResolvedValue({ + data: { + success: true, + data: [ + { ...channels[0], id: 1, models: ['delete-a'] }, + { ...channels[0], id: 2, models: ['delete-a'] } + ] + } + }); + mocks.put.mockRejectedValueOnce(new Error('update failed')); + + await expect(removeModelsFromAIProxyChannels({ models: ['delete-a'] })).rejects.toThrow( + 'update failed' + ); + + expect(mocks.put).toHaveBeenCalledTimes(1); + }); +}); + +describe('withTemporaryModelChannelBinding', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getAIProxyAdminConfig.mockReturnValue({ + baseUrl: 'https://aiproxy.example.com', + token: 'admin-token' + }); + mocks.get.mockResolvedValue({ data: { success: true, data: channels } }); + mocks.put.mockResolvedValue({ data: { success: true } }); + }); + + const mockDraftBindingSnapshots = () => { + mocks.get.mockResolvedValueOnce({ data: { success: true, data: channels } }).mockResolvedValue({ + data: { + success: true, + data: [channels[0], { ...channels[1], models: ['draft-model'] }] + } + }); + }; + + it('restores the original channel set after a draft test', async () => { + mockDraftBindingSnapshots(); + await expect( + withTemporaryModelChannelBinding({ + model: 'draft-model', + channelId: 2, + run: async () => 'tested' + }) + ).resolves.toBe('tested'); + + expect(mocks.put).toHaveBeenNthCalledWith( + 1, + 'https://aiproxy.example.com/api/channel/2', + expect.objectContaining({ models: ['draft-model'] }), + expect.any(Object) + ); + expect(mocks.put).toHaveBeenNthCalledWith( + 2, + 'https://aiproxy.example.com/api/channel/2', + expect.objectContaining({ models: [] }), + expect.any(Object) + ); + }); + + it('restores the original channel set when the draft test fails', async () => { + mockDraftBindingSnapshots(); + await expect( + withTemporaryModelChannelBinding({ + model: 'draft-model', + channelId: 2, + run: async () => Promise.reject(new Error('test failed')) + }) + ).rejects.toThrow('test failed'); + + expect(mocks.put).toHaveBeenCalledTimes(2); + expect(mocks.put).toHaveBeenLastCalledWith( + 'https://aiproxy.example.com/api/channel/2', + expect.objectContaining({ models: [] }), + expect.any(Object) + ); + }); + + it('only removes its temporary binding and preserves latest unrelated configuration', async () => { + mocks.get + .mockResolvedValueOnce({ data: { success: true, data: channels } }) + .mockResolvedValueOnce({ + data: { + success: true, + data: [ + { ...channels[0], models: ['existing-model', 'draft-model'] }, + { ...channels[1], name: 'updated-channel', models: ['draft-model', 'other-model'] } + ] + } + }); + await withTemporaryModelChannelBinding({ + model: 'draft-model', + channelId: 2, + run: async () => undefined + }); + expect(mocks.put).toHaveBeenCalledTimes(2); + expect(mocks.put).toHaveBeenLastCalledWith( + 'https://aiproxy.example.com/api/channel/2', + expect.objectContaining({ name: 'updated-channel', models: ['other-model'] }), + expect.any(Object) + ); + }); + + it('rejects a missing channel before running the model test', async () => { + const run = vi.fn(); + await expect( + withTemporaryModelChannelBinding({ model: 'draft-model', channelId: 999, run }) + ).rejects.toThrow('AI Proxy channel does not exist: 999'); + expect(run).not.toHaveBeenCalled(); + expect(mocks.put).not.toHaveBeenCalled(); + }); + + it('does not rewrite an already bound target channel', async () => { + await withTemporaryModelChannelBinding({ + model: 'existing-model', + channelId: 1, + run: async () => undefined + }); + + expect(mocks.get).toHaveBeenCalledOnce(); + expect(mocks.put).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/service/thirdProvider/aiproxy/channel.ts b/packages/service/thirdProvider/aiproxy/channel.ts new file mode 100644 index 000000000000..7f9089d3c3ba --- /dev/null +++ b/packages/service/thirdProvider/aiproxy/channel.ts @@ -0,0 +1,305 @@ +import { getModelProviderMetadata } from '../../core/app/provider/controller'; +import { withAIProxyChannelMutation } from './lease'; +import { z } from 'zod'; +import { axiosWithoutSSRF } from '../../common/api/axios'; +import { getAIProxyAdminConfig } from './config'; + +const AIProxyChannelSchema = z + .object({ + id: z.number().int().positive(), + models: z.array(z.string()), + type: z.number().int(), + name: z.string(), + base_url: z.string().optional(), + proxy_url: z.string().nullable().optional(), + // AI Proxy 会把未配置的模型映射返回为 null,更新时需原样透传。 + model_mapping: z.record(z.string(), z.unknown()).nullable().optional(), + configs: z.record(z.string(), z.unknown()).nullable().optional(), + key: z.string().optional(), + status: z.number().int().optional(), + priority: z.number().optional(), + sets: z.array(z.string()).nullable().optional(), + enabled_auto_balance_check: z.boolean().optional(), + balance_threshold: z.number().optional(), + skip_tls_verify: z.boolean().optional(), + enabled_no_permission_ban: z.boolean().optional(), + warn_error_rate: z.number().optional(), + max_error_rate: z.number().optional(), + created_at: z.number().optional() + }) + .passthrough(); + +const AIProxyChannelListResponseSchema = z.object({ + success: z.literal(true), + data: z.array(AIProxyChannelSchema) +}); + +const AIProxyMutationResponseSchema = z.object({ + success: z.literal(true) +}); + +type AIProxyChannel = z.infer; + +/** 阻止 AI Proxy v0.6.5 的完整更新接口静默破坏无法往返的字段。 */ +const assertChannelUpdateSupported = (channel: AIProxyChannel) => { + if (channel.balance_threshold !== undefined && channel.balance_threshold !== 0) { + throw new Error(`AI Proxy v0.6.5 cannot preserve balance_threshold for channel: ${channel.id}`); + } +}; + +const getChannelUpdateData = (channel: AIProxyChannel, models: string[]) => { + assertChannelUpdateSupported(channel); + + return { + type: channel.type, + name: channel.name, + base_url: channel.base_url, + proxy_url: channel.proxy_url, + model_mapping: channel.model_mapping, + configs: channel.configs, + key: channel.key, + status: channel.status, + priority: channel.priority, + sets: channel.sets, + enabled_auto_balance_check: channel.enabled_auto_balance_check, + skip_tls_verify: channel.skip_tls_verify, + enabled_no_permission_ban: channel.enabled_no_permission_ban, + warn_error_rate: channel.warn_error_rate, + max_error_rate: channel.max_error_rate, + models + }; +}; + +/** 读取 AI Proxy 的完整渠道快照,供模型绑定查询与替换共用。 */ +const getAIProxyChannels = async () => { + const { baseUrl, token } = getAIProxyAdminConfig(); + const headers = { Authorization: `Bearer ${token}` }; + // AI Proxy v0.6.5 的 /channels/all 是无分页接口,查询参数不会参与服务端处理。 + const { data: response } = await axiosWithoutSSRF.get(`${baseUrl}/api/channels/all`, { headers }); + + return { + channels: AIProxyChannelListResponseSchema.parse(response).data, + baseUrl, + headers + }; +}; + +/** 读取 AI Proxy 的完整渠道列表,供服务端聚合模型与渠道关系。 */ +export const getAIProxyChannelList = async () => { + const { channels } = await getAIProxyChannels(); + return channels; +}; + +/** + * 聚合管理员模型界面需要的渠道展示信息。 + * + * 名称和状态来自 AI Proxy,协议名称与图标来自 Plugin 缓存;结果统一按创建时间倒序, + * 保证模型列表、详情弹窗、关联弹窗与渠道管理页看到相同的渠道顺序。 + */ +export const getAdminAIProxyChannelItems = async () => { + const channels = await getAIProxyChannelList(); + const protocolMap = new Map( + getModelProviderMetadata().aiproxyChannels.map((protocol) => [protocol.channelId, protocol]) + ); + + return [...channels] + .sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0) || b.id - a.id) + .map((channel) => { + const protocol = protocolMap.get(channel.type); + + return { + models: channel.models, + summary: { + id: channel.id, + name: channel.name, + protocol: protocol + ? { name: protocol.name, avatar: protocol.avatar } + : { + name: { + en: String(channel.type), + 'zh-CN': String(channel.type), + 'zh-Hant': String(channel.type) + }, + avatar: '' + }, + status: channel.status ?? 0 + } + }; + }); +}; + +/** + * 用目标渠道集合替换不可变模型标识的绑定。 + * + * 渠道按顺序更新且不补偿已成功项,保持已确认的跨 AI Proxy 操作失败语义。 + */ +export const replaceModelInAIProxyChannels = async ({ + model, + channelIds +}: { + model: string; + channelIds: number[]; +}) => { + const selectedIds = new Set(channelIds); + return withAIProxyChannelMutation(async ({ signal, assertValid }) => { + const { channels, baseUrl, headers } = await getAIProxyChannels(); + const channelMap = new Map(channels.map((channel) => [channel.id, channel])); + + for (const channelId of selectedIds) { + if (!channelMap.has(channelId)) { + throw new Error(`AI Proxy channel does not exist: ${channelId}`); + } + } + + // 所有可提前识别的不兼容都必须在第一次外部写入前失败。 + for (const channel of channels) { + const shouldBind = selectedIds.has(channel.id); + if (shouldBind !== channel.models.includes(model)) assertChannelUpdateSupported(channel); + } + + for (const channel of channels) { + const shouldBind = selectedIds.has(channel.id); + const hasModel = channel.models.includes(model); + if (shouldBind === hasModel) continue; + + const nextModels = shouldBind + ? [...new Set([...channel.models, model])] + : channel.models.filter((channelModel) => channelModel !== model); + + assertValid(); + const { data: updateResponse } = await axiosWithoutSSRF.put( + `${baseUrl}/api/channel/${channel.id}`, + getChannelUpdateData(channel, nextModels), + { headers, signal, timeout: 30000 } + ); + AIProxyMutationResponseSchema.parse(updateResponse); + } + }); +}; + +/** + * 从全部 AI Proxy 渠道中移除一组不可变模型标识。 + * + * 删除模型时在 MongoDB 删除提交和缓存刷新后执行;解绑失败不恢复已经删除的模型。 + * 渠道按快照顺序更新且不补偿已成功项,与现有跨系统写入失败语义保持一致。 + */ +export const removeModelsFromAIProxyChannels = async ({ models }: { models: string[] }) => { + const modelSet = new Set(models); + if (modelSet.size === 0) return; + + return withAIProxyChannelMutation(async ({ signal, assertValid }) => { + const { channels, baseUrl, headers } = await getAIProxyChannels(); + + for (const channel of channels) { + if (channel.models.some((model) => modelSet.has(model))) + assertChannelUpdateSupported(channel); + } + + for (const channel of channels) { + const nextModels = channel.models.filter((model) => !modelSet.has(model)); + if (nextModels.length === channel.models.length) continue; + + assertValid(); + const { data: updateResponse } = await axiosWithoutSSRF.put( + `${baseUrl}/api/channel/${channel.id}`, + getChannelUpdateData(channel, nextModels), + { headers, signal, timeout: 30000 } + ); + AIProxyMutationResponseSchema.parse(updateResponse); + } + }); +}; + +/** + * 把模型标识追加到指定 AI Proxy 渠道。 + * + * 渠道按顺序逐个提交;任一渠道失败立即终止,之前成功的渠道不回滚。这与模型创建的 + * 已确认跨系统失败语义一致:只有所有渠道绑定成功后,调用方才可以开始 MongoDB 事务。 + */ +export const appendModelsToAIProxyChannels = async ({ + channelIds, + models +}: { + channelIds: number[]; + models: string[]; +}) => { + const uniqueChannelIds = [...new Set(channelIds)]; + const uniqueModels = [...new Set(models)]; + if (uniqueChannelIds.length === 0 || uniqueModels.length === 0) return; + + return withAIProxyChannelMutation(async ({ signal, assertValid }) => { + const { channels, baseUrl, headers } = await getAIProxyChannels(); + const channelMap = new Map(channels.map((channel) => [channel.id, channel])); + + // 先校验完整目标集合,避免后面的无效 ID 让前面渠道已被部分写入。 + for (const channelId of uniqueChannelIds) { + if (!channelMap.has(channelId)) { + throw new Error(`AI Proxy channel does not exist: ${channelId}`); + } + } + + for (const channelId of uniqueChannelIds) { + assertChannelUpdateSupported(channelMap.get(channelId)!); + } + + for (const channelId of uniqueChannelIds) { + const channel = channelMap.get(channelId)!; + + assertValid(); + const { data: updateResponse } = await axiosWithoutSSRF.put( + `${baseUrl}/api/channel/${channelId}`, + getChannelUpdateData(channel, [...new Set([...channel.models, ...uniqueModels])]), + { headers, signal, timeout: 30000 } + ); + AIProxyMutationResponseSchema.parse(updateResponse); + } + }); +}; + +/** + * 为未落库的模型测试临时建立渠道绑定,并在测试完成后恢复原绑定集合。 + * AI Proxy 通过渠道的 models 字段选择实际连接,草稿模型尚未进入该字段时即使请求 + * 带有 Aiproxy-Channel 也会被判定为模型不存在,因此测试期间必须短暂补齐绑定。 + */ +export const withTemporaryModelChannelBinding = async ({ + model, + channelId, + run +}: { + model: string; + channelId: number; + run: () => Promise; +}): Promise => { + return withAIProxyChannelMutation(async ({ signal, assertValid }) => { + const { channels, baseUrl, headers } = await getAIProxyChannels(); + const channel = channels.find((item) => item.id === channelId); + if (!channel) throw new Error(`AI Proxy channel does not exist: ${channelId}`); + if (channel.models.includes(model)) return run(); + + /** 在同一租约内只修改目标渠道,恢复时保留最新快照中的其他配置。 */ + const writeModels = async (target: AIProxyChannel, models: string[]) => { + const payload = getChannelUpdateData(target, models); + assertValid(); + const { data } = await axiosWithoutSSRF.put(`${baseUrl}/api/channel/${channelId}`, payload, { + headers, + signal, + timeout: 30000 + }); + AIProxyMutationResponseSchema.parse(data); + }; + + await writeModels(channel, [...channel.models, model]); + try { + return await run(); + } finally { + const { channels: latestChannels } = await getAIProxyChannels(); + const latest = latestChannels.find((item) => item.id === channelId); + if (latest?.models.includes(model)) { + await writeModels( + latest, + latest.models.filter((item) => item !== model) + ); + } + } + }); +}; diff --git a/packages/service/thirdProvider/aiproxy/config.ts b/packages/service/thirdProvider/aiproxy/config.ts index ceefab446bcc..ad5aca8d191a 100644 --- a/packages/service/thirdProvider/aiproxy/config.ts +++ b/packages/service/thirdProvider/aiproxy/config.ts @@ -3,7 +3,8 @@ import { serviceEnv } from '../../env'; export const aiProxyApiEndpoint = serviceEnv.AIPROXY_API_ENDPOINT; export const aiProxyApiToken = serviceEnv.AIPROXY_API_TOKEN; -export const hasAIProxyApiEndpoint = () => !!aiProxyApiEndpoint; +/** AI Proxy 是启动必填依赖;保留布尔查询供现有前端配置契约使用。 */ +export const hasAIProxyApiEndpoint = () => true; export const getAIProxyAdminConfig = () => { if (!aiProxyApiEndpoint || !aiProxyApiToken) { diff --git a/packages/service/thirdProvider/aiproxy/lease.ts b/packages/service/thirdProvider/aiproxy/lease.ts new file mode 100644 index 000000000000..e251058fd2ec --- /dev/null +++ b/packages/service/thirdProvider/aiproxy/lease.ts @@ -0,0 +1,31 @@ +import { + LeaseCache, + RedisLeaseUnavailableError, + type RedisLeaseContext +} from '@fastgpt/dal/redis/caches'; +import { UserError } from '@fastgpt/global/common/error/utils'; +import { getLogger, LogCategories } from '../../common/logger'; + +const channelLease = new LeaseCache({ logger: getLogger(LogCategories.INFRA.REDIS) }); + +/** + * FastGPT 所有渠道写入口共用租约,获取后再读取渠道快照,减少全量 PUT 的读改写覆盖。 + * 不自动重试部分成功的操作;外部写入或已发出的迟到请求仍需要 AI Proxy CAS 才能完全隔离。 + */ +export const withAIProxyChannelMutation = async ( + fn: (context: RedisLeaseContext) => Promise +) => { + try { + return await channelLease.withLease({ + key: 'aiproxy:channel-mutation', + label: 'AI Proxy channels', + ttlMs: 120000, + fn + }); + } catch (error) { + if (error instanceof RedisLeaseUnavailableError) { + throw new UserError('AI Proxy channels are being updated. Refresh and retry.'); + } + throw error; + } +}; diff --git a/packages/service/vitest.config.ts b/packages/service/vitest.config.ts index 8480c5d9672a..67d4f75024b0 100644 --- a/packages/service/vitest.config.ts +++ b/packages/service/vitest.config.ts @@ -18,6 +18,8 @@ export default defineConfig({ }, test: { env: { + AIPROXY_API_ENDPOINT: process.env.AIPROXY_API_ENDPOINT ?? 'http://127.0.0.1:3000', + AIPROXY_API_TOKEN: process.env.AIPROXY_API_TOKEN ?? 'test-aiproxy-token', FILE_TOKEN_KEY: process.env.FILE_TOKEN_KEY ?? 'bfd697e7e798f75deaf2d31210bc93a2e41ad4eed9e7831071d77821b7b97cff', diff --git a/packages/service/vitest.integration.config.ts b/packages/service/vitest.integration.config.ts index ebd3ea21c41b..8ef7eb812877 100644 --- a/packages/service/vitest.integration.config.ts +++ b/packages/service/vitest.integration.config.ts @@ -15,6 +15,8 @@ export default defineConfig({ }, test: { env: { + AIPROXY_API_ENDPOINT: process.env.AIPROXY_API_ENDPOINT ?? 'http://127.0.0.1:3000', + AIPROXY_API_TOKEN: process.env.AIPROXY_API_TOKEN ?? 'test-aiproxy-token', FILE_TOKEN_KEY: process.env.FILE_TOKEN_KEY ?? 'bfd697e7e798f75deaf2d31210bc93a2e41ad4eed9e7831071d77821b7b97cff', diff --git a/packages/service/vitest.sandbox.integration.config.ts b/packages/service/vitest.sandbox.integration.config.ts index 334d54ce0dd8..e3d3a37f8ed9 100644 --- a/packages/service/vitest.sandbox.integration.config.ts +++ b/packages/service/vitest.sandbox.integration.config.ts @@ -15,6 +15,8 @@ export default defineConfig({ }, test: { env: { + AIPROXY_API_ENDPOINT: process.env.AIPROXY_API_ENDPOINT ?? 'http://127.0.0.1:3000', + AIPROXY_API_TOKEN: process.env.AIPROXY_API_TOKEN ?? 'test-aiproxy-token', NODE_ENV: 'test', FILE_TOKEN_KEY: process.env.FILE_TOKEN_KEY ?? diff --git a/packages/web/components/common/MyPopover/PopoverConfirm.tsx b/packages/web/components/common/MyPopover/PopoverConfirm.tsx index cfa885cc399b..38c51481d90c 100644 --- a/packages/web/components/common/MyPopover/PopoverConfirm.tsx +++ b/packages/web/components/common/MyPopover/PopoverConfirm.tsx @@ -23,6 +23,7 @@ const PopoverConfirm = ({ type, Trigger, placement = 'auto', + closeOnBlur = true, offset, modifiers, onConfirm, @@ -34,6 +35,7 @@ const PopoverConfirm = ({ type?: 'info' | 'delete'; Trigger: React.ReactNode; placement?: PlacementWithLogical; + closeOnBlur?: boolean; offset?: [number, number]; modifiers?: PopoverProps['modifiers']; onConfirm: () => Promise | any; @@ -73,7 +75,7 @@ const PopoverConfirm = ({ placement={placement} offset={offset} modifiers={modifiers} - closeOnBlur={true} + closeOnBlur={closeOnBlur} trigger={'click'} openDelay={100} closeDelay={100} @@ -139,9 +141,10 @@ const PopoverConfirm = ({ isLoading={loading} variant={map.variant} size="sm" - onClick={async (e) => { + onClick={(e) => { e.stopPropagation(); - await onclickConfirm(); + // useRequest 已展示失败提示;保留确认框重试,不把拒绝继续抛给浏览器事件。 + void onclickConfirm().catch(() => {}); }} > {confirmText || t('common:Confirm')} diff --git a/packages/web/components/common/MyPopover/index.tsx b/packages/web/components/common/MyPopover/index.tsx index f71332490f00..bfc6360796ca 100644 --- a/packages/web/components/common/MyPopover/index.tsx +++ b/packages/web/components/common/MyPopover/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Popover, + PopoverAnchor, PopoverTrigger, PopoverContent, useDisclosure, @@ -24,6 +25,8 @@ interface Props extends PopoverContentProps { closeOnBlur?: boolean; usePortal?: boolean; flip?: boolean; + /** hover 模式下仅由 Trigger 控制开关;鼠标进入浮层不会保持打开。 */ + closeOnTriggerLeave?: boolean; } const MyPopover = ({ @@ -38,6 +41,7 @@ const MyPopover = ({ closeOnBlur = false, usePortal = true, flip = true, + closeOnTriggerLeave = false, onBackdropClick, ...props }: Props) => { @@ -52,30 +56,42 @@ const MyPopover = ({ ); + const triggerOnlyHover = trigger === 'hover' && closeOnTriggerLeave; + const handleOpen = () => { + onOpen(); + onOpenFunc?.(); + }; + const handleClose = () => { + onClose(); + onCloseFunc?.(); + }; + return ( { - onOpen(); - onOpenFunc?.(); - }} - onClose={() => { - onClose(); - onCloseFunc?.(); - }} + onOpen={handleOpen} + onClose={handleClose} placement={placement} offset={offset} flip={flip} closeOnBlur={closeOnBlur} - trigger={trigger} + trigger={triggerOnlyHover ? undefined : trigger} openDelay={100} closeDelay={100} isLazy lazyBehavior="unmount" autoFocus={false} > - {Trigger} + {triggerOnlyHover ? ( + + + {Trigger} + + + ) : ( + {Trigger} + )} {isOpen && onBackdropClick && ( onBackdropClick()} /> diff --git a/packages/web/components/common/Textarea/JsonEditor/index.tsx b/packages/web/components/common/Textarea/JsonEditor/index.tsx index 4d32dfe514de..2a7360d8294b 100644 --- a/packages/web/components/common/Textarea/JsonEditor/index.tsx +++ b/packages/web/components/common/Textarea/JsonEditor/index.tsx @@ -27,6 +27,8 @@ type Props = Omit & { isDisabled?: boolean; readOnly?: boolean; isInvalid?: boolean; + /** 提交入口统一展示校验错误时可关闭失焦提示,避免同一次提交产生重复 Toast。 */ + validateOnBlur?: boolean; }; const options = { @@ -65,6 +67,7 @@ const JSONEditor = ({ isDisabled = false, readOnly = false, isInvalid = false, + validateOnBlur = true, ...props }: Props) => { const { toast } = useToast(); @@ -176,7 +179,7 @@ const JSONEditor = ({ }, [value]); const onBlur = useCallback(() => { - if (!formatedValue) return; + if (!validateOnBlur || !formatedValue) return; // replace {{xx}} to true const replaceValue = formatedValue?.replace(/{{(.*?)}}/g, 'true'); try { @@ -187,7 +190,7 @@ const JSONEditor = ({ title: t('common:json_parse_error') }); } - }, [formatedValue, toast, t]); + }, [formatedValue, toast, t, validateOnBlur]); const beforeMount = useCallback( (monaco: Monaco) => { diff --git a/packages/web/hooks/useFixedTableHeader.ts b/packages/web/hooks/useFixedTableHeader.ts new file mode 100644 index 000000000000..8a62d1d765c1 --- /dev/null +++ b/packages/web/hooks/useFixedTableHeader.ts @@ -0,0 +1,45 @@ +import { type RefObject, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +const useBrowserLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; + +/** + * 同步分离式 Table 的表头与滚动表体。 + * + * 表体出现纵向滚动条时返回扣除滚动条后的表头宽度;横向滚动时同步表头位置。 + * 可传入虚拟列表等场景已有的表体 ref,否则使用 Hook 内部创建的 ref。 + */ +export const useFixedTableHeader = (externalBodyRef?: RefObject) => { + const internalBodyRef = useRef(null); + const headerContainerRef = useRef(null); + const bodyContainerRef = externalBodyRef ?? internalBodyRef; + const [scrollbarWidth, setScrollbarWidth] = useState(0); + + useBrowserLayoutEffect(() => { + const body = bodyContainerRef.current; + const header = headerContainerRef.current; + if (!body || !header) return; + + const updateScrollbarWidth = () => { + setScrollbarWidth(body.offsetWidth - body.clientWidth); + }; + const syncHorizontalScroll = () => { + header.scrollLeft = body.scrollLeft; + }; + updateScrollbarWidth(); + syncHorizontalScroll(); + + const resizeObserver = new ResizeObserver(updateScrollbarWidth); + resizeObserver.observe(body); + body.addEventListener('scroll', syncHorizontalScroll, { passive: true }); + return () => { + resizeObserver.disconnect(); + body.removeEventListener('scroll', syncHorizontalScroll); + }; + }, [bodyContainerRef]); + + return { + headerContainerRef, + bodyContainerRef, + headerTableWidth: scrollbarWidth > 0 ? `calc(100% - ${scrollbarWidth}px)` : '100%' + }; +}; diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index b136d86d7342..b0cba637b857 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -916,6 +916,7 @@ "invalid_value": "Invalid value", "model_disabled": "{{model}} has been disabled", "model_delisted": "This model has been removed", + "model_id_already_exists": "Model ID already exists", "month_text": "month", "move.confirm": "Confirm move", "move_success": "Moved Successfully", diff --git a/packages/web/i18n/en/config_model.json b/packages/web/i18n/en/config_model.json index 00cce31d1e68..750fc4d22101 100644 --- a/packages/web/i18n/en/config_model.json +++ b/packages/web/i18n/en/config_model.json @@ -1,4 +1,7 @@ { + "max_response_tokens": "Maximum response Tokens", + "voices_array_required": "Voices must be a non-empty array with label and value in each item", + "select_model_provider_placeholder": "Select a provider", "aipoint_usage": "AI points", "all": "All", "api_key": "API key", @@ -12,6 +15,8 @@ "cache_hit_count": "Number of cache hits", "cache_hit_rate": "Cache hit rate", "channel_name": "Channel", + "channel_name_duplicate": "A channel with the same name already exists", + "channel_name_placeholder": "Enter a channel name", "channel_priority": "Priority", "channel_priority_tip": "The higher the priority channel, the easier it is to be requested", "channel_status": "state", @@ -19,6 +24,7 @@ "channel_status_disabled": "Disabled", "channel_status_enabled": "Enable", "channel_status_unknown": "unknown", + "channel_test": "Channel testing", "channel_type": "Protocol Type", "clear": "Clear", "clear_model": "Clear the model", @@ -38,6 +44,8 @@ "forbid_channel": "Disabled", "input": "Input", "key_type": "API key format:", + "api_key_placeholder": "Enter an API key", + "leave_blank_use_default_url": "(Leave blank to use the default URL)", "log": "Call log", "log_detail": "Log details", "log_request_id_search": "Search by requestId", @@ -45,17 +53,20 @@ "mapping": "Model Mapping", "mapping_tip": "Enter valid JSON to map model names before requests are sent to the endpoint. For example:\n{\n \"gpt-4o\": \"gpt-4o-test\"\n}\nWhen FastGPT requests gpt-4o, the endpoint receives gpt-4o-test instead.", "maxToken_tip": "Model max_tokens parameter", - "rerank_max_token": "Max Token Limit", - "rerank_max_token_tip": "Token limit per rerank request (query + single document). Documents exceeding the limit will be automatically split.", + "rerank_max_token": "Max Tokens Limit", + "rerank_max_token_tip": "Tokens limit per rerank request (query + single document). Documents exceeding the limit will be automatically split.", "max_rpm": "Max RPM (Requests Per Minute)", "max_temperature_tip": "If the model temperature parameter is not filled in, it means that the model does not support the temperature parameter.", "max_tpm": "Max TPM (Tokens Per Minute)", "model": "Model", + "model_count": "Model count", "model_error_rate": "Error rate", "model_error_request_times": "Number of failures", "model_name": "Model name", "model_request_times": "Request times", "model_test": "Model testing", + "model_channel_test_success": "{{model}} ran successfully on {{channel}}", + "model_channel_test_failed": "{{model}} failed to run on {{channel}}: {{reason}}", "model_tokens": "Input/Output tokens", "model_ttfb_time": "Response time of first word", "monitoring": "Monitoring", @@ -66,13 +77,17 @@ "retry_times": "Number of retry times", "running_test": "In testing", "search_model": "Search for models", + "model_search_empty": "No matching models found", "select_channel": "Select a channel name", "select_model": "Select a model", "select_model_placeholder": "Select the model available under this channel", "select_provider_placeholder": "Search protocol type", "selected_model_empty": "Choose at least one model", "start_test": "Batch test {{num}} models", + "status_enabled": "{{name}} enabled", + "status_disabled": "{{name}} disabled", "test_failed": "There are {{num}} models that report errors", + "test": "Test", "timespan_day": "Day", "timespan_hour": "Hour", "timespan_label": "Time Granularity", @@ -89,7 +104,6 @@ "video": "Video recognition", "video_tip": "Turn this on if the model supports video recognition.", "waiting_test": "Waiting for testing", - "active_model": "Available models", "channel": "Model provider", "config_model": "Model configuration", "create_model": "Add new model", @@ -97,13 +111,19 @@ "reset_default": "Restore default", "model.action": "Action", "model.active": "Active", + "model.batch_enable": "Enable selected", + "model.batch_disable": "Disable selected", + "model.batch_status_enabled": "Enabled {{count}} models", + "model.batch_status_disabled": "Disabled {{count}} models", + "model.batch_delete": "Delete selected", + "model.batch_delete_confirm": "Delete the {{count}} selected models? This action cannot be undone.", "model.alias": "Alias", "model.alias_tip": "The name of the model displayed in the system is convenient for users to understand.", "model.basic_config_section": "Basic config", "model.censor": "Censor check", "model.censor_tip": "If sensitive verification is required, turn on this switch", "model.charsPointsPrice": "Chars Price", - "model.charsPointsPrice_tip": "Combine the model input and output for Token billing. If the language model is configured with input and output billing separately, the input and output will be calculated separately.", + "model.charsPointsPrice_tip": "Combine the model input and output for Tokens billing. If the language model is configured with input and output billing separately, the input and output will be calculated separately.", "model.defaultConfig": "Additional Body parameters", "model.defaultConfig_tip": "Each request will carry this additional Body parameter.", "model.default_config": "Body extra fields", @@ -115,7 +135,7 @@ "model.default_token": "Default chunk length", "model.default_token_tip": "The default text chunk length for the Embedding model. It must be shorter than the maximum context length above.", "model.delete_model_confirm": "Confirm to delete this model?", - "model.edit_model": "Model parameter editing", + "model.edit_model": "Edit model", "model.feature_config_section": "Feature config", "model.json_config": "File config", "model.json_config_confirm": "Confirm to use this configuration for override?", @@ -134,9 +154,9 @@ "model.reasoning": "Model supports thinking", "model.reasoning_tip": "For example, Deepseek-reasoner can output the thinking process.", "model.reasoning_effort": "Support thinking config", - "model.request_auth": "Custom key", + "model.request_auth": "Custom key (To be deprecated; use channels instead)", "model.request_auth_tip": "When making a request to a custom request address, carry the request header: Authorization: Bearer xxx to make the request.", - "model.request_url": "Custom url", + "model.request_url": "Custom URL (To be deprecated; use channels instead)", "model.request_url_tip": "When set, requests are sent directly to this URL instead of using the model provider configuration.\nThe endpoint must follow the OpenAI API format. Enter the complete request URL, for example:\nLLM: {{host}}/v1/chat/completions\nEmbedding: {{host}}/v1/embeddings\nSTT: {{host}}/v1/audio/transcriptions\nTTS: {{host}}/v1/audio/speech\nRerank: {{host}}/v1/rerank", "model.response_format": "Response format", "model.show_stop_sign": "Display stop sequence parameters", @@ -150,5 +170,44 @@ "model.vision_tip": "If the model supports image recognition, turn on this switch.", "model.embedding_vision_tip": "Enable this when the embedding model can accept image input for image vector indexing and image search.", "model.voices": "voice role", - "model.voices_tip": "Configure multiple voices as an array, for example:\n[\n {\n \"label\": \"Alloy\",\n \"value\": \"alloy\"\n },\n {\n \"label\": \"Echo\",\n \"value\": \"echo\"\n }\n]" + "fill_voice_before_test": "Configure at least one voice before testing", + "model.voices_tip": "Configure multiple voices as an array, for example:\n[\n {\n \"label\": \"Alloy\",\n \"value\": \"alloy\"\n },\n {\n \"label\": \"Echo\",\n \"value\": \"echo\"\n }\n]", + "create_from_template": "Create from template", + "create_from_blank": "Create from scratch", + "select_model_type": "Select model type", + "model_type_description.llm": "Generate text for chats, workflows, and more", + "model_type_description.embedding": "Vectorize knowledge base content", + "model_type_description.tts": "Convert text to speech", + "model_type_description.stt": "Convert speech to text", + "model_type_description.rerank": "Improve retrieval result ordering", + "select_model_template": "Select model templates", + "configure_model_channels": "Configure model channels", + "model.channels": "Model channels", + "selected_models": "Selected models", + "no_available_templates": "No available model templates", + "template_load_failed": "Failed to load model templates", + "retry": "Retry", + "no_channels": "No model channels. You can save without selecting one.", + "channel_list_empty": "No model channels.", + "previous_step": "Previous", + "next_step": "Next", + "no_models": "No models. Create one to get started.", + "channel_count": "{{count}} channels", + "no_channel_configured": "No channels", + "current_model": "Current model", + "fixed_channel_model_tip": "The new channel is automatically associated with this model and cannot be changed.", + "fill_model_id_before_test": "Enter a model ID first", + "model_pending_creation": "Bind after model creation", + "deferred_channel_model_tip": "The channel is created without models and bound after the model is created.", + "associate_existing_channels": "Associate existing channels", + "associated_channels": "Associated channels ({{count}})", + "channel_management": "Channel management", + "confirm_go_to_channel_management": "Go to channel management?", + "unsaved_model_config_leave_tip": "There are unsaved changes. Are you sure you want to leave?", + "model_channel_empty": "No channels are associated with this model.", + "remove_channel_association": "Remove association", + "associate": "Associate", + "associate_success": "Channels associated successfully", + "included_models": "Includes {{count}} models", + "selected_model_count": "{{count}} models selected" } diff --git a/packages/web/i18n/ko-KR/common.json b/packages/web/i18n/ko-KR/common.json index 78ea66a703cd..239c225a0525 100644 --- a/packages/web/i18n/ko-KR/common.json +++ b/packages/web/i18n/ko-KR/common.json @@ -913,6 +913,7 @@ "invalid_value": "유효하지 않은 값", "model_disabled": "{{model}} 모델이 비활성화되었습니다", "model_delisted": "이 모델은 더 이상 제공되지 않습니다", + "model_id_already_exists": "모델 ID가 이미 존재합니다", "month_text": "월", "move.confirm": "이동 확인", "move_success": "이동되었습니다", diff --git a/packages/web/i18n/ko-KR/config_model.json b/packages/web/i18n/ko-KR/config_model.json index 4fd2815b10ad..7ad328a10565 100644 --- a/packages/web/i18n/ko-KR/config_model.json +++ b/packages/web/i18n/ko-KR/config_model.json @@ -1,4 +1,7 @@ { + "max_response_tokens": "최대 응답 Tokens", + "voices_array_required": "음색은 각 항목에 label과 value가 있는 비어 있지 않은 배열이어야 합니다", + "select_model_provider_placeholder": "공급자를 선택하세요", "aipoint_usage": "AI 포인트", "all": "전체", "api_key": "API 키", @@ -12,6 +15,8 @@ "cache_hit_count": "캐시 적중 횟수", "cache_hit_rate": "캐시 적중률", "channel_name": "채널", + "channel_name_duplicate": "같은 이름의 채널이 이미 있습니다", + "channel_name_placeholder": "채널 이름을 입력하세요", "channel_priority": "우선순위", "channel_priority_tip": "우선순위가 높은 채널일수록 요청되기 쉽습니다", "channel_status": "상태", @@ -19,6 +24,7 @@ "channel_status_disabled": "비활성화됨", "channel_status_enabled": "활성화", "channel_status_unknown": "알 수 없음", + "channel_test": "채널 테스트", "channel_type": "프로토콜 유형", "clear": "지우기", "clear_model": "모델 지우기", @@ -38,6 +44,8 @@ "forbid_channel": "비활성화됨", "input": "입력", "key_type": "API 키 형식:", + "api_key_placeholder": "API 키를 입력하세요", + "leave_blank_use_default_url": "(비워 두면 기본 주소 사용)", "log": "호출 로그", "log_detail": "로그 상세", "log_request_id_search": "requestId로 검색", @@ -51,11 +59,14 @@ "max_temperature_tip": "모델 temperature 파라미터를 입력하지 않으면, 해당 모델이 temperature 파라미터를 지원하지 않는다는 의미입니다.", "max_tpm": "최대 TPM(분당 토큰 수)", "model": "모델", + "model_count": "모델 수", "model_error_rate": "오류율", "model_error_request_times": "실패 횟수", "model_name": "모델 이름", "model_request_times": "요청 횟수", "model_test": "모델 테스트", + "model_channel_test_success": "{{model}} 모델이 {{channel}}에서 성공적으로 실행되었습니다", + "model_channel_test_failed": "{{model}} 모델이 {{channel}}에서 실행되지 못했습니다: {{reason}}", "model_tokens": "입력/출력 토큰", "model_ttfb_time": "첫 단어 응답 시간", "monitoring": "모니터링", @@ -66,13 +77,17 @@ "retry_times": "재시도 횟수", "running_test": "테스트 중", "search_model": "모델 검색", + "model_search_empty": "일치하는 모델을 찾을 수 없습니다", "select_channel": "채널 이름 선택", "select_model": "모델 선택", "select_model_placeholder": "이 채널에서 사용 가능한 모델을 선택하세요", "select_provider_placeholder": "프로토콜 유형 검색", "selected_model_empty": "최소 하나의 모델을 선택하세요", "start_test": "모델 {{num}}개 일괄 테스트", + "status_enabled": "{{name}} 활성화됨", + "status_disabled": "{{name}} 비활성화됨", "test_failed": "{{num}}개 모델에서 오류가 발생했습니다", + "test": "테스트", "timespan_day": "일", "timespan_hour": "시", "timespan_label": "시간 단위", @@ -89,7 +104,6 @@ "video": "영상 인식", "video_tip": "모델이 영상 인식을 지원하는 경우 켜세요.", "waiting_test": "테스트 대기 중", - "active_model": "사용 가능한 모델", "channel": "모델 제공업체", "config_model": "모델 설정", "create_model": "새 모델 추가", @@ -97,6 +111,12 @@ "reset_default": "기본값으로 복원", "model.action": "작업", "model.active": "활성", + "model.batch_enable": "선택 항목 활성화", + "model.batch_disable": "선택 항목 비활성화", + "model.batch_status_enabled": "모델 {{count}}개를 활성화했습니다", + "model.batch_status_disabled": "모델 {{count}}개를 비활성화했습니다", + "model.batch_delete": "선택 항목 삭제", + "model.batch_delete_confirm": "선택한 모델 {{count}}개를 삭제하시겠습니까? 삭제 후에는 복구할 수 없습니다.", "model.alias": "별칭", "model.alias_tip": "시스템에 표시되는 모델 이름으로, 사용자가 이해하기 쉽습니다.", "model.basic_config_section": "기본 설정", @@ -115,7 +135,7 @@ "model.default_token": "기본 청크 길이", "model.default_token_tip": "임베딩 모델의 기본 텍스트 청크 길이이며 위의 최대 컨텍스트 길이보다 작아야 합니다", "model.delete_model_confirm": "이 모델을 삭제하시겠습니까?", - "model.edit_model": "모델 파라미터 편집", + "model.edit_model": "모델 편집", "model.feature_config_section": "기능 설정", "model.json_config": "파일 설정", "model.json_config_confirm": "이 설정으로 덮어쓰시겠습니까?", @@ -134,9 +154,9 @@ "model.reasoning": "모델이 사고 과정을 지원함", "model.reasoning_tip": "예를 들어 Deepseek-reasoner는 사고 과정을 출력할 수 있습니다.", "model.reasoning_effort": "사고 과정 설정 지원", - "model.request_auth": "커스텀 키", + "model.request_auth": "커스텀 키(지원 중단 예정, 채널을 직접 사용하세요)", "model.request_auth_tip": "커스텀 요청 주소로 요청할 때 요청 헤더 Authorization: Bearer xxx를 포함하여 요청합니다.", - "model.request_url": "커스텀 URL", + "model.request_url": "커스텀 URL(지원 중단 예정, 채널을 직접 사용하세요)", "model.request_url_tip": "이 값을 입력하면 모델 제공업체 설정을 거치지 않고 해당 URL로 직접 요청합니다.\n엔드포인트는 OpenAI API 형식을 따라야 합니다. 다음과 같이 전체 요청 URL을 입력하세요:\nLLM: {{host}}/v1/chat/completions\nEmbedding: {{host}}/v1/embeddings\nSTT: {{host}}/v1/audio/transcriptions\nTTS: {{host}}/v1/audio/speech\nRerank: {{host}}/v1/rerank", "model.response_format": "응답 형식", "model.show_stop_sign": "정지 시퀀스 파라미터 표시", @@ -150,5 +170,44 @@ "model.vision_tip": "모델이 이미지 인식을 지원하는 경우 이 스위치를 켜세요.", "model.embedding_vision_tip": "임베딩 모델이 이미지 벡터 인덱싱 및 이미지 검색을 위해 이미지 입력을 받을 수 있는 경우 이 옵션을 켜세요.", "model.voices": "음성 역할", - "model.voices_tip": "배열을 사용해 여러 음성을 설정할 수 있습니다. 예:\n[\n {\n \"label\": \"Alloy\",\n \"value\": \"alloy\"\n },\n {\n \"label\": \"Echo\",\n \"value\": \"echo\"\n }\n]" + "fill_voice_before_test": "테스트하기 전에 음성 역할을 하나 이상 설정하세요", + "model.voices_tip": "배열을 사용해 여러 음성을 설정할 수 있습니다. 예:\n[\n {\n \"label\": \"Alloy\",\n \"value\": \"alloy\"\n },\n {\n \"label\": \"Echo\",\n \"value\": \"echo\"\n }\n]", + "create_from_template": "템플릿에서 만들기", + "create_from_blank": "빈 모델 만들기", + "select_model_type": "모델 유형 선택", + "model_type_description.llm": "대화와 워크플로 등의 텍스트 생성에 사용", + "model_type_description.embedding": "지식베이스 콘텐츠를 벡터화", + "model_type_description.tts": "텍스트를 음성으로 변환", + "model_type_description.stt": "음성을 텍스트로 변환", + "model_type_description.rerank": "검색 결과 순서를 최적화", + "select_model_template": "모델 템플릿 선택", + "configure_model_channels": "모델 채널 구성", + "model.channels": "모델 채널", + "selected_models": "선택한 모델", + "no_available_templates": "사용 가능한 모델 템플릿이 없습니다", + "template_load_failed": "모델 템플릿을 불러오지 못했습니다", + "retry": "다시 시도", + "no_channels": "모델 채널이 없습니다. 채널 없이 저장할 수 있습니다.", + "channel_list_empty": "모델 채널이 없습니다.", + "previous_step": "이전", + "next_step": "다음", + "no_models": "모델이 없습니다. 모델을 추가하세요.", + "channel_count": "채널 {{count}}개", + "no_channel_configured": "구성된 채널 없음", + "current_model": "현재 모델", + "fixed_channel_model_tip": "새 채널은 현재 모델에 자동으로 연결되며 변경할 수 없습니다.", + "fill_model_id_before_test": "먼저 모델 ID를 입력하세요", + "model_pending_creation": "모델 생성 후 연결", + "deferred_channel_model_tip": "채널은 모델 없이 먼저 생성되고 모델 생성이 완료되면 자동으로 연결됩니다.", + "associate_existing_channels": "기존 채널 연결", + "associated_channels": "연결된 채널({{count}})", + "channel_management": "채널 관리", + "confirm_go_to_channel_management": "채널 관리로 이동하시겠습니까?", + "unsaved_model_config_leave_tip": "저장되지 않은 설정이 있습니다. 나가시겠습니까?", + "model_channel_empty": "현재 모델에 구성된 채널이 없습니다.", + "remove_channel_association": "연결 해제", + "associate": "연결", + "associate_success": "채널이 성공적으로 연결되었습니다", + "included_models": "모델 {{count}}개 포함", + "selected_model_count": "모델 {{count}}개 선택됨" } diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 18437075e850..5bc25c1f69ae 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -916,6 +916,7 @@ "invalid_value": "无效值", "model_disabled": "{{model}}已停用", "model_delisted": "该模型已下架", + "model_id_already_exists": "模型 ID 已存在", "month_text": "个月", "move.confirm": "确认移动", "move_success": "移动成功", diff --git a/packages/web/i18n/zh-CN/config_model.json b/packages/web/i18n/zh-CN/config_model.json index e5ff0b2efdac..a7357ddf9639 100644 --- a/packages/web/i18n/zh-CN/config_model.json +++ b/packages/web/i18n/zh-CN/config_model.json @@ -1,4 +1,7 @@ { + "max_response_tokens": "最大响应 Tokens", + "voices_array_required": "音色必须是非空数组,且每项包含 label 和 value", + "select_model_provider_placeholder": "请选择提供商", "aipoint_usage": "积分消耗", "all": "全部", "api_key": "API 密钥", @@ -12,6 +15,8 @@ "cache_hit_count": "缓存命中次数", "cache_hit_rate": "缓存命中率", "channel_name": "渠道名", + "channel_name_duplicate": "已有同名渠道", + "channel_name_placeholder": "请输入渠道名", "channel_priority": "优先级", "channel_priority_tip": "优先级越高的渠道,越容易被请求到", "channel_status": "状态", @@ -19,6 +24,7 @@ "channel_status_disabled": "禁用", "channel_status_enabled": "启用", "channel_status_unknown": "未知", + "channel_test": "渠道测试", "channel_type": "协议类型", "clear": "清空", "clear_model": "清空模型", @@ -38,6 +44,8 @@ "forbid_channel": "禁用", "input": "输入", "key_type": "API key 格式: ", + "api_key_placeholder": "请输入 API 密钥", + "leave_blank_use_default_url": "(留空使用默认地址)", "log": "调用日志", "log_detail": "日志详情", "log_request_id_search": "根据 requestId 搜索", @@ -45,17 +53,20 @@ "mapping": "模型映射", "mapping_tip": "需填写一个有效 Json。可在向实际地址发送请求时,对模型进行映射。例如:\n{\n \"gpt-4o\": \"gpt-4o-test\"\n}\n当 FastGPT 请求 gpt-4o 模型时,会向实际地址发送 gpt-4o-test 的模型,而不是 gpt-4o。", "maxToken_tip": "模型 max_tokens 参数", - "rerank_max_token": "最大 Token 限制", + "rerank_max_token": "最大 Tokens", "rerank_max_token_tip": "Rerank 阶段单次请求的 token 上限(query + 单个文档),超出限制的文档会被自动切分", "max_rpm": "最大RPM (每分钟请求数)", "max_temperature_tip": "模型 temperature 参数,不填则代表模型不支持 temperature 参数。", - "max_tpm": "最大TPM (每分钟Token数)", + "max_tpm": "最大TPM (每分钟Tokens数)", "model": "模型", + "model_count": "模型数量", "model_error_rate": "失败率", "model_error_request_times": "失败次数", "model_name": "模型名", "model_request_times": "请求次数", "model_test": "模型测试", + "model_channel_test_success": "{{model}} 在 {{channel}} 运行成功", + "model_channel_test_failed": "{{model}} 在 {{channel}} 运行失败: {{reason}}", "model_tokens": "输入/输出 Tokens", "model_ttfb_time": "首字响应时长", "monitoring": "监控", @@ -66,13 +77,17 @@ "retry_times": "重试次数", "running_test": "测试中", "search_model": "搜索模型", + "model_search_empty": "未找到匹配模型", "select_channel": "选择渠道名", "select_model": "选择模型", "select_model_placeholder": "选择该渠道下可用的模型", "select_provider_placeholder": "搜索协议类型", "selected_model_empty": "至少选择一个模型", "start_test": "批量测试{{num}}个模型", + "status_enabled": "{{name}} 已启用", + "status_disabled": "{{name}} 已停用", "test_failed": "有{{num}}个模型报错", + "test": "测试", "timespan_day": "天", "timespan_hour": "小时", "timespan_label": "时间颗粒度", @@ -89,7 +104,6 @@ "video": "支持视频识别", "video_tip": "如果模型支持视频识别,则打开该开关。", "waiting_test": "等待测试", - "active_model": "可用模型", "channel": "模型渠道", "config_model": "模型配置", "create_model": "新增模型", @@ -97,13 +111,19 @@ "reset_default": "恢复默认", "model.action": "操作", "model.active": "启用", + "model.batch_enable": "批量启用", + "model.batch_disable": "批量停用", + "model.batch_status_enabled": "已启用 {{count}} 个模型", + "model.batch_status_disabled": "已停用 {{count}} 个模型", + "model.batch_delete": "批量删除", + "model.batch_delete_confirm": "确认删除选中的 {{count}} 个模型?删除后无法恢复。", "model.alias": "别名", "model.alias_tip": "模型在系统中展示的名字,方便用户理解", "model.basic_config_section": "基本配置", "model.censor": "启用敏感校验", "model.censor_tip": "如果需要进行敏感校验,则开启该开关", "model.charsPointsPrice": "模型综合价格", - "model.charsPointsPrice_tip": "将模型输入和输出合并起来进行 Token 计费,语言模型如果单独配置了输入和输出计费,则按输入和输出分别计算", + "model.charsPointsPrice_tip": "将模型输入和输出合并起来进行 Tokens 计费,语言模型如果单独配置了输入和输出计费,则按输入和输出分别计算", "model.defaultConfig": "额外 Body 参数", "model.defaultConfig_tip": "每次请求时候,都会携带该额外 Body 参数", "model.default_config": "Body 额外字段", @@ -115,7 +135,7 @@ "model.default_token": "默认分块长度", "model.default_token_tip": "索引模型默认文本分块的长度,必须小于最大上文", "model.delete_model_confirm": "确认删除该模型?", - "model.edit_model": "模型参数编辑", + "model.edit_model": "模型编辑", "model.feature_config_section": "功能配置", "model.json_config": "配置文件", "model.json_config_confirm": "确认使用该配置进行覆盖?", @@ -134,9 +154,9 @@ "model.reasoning": "模型支持思考", "model.reasoning_tip": "例如 Deepseek-reasoner,可以输出思考过程。", "model.reasoning_effort": "支持思考配置", - "model.request_auth": "自定义请求 Key", + "model.request_auth": "自定义请求 Key(即将弃用,请直接使用渠道)", "model.request_auth_tip": "向自定义请求地址发起请求时候,携带请求头:Authorization: Bearer xxx 进行请求", - "model.request_url": "自定义请求地址", + "model.request_url": "自定义请求地址(即将弃用,请直接使用渠道)", "model.request_url_tip": "如果填写该值,则会直接向该地址发起请求,不经过模型渠道的配置。\n接口需要遵循 OpenAI 的 API格式,并填写完整请求地址,例如:\nLLM: {{host}}/v1/chat/completions\nEmbedding: {{host}}/v1/embeddings\nSTT: {{host}}/v1/audio/transcriptions\nTTS: {{host}}/v1/audio/speech\nRerank: {{host}}/v1/rerank", "model.response_format": "响应格式", "model.show_stop_sign": "展示停止序列参数", @@ -150,5 +170,44 @@ "model.vision_tip": "如果模型支持图片识别,则打开该开关。", "model.embedding_vision_tip": "开启后该索引模型可接收图片输入,并用于图片向量索引和图搜图。", "model.voices": "声音角色", - "model.voices_tip": "通过一个数组配置多个,例如:\n[\n {\n \"label\": \"Alloy\",\n \"value\": \"alloy\"\n },\n {\n \"label\": \"Echo\",\n \"value\": \"echo\"\n }\n]" + "fill_voice_before_test": "请先配置至少一个声音角色", + "model.voices_tip": "通过一个数组配置多个,例如:\n[\n {\n \"label\": \"Alloy\",\n \"value\": \"alloy\"\n },\n {\n \"label\": \"Echo\",\n \"value\": \"echo\"\n }\n]", + "create_from_template": "从模板新建", + "create_from_blank": "从空白新建", + "select_model_type": "选择模型类型", + "model_type_description.llm": "用于对话、工作流等文本生成", + "model_type_description.embedding": "用于知识库向量化", + "model_type_description.tts": "将文字转化为语音", + "model_type_description.stt": "将语音转化为文字", + "model_type_description.rerank": "优化检索结果排序", + "select_model_template": "选择模型模板", + "configure_model_channels": "配置模型渠道", + "model.channels": "模型渠道", + "selected_models": "已选模型", + "no_available_templates": "暂无可添加的模型模板", + "template_load_failed": "模型模板加载失败", + "retry": "重试", + "no_channels": "暂无模型渠道,可不选渠道直接保存", + "channel_list_empty": "暂无模型渠道", + "previous_step": "上一步", + "next_step": "下一步", + "no_models": "暂无模型,请新增模型", + "channel_count": "{{count}} 个渠道", + "no_channel_configured": "未配置渠道", + "current_model": "当前模型", + "fixed_channel_model_tip": "新建渠道将自动关联当前模型,且不可修改", + "fill_model_id_before_test": "请先填写模型 ID", + "model_pending_creation": "模型创建后绑定", + "deferred_channel_model_tip": "渠道先以空模型创建,模型创建成功后自动绑定", + "associate_existing_channels": "关联已有渠道", + "associated_channels": "已关联渠道({{count}})", + "channel_management": "渠道管理", + "confirm_go_to_channel_management": "确认前往渠道管理吗", + "unsaved_model_config_leave_tip": "有尚未保存的配置,确认要离开吗?", + "model_channel_empty": "当前模型暂未配置渠道", + "remove_channel_association": "取消关联", + "associate": "关联", + "associate_success": "关联渠道成功", + "included_models": "包含 {{count}} 个模型", + "selected_model_count": "已选择 {{count}} 个模型" } diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 8c6bc60bd75e..aa6550e84a3e 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -916,6 +916,7 @@ "invalid_value": "無效值", "model_disabled": "{{model}}已停用", "model_delisted": "該模型已下架", + "model_id_already_exists": "模型 ID 已存在", "month_text": "個月", "move.confirm": "確認移動", "move_success": "移動成功", diff --git a/packages/web/i18n/zh-Hant/config_model.json b/packages/web/i18n/zh-Hant/config_model.json index 6a8300cfc829..2233f4d75ae5 100644 --- a/packages/web/i18n/zh-Hant/config_model.json +++ b/packages/web/i18n/zh-Hant/config_model.json @@ -1,4 +1,7 @@ { + "max_response_tokens": "最大回應 Tokens", + "voices_array_required": "音色必須是非空陣列,且每項包含 label 和 value", + "select_model_provider_placeholder": "請選擇提供商", "aipoint_usage": "積分消耗", "all": "全部", "api_key": "API 金鑰", @@ -12,6 +15,8 @@ "cache_hit_count": "緩存命中次數", "cache_hit_rate": "緩存命中率", "channel_name": "管道名稱", + "channel_name_duplicate": "已有同名渠道", + "channel_name_placeholder": "請輸入渠道名稱", "channel_priority": "優先順序", "channel_priority_tip": "優先順序越高的管道,越容易被請求到", "channel_status": "狀態", @@ -19,6 +24,7 @@ "channel_status_disabled": "停用", "channel_status_enabled": "啟用", "channel_status_unknown": "未知", + "channel_test": "渠道測試", "channel_type": "協議類型", "clear": "清空", "clear_model": "清空模型", @@ -38,6 +44,8 @@ "forbid_channel": "停用", "input": "輸入", "key_type": "API key 格式:", + "api_key_placeholder": "請輸入 API 密鑰", + "leave_blank_use_default_url": "(留空使用預設地址)", "log": "呼叫日誌", "log_detail": "日誌詳細資訊", "log_request_id_search": "根據 requestId 搜尋", @@ -45,17 +53,20 @@ "mapping": "模型對映", "mapping_tip": "請輸入有效的 JSON,以便在向實際端點傳送請求前對應模型名稱。例如:\n{\n \"gpt-4o\": \"gpt-4o-test\"\n}\n當 FastGPT 請求 gpt-4o 時,實際端點會收到 gpt-4o-test。", "maxToken_tip": "模型 max_tokens 參數", - "rerank_max_token": "最大 Token 限制", + "rerank_max_token": "最大 Tokens", "rerank_max_token_tip": "Rerank 階段單次請求的 token 上限(query + 單個文件),超出限制的文件會被自動切分", "max_rpm": "最大RPM (每分鐘請求數)", "max_temperature_tip": "模型 temperature 參數,不填則代表模型不支援 temperature 參數。", - "max_tpm": "最大TPM (每分鐘Token數)", + "max_tpm": "最大TPM (每分鐘Tokens數)", "model": "模型", + "model_count": "模型數量", "model_error_rate": "失敗率", "model_error_request_times": "失敗次數", "model_name": "模型名", "model_request_times": "請求次數", "model_test": "模型測試", + "model_channel_test_success": "{{model}} 在 {{channel}} 執行成功", + "model_channel_test_failed": "{{model}} 在 {{channel}} 執行失敗: {{reason}}", "model_tokens": "輸入/輸出 Tokens", "model_ttfb_time": "首字響應時長", "monitoring": "監控", @@ -66,13 +77,17 @@ "retry_times": "重試次數", "running_test": "測試中", "search_model": "搜尋模型", + "model_search_empty": "未找到符合的模型", "select_channel": "選擇管道名稱", "select_model": "選擇模型", "select_model_placeholder": "選擇該管道下可用的模型", "select_provider_placeholder": "搜索協議類型", "selected_model_empty": "至少選擇一個模型", "start_test": "批次測試{{num}}個模型", + "status_enabled": "{{name}} 已啟用", + "status_disabled": "{{name}} 已停用", "test_failed": "有{{num}}個模型報錯", + "test": "測試", "timespan_day": "天", "timespan_hour": "小時", "timespan_label": "時間顆粒度", @@ -89,7 +104,6 @@ "video": "支援視頻識別", "video_tip": "如果模型支援視頻識別,則開啟該開關。", "waiting_test": "等待測試", - "active_model": "可用模型", "channel": "模型管道", "config_model": "模型設定", "create_model": "新增模型", @@ -97,13 +111,19 @@ "reset_default": "恢復預設", "model.action": "操作", "model.active": "啟用", + "model.batch_enable": "批次啟用", + "model.batch_disable": "批次停用", + "model.batch_status_enabled": "已啟用 {{count}} 個模型", + "model.batch_status_disabled": "已停用 {{count}} 個模型", + "model.batch_delete": "批次刪除", + "model.batch_delete_confirm": "確認刪除選取的 {{count}} 個模型?刪除後無法復原。", "model.alias": "別名", "model.alias_tip": "模型在系統中展示的名字,方便使用者理解", "model.basic_config_section": "基本配置", "model.censor": "啟用敏感校驗", "model.censor_tip": "如果需要進行敏感校驗,則開啟該開關", "model.charsPointsPrice": "模型綜合價格", - "model.charsPointsPrice_tip": "將模型輸入和輸出合併起來進行 Token 計費,語言模型如果單獨設定了輸入和輸出計費,則按輸入和輸出分別計算", + "model.charsPointsPrice_tip": "將模型輸入和輸出合併起來進行 Tokens 計費,語言模型如果單獨設定了輸入和輸出計費,則按輸入和輸出分別計算", "model.defaultConfig": "額外 Body 參數", "model.defaultConfig_tip": "每次請求時候,都會攜帶該額外 Body 參數", "model.default_config": "Body 額外欄位", @@ -115,7 +135,7 @@ "model.default_token": "預設分塊長度", "model.default_token_tip": "索引模型預設文字分塊的長度,必須小於最大上文", "model.delete_model_confirm": "確認刪除該模型?", - "model.edit_model": "模型參數編輯", + "model.edit_model": "模型編輯", "model.feature_config_section": "功能配置", "model.json_config": "設定檔", "model.json_config_confirm": "確認使用該設定進行覆蓋?", @@ -134,9 +154,9 @@ "model.reasoning": "模型支援思考", "model.reasoning_tip": "例如 Deepseek-reasoner,可以輸出思考過程。", "model.reasoning_effort": "支援思考配置", - "model.request_auth": "自訂請求 Key", + "model.request_auth": "自訂請求 Key(即將棄用,請直接使用渠道)", "model.request_auth_tip": "向自訂請求地址發起請求時候,攜帶請求頭:Authorization: Bearer xxx 進行請求", - "model.request_url": "自訂請求地址", + "model.request_url": "自訂請求地址(即將棄用,請直接使用渠道)", "model.request_url_tip": "填寫後,請求會直接傳送到此網址,不使用模型渠道設定。\n端點必須遵循 OpenAI API 格式,並填寫完整請求網址,例如:\nLLM: {{host}}/v1/chat/completions\nEmbedding: {{host}}/v1/embeddings\nSTT: {{host}}/v1/audio/transcriptions\nTTS: {{host}}/v1/audio/speech\nRerank: {{host}}/v1/rerank", "model.response_format": "響應格式", "model.show_stop_sign": "展示停止序列參數", @@ -150,5 +170,44 @@ "model.vision_tip": "如果模型支援圖片識別,則開啟該開關。", "model.embedding_vision_tip": "開啟後該索引模型可接收圖片輸入,並用於圖片向量索引和圖搜圖。", "model.voices": "聲音角色", - "model.voices_tip": "透過一個陣列設定多個,例如:\n[\n {\n \"label\": \"Alloy\",\n \"value\": \"alloy\"\n },\n {\n \"label\": \"Echo\",\n \"value\": \"echo\"\n }\n]" + "fill_voice_before_test": "請先設定至少一個聲音角色", + "model.voices_tip": "透過一個陣列設定多個,例如:\n[\n {\n \"label\": \"Alloy\",\n \"value\": \"alloy\"\n },\n {\n \"label\": \"Echo\",\n \"value\": \"echo\"\n }\n]", + "create_from_template": "從範本新增", + "create_from_blank": "從空白新增", + "select_model_type": "選擇模型類型", + "model_type_description.llm": "用於對話、工作流等文字生成", + "model_type_description.embedding": "用於知識庫向量化", + "model_type_description.tts": "將文字轉換為語音", + "model_type_description.stt": "將語音轉換為文字", + "model_type_description.rerank": "最佳化檢索結果排序", + "select_model_template": "選擇模型範本", + "configure_model_channels": "設定模型渠道", + "model.channels": "模型渠道", + "selected_models": "已選模型", + "no_available_templates": "暫無可新增的模型範本", + "template_load_failed": "模型範本載入失敗", + "retry": "重試", + "no_channels": "暫無模型渠道,可直接儲存", + "channel_list_empty": "暫無模型渠道", + "previous_step": "上一步", + "next_step": "下一步", + "no_models": "暫無模型,請新增模型", + "channel_count": "{{count}} 個渠道", + "no_channel_configured": "未設定渠道", + "current_model": "目前模型", + "fixed_channel_model_tip": "新增渠道將自動關聯目前模型,且不可修改", + "fill_model_id_before_test": "請先填寫模型 ID", + "model_pending_creation": "模型建立後綁定", + "deferred_channel_model_tip": "渠道先以空模型建立,模型建立成功後自動綁定", + "associate_existing_channels": "關聯已有渠道", + "associated_channels": "已關聯渠道({{count}})", + "channel_management": "渠道管理", + "confirm_go_to_channel_management": "確認前往渠道管理嗎", + "unsaved_model_config_leave_tip": "有尚未儲存的設定,確認要離開嗎?", + "model_channel_empty": "目前模型暫未設定渠道", + "remove_channel_association": "取消關聯", + "associate": "關聯", + "associate_success": "關聯渠道成功", + "included_models": "包含 {{count}} 個模型", + "selected_model_count": "已選 {{count}} 個模型" } diff --git a/packages/web/styles/theme.ts b/packages/web/styles/theme.ts index 7c6be15928dd..81acf2d24216 100644 --- a/packages/web/styles/theme.ts +++ b/packages/web/styles/theme.ts @@ -728,6 +728,13 @@ const Modal = modalMultiStyle({ }); const Table = tableMultiStyle({ + baseStyle: { + table: { + 'thead, thead tr, thead th, thead td': { + borderBottom: 'none' + } + } + }, sizes: { md: defineStyle({ table: { @@ -737,7 +744,7 @@ const Table = tableMultiStyle({ tr: { bg: 'myGray.100', fontSize: 'sm', - borderBottom: 'base', + borderBottom: 'none', th: { borderBottom: 'none', overflow: 'hidden', @@ -819,7 +826,7 @@ const Table = tableMultiStyle({ letterSpacing: '0.5px', textTransform: 'none', borderRadius: 'none !important', - borderBottom: '1px solid', + borderBottom: 'none', borderColor: 'myGray.200', '&:not(:first-of-type)': { borderLeft: '1px solid', diff --git a/pro b/pro index 1924bf11d505..22d610f7860c 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 1924bf11d505697480d58c80b3b1b98d0fb62e65 +Subproject commit 22d610f7860c9939092da3c63b3d38fdb0ff0b61 diff --git a/projects/app/next.config.ts b/projects/app/next.config.ts index 39c1265f2436..f43a8d8d8367 100644 --- a/projects/app/next.config.ts +++ b/projects/app/next.config.ts @@ -36,6 +36,7 @@ const optimizedPackageImports = [ ]; const nextConfig: NextConfig = { + agentRules: false, basePath: webEnv.NEXT_PUBLIC_BASE_URL || undefined, env: { SYSTEM_NAME: appEnv.SYSTEM_NAME, diff --git a/projects/app/src/components/Layout/index.tsx b/projects/app/src/components/Layout/index.tsx index cab3754b202a..0d06647c0a15 100644 --- a/projects/app/src/components/Layout/index.tsx +++ b/projects/app/src/components/Layout/index.tsx @@ -132,6 +132,8 @@ const Layout = ({ children }: { children: JSX.Element }) => { // 仅 root 使用管理员模型列表检查一次系统模型配置,不触发用户模型目录加载。 useEffect(() => { if (userInfo?.username !== 'root') return; + // 模型配置页会自行加载同一份数据;这里跳过,避免首屏重复请求。 + if (router.pathname === '/config/model') return; const identity = `${userInfo.team.teamId}:${userInfo.team.tmbId}:${modelLoginGeneration}`; if (checkedModelIdentityRef.current === identity) return; @@ -164,7 +166,7 @@ const Layout = ({ children }: { children: JSX.Element }) => { checkedModelIdentityRef.current = undefined; } }); - }, [modelLoginGeneration, router, t, toast, userInfo]); + }, [modelLoginGeneration, router, router.pathname, t, toast, userInfo]); // Route watch useEffect(() => { diff --git a/projects/app/src/components/core/ai/ModelListFilters.tsx b/projects/app/src/components/core/ai/ModelListFilters.tsx new file mode 100644 index 000000000000..2543e9d66507 --- /dev/null +++ b/projects/app/src/components/core/ai/ModelListFilters.tsx @@ -0,0 +1,91 @@ +import { Box, Flex, type FlexProps } from '@chakra-ui/react'; +import { modelTypeList } from '@fastgpt/global/core/ai/constants'; +import type { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import type { ModelProviderItemType } from '@fastgpt/global/core/ai/provider'; +import SearchInput from '@fastgpt/web/components/common/Input/SearchInput'; +import { SingleSelectFilter } from '@fastgpt/web/components/common/TagFilter'; +import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; +import { useMemo } from 'react'; + +type ModelListFiltersProps = { + providers: ModelProviderItemType[]; + models: ReadonlyArray<{ provider: string }>; + provider: string; + onProviderChange: (provider: string) => void; + modelType: ModelTypeEnum | ''; + onModelTypeChange: (modelType: ModelTypeEnum | '') => void; + search: string; + onSearchChange: (search: string) => void; + px?: FlexProps['px']; +}; + +/** + * 账号与管理员模型列表共用的筛选栏,统一提供商、模型类型和模型名称搜索交互。 + * 提供商选项只展示当前列表实际包含的提供商,避免选中后得到必然为空的结果。 + */ +const ModelListFilters = ({ + providers, + models, + provider, + onProviderChange, + modelType, + onModelTypeChange, + search, + onSearchChange, + px +}: ModelListFiltersProps) => { + const { t } = useClientTranslation(); + + const providerOptions = useMemo(() => { + const availableProviderIdSet = new Set(models.map((model) => model.provider)); + + return [ + { label: t('common:All'), value: '' }, + ...providers + .filter((item) => availableProviderIdSet.has(item.id)) + .map((item) => ({ + label: item.name, + avatar: item.avatar, + value: item.id + })) + ]; + }, [models, providers, t]); + + const modelTypeOptions = useMemo( + () => [ + { label: t('common:All'), value: '' as const }, + ...modelTypeList.map((item) => ({ label: t(item.label), value: item.value })) + ], + [t] + ); + + return ( + + + onSearchChange(event.target.value)} + placeholder={t('common:model.search_name_placeholder')} + /> + + + + + ); +}; + +export default ModelListFilters; diff --git a/projects/app/src/components/core/app/DatasetCard.tsx b/projects/app/src/components/core/app/DatasetCard.tsx index d2e5821aa53c..e63b08cae2b1 100644 --- a/projects/app/src/components/core/app/DatasetCard.tsx +++ b/projects/app/src/components/core/app/DatasetCard.tsx @@ -53,8 +53,7 @@ const DatasetCard = React.memo(function DatasetCard({ ...flexProps?._hover, borderColor: isDeleted ? 'red.600' : 'primary.300', '& .dataset-card-controller': { - opacity: 1, - pointerEvents: 'auto' + display: 'flex' } }} > @@ -76,10 +75,8 @@ const DatasetCard = React.memo(function DatasetCard({ className="dataset-card-controller" ml={2} flexShrink={0} - display={'flex'} + display={['flex', 'none']} alignItems={'center'} - opacity={[1, 0]} - pointerEvents={['auto', 'none']} > {hasPreviewButton && ( { if (!isSelectableDataset(item)) return true; - return !!activeVectorModel && activeVectorModel !== item.vectorModel.model; + return !!activeVectorModelId && activeVectorModelId !== item.vectorModel.modelId; }; // Cache compatible datasets by vector model to avoid repeated filtering const compatibleDatasetsByModel = useMemo(() => { const visibleDatasets = datasets.filter(isSelectableDataset); - const targetModel = activeVectorModel || visibleDatasets[0]?.vectorModel?.model; - if (!targetModel) { + const targetModelId = activeVectorModelId ?? visibleDatasets[0]?.vectorModel?.modelId; + if (!targetModelId) { return []; } - return visibleDatasets.filter((item) => item.vectorModel.model === targetModel); - }, [datasets, activeVectorModel]); + return visibleDatasets.filter((item) => item.vectorModel.modelId === targetModelId); + }, [datasets, activeVectorModelId]); // Check if all compatible datasets are selected const isAllSelected = useMemo(() => { @@ -143,7 +143,9 @@ export const DatasetSelectModal = ({ if (!isSelectableDataset(item)) { return toast({ status: 'warning', - title: t('dataset:index_model_unavailable') + title: item.vectorModel + ? t('common:model_disabled', { model: item.vectorModel.name }) + : t('common:model_delisted') }); } if (isDatasetDisabled(item)) { @@ -373,7 +375,17 @@ export const DatasetSelectModal = ({ ) : ( <> {t('app:Index')}:{' '} - {item.vectorModel?.name ?? t('dataset:index_model_unavailable')} + {item.vectorModel?.isActive ? ( + item.vectorModel.name + ) : ( + + {item.vectorModel + ? t('common:model_disabled', { + model: item.vectorModel.name + }) + : t('common:model_delisted')} + + )} )} diff --git a/projects/app/src/global/aiproxy/type.ts b/projects/app/src/global/aiproxy/type.ts index 15125d233918..1218971a260c 100644 --- a/projects/app/src/global/aiproxy/type.ts +++ b/projects/app/src/global/aiproxy/type.ts @@ -2,16 +2,25 @@ import z from 'zod'; import type { ChannelStatusEnum } from './constants'; export type ChannelInfoType = { - model_mapping: Record; + model_mapping: Record | null; + configs?: Record | null; key: string; name: string; base_url: string; + proxy_url?: string | null; models: any[]; id: number; status: ChannelStatusEnum; type: number; created_at: number; priority: number; + sets?: string[] | null; + enabled_auto_balance_check?: boolean; + balance_threshold?: number; + skip_tls_verify?: boolean; + enabled_no_permission_ban?: boolean; + warn_error_rate?: number; + max_error_rate?: number; }; // Channel api diff --git a/projects/app/src/migration/tasks/20260903_migrate_legacy_system_models/index.ts b/projects/app/src/migration/tasks/20260903_migrate_legacy_system_models/index.ts index 199a1466617a..939920a50870 100644 --- a/projects/app/src/migration/tasks/20260903_migrate_legacy_system_models/index.ts +++ b/projects/app/src/migration/tasks/20260903_migrate_legacy_system_models/index.ts @@ -3,8 +3,8 @@ import type { SystemMigrationContext } from '../../registry'; /** * 将旧 system_models 及默认模型标记迁移到新的模型集合。 - * 旧模型数据量较小,因此不保存 checkpoint;每次执行都在事务内按 model 追加缺失模型, - * 同名模型保留新表 ID 但其余字段以旧表为准,并保留有效默认配置,重复执行不会重复新增数据。 + * 旧模型数据量较小,因此采用幂等全量重跑且不保存 checkpoint;每次执行都在事务内 + * 按 model 更新同名模型并追加缺失模型,新表独有模型保留,重复执行不会产生重复数据。 */ export const migrateLegacySystemModels = async (context: SystemMigrationContext) => { await context.reportProgress({ @@ -12,21 +12,27 @@ export const migrateLegacySystemModels = async (context: SystemMigrationContext) status: SystemMigrationStatusEnum.running }); - const [ - { preloadModelProviders }, - { getPluginSystemModelDocuments, loadInstalledModels, syncPreinstalledSystemModels }, - { bootstrapAIModelsFromLegacy } - ] = await Promise.all([ - // 动态导入避免 migration 基础设施加载时反向拉入整套 AI 模型初始化依赖。 - import('@fastgpt/service/core/app/provider/controller'), + const [{ loadInstalledModels }, migrationService] = await Promise.all([ import('@fastgpt/service/core/ai/config/utils'), import('./service') ]); - await preloadModelProviders(); - // 插件模型文档参与旧记录修复,必须在读取和重建目标集合前准备完成。 - // 即使启动阶段已经初始化过模型,这里仍重新读取一次,避免迁移结果隐式依赖外部缓存快照。 - const pluginDocuments = await getPluginSystemModelDocuments(); + const migrationState = await migrationService.inspectLegacySystemModelMigration(); + const pluginDocuments = await (async () => { + if (migrationState.sourceCount === 0) { + return []; + } + + const [{ preloadModelProviders }, { getPluginSystemModelDocuments }] = await Promise.all([ + // 动态导入避免 migration 基础设施加载时反向拉入整套 AI 模型初始化依赖。 + import('@fastgpt/service/core/app/provider/controller'), + import('@fastgpt/service/core/ai/config/utils') + ]); + await preloadModelProviders(); + // Plugin 只参与损坏旧字段修复,不产生任何预装模型。 + return getPluginSystemModelDocuments(); + })(); + await context.reportProgress({ key: 'loading_templates', status: SystemMigrationStatusEnum.succeeded @@ -37,7 +43,7 @@ export const migrateLegacySystemModels = async (context: SystemMigrationContext) status: SystemMigrationStatusEnum.running }); - const result = await bootstrapAIModelsFromLegacy({ pluginDocuments }); + const result = await migrationService.bootstrapAIModelsFromLegacy({ pluginDocuments }); await context.reportProgress({ key: 'migrating', status: SystemMigrationStatusEnum.succeeded @@ -49,9 +55,7 @@ export const migrateLegacySystemModels = async (context: SystemMigrationContext) status: SystemMigrationStatusEnum.running }); - // 迁移追加完成后恢复正常预装流程,并发布完整运行时缓存。 - await syncPreinstalledSystemModels({ pluginDocuments }); - await loadInstalledModels({ pluginDocuments }); + await loadInstalledModels(); await context.reportProgress({ key: 'reloading_models', status: SystemMigrationStatusEnum.succeeded diff --git a/projects/app/src/migration/tasks/20260903_migrate_legacy_system_models/service.ts b/projects/app/src/migration/tasks/20260903_migrate_legacy_system_models/service.ts index 9c3ab2b921e5..32c3344e8f29 100644 --- a/projects/app/src/migration/tasks/20260903_migrate_legacy_system_models/service.ts +++ b/projects/app/src/migration/tasks/20260903_migrate_legacy_system_models/service.ts @@ -20,15 +20,24 @@ export type BootstrapAIModelsResult = { }; /** - * 将旧 system_models 中尚不存在的模型确定性追加到 ai_models。 - * - * 目标表已经存在同名 system 模型时,保留它的 _id 以维持已回填的业务引用,其余 - * canonical 字段整体以旧表转换结果覆盖。这是因为启动阶段会先自动预装插件模型, - * 预装值不能遮蔽升级前用户在 system_models 中的配置和激活状态。只有旧表独有模型 - * 才沿用旧 _id 插入;ai_models 独有的预装模型不删除。 + * 读取旧表与 system scope 新表数量。源记录数用于在空源场景跳过 Plugin 请求; + * 目标记录数仅用于诊断,不决定任务是否执行。 + */ +export const inspectLegacySystemModelMigration = async () => { + const legacyCollection = MongoAIModel.db.collection(LegacySystemModelCollectionName); + const [sourceCount, targetCount] = await Promise.all([ + legacyCollection.countDocuments(), + MongoAIModel.countDocuments({ scope: ModelScopeEnum.system }) + ]); + + return { sourceCount, targetCount }; +}; + +/** + * 将旧 system_models 确定性合并到 ai_models。 * - * 现有且有效的默认模型优先,旧表默认标记只补齐缺失或无效槽位。整个追加过程可 - * 重复执行,是否需要执行只由 Runner 的迁移状态决定。 + * 同名模型保留新表 `_id`,其余 canonical 字段以旧表为准;旧表独有模型沿用旧 `_id` + * 新增,新表独有模型保留。模型与默认配置在同一事务内写入,整体可幂等重放。 */ export const bootstrapAIModelsFromLegacy = async ({ pluginDocuments @@ -78,10 +87,14 @@ export const bootstrapAIModelsFromLegacy = async ({ }); return mongoSessionRun(async (session) => { - const [targetRecords, existingDefaultModel] = await Promise.all([ - MongoAIModel.collection.find({ scope: ModelScopeEnum.system }, { session }).toArray(), - MongoAIDefaultModel.collection.findOne({ scope: ModelScopeEnum.system }, { session }) - ]); + // 同一 MongoDB 事务 session 内串行执行,避免驱动不支持的并行事务操作。 + const targetRecords = await MongoAIModel.collection + .find({ scope: ModelScopeEnum.system }, { session }) + .toArray(); + const existingDefaultModel = await MongoAIDefaultModel.collection.findOne( + { scope: ModelScopeEnum.system }, + { session } + ); const targetModels = targetRecords.map((record) => ({ _id: record._id, document: SystemModelDocumentDataSchema.parse(record) @@ -106,7 +119,6 @@ export const bootstrapAIModelsFromLegacy = async ({ } return { _id: resolvedId, - // 同名时只复用新表 ID,其余字段以升级前的旧表数据为准。 document, defaultFlags, shouldInsert: !sameModel, @@ -190,9 +202,7 @@ export const bootstrapAIModelsFromLegacy = async ({ return { status: 'migrated', sourceCount: records.length, - // 最终表数量包含升级前已经存在的模型,以及本次从旧表追加的模型。 targetCount: finalModelsById.size, - // 同名目标也代表该旧模型已成功落到新结构;这里不再把“新增数”冒充“迁移成功数”。 migratedCount: resolvedCandidates.length }; }); diff --git a/projects/app/src/pageComponents/account/model/AddModelBox.tsx b/projects/app/src/pageComponents/account/model/AddModelBox.tsx deleted file mode 100644 index 319a0b40b26e..000000000000 --- a/projects/app/src/pageComponents/account/model/AddModelBox.tsx +++ /dev/null @@ -1,1252 +0,0 @@ -import { - Box, - Flex, - HStack, - Table, - Tbody, - Td, - Th, - Thead, - Tr, - Switch, - Input, - Button, - type ButtonProps, - type BoxProps, - Grid, - GridItem -} from '@chakra-ui/react'; -import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import MySelect from '@fastgpt/web/components/common/MySelect'; -import MultipleSelect from '@fastgpt/web/components/common/MySelect/MultipleSelect'; -import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; -import Avatar from '@fastgpt/web/components/common/Avatar'; -import { useRequest } from '@fastgpt/web/hooks/useRequest'; -import { getSystemModelDefaultConfig, postSystemModel, putSystemModel } from '@/web/core/ai/config'; -import { type SystemModelDataType } from '@fastgpt/global/core/ai/model.schema'; -import { - useFieldArray, - useForm, - useWatch, - type Control, - type UseFormGetValues, - type UseFormRegister, - type UseFormSetValue -} from 'react-hook-form'; -import MyNumberInput from '@fastgpt/web/components/common/Input/NumberInput'; -import MyTextarea from '@/components/common/Textarea/MyTextarea'; -import JsonEditor from '@fastgpt/web/components/common/Textarea/JsonEditor'; -import MyMenu from '@fastgpt/web/components/common/MyMenu'; -import { useSystemStore } from '@/web/common/system/useSystemStore'; -import { useAdminModelConfig } from '@/web/core/ai/model/useAdminModelConfig'; -import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; -import { sanitizeModelPriceTiers } from '@fastgpt/global/core/ai/pricing'; -import MyModal from '@fastgpt/web/components/v2/common/MyModal'; - -export const AddModelButton = ({ - onCreate, - buttonBoxProps, - ...props -}: { - onCreate: (type: ModelTypeEnum) => void; - buttonBoxProps?: BoxProps; -} & ButtonProps) => { - const { t } = useClientTranslation('config_model'); - - return ( - {t('config_model:create_model')}} - menuList={[ - { - children: [ - { - label: t('common:model.type.chat'), - onClick: () => onCreate(ModelTypeEnum.llm) - }, - { - label: t('common:model.type.embedding'), - onClick: () => onCreate(ModelTypeEnum.embedding) - }, - { - label: t('common:model.type.tts'), - onClick: () => onCreate(ModelTypeEnum.tts) - }, - { - label: t('common:model.type.stt'), - onClick: () => onCreate(ModelTypeEnum.stt) - }, - { - label: t('common:model.type.reRank'), - onClick: () => onCreate(ModelTypeEnum.rerank) - } - ] - } - ]} - /> - ); -}; - -const ControlHeight = '32px'; - -const InputStyles = { - maxW: '100%', - bg: 'white', - w: '100%', - h: ControlHeight, - minH: ControlHeight, - fontSize: 'sm' -}; - -const NumberInputStyles = { - ...InputStyles, - inputFieldProps: { - bg: 'transparent', - h: ControlHeight, - minH: ControlHeight, - px: 3, - fontSize: 'sm' - } -}; - -const MultilineInputStyles = { - maxW: '100%', - bg: 'white', - w: '100%', - rows: 3 -}; - -const PriceInputStyles = { - bg: 'transparent', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - h: '24px', - minH: '24px', - py: '4px', - lineHeight: '16px' -}; - -const BorderlessPriceInputStyles = { - variant: 'unstyled' as const, - bg: 'transparent', - border: 'none', - boxShadow: 'none', - _focus: { - boxShadow: 'none' - }, - _focusVisible: { - boxShadow: 'none' - } -}; - -const FixedPriceValueInputStyles = { - boxSizing: 'border-box' as const, - appearance: 'textfield' as const, - sx: { - '&::-webkit-outer-spin-button, &::-webkit-inner-spin-button': { - appearance: 'none', - margin: 0 - } - } -}; - -const InvalidPriceInputStyles = { - borderColor: 'red.500', - _hover: { - borderColor: 'red.500' - }, - _focus: { - borderColor: 'red.500', - boxShadow: '0 0 0 1px var(--chakra-colors-red-500)' - }, - _focusVisible: { - borderColor: 'red.500', - boxShadow: '0 0 0 1px var(--chakra-colors-red-500)' - } -}; - -const emptyPriceTier = { - minInputTokens: 0, - maxInputTokens: undefined, - inputPrice: undefined, - outputPrice: undefined -}; - -const getOptionalNumber = (value: unknown) => { - if (value === '' || value === null || value === undefined) return undefined; - - if (typeof value === 'number') { - return Number.isFinite(value) ? value : undefined; - } - - if (typeof value === 'string') { - const trimmedValue = value.trim(); - if (!trimmedValue) return undefined; - - const parsedValue = Number(trimmedValue); - return Number.isFinite(parsedValue) ? parsedValue : undefined; - } - - return undefined; -}; - -const defaultResponseFormatOptions = ['text', 'json_schema', 'json_object']; - -const Section = ({ - title, - children, - showBorder = true -}: { - title: string; - children: React.ReactNode; - showBorder?: boolean; -}) => ( - - - {title} - - {children} - -); - -const Field = ({ - label, - tip, - children, - colSpan = 1 -}: { - label: string; - tip?: string; - children: React.ReactNode; - colSpan?: number | number[]; -}) => ( - - - - {label} - - {tip && } - - {children} - -); - -const SwitchField = ({ - label, - tip, - field, - register -}: { - label: string; - tip?: string; - field: string; - register: UseFormRegister; -}) => ( - - - - {label} - - {tip && } - - - -); - -const ProviderField = React.memo(function ProviderField({ - control, - setValue, - providerList, - t -}: { - control: Control; - setValue: UseFormSetValue; - providerList: { label: React.ReactNode; value: string }[]; - t: any; -}) { - const provider = useWatch({ - control, - name: 'provider' - }); - - return ( - - setValue('provider', value)} - list={providerList} - {...InputStyles} - maxW={['100%', '360px']} - /> - - ); -}); - -const ResponseFormatField = React.memo(function ResponseFormatField({ - control, - setValue, - t -}: { - control: Control; - setValue: UseFormSetValue; - t: any; -}) { - const responseFormatList = useWatch({ - control, - name: 'config.responseFormatList' - }); - const responseFormatOptions = useMemo(() => { - const valueSet = new Set([ - ...defaultResponseFormatOptions, - ...(Array.isArray(responseFormatList) ? responseFormatList : []) - ]); - - return Array.from(valueSet).map((item) => ({ - value: item, - label: item - })); - }, [responseFormatList]); - - return ( - - - list={responseFormatOptions} - value={Array.isArray(responseFormatList) ? responseFormatList : []} - onSelect={(value) => setValue('config.responseFormatList', value)} - placeholder={t('config_model:model.response_format')} - {...InputStyles} - borderRadius={'md'} - tagStyle={{ - bg: 'transparent', - color: 'myGray.700', - borderColor: 'myGray.200', - borderWidth: '1px', - borderRadius: '6px', - px: 2, - py: 1, - fontSize: '10px' - }} - /> - - ); -}); - -const PriceTiersTable = React.memo(function PriceTiersTable({ - control, - register, - getValues, - setValue, - t -}: { - control: Control; - register: UseFormRegister; - getValues: UseFormGetValues; - setValue: UseFormSetValue; - t: any; -}) { - const [invalidMaxInputMap, setInvalidMaxInputMap] = useState>({}); - const { - fields: priceTierFields, - append: appendPriceTier, - remove: removePriceTier - } = useFieldArray({ - control, - name: 'priceTiers' as never - }); - - const watchedPriceTiers = useWatch({ - control, - name: 'priceTiers' - }); - - const ensureNextEmptyPriceTier = useCallback( - (index: number, value?: number, inputEl?: HTMLInputElement | null, lowerBound?: number) => { - if (typeof value !== 'number' || Number.isNaN(value)) return; - if (typeof lowerBound === 'number' && value <= lowerBound) return; - - const tiers = getValues('priceTiers') || []; - const isLastTier = index === tiers.length - 1; - - if (!isLastTier) return; - - appendPriceTier(emptyPriceTier as any); - - if (inputEl) { - const selectionStart = inputEl.selectionStart; - const selectionEnd = inputEl.selectionEnd; - - requestAnimationFrame(() => { - inputEl.focus(); - if (selectionStart !== null && selectionEnd !== null) { - inputEl.setSelectionRange(selectionStart, selectionEnd); - } - }); - } - }, - [appendPriceTier, getValues] - ); - - const clearPriceTier = useCallback( - (index: number) => { - const total = priceTierFields.length; - - if (total === 1) { - setValue(`priceTiers.${index}.maxInputTokens` as any, undefined as any); - setValue(`priceTiers.${index}.inputPrice` as any, undefined as any); - setValue(`priceTiers.${index}.outputPrice` as any, undefined as any); - return; - } - - removePriceTier(index); - }, - [priceTierFields.length, removePriceTier, setValue] - ); - - return ( - - - - - - - - - - - - - {priceTierFields.map((field, index) => { - const currentTier = watchedPriceTiers?.[index]; - const previousTier = watchedPriceTiers?.[index - 1]; - const previousTierMax = - index === 0 - ? 0 - : typeof previousTier?.maxInputTokens === 'number' && - Number.isFinite(previousTier.maxInputTokens) - ? previousTier.maxInputTokens - : 0; - const lowerBound = index === 0 ? 0 : previousTierMax; - const minAllowedMax = lowerBound; - const lowerBoundLabel = String(lowerBound); - const isLastTier = index === priceTierFields.length - 1; - const isInvalidMaxInput = - invalidMaxInputMap[index] ?? - (typeof currentTier?.maxInputTokens === 'number' && - currentTier.maxInputTokens <= lowerBound); - const isEmptyAction = - !currentTier?.maxInputTokens && - !currentTier?.inputPrice && - !currentTier?.outputPrice; - const maxInputTokensRegister = register(`priceTiers.${index}.maxInputTokens`, { - min: minAllowedMax, - setValueAs: getOptionalNumber - }); - const inputPriceRegister = register(`priceTiers.${index}.inputPrice`, { - setValueAs: getOptionalNumber - }); - const outputPriceRegister = register(`priceTiers.${index}.outputPrice`, { - setValueAs: getOptionalNumber - }); - - return ( - - - - - - - - - ); - })} - -
- {t('common:model.price_tier_range')} - - {t('common:model.input_price')} - - {t('common:model.output_price')} - - {t('config_model:model.action')} -
- - - - {' < '} - {t('common:Input')} - {' <= '} - - { - maxInputTokensRegister.onChange(e); - const nextValue = getOptionalNumber(e.target.value); - setInvalidMaxInputMap((state) => ({ - ...state, - [index]: typeof nextValue === 'number' ? nextValue <= lowerBound : false - })); - }} - onBlur={(e) => { - maxInputTokensRegister.onBlur(e); - const nextValue = getOptionalNumber(e.target.value); - setInvalidMaxInputMap((state) => ({ - ...state, - [index]: typeof nextValue === 'number' ? nextValue <= lowerBound : false - })); - ensureNextEmptyPriceTier(index, nextValue, e.currentTarget, lowerBound); - }} - isInvalid={isInvalidMaxInput} - {...(isInvalidMaxInput ? InvalidPriceInputStyles : {})} - /> - - - - - - {t('common:support.wallet.subscription.point')} - - - - - - - {t('common:support.wallet.subscription.point')} - - - - -
-
-
- ); -}); - -const DefaultConfigField = React.memo(function DefaultConfigField({ - control, - setValue, - label, - tip -}: { - control: Control; - setValue: UseFormSetValue; - label: string; - tip: string; -}) { - const defaultConfig = useWatch({ - control, - name: 'config.defaultConfig' - }); - - return ( - - { - if (!e) { - setValue('config.defaultConfig', {}, { shouldDirty: true }); - return; - } - try { - setValue('config.defaultConfig', JSON.parse(e.trim()), { shouldDirty: true }); - } catch (error) { - console.error(error); - } - }} - {...MultilineInputStyles} - pr={2.5} - /> - - ); -}); - -const VoicesField = React.memo(function VoicesField({ - control, - setValue, - t -}: { - control: Control; - setValue: UseFormSetValue; - t: any; -}) { - const voices = useWatch({ - control, - name: 'config.voices' - }); - - return ( - - { - try { - setValue('config.voices', JSON.parse(e)); - } catch (error) { - console.error(error); - } - }} - {...MultilineInputStyles} - /> - - ); -}); - -export const ModelEditModal = ({ - modelData, - onSuccess, - onClose -}: { - modelData: SystemModelDataType; - onSuccess: () => void; - onClose: () => void; -}) => { - const { t, i18n } = useClientTranslation('config_model'); - const { feConfigs } = useSystemStore(); - const { getModelProviders } = useAdminModelConfig(); - - const { control, register, getValues, setValue, handleSubmit, reset } = - useForm({ - defaultValues: { - ...modelData, - priceTiers: (() => { - if (modelData.type !== ModelTypeEnum.llm) return undefined; - const tiers = modelData.priceTiers || []; - if (tiers.length === 0) return [emptyPriceTier]; - - const last = tiers[tiers.length - 1]; - if (!last.maxInputTokens) return tiers; - - return [ - ...tiers, - { - ...emptyPriceTier, - minInputTokens: last.maxInputTokens - } - ]; - })() - } - }); - - const reasoningEnabled = useWatch({ control, name: 'config.reasoning' }); - useEffect(() => { - // 仅在 reasoning 关闭且 reasoningEffort 实际为 true 时才清,避免挂载即把表单标 dirty - if (!reasoningEnabled && getValues('config.reasoningEffort')) { - setValue('config.reasoningEffort', false, { shouldDirty: false }); - } - }, [reasoningEnabled, getValues, setValue]); - - const isCustom = !!modelData.isCustom; - const isLLMModel = modelData?.type === ModelTypeEnum.llm; - const isEmbeddingModel = modelData?.type === ModelTypeEnum.embedding; - const isTTSModel = modelData?.type === ModelTypeEnum.tts; - const isSTTModel = modelData?.type === ModelTypeEnum.stt; - const isRerankModel = modelData?.type === ModelTypeEnum.rerank; - - const providerList = useMemo( - () => - getModelProviders(i18n.language).map((item) => ({ - label: ( - - - {item.name} - - ), - value: item.id - })), - [getModelProviders, i18n.language] - ); - - const priceUnit = useMemo(() => { - if (isLLMModel || isEmbeddingModel || isRerankModel) return '/ 1k Tokens'; - if (isTTSModel) return `/ 1k ${t('common:unit.character')}`; - if (isSTTModel) return `/ 60 ${t('common:unit.seconds')}`; - return ''; - }, [isLLMModel, isEmbeddingModel, isTTSModel, t, isSTTModel, isRerankModel]); - - const { runAsync: updateModel, loading: updatingModel } = useRequest( - async (data: SystemModelDataType) => { - if (data.type === ModelTypeEnum.llm) { - // 空数字输入会被 react-hook-form 解析为 NaN;显式转成协议允许的 null, - // 避免依赖 JSON.stringify 将 NaN 隐式转换成 null。 - if (Number.isNaN(data.config.maxTemperature)) { - data.config.maxTemperature = null; - } - - const priceTiers = sanitizeModelPriceTiers(data.priceTiers); - - let currentLowerExclusiveBound = 0; - - for (let index = 0; index < priceTiers.length; index++) { - const tier = priceTiers[index]; - const hasPrice = - typeof tier.inputPrice === 'number' || typeof tier.outputPrice === 'number'; - - if (!hasPrice) { - return Promise.reject(t('config_model:model.price_tier_price_required')); - } - - if (index < priceTiers.length - 1 && typeof tier.maxInputTokens !== 'number') { - return Promise.reject(t('config_model:model.price_tier_max_required')); - } - - if ( - typeof tier.maxInputTokens === 'number' && - tier.maxInputTokens <= currentLowerExclusiveBound - ) { - return Promise.reject(t('config_model:model.price_tier_range_invalid')); - } - - if (typeof tier.maxInputTokens === 'number') { - currentLowerExclusiveBound = tier.maxInputTokens; - } - } - - data.priceTiers = priceTiers as any; - } - - const modelData = data as Record; - for (const key of Object.keys(modelData)) { - const val = modelData[key]; - if (val === null || val === undefined || Number.isNaN(val)) { - delete modelData[key]; - } - } - - const { modelId, avatar: _avatar, isCustom: _isCustom, ...persistedModelData } = data; - - return ( - modelId - ? putSystemModel({ modelId, modelData: persistedModelData }) - : postSystemModel({ modelData: persistedModelData }) - ).then(onSuccess); - }, - { - onSuccess: () => { - onClose(); - }, - successToast: t('common:Success') - } - ); - - const [key, setKey] = useState(0); - const { runAsync: loadDefaultConfig, loading: loadingDefaultConfig } = useRequest( - getSystemModelDefaultConfig, - { - onSuccess(res) { - reset({ - ...getValues(), - ...res - }); - setTimeout(() => { - setKey((prev) => prev + 1); - }, 0); - } - } - ); - - const CustomApi = useMemo( - () => ( - <> - - - - {t('config_model:model.request_url')} - - - - - - - - - {t('config_model:model.request_auth')} - - - - - - - ), - [register, t] - ); - - return ( - - {!modelData.isCustom && ( - - )} - - - - } - > -
- - - - - - - - - - - -
- - {isLLMModel && ( -
- - - - - - - - - - - - - - - - - - - - - - - -
- )} - - {isEmbeddingModel && ( -
- - - - - - - - - - - - -
- )} - - {isRerankModel && ( -
- - - - - -
- )} - - {isLLMModel && ( -
- - - - - - - {reasoningEnabled && ( - - )} - {feConfigs?.isPlus && ( - - )} - -
- )} - - {isEmbeddingModel && ( -
- - - -
- )} - - {priceUnit && feConfigs?.isPlus && ( -
- {isLLMModel ? ( - - ) : ( - - - - - - / 1k Tokens - - - - - )} -
- )} - -
- - {isLLMModel && ( - - - - )} - {(isLLMModel || isEmbeddingModel || isRerankModel) && ( - - )} - {isTTSModel && } - {CustomApi} - - -
-
- ); -}; - -export default function Dom() { - return <>; -} diff --git a/projects/app/src/pageComponents/account/model/Channel/EditChannelModal.tsx b/projects/app/src/pageComponents/account/model/Channel/EditChannelModal.tsx deleted file mode 100644 index d79225a48503..000000000000 --- a/projects/app/src/pageComponents/account/model/Channel/EditChannelModal.tsx +++ /dev/null @@ -1,507 +0,0 @@ -import { type ChannelInfoType } from '@/global/aiproxy/type'; -import { - Box, - type BoxProps, - Button, - Flex, - Input, - type MenuItemProps, - ModalBody, - ModalFooter, - useDisclosure, - Menu, - MenuButton, - MenuList, - MenuItem, - HStack, - useOutsideClick -} from '@chakra-ui/react'; -import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel'; -import MyModal from '@fastgpt/web/components/common/MyModal'; -import MySelect from '@fastgpt/web/components/common/MySelect'; -import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { AddModelButton } from '../AddModelBox'; -import dynamic from 'next/dynamic'; -import { type SystemModelDataType } from '@fastgpt/global/core/ai/model.schema'; -import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; -import { useRequest } from '@fastgpt/web/hooks/useRequest'; -import MyIcon from '@fastgpt/web/components/common/Icon'; -import MyAvatar from '@fastgpt/web/components/common/Avatar'; -import MyTag from '@fastgpt/web/components/common/Tag/index'; -import { useCopyData } from '@fastgpt/web/hooks/useCopyData'; -import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; -import JsonEditor from '@fastgpt/web/components/common/Textarea/JsonEditor'; -import { getChannelProviders, postCreateChannel, putChannel } from '@/web/core/ai/channel'; -import CopyBox from '@fastgpt/web/components/common/String/CopyBox'; -import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; -import type { localeType } from '@fastgpt/global/common/i18n/type'; -import { defaultProvider } from '@fastgpt/global/core/ai/provider'; -import { useAdminModelConfig } from '@/web/core/ai/model/useAdminModelConfig'; - -const ModelEditModal = dynamic(() => import('../AddModelBox').then((mod) => mod.ModelEditModal)); - -const LabelStyles: BoxProps = { - fontSize: 'sm', - color: 'myGray.900', - flex: '0 0 70px' -}; -const EditChannelModal = ({ - defaultConfig, - onClose, - onSuccess -}: { - defaultConfig: ChannelInfoType; - onClose: () => void; - onSuccess: () => void; -}) => { - const { t, i18n } = useClientTranslation('config_model'); - const { - aiproxyChannels, - defaultModelIds, - getModelProvider, - systemModelList, - runAsync: refreshSystemModelList, - loading: loadingModels - } = useAdminModelConfig(); - const defaultModels = useMemo( - () => - Object.fromEntries( - Object.entries(defaultModelIds).map(([key, modelId]) => [ - key, - systemModelList.find((model) => model.modelId === modelId) - ]) - ), - [defaultModelIds, systemModelList] - ); - const isEdit = defaultConfig.id !== 0; - - const { register, handleSubmit, watch, setValue } = useForm({ - defaultValues: defaultConfig - }); - - const providerType = watch('type'); - const { data: channelProviderMetas = {}, loading: loadingChannelProviderMetas } = useRequest( - getChannelProviders, - { manual: false } - ); - const providerList = useMemo( - () => - aiproxyChannels.flatMap((channel) => { - const mapData = channelProviderMetas[channel.channelId]; - if (!mapData) return []; - - return [ - { - defaultBaseUrl: mapData.defaultBaseUrl, - keyHelp: mapData.keyHelp, - icon: channel.avatar, - label: parseI18nString(channel.name, i18n.language as localeType), - value: channel.channelId - } - ]; - }), - [aiproxyChannels, channelProviderMetas, i18n.language] - ); - - const selectedProvider = useMemo(() => { - const res = providerList.find((item) => item.value === providerType); - return res; - }, [providerList, providerType]); - - const [editModelData, setEditModelData] = useState(); - const onCreateModel = (type: ModelTypeEnum) => { - const defaultModel = defaultModels[type]; - - setEditModelData({ - ...defaultModel, - model: '', - name: '', - charsPointsPrice: 0, - inputPrice: undefined, - outputPrice: undefined, - - isCustom: true, - isActive: true, - ...(type === ModelTypeEnum.llm - ? { - vision: false, - audio: false, - video: false - } - : {}), - // @ts-ignore - type - }); - }; - - const models = watch('models'); - const modelList = useMemo(() => { - return systemModelList.map((item) => { - const provider = getModelProvider(item.provider, i18n.language); - - return { - provider: item.provider, - icon: provider?.avatar, - label: item.model, - value: item.model - }; - }); - }, [getModelProvider, i18n.language, systemModelList]); - - const modelMapping = watch('model_mapping'); - - const { runAsync: onSubmit, loading: loadingCreate } = useRequest( - (data: ChannelInfoType) => { - if (data.models.length === 0) { - return Promise.reject(t('config_model:selected_model_empty')); - } - return isEdit ? putChannel(data) : postCreateChannel(data); - }, - { - onSuccess() { - onSuccess(); - onClose(); - }, - successToast: isEdit ? t('common:update_success') : t('common:create_success'), - manual: true - } - ); - - const isLoading = loadingModels || loadingChannelProviderMetas || loadingCreate; - - return ( - <> - - - {/* Chnnel name */} - - - {t('config_model:channel_name')} - - - - {/* Provider */} - - - {t('config_model:channel_type')} - - - { - setValue('type', val); - }} - /> - - - {/* Model */} - - - - {t('config_model:model')}({models.length}) - - - - - - - { - setValue('models', val); - }} - /> - - - {/* Mapping */} - - - {t('config_model:mapping')} - - - - { - if (!val) { - setValue('model_mapping', {}); - } else { - try { - setValue('model_mapping', JSON.parse(val)); - } catch (error) {} - } - }} - /> - - - {/* url and key */} - - - {t('config_model:base_url')} - {selectedProvider && ( - - {'('} - {t('config_model:default_url')}: - - {selectedProvider?.defaultBaseUrl || ''} - - {')'} - - )} - - - - - - {t('config_model:api_key')} - {selectedProvider?.keyHelp && ( - - {'('} - {t('config_model:key_type')} - {selectedProvider.keyHelp} - {')'} - - )} - - - - - - - - - - {!!editModelData && ( - setEditModelData(undefined)} - /> - )} - - ); -}; -export default EditChannelModal; - -type SelectProps = { - list: { - icon?: string; - label: string; - value: string; - }[]; - value: string[]; - onSelect: (val: string[]) => void; -}; -const menuItemStyles: MenuItemProps = { - borderRadius: 'sm', - py: 2, - display: 'flex', - alignItems: 'center', - _hover: { - backgroundColor: 'myGray.100' - }, - _notLast: { - mb: 0.5 - } -}; -const MultipleSelect = ({ value = [], list = [], onSelect }: SelectProps) => { - const ref = useRef(null); - const BoxRef = useRef(null); - - const { t } = useClientTranslation('config_model'); - const { isOpen, onOpen, onClose } = useDisclosure(); - const { copyData } = useCopyData(); - - const [search, setSearch] = useState(''); - - const onclickItem = useCallback( - (val: string) => { - if (value.includes(val)) { - onSelect(value.filter((i) => i !== val)); - } else { - onSelect([...value, val]); - BoxRef.current?.scrollTo({ - top: BoxRef.current.scrollHeight - }); - } - setSearch(''); - }, - [value, onSelect] - ); - - const filterUnSelected = useMemo(() => { - return list - .filter((item) => !value.includes(item.value)) - .filter((item) => { - if (!search) return true; - const regx = new RegExp(search, 'i'); - return regx.test(item.label); - }); - }, [list, value, search]); - - useOutsideClick({ - ref, - handler: () => { - onClose(); - } - }); - - return ( - - - { - onOpen(); - setSearch(''); - } - })} - > - - - {value.length === 0 ? ( - - {t('config_model:select_model_placeholder')} - - ) : ( - - {value.map((item) => ( - { - e.stopPropagation(); - copyData(item, t('config_model:copy_model_id_success')); - }} - > - {item} - { - e.stopPropagation(); - onclickItem(item); - }} - /> - - ))} - {isOpen && ( - setSearch(e.target.value)} - placeholder={t('config_model:search_model')} - onClick={(e) => { - e.stopPropagation(); - }} - /> - )} - - )} - - - - - - {filterUnSelected.map((item, i) => { - return ( - { - onclickItem(item.value); - }} - whiteSpace={'pre-wrap'} - fontSize={'sm'} - gap={2} - {...menuItemStyles} - > - {item.icon && } - {item.label} - - ); - })} - - - - ); -}; diff --git a/projects/app/src/pageComponents/account/model/Channel/index.tsx b/projects/app/src/pageComponents/account/model/Channel/index.tsx deleted file mode 100644 index 028b11e5cbb2..000000000000 --- a/projects/app/src/pageComponents/account/model/Channel/index.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { - deleteChannel, - getChannelList, - getChannelProviders, - putChannel, - putChannelStatus -} from '@/web/core/ai/channel'; -import { useRequest } from '@fastgpt/web/hooks/useRequest'; -import React, { useState } from 'react'; -import { - Table, - Thead, - Tbody, - Tr, - Th, - Td, - TableContainer, - Box, - Button, - HStack -} from '@chakra-ui/react'; -import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; -import MyBox from '@fastgpt/web/components/common/MyBox'; -import MyIconButton from '@fastgpt/web/components/common/Icon/button'; -import { useUserStore } from '@/web/support/user/useUserStore'; -import { type ChannelInfoType } from '@/global/aiproxy/type'; -import MyTag from '@fastgpt/web/components/common/Tag/index'; -import { useSystemStore } from '@/web/common/system/useSystemStore'; -import { ChannelStatusEnum, ChannelStautsMap, defaultChannel } from '@/global/aiproxy/constants'; -import MyMenu from '@fastgpt/web/components/common/MyMenu'; -import dynamic from 'next/dynamic'; -import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; -import MyNumberInput from '@fastgpt/web/components/common/Input/NumberInput'; -import { useConfirm } from '@fastgpt/web/hooks/useConfirm'; -import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; -import Avatar from '@fastgpt/web/components/common/Avatar'; -import ModelTabHeader from '../ModelTabHeader'; - -const EditChannelModal = dynamic(() => import('./EditChannelModal'), { ssr: false }); -const ModelTest = dynamic(() => import('./ModelTest'), { ssr: false }); - -const ChannelTable = ({ Tab }: { Tab: React.ReactNode }) => { - const { t, i18n } = useClientTranslation('config_model'); - const { userInfo } = useUserStore(); - const { aiproxyChannels } = useSystemStore(); - - const isRoot = userInfo?.username === 'root'; - - const { - data: channelList = [], - runAsync: refreshChannelList, - loading: loadingChannelList - } = useRequest(getChannelList, { - manual: false - }); - - const { data: _channelProviders = {} } = useRequest(getChannelProviders, { - manual: false - }); - - const [editChannel, setEditChannel] = useState(); - - const { runAsync: updateChannel, loading: loadingUpdateChannel } = useRequest(putChannel, { - manual: true, - onSuccess: () => { - refreshChannelList(); - } - }); - const { runAsync: updateChannelStatus, loading: loadingUpdateChannelStatus } = useRequest( - putChannelStatus, - { - onSuccess: () => { - refreshChannelList(); - } - } - ); - - const { openConfirm, ConfirmModal } = useConfirm({ - type: 'delete' - }); - const { runAsync: onDeleteChannel, loading: loadingDeleteChannel } = useRequest(deleteChannel, { - manual: true, - onSuccess: () => { - refreshChannelList(); - } - }); - - const [modelTestData, setTestModelData] = useState<{ channelId: number; models: string[] }>(); - - const isLoading = - loadingChannelList || - loadingUpdateChannel || - loadingDeleteChannel || - loadingUpdateChannelStatus; - - return ( - <> - {isRoot && ( - - - - )} - - - - - - - - - - - - - - - {channelList.map((item) => { - const providerData = aiproxyChannels.find( - (channel) => channel.channelId === item.type - ) || { - name: 'Invalid provider', - avatar: 'model/huggingface' - }; - return ( - - - - - - - - - ); - })} - -
ID{t('config_model:channel_name')}{t('config_model:channel_type')}{t('config_model:channel_status')} - {t('config_model:channel_priority')} - -
{item.id}{item.name} - - - {parseI18nString(providerData.name, i18n.language)} - - - - {t(ChannelStautsMap[item.status]?.label as any) || - t('config_model:channel_status_unknown')} - - - { - const val = (() => { - if (!e) return 1; - return e; - })(); - updateChannel({ - ...item, - priority: val - }); - }} - /> - - - setTestModelData({ - channelId: item.id, - models: item.models - }) - }, - ...(item.status === ChannelStatusEnum.ChannelStatusEnabled - ? [ - { - icon: 'common/disable', - label: t('config_model:forbid_channel'), - onClick: () => - updateChannelStatus( - item.id, - ChannelStatusEnum.ChannelStatusDisabled - ) - } - ] - : [ - { - icon: 'common/enable', - label: t('config_model:enable_channel'), - onClick: () => - updateChannelStatus( - item.id, - ChannelStatusEnum.ChannelStatusEnabled - ) - } - ]), - { - icon: 'common/settingLight', - label: t('config_model:edit'), - onClick: () => setEditChannel(item) - }, - { - type: 'danger', - icon: 'delete', - label: t('common:Delete'), - onClick: () => - openConfirm({ - onConfirm: () => onDeleteChannel(item.id), - customContent: t('config_model:confirm_delete_channel', { - name: item.name - }) - })() - } - ] - } - ]} - Button={} - /> -
-
-
- - {!!editChannel && ( - setEditChannel(undefined)} - onSuccess={refreshChannelList} - /> - )} - {!!modelTestData && ( - setTestModelData(undefined)} /> - )} - - - ); -}; - -export default ChannelTable; diff --git a/projects/app/src/pageComponents/account/model/ModelConfigTable.tsx b/projects/app/src/pageComponents/account/model/ModelConfigTable.tsx deleted file mode 100644 index 12732d85ef56..000000000000 --- a/projects/app/src/pageComponents/account/model/ModelConfigTable.tsx +++ /dev/null @@ -1,901 +0,0 @@ -import { - Box, - Flex, - Grid, - HStack, - Table, - TableContainer, - Tbody, - Td, - Th, - Thead, - Tr, - Switch, - ModalBody, - ModalFooter, - Button, - useDisclosure -} from '@chakra-ui/react'; -import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; -import React, { useCallback, useMemo, useState } from 'react'; -import { SingleSelectFilter } from '@fastgpt/web/components/common/TagFilter'; -import { modelTypeList, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; -import SearchInput from '@fastgpt/web/components/common/Input/SearchInput'; -import Avatar from '@fastgpt/web/components/common/Avatar'; -import MyTag from '@fastgpt/web/components/common/Tag/index'; -import dynamic from 'next/dynamic'; -import { useRequest } from '@fastgpt/web/hooks/useRequest'; -import { - deleteSystemModel, - getModelConfigJson, - getSystemModelDetail, - getAdminModelConfig, - getTestModel, - putSystemModel, - putUpdateDefaultModels -} from '@/web/core/ai/config'; -import MyBox from '@fastgpt/web/components/common/MyBox'; -import { type SystemModelDataType } from '@fastgpt/global/core/ai/model.schema'; -import MyIconButton from '@fastgpt/web/components/common/Icon/button'; -import JsonEditor from '@fastgpt/web/components/common/Textarea/JsonEditor'; -import { useUserStore } from '@/web/support/user/useUserStore'; -import { useSystemStore } from '@/web/common/system/useSystemStore'; -import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; -import { putUpdateWithJson } from '@/web/core/ai/config'; -import CopyBox from '@fastgpt/web/components/common/String/CopyBox'; -import MyIcon from '@fastgpt/web/components/common/Icon'; -import AIModelSelector from '@/components/Select/AIModelSelector'; -import MyDivider from '@fastgpt/web/components/common/MyDivider'; -import { AddModelButton } from './AddModelBox'; -import PopoverConfirm from '@fastgpt/web/components/common/MyPopover/PopoverConfirm'; -import PriceTiersLabel from '@/components/core/ai/PriceTiersLabel'; -import TestModeBetaTag from '@/components/core/ai/TestModeBetaTag'; -import ModelCapabilityTags from '@/components/core/ai/ModelCapabilityTags'; -import { accountContentScrollStyles, accountPageRootStyles } from '@/pageComponents/account/styles'; -import ModelTabHeader from './ModelTabHeader'; -import { useUserModelStore } from '@/web/core/ai/model/useUserModelStore'; -import { - formatModelProviders, - getModelProviderFromCache, - getModelProviderListFromCache -} from '@fastgpt/global/core/ai/provider'; -import type { ModelDefaultIds } from '@fastgpt/global/core/ai/defaultModel'; -import { useSet } from 'ahooks'; - -const MyModal = dynamic(() => import('@fastgpt/web/components/common/MyModal')); -const ModelEditModal = dynamic(() => import('./AddModelBox').then((mod) => mod.ModelEditModal)); - -const ModelTable = ({ Tab }: { Tab: React.ReactNode }) => { - const { t, i18n } = useClientTranslation('config_model'); - const { userInfo } = useUserStore(); - const { feConfigs } = useSystemStore(); - - const { - data: adminConfig, - runAsync: refreshSystemModelList, - loading: loadingModels - } = useRequest(getAdminModelConfig, { manual: false }); - const systemModelList = useMemo(() => adminConfig?.models ?? [], [adminConfig?.models]); - const providerCache = useMemo( - () => formatModelProviders(adminConfig?.providers ?? []), - [adminConfig?.providers] - ); - const getModelProviders = useCallback( - (language?: string) => - getModelProviderListFromCache(providerCache.ModelProviderListCache, language), - [providerCache.ModelProviderListCache] - ); - const getModelProvider = useCallback( - (provider?: string, language?: string) => - getModelProviderFromCache({ cache: providerCache.ModelProviderMapCache, provider, language }), - [providerCache.ModelProviderMapCache] - ); - const defaultModels = useMemo( - () => - Object.fromEntries( - Object.entries(adminConfig?.defaultModelIds ?? {}).map(([key, modelId]) => [ - key, - systemModelList.find((model) => model.modelId === modelId) - ]) - ), - [adminConfig?.defaultModelIds, systemModelList] - ); - - const isRoot = userInfo?.username === 'root'; - - const [provider, setProvider] = useState(''); - const providerList = useMemo< - { label: string; value: string | ''; searchText?: string; avatar?: string }[] - >( - () => [ - { label: t('common:All'), value: '' }, - ...getModelProviders(i18n.language).map((item) => ({ - label: item.name, - avatar: item.avatar, - searchText: item.name, - value: item.id - })) - ], - [getModelProviders, i18n.language, t] - ); - - const [modelType, setModelType] = useState(''); - const selectModelTypeList = useMemo<{ label: string; value: ModelTypeEnum | '' }[]>( - () => [ - { label: t('common:All'), value: '' }, - ...modelTypeList.map((item) => ({ label: t(item.label), value: item.value })) - ], - [t] - ); - - const [search, setSearch] = useState(''); - const [showActive, setShowActive] = useState(false); - - const refreshModels = useCallback(async () => { - useUserModelStore.getState().clearMemory(); - await refreshSystemModelList(); - }, [refreshSystemModelList]); - - const modelList = useMemo(() => { - const formatLLMModelList = systemModelList - .filter((item) => item.type === ModelTypeEnum.llm) - .map((item) => ({ - ...item, - typeLabel: t('common:model.type.chat'), - priceLabel: ( - - ), - tagColor: 'blue' - })); - const formatVectorModelList = systemModelList - .filter((item) => item.type === ModelTypeEnum.embedding) - .map((item) => ({ - ...item, - typeLabel: t('common:model.type.embedding'), - priceLabel: ( - - {`${t('common:Input')}: `} - - {item.charsPointsPrice || 0} - - {` ${t('common:support.wallet.subscription.point')} / 1K Tokens`} - - ), - tagColor: 'yellow' - })); - const formatAudioSpeechModelList = systemModelList - .filter((item) => item.type === ModelTypeEnum.tts) - .map((item) => ({ - ...item, - typeLabel: t('common:model.type.tts'), - priceLabel: ( - - - {item.charsPointsPrice || 0} - - {` ${t('common:support.wallet.subscription.point')} / 1K ${t('common:unit.character')}`} - - ), - tagColor: 'green' - })); - const formatWhisperModel = systemModelList - .filter((item) => item.type === ModelTypeEnum.stt) - .map((item) => ({ - ...item, - typeLabel: t('common:model.type.stt'), - priceLabel: ( - - - {item.charsPointsPrice || 0} - - {` ${t('common:support.wallet.subscription.point')} / 60${t('common:unit.seconds')}`} - - ), - tagColor: 'purple' - })); - const formatRerankModelList = systemModelList - .filter((item) => item.type === ModelTypeEnum.rerank) - .map((item) => ({ - ...item, - typeLabel: t('common:model.type.reRank'), - priceLabel: item.charsPointsPrice ? ( - - {`${t('common:Input')}: `} - - {item.charsPointsPrice} - - {` ${t('common:support.wallet.subscription.point')} / 1K Tokens`} - - ) : ( - '-' - ), - tagColor: 'red' - })); - - const list = (() => { - if (modelType === ModelTypeEnum.llm) return formatLLMModelList; - if (modelType === ModelTypeEnum.embedding) return formatVectorModelList; - if (modelType === ModelTypeEnum.tts) return formatAudioSpeechModelList; - if (modelType === ModelTypeEnum.stt) return formatWhisperModel; - if (modelType === ModelTypeEnum.rerank) return formatRerankModelList; - - return [ - ...formatLLMModelList, - ...formatVectorModelList, - ...formatAudioSpeechModelList, - ...formatWhisperModel, - ...formatRerankModelList - ]; - })(); - - const formatList = list.map((item) => { - const provider = getModelProvider(item.provider, i18n.language); - return { - ...item, - avatar: provider.avatar, - providerId: provider.id, - providerName: t(provider.name as any), - order: provider.order, - contextToken: - item.type === ModelTypeEnum.llm - ? item.config.maxContext - : item.type === ModelTypeEnum.embedding || item.type === ModelTypeEnum.rerank - ? item.config.maxToken - : undefined, - vision: - item.type === ModelTypeEnum.llm || item.type === ModelTypeEnum.embedding - ? item.config.vision - : undefined, - audio: item.type === ModelTypeEnum.llm ? item.config.audio : undefined, - video: item.type === ModelTypeEnum.llm ? item.config.video : undefined, - reasoning: item.type === ModelTypeEnum.llm ? item.config.reasoning : undefined - }; - }); - formatList.sort((a, b) => a.order - b.order); - - const filterList = formatList.filter((item) => { - const providerFilter = provider ? item.providerId === provider : true; - - const regx = new RegExp(search, 'i'); - const nameFilter = search ? regx.test(item.name) : true; - - const activeFilter = showActive ? item.isActive : true; - - return providerFilter && nameFilter && activeFilter; - }); - - return filterList; - }, [ - systemModelList, - t, - modelType, - getModelProvider, - i18n.language, - provider, - search, - showActive - ]); - const activeModelLength = useMemo(() => { - return modelList.filter((item) => item.isActive).length; - }, [modelList]); - - const filterProviderList = useMemo(() => { - const allProviderIds: string[] = systemModelList.map((model) => model.provider); - - return providerList.filter((item) => allProviderIds.includes(item.value) || item.value === ''); - }, [providerList, systemModelList]); - - const [testingModelIds, testingModelIdsDispatch] = useSet(); - const { runAsync: onTestModel } = useRequest( - async (data: Parameters[0]) => { - testingModelIdsDispatch.add(data.modelId); - try { - return await getTestModel(data); - } finally { - testingModelIdsDispatch.remove(data.modelId); - } - }, - { - manual: true, - successToast: t('common:Success') - } - ); - const { runAsync: updateModel, loading: updatingModel } = useRequest(putSystemModel, { - onSuccess: refreshModels - }); - - /** - * 启停接口仍接收完整模型配置,因此必须从接口原始数据构造请求。 - * modelList 包含 priceLabel 等 React 展示节点,直接展开会因 Fiber 循环引用而无法序列化。 - */ - const toggleModelActive = useCallback( - (modelId: string, isActive: boolean) => { - const sourceModel = systemModelList.find((model) => model.modelId === modelId); - if (!sourceModel) return; - - const { modelId: _modelId, avatar: _avatar, isCustom: _isCustom, ...modelData } = sourceModel; - - return updateModel({ - modelId, - modelData: { - ...modelData, - isActive - } - }); - }, - [systemModelList, updateModel] - ); - - const { runAsync: deleteModel } = useRequest(deleteSystemModel, { - onSuccess: refreshModels - }); - - const [editModelData, setEditModelData] = useState(); - const { runAsync: onEditModel, loading: loadingData } = useRequest( - (modelId: string) => getSystemModelDetail(modelId), - { - onSuccess: (data: SystemModelDataType) => { - setEditModelData(data); - } - } - ); - - const onCreateModel = (type: ModelTypeEnum) => { - const defaultModel = defaultModels[type]; - - const modelData = { - ...defaultModel, - model: '', - name: '', - charsPointsPrice: 0, - inputPrice: undefined, - outputPrice: undefined, - priceTiers: undefined, - - isCustom: true, - isActive: true, - - type, - ...(type === ModelTypeEnum.llm - ? { - config: { - ...defaultModel?.config, - vision: false, - audio: false, - video: false - } - } - : {}) - } as SystemModelDataType; - - setEditModelData(modelData); - }; - - const { - isOpen: isOpenJsonConfig, - onOpen: onOpenJsonConfig, - onClose: onCloseJsonConfig - } = useDisclosure(); - const { - onOpen: onOpenDefaultModel, - onClose: onCloseDefaultModel, - isOpen: isOpenDefaultModel - } = useDisclosure(); - - const isLoading = loadingModels || loadingData || updatingModel; - - const [showModelId, setShowModelId] = useState(true); - - return ( - <> - {isRoot && ( - - - - - - - - )} - - - - - - - setSearch(e.target.value)} - placeholder={t('common:model.search_name_placeholder')} - /> - - - - - - - - - - {feConfigs?.isPlus && } - - - - - - {modelList.map((item) => ( - - - - {feConfigs?.isPlus && } - - - - ))} - -
- setShowModelId(!showModelId)} - > - - {showModelId ? t('config_model:model.model_id') : t('common:model.name')} - - - - {t('common:model.model_type')}{t('common:model.billing')} - setShowActive(!showActive)} - color={showActive ? 'primary.600' : 'myGray.600'} - > - {t('config_model:model.active')}({activeModelLength}) - -
- - - - - {showModelId ? item.model : item.name} - - {item.testMode && } - - - - - {item.typeLabel} - {item.priceLabel} - toggleModelActive(item.modelId, e.target.checked)} - colorScheme={'myBlue'} - /> - - - onTestModel({ modelId: item.modelId })} - /> - onEditModel(item.modelId!)} - /> - {item.isCustom && ( - - - - } - type="delete" - content={t('config_model:model.delete_model_confirm')} - onConfirm={() => deleteModel({ modelId: item.modelId })} - /> - )} - -
-
-
-
-
- - {!!editModelData && ( - setEditModelData(undefined)} - /> - )} - {isOpenJsonConfig && ( - - )} - {isOpenDefaultModel && ( - - )} - - ); -}; - -const JsonConfigModal = ({ - onClose, - onSuccess -}: { - onClose: () => void; - onSuccess: () => void; -}) => { - const { t } = useClientTranslation('config_model'); - - const [data, setData] = useState(''); - const { loading } = useRequest(getModelConfigJson, { - manual: false, - onSuccess(res) { - setData(res); - } - }); - - const { runAsync } = useRequest(putUpdateWithJson, { - onSuccess: () => { - onSuccess(); - onClose(); - } - }); - - return ( - - - - {t('config_model:model.json_config_tip')} - - - - - - - - - {t('common:Confirm')}} - type="info" - content={t('config_model:model.json_config_confirm')} - onConfirm={() => runAsync({ config: data })} - /> - - - ); -}; - -const labelStyles = { - fontSize: 'sm', - color: 'myGray.900', - mb: 0.5 -}; -const DefaultModelModal = ({ - models, - defaultModelIds, - onSuccess, - onClose -}: { - models: SystemModelDataType[]; - defaultModelIds: ModelDefaultIds; - onSuccess: () => void; - onClose: () => void; -}) => { - const { t } = useClientTranslation('config_model'); - const activeModels = models.filter((model) => model.isActive); - const llmModelList = activeModels.filter((model) => model.type === ModelTypeEnum.llm); - const embeddingModelList = activeModels.filter((model) => model.type === ModelTypeEnum.embedding); - const ttsModelList = activeModels.filter((model) => model.type === ModelTypeEnum.tts); - const sttModelList = activeModels.filter((model) => model.type === ModelTypeEnum.stt); - const reRankModelList = activeModels.filter((model) => model.type === ModelTypeEnum.rerank); - const vlmModelList = llmModelList.filter((model) => !!model.config.vision); - const defaultModels = Object.fromEntries( - Object.entries(defaultModelIds).map(([key, modelId]) => [ - key, - models.find((model) => model.modelId === modelId) - ]) - ) as Record; - - // Create a copy of defaultModels for local state management - const [defaultData, setDefaultData] = useState(defaultModels); - - const { runAsync, loading } = useRequest(putUpdateDefaultModels, { - onSuccess: () => { - onSuccess(); - onClose(); - }, - successToast: t('common:update_success') - }); - - return ( - - - - {t('common:model.type.chat')} - - ({ - value: item.modelId, - label: item.name - }))} - onChange={(e) => { - setDefaultData((state) => ({ - ...state, - llm: llmModelList.find((item) => item.modelId === e) - })); - }} - /> - - - - {t('common:model.type.embedding')} - - ({ - value: item.modelId, - label: item.name - }))} - onChange={(e) => { - setDefaultData((state) => ({ - ...state, - embedding: embeddingModelList.find((item) => item.modelId === e) - })); - }} - /> - - - - {t('common:model.type.tts')} - - ({ - value: item.modelId, - label: item.name - }))} - onChange={(e) => { - setDefaultData((state) => ({ - ...state, - tts: ttsModelList.find((item) => item.modelId === e) - })); - }} - /> - - - - {t('common:model.type.stt')} - - ({ - value: item.modelId, - label: item.name - }))} - onChange={(e) => { - setDefaultData((state) => ({ - ...state, - stt: sttModelList.find((item) => item.modelId === e) - })); - }} - /> - - - - {t('common:model.type.reRank')} - - ({ - value: item.modelId, - label: item.name - }))} - onChange={(e) => { - setDefaultData((state) => ({ - ...state, - rerank: reRankModelList.find((item) => item.modelId === e) - })); - }} - /> - - - - - - {t('common:core.ai.model.Dataset Agent Model')} - - - - ({ - value: item.modelId, - label: item.name - }))} - onChange={(e) => { - setDefaultData((state) => ({ - ...state, - datasetTextLLM: llmModelList.find((item) => item.modelId === e) - })); - }} - /> - - - - - {t('config_model:vlm_model')} - - - - ({ - value: item.modelId, - label: item.name - }))} - onChange={(e) => { - setDefaultData((state) => ({ - ...state, - datasetImageLLM: vlmModelList.find((item) => item.modelId === e) - })); - }} - /> - - - - - {t('config_model:chat_title_model')} - - - - ({ - value: item.modelId, - label: item.name - }))} - onChange={(e) => { - setDefaultData((state) => ({ - ...state, - chatTitleLLM: llmModelList.find((item) => item.modelId === e) - })); - }} - /> - - - - - - - - - ); -}; - -export default ModelTable; diff --git a/projects/app/src/pageComponents/model/AddModel.tsx b/projects/app/src/pageComponents/model/AddModel.tsx new file mode 100644 index 000000000000..01480a27cea9 --- /dev/null +++ b/projects/app/src/pageComponents/model/AddModel.tsx @@ -0,0 +1,835 @@ +import { useModelChannelTest } from './useModelChannelTest'; +import type { AdminModelChannel } from '@fastgpt/global/openapi/admin/core/ai/model/api'; +import { getAdminModelTemplates, postSystemModelsFromTemplates } from '@/web/core/ai/config'; +import { defaultChannel } from '@/global/aiproxy/constants'; +import { + Box, + Button, + Checkbox, + Flex, + Grid, + HStack, + Radio, + Table, + TableContainer, + Tbody, + Td, + Th, + Thead, + Tr, + type BoxProps, + type ButtonProps +} from '@chakra-ui/react'; +import { ModelScopeEnum, modelTypeList, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import type { + SystemModelDataType, + SystemModelDocumentDataType +} from '@fastgpt/global/core/ai/model.schema'; +import type { ModelProviderItemType } from '@fastgpt/global/core/ai/provider'; +import { i18nT, parseI18nString } from '@fastgpt/global/common/i18n/utils'; +import MyBox from '@fastgpt/web/components/common/MyBox'; +import MyMenu from '@fastgpt/web/components/common/MyMenu'; +import MyModal from '@fastgpt/web/components/v2/common/MyModal'; +import MyTag, { type ColorSchemaType } from '@fastgpt/web/components/common/Tag'; +import Avatar from '@fastgpt/web/components/common/Avatar'; +import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import { useConfirm } from '@fastgpt/web/hooks/useConfirm'; +import { useStaticVirtualList } from '@fastgpt/web/hooks/useVirtualList'; +import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; +import { useLockFn } from 'ahooks'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useRouter } from 'next/router'; +import dynamic from 'next/dynamic'; +import ModelConfigForm, { type ModelConfigFormGetValues } from './ModelConfigForm'; +import ModelChannelModal, { ModelChannelSelector } from './ModelChannelModal'; +import ModelLinkedChannels from './ModelLinkedChannels'; +import { submitCreatedSystemModel } from './submit'; +import ModelListFilters from '@/components/core/ai/ModelListFilters'; +import ModelCapabilityTags from '@/components/core/ai/ModelCapabilityTags'; +import TestModeBetaTag from '@/components/core/ai/TestModeBetaTag'; + +const EditChannelModal = dynamic(() => import('./Channel/EditChannelModal'), { ssr: false }); + +/** 空白模型只使用固定默认值;数值草稿的 NaN 表示未填写,提交时再补齐引用上限。 */ +export const createBlankSystemModelData = ({ + type +}: { + type: ModelTypeEnum; +}): SystemModelDocumentDataType => { + const base = { + scope: ModelScopeEnum.system as ModelScopeEnum.system, + provider: '', + model: '', + name: '', + charsPointsPrice: 0, + isActive: false + }; + + if (type === ModelTypeEnum.llm) { + return { + ...base, + type, + config: { + maxContext: 64000, + maxResponse: 16000, + quoteMaxToken: Number.NaN, + maxTemperature: undefined, + vision: false, + audio: false, + video: false + } + }; + } + if (type === ModelTypeEnum.embedding) { + return { + ...base, + type, + config: { + batchSize: 1, + defaultToken: 512, + maxToken: 8192, + weight: 0 + } + }; + } + if (type === ModelTypeEnum.tts) { + return { ...base, type, config: { voices: [] } }; + } + if (type === ModelTypeEnum.stt) return { ...base, type, config: {} }; + return { ...base, type: ModelTypeEnum.rerank, config: { maxToken: 8000 } }; +}; + +export const AddModelButton = ({ + onCreateFromBlank, + onCreateFromTemplate, + buttonBoxProps, + ...props +}: { + onCreateFromBlank: () => void; + onCreateFromTemplate?: () => void; + buttonBoxProps?: BoxProps; +} & ButtonProps) => { + const { t } = useClientTranslation('config_model'); + + return ( + {t('config_model:create_model')}} + menuList={[ + { + children: [ + ...(onCreateFromTemplate + ? [ + { + label: t('config_model:create_from_template'), + onClick: onCreateFromTemplate + } + ] + : []), + { + label: t('config_model:create_from_blank'), + onClick: onCreateFromBlank + } + ] + } + ]} + /> + ); +}; + +const createFormId = 'system-model-create-form'; + +const modelTypeTagLabelMap: Record = { + [ModelTypeEnum.llm]: 'LLM', + [ModelTypeEnum.embedding]: 'Embedding', + [ModelTypeEnum.tts]: 'TTS', + [ModelTypeEnum.stt]: 'STT', + [ModelTypeEnum.rerank]: 'Rerank' +}; + +const modelTypeTagColorMap: Record = { + [ModelTypeEnum.llm]: 'blue', + [ModelTypeEnum.embedding]: 'yellow', + [ModelTypeEnum.tts]: 'green', + [ModelTypeEnum.stt]: 'purple', + [ModelTypeEnum.rerank]: 'red' +}; + +const TemplateTableColumns = () => ( + + + + + +); + +const modelTypeDescriptionKeyMap = { + [ModelTypeEnum.llm]: i18nT('config_model:model_type_description.llm'), + [ModelTypeEnum.embedding]: i18nT('config_model:model_type_description.embedding'), + [ModelTypeEnum.tts]: i18nT('config_model:model_type_description.tts'), + [ModelTypeEnum.stt]: i18nT('config_model:model_type_description.stt'), + [ModelTypeEnum.rerank]: i18nT('config_model:model_type_description.rerank') +} as const satisfies Record; + +/** 模型类型选择内容不持有弹窗状态,供空白新增流程嵌入稳定的 Modal 外壳。 */ +const ModelTypeSelector = ({ + value, + onChange +}: { + value: ModelTypeEnum; + onChange: (type: ModelTypeEnum) => void; +}) => { + const { t } = useClientTranslation('config_model'); + + return ( + + {[ + [ModelTypeEnum.llm, t('common:model.type.chat')], + [ModelTypeEnum.embedding, t('common:model.type.embedding')], + [ModelTypeEnum.tts, t('common:model.type.tts')], + [ModelTypeEnum.stt, t('common:model.type.stt')], + [ModelTypeEnum.rerank, t('common:model.type.reRank')] + ].map(([type, label]) => ( + onChange(type as ModelTypeEnum)} + > + + + {label} + + {t(modelTypeDescriptionKeyMap[type as ModelTypeEnum])} + + + + {modelTypeTagLabelMap[type as ModelTypeEnum]} + + + ))} + + ); +}; + +/** + * 空白新建模型的两步控制器。 + * + * 类型选择和参数表单共享同一个 Modal,创建状态只包含持久化字段,不持有或发送 modelId。 + */ +export const BlankModelCreateModal = ({ + createModelData, + providers, + channels, + onSuccess, + onClose +}: { + createModelData: (type: ModelTypeEnum) => SystemModelDocumentDataType; + providers: ModelProviderItemType[]; + channels: AdminModelChannel[]; + onSuccess: () => unknown | Promise; + onClose: () => void; +}) => { + const { t } = useClientTranslation('config_model'); + const router = useRouter(); + const [step, setStep] = useState<'type' | 'config'>('type'); + const [selectedType, setSelectedType] = useState(ModelTypeEnum.llm); + const [submitting, setSubmitting] = useState(false); + const [selectedChannelIds, setSelectedChannelIds] = useState>(new Set()); + const [showAssociateChannel, setShowAssociateChannel] = useState(false); + const [showCreateChannel, setShowCreateChannel] = useState(false); + const [draftModel, setDraftModel] = useState(''); + const [isFormDirty, setIsFormDirty] = useState(false); + const modelFormGetValuesRef = useRef(null); + const modelData = useMemo(() => createModelData(selectedType), [createModelData, selectedType]); + const { openConfirm: openLeaveConfirm, ConfirmModal: LeaveConfirmModal } = useConfirm(); + + const { testingChannelIds, testModelChannel: handleTestModelChannel } = useModelChannelTest({ + target: { source: 'draft', getModelData: () => modelFormGetValuesRef.current?.() }, + channels + }); + + const navigateToChannelManagement = () => { + onClose(); + void router.push( + { + pathname: router.pathname, + query: { ...router.query, modelTab: 'channel' } + }, + undefined, + { shallow: true } + ); + }; + + const goToChannelManagement = () => { + if (!isFormDirty && selectedChannelIds.size === 0) { + navigateToChannelManagement(); + return; + } + + openLeaveConfirm({ + title: t('config_model:confirm_go_to_channel_management'), + customContent: t('config_model:unsaved_model_config_leave_tip'), + confirmButtonVariant: 'dangerFill', + onConfirm: navigateToChannelManagement + })(); + }; + + return ( + <> + + + + + ) : ( + <> + + + + ) + } + > + {step === 'type' ? ( + + ) : ( + selectedChannelIds.has(channel.id)).length + }), + content: ( + setShowCreateChannel(true)} + onAssociate={() => setShowAssociateChannel(true)} + onManage={goToChannelManagement} + onTest={(channelId) => void handleTestModelChannel(channelId)} + testingChannelIds={testingChannelIds} + onRemove={(channelId) => + setSelectedChannelIds((current) => { + const next = new Set(current); + next.delete(channelId); + return next; + }) + } + /> + ) + }} + onSubmittingChange={setSubmitting} + onDirtyChange={setIsFormDirty} + onSuccess={() => { + onClose(); + void Promise.resolve(onSuccess()).catch(() => {}); + }} + onSubmit={async (data) => { + await submitCreatedSystemModel({ + modelData: data, + channelIds: [...selectedChannelIds] + }); + }} + /> + )} + + + {showAssociateChannel && ( + { + setSelectedChannelIds(new Set(channelIds)); + setShowAssociateChannel(false); + }} + onClose={() => setShowAssociateChannel(false)} + /> + )} + + {showCreateChannel && ( + { + if (createdChannelId !== undefined) { + setSelectedChannelIds((current) => new Set([...current, createdChannelId])); + } + // 渠道已经创建成功,列表刷新失败不能把写入结果误报为创建失败。 + await Promise.resolve(onSuccess()).catch(() => {}); + }} + onClose={() => setShowCreateChannel(false)} + /> + )} + + + + ); +}; + +const TemplateCreateModal = ({ + installedModels, + channels, + onClose, + onSuccess, + onRefresh +}: { + installedModels: SystemModelDataType[]; + channels: AdminModelChannel[]; + onClose: () => void; + onSuccess: () => Promise; + onRefresh?: () => Promise; +}) => { + const { t, i18n } = useClientTranslation('config_model'); + const [step, setStep] = useState<1 | 2>(1); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + const [providerFilter, setProviderFilter] = useState(''); + const [typeFilter, setTypeFilter] = useState(''); + const [templateSearch, setTemplateSearch] = useState(''); + const [selectedChannelIds, setSelectedChannelIds] = useState([]); + const [showCreateChannel, setShowCreateChannel] = useState(false); + const { + data, + error, + loading, + runAsync: refreshTemplates + } = useRequest(getAdminModelTemplates, { manual: false, errorToast: '' }); + + const installedModelNames = useMemo( + () => new Set(installedModels.map((model) => model.model)), + [installedModels] + ); + const availableTemplates = useMemo(() => { + const modelNames = new Set(); + + return (data?.models ?? []).filter((model) => { + if (installedModelNames.has(model.model) || modelNames.has(model.model)) return false; + modelNames.add(model.model); + return true; + }); + }, [data?.models, installedModelNames]); + const templates = useMemo(() => { + const search = templateSearch.trim().toLowerCase(); + return availableTemplates.filter( + (model) => + (!providerFilter || model.provider === providerFilter) && + (!typeFilter || model.type === typeFilter) && + (!search || + model.name.toLowerCase().includes(search) || + model.model.toLowerCase().includes(search)) + ); + }, [availableTemplates, providerFilter, templateSearch, typeFilter]); + const { + containerRef: templateListContainerRef, + virtualDataList: virtualTemplates, + topPlaceholderHeight: templateTopPlaceholderHeight, + bottomPlaceholderHeight: templateBottomPlaceholderHeight, + scrollToTop: scrollTemplateListToTop + } = useStaticVirtualList({ + data: templates, + itemHeight: 80, + overscan: 6 + }); + useEffect(() => { + scrollTemplateListToTop(); + }, [providerFilter, scrollTemplateListToTop, templateSearch, typeFilter]); + const selectedTemplates = availableTemplates.filter((model) => + selectedKeys.has(`${model.type}:${model.model}`) + ); + const providerMap = useMemo( + () => new Map((data?.providers ?? []).map((provider) => [provider.provider, provider])), + [data?.providers] + ); + const filterProviders = useMemo( + () => + (data?.providers ?? []).map((provider, index) => ({ + id: provider.provider, + name: parseI18nString(provider.value, i18n.language), + avatar: provider.avatar, + order: index + })), + [data?.providers, i18n.language] + ); + const visibleTemplateKeys = templates.map((model) => `${model.type}:${model.model}`); + const isAllVisibleSelected = + visibleTemplateKeys.length > 0 && visibleTemplateKeys.every((key) => selectedKeys.has(key)); + const toggleSelectAllVisible = () => { + setSelectedKeys((previous) => { + const next = new Set(previous); + visibleTemplateKeys.forEach((key) => + isAllVisibleSelected ? next.delete(key) : next.add(key) + ); + return next; + }); + }; + + const { runAsync: createModelsRequest, loading: creatingModels } = useRequest( + () => + postSystemModelsFromTemplates({ + templates: selectedTemplates.map(({ type, model }) => ({ type, model })), + channelIds: selectedChannelIds + }), + { + onSuccess: () => { + onClose(); + void onSuccess().catch(() => {}); + }, + successToast: t('common:Success') + } + ); + const createModels = useLockFn(createModelsRequest); + + const toggleKey = (key: string) => { + setSelectedKeys((previous) => { + const next = new Set(previous); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + return ( + + + + + ) : ( + <> + + + + ) + } + > + {step === 1 ? ( + + {error && !data ? ( + + {t('config_model:template_load_failed')} + + + } + /> + ) : ( + + + + + + + + + + + + +
+ + selectedKeys.has(key)) + } + onChange={toggleSelectAllVisible} + /> + {t('common:Select_all')} + + {t('config_model:model.model_id')}{t('common:model.model_type')}
+ + + + + {templateTopPlaceholderHeight > 0 && ( + + + )} + {virtualTemplates.map(({ data: model }) => { + const key = `${model.type}:${model.model}`; + const typeLabel = modelTypeList.find( + (item) => item.value === model.type + )?.label; + const provider = providerMap.get(model.provider); + const contextToken = + model.type === ModelTypeEnum.llm + ? model.config.maxContext + : model.type === ModelTypeEnum.embedding || + model.type === ModelTypeEnum.rerank + ? model.config.maxToken + : undefined; + return ( + toggleKey(key)} + > + + + + + ); + })} + {templateBottomPlaceholderHeight > 0 && ( + + + )} + {!loading && templates.length === 0 && ( + + + + )} + +
+
+ + + + + + + {model.model} + + {model.testMode && } + + + + + + {typeLabel ? t(typeLabel) : model.type} + +
+
+ +
+
+
+
+ )} +
+ ) : ( + ({ + model: model.model, + modelData: model, + avatar: providerMap.get(model.provider)?.avatar + }))} + channels={channels} + selectedChannelIds={selectedChannelIds} + onChange={setSelectedChannelIds} + showCurrentModel={false} + showSelectedModelCount + showTest={false} + onCreate={() => setShowCreateChannel(true)} + /> + )} + + {showCreateChannel && ( + ({ + model: model.model, + avatar: providerMap.get(model.provider)?.avatar + }))} + allowEmptyModels + onSuccess={async (createdChannelId) => { + if (createdChannelId !== undefined) { + setSelectedChannelIds((current) => + current.includes(createdChannelId) ? current : [...current, createdChannelId] + ); + } + await Promise.resolve(onRefresh?.()).catch(() => {}); + }} + onClose={() => setShowCreateChannel(false)} + /> + )} +
+ ); +}; + +/** 聚合“从模板新建”和“从空白新建”的完整添加模型交互。 */ +const AddModel = ({ + installedModels, + channels, + providers, + onSuccess, + buttonBoxProps, + ...buttonProps +}: { + installedModels: SystemModelDataType[]; + channels: AdminModelChannel[]; + providers: ModelProviderItemType[]; + onSuccess: () => Promise; + buttonBoxProps?: BoxProps; +} & ButtonProps) => { + const [showBlankCreate, setShowBlankCreate] = useState(false); + const [showTemplateCreate, setShowTemplateCreate] = useState(false); + const getBlankModelData = useCallback( + (type: ModelTypeEnum) => createBlankSystemModelData({ type }), + [] + ); + + return ( + <> + setShowBlankCreate(true)} + onCreateFromTemplate={() => setShowTemplateCreate(true)} + /> + {showBlankCreate && ( + setShowBlankCreate(false)} + onSuccess={onSuccess} + /> + )} + {showTemplateCreate && ( + setShowTemplateCreate(false)} + onSuccess={onSuccess} + onRefresh={onSuccess} + /> + )} + + ); +}; + +export default AddModel; diff --git a/projects/app/src/pageComponents/model/Channel/EditChannelModal.tsx b/projects/app/src/pageComponents/model/Channel/EditChannelModal.tsx new file mode 100644 index 000000000000..df2a3fbe5e57 --- /dev/null +++ b/projects/app/src/pageComponents/model/Channel/EditChannelModal.tsx @@ -0,0 +1,368 @@ +import { type ChannelInfoType } from '@/global/aiproxy/type'; +import { Box, type BoxProps, Button, Flex, Input, HStack } from '@chakra-ui/react'; +import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel'; +import MyModal from '@fastgpt/web/components/v2/common/MyModal'; +import MySelect from '@fastgpt/web/components/common/MySelect'; +import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; +import { useMemo } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import MyAvatar from '@fastgpt/web/components/common/Avatar'; +import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; +import JsonEditor from '@fastgpt/web/components/common/Textarea/JsonEditor'; +import { getChannelProviders, postCreateChannel, putChannel } from '@/web/core/ai/channel'; +import CopyBox from '@fastgpt/web/components/common/String/CopyBox'; +import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; +import type { localeType } from '@fastgpt/global/common/i18n/type'; +import { useAdminModelConfig } from '@/web/core/ai/model/useAdminModelConfig'; +import MultipleSelect from '@fastgpt/web/components/common/MySelect/MultipleSelect'; +import { useLockFn } from 'ahooks'; + +const LabelStyles: BoxProps = { + fontSize: 'sm', + color: 'myGray.900', + flex: '0 0 70px' +}; + +const CompactLabelStyles: BoxProps = { + fontSize: 'sm', + color: 'myGray.900', + flex: '0 0 64px' +}; + +const EditChannelModal = ({ + defaultConfig, + fixedModel, + fixedModels, + allowEmptyModels = false, + onClose, + onSuccess +}: { + defaultConfig: ChannelInfoType; + fixedModel?: { model: string; avatar?: string }; + fixedModels?: { model: string; avatar?: string }[]; + allowEmptyModels?: boolean; + onClose: () => void; + onSuccess: (createdChannelId?: number) => unknown | Promise; +}) => { + const { t, i18n } = useClientTranslation('config_model'); + const { + aiproxyChannels, + getModelProvider, + systemModelList, + loading: loadingModels + } = useAdminModelConfig(); + const isEdit = defaultConfig.id !== 0; + const currentModels = fixedModels ?? (fixedModel ? [fixedModel] : []); + const isCompactCreate = !isEdit && currentModels.length > 0; + + const { register, handleSubmit, control, setValue } = useForm({ + defaultValues: defaultConfig + }); + + const providerType = useWatch({ control, name: 'type' }); + const { data: channelProviderMetas = {}, loading: loadingChannelProviderMetas } = useRequest( + getChannelProviders, + { manual: false } + ); + const providerList = useMemo( + () => + aiproxyChannels.flatMap((channel) => { + const mapData = channelProviderMetas[channel.channelId]; + if (!mapData) return []; + + return [ + { + defaultBaseUrl: mapData.defaultBaseUrl, + keyHelp: mapData.keyHelp, + icon: channel.avatar, + label: parseI18nString(channel.name, i18n.language as localeType), + value: channel.channelId + } + ]; + }), + [aiproxyChannels, channelProviderMetas, i18n.language] + ); + + const selectedProvider = useMemo(() => { + const res = providerList.find((item) => item.value === providerType); + return res; + }, [providerList, providerType]); + + const models = useWatch({ control, name: 'models' }); + const modelList = useMemo(() => { + return systemModelList.map((item) => { + const provider = getModelProvider(item.provider, i18n.language); + + return { + icon: provider?.avatar, + label: item.model, + value: item.model, + searchText: item.model + }; + }); + }, [getModelProvider, i18n.language, systemModelList]); + + const modelMapping = useWatch({ control, name: 'model_mapping' }); + const { runAsync: submitRequest, loading: loadingCreate } = useRequest( + async (data: ChannelInfoType) => { + if (!allowEmptyModels && data.models.length === 0) { + return Promise.reject(t('config_model:selected_model_empty')); + } + if (isEdit) { + await putChannel(data); + await onSuccess(); + return; + } + + const createdChannel = await postCreateChannel({ + ...data, + model_mapping: data.model_mapping ?? {} + }); + await onSuccess(createdChannel.id); + return createdChannel; + }, + { + onSuccess() { + onClose(); + }, + successToast: isEdit ? t('common:update_success') : t('common:create_success'), + manual: true + } + ); + const onSubmit = useLockFn(submitRequest); + + const isLoading = loadingModels || loadingChannelProviderMetas || loadingCreate; + + return ( + + + + + } + > + {isCompactCreate ? ( + + + + {t('common:Name')} + + + + + + + {t('config_model:channel_type')} + + + setValue('type', val)} + /> + + + + + + {t('config_model:base_url')} + {t('config_model:leave_blank_use_default_url')} + + + + + + {t('config_model:api_key')} + + + + + + {t('config_model:current_model')} + + + {currentModels.length > 1 ? ( + {t('config_model:selected_model_count', { count: currentModels.length })} + ) : ( + currentModels.map((model) => ( + + + {model.model} + + )) + )} + + + + + {t('config_model:mapping')} + + + { + if (!val) { + setValue('model_mapping', {}); + return; + } + try { + setValue('model_mapping', JSON.parse(val)); + } catch (_error) {} + }} + /> + + + ) : ( + + {/* Chnnel name */} + + + {t('common:Name')} + + + + {/* Provider */} + + + {t('config_model:channel_type')} + + + { + setValue('type', val); + }} + /> + + + {/* Proxy URL */} + + + {t('config_model:base_url')} + {selectedProvider && ( + + {'('} + {t('config_model:default_url')}: + + {selectedProvider?.defaultBaseUrl || ''} + + {')'} + + )} + + + + {/* API key */} + + + {t('config_model:api_key')} + {selectedProvider?.keyHelp && ( + + {'('} + {t('config_model:key_type')} + {selectedProvider.keyHelp} + {')'} + + )} + + + + {/* Model */} + + + + {t('config_model:model')}({models.length}) + + + + + + { + setValue('models', val); + }} + placeholder={t('config_model:select_model_placeholder')} + itemWrap + closeable + isSearch + searchPlaceholder={t('config_model:search_model')} + emptyText={t('config_model:model_search_empty')} + virtualScroll + /> + + + {/* Mapping */} + + + {t('config_model:mapping')} + + + + { + if (!val) { + setValue('model_mapping', {}); + } else { + try { + setValue('model_mapping', JSON.parse(val)); + } catch (_error) {} + } + }} + /> + + + + )} + + ); +}; +export default EditChannelModal; diff --git a/projects/app/src/pageComponents/account/model/Channel/ModelTest.tsx b/projects/app/src/pageComponents/model/Channel/ModelTest.tsx similarity index 70% rename from projects/app/src/pageComponents/account/model/Channel/ModelTest.tsx rename to projects/app/src/pageComponents/model/Channel/ModelTest.tsx index 129d46cb385d..92009fec10d3 100644 --- a/projects/app/src/pageComponents/account/model/Channel/ModelTest.tsx +++ b/projects/app/src/pageComponents/model/Channel/ModelTest.tsx @@ -1,4 +1,4 @@ -import { getSystemModelList, getTestModel } from '@/web/core/ai/config'; +import { getTestModel } from '@/web/core/ai/config'; import { Table, Thead, @@ -10,15 +10,13 @@ import { Box, Flex, Button, - HStack, - ModalBody, - ModalFooter + HStack } from '@chakra-ui/react'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import MyIcon from '@fastgpt/web/components/common/Icon'; import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; -import MyModal from '@fastgpt/web/components/common/MyModal'; +import MyModal from '@fastgpt/web/components/v2/common/MyModal'; import MyTag from '@fastgpt/web/components/common/Tag/index'; import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; import { getErrText } from '@fastgpt/global/common/error/utils'; @@ -26,6 +24,7 @@ import { batchRun } from '@fastgpt/global/common/system/utils'; import { useToast } from '@fastgpt/web/hooks/useToast'; import MyIconButton from '@fastgpt/web/components/common/Icon/button'; import { useAdminModelConfig } from '@/web/core/ai/model/useAdminModelConfig'; +import { useFixedTableHeader } from '@fastgpt/web/hooks/useFixedTableHeader'; type ModelTestItem = { label: React.ReactNode; @@ -47,7 +46,7 @@ const ModelTest = ({ onClose: () => void; }) => { const { t, i18n } = useClientTranslation('config_model'); - const { getModelProvider } = useAdminModelConfig(); + const { getModelProvider, systemModelList, loading: loadingModels } = useAdminModelConfig(); const { toast } = useToast(); const [testModelList, setTestModelList] = useState([]); @@ -70,33 +69,29 @@ const ModelTest = ({ } }); - const { loading: loadingModels } = useRequest(getSystemModelList, { - manual: false, - refreshDeps: [models], - onSuccess(res) { - const list = models - .map((model) => { - const modelData = res.find((item) => item.model === model); - if (!modelData) return null; - const provider = getModelProvider(modelData.provider, i18n.language); + useEffect(() => { + const list = models.flatMap((model) => { + const modelData = systemModelList.find((item) => item.model === model); + if (!modelData) return []; + const provider = getModelProvider(modelData.provider, i18n.language); - return { - label: ( - - - {t(modelData.name as any)} - - ), - modelId: modelData.modelId, - model: modelData.model, - status: 'waiting', - loading: false - }; - }) - .filter(Boolean) as ModelTestItem[]; - setTestModelList(list); - } - }); + return [ + { + label: ( + + + {modelData.name} + + ), + modelId: modelData.modelId, + model: modelData.model, + status: 'waiting' as const, + loading: false + } + ]; + }); + setTestModelList(list); + }, [getModelProvider, i18n.language, models, systemModelList, t]); const { runAsync: onStartTest, loading: isAnyModelLoading } = useRequest( async () => { @@ -189,19 +184,44 @@ const ModelTest = ({ ); const isTesting = isAnyModelLoading || testingOneModel; + const { headerContainerRef, bodyContainerRef, headerTableWidth } = useFixedTableHeader(); return ( + + + + } > - - - + + +
+ + + + + + @@ -210,6 +230,23 @@ const ModelTest = ({ +
{t('config_model:model_name')}
+
+ + + + + + + + {testModelList.map((item) => { const data = statusMap.current[item.status]; @@ -248,15 +285,7 @@ const ModelTest = ({
-
- - - - +
); }; diff --git a/projects/app/src/pageComponents/model/Channel/index.tsx b/projects/app/src/pageComponents/model/Channel/index.tsx new file mode 100644 index 000000000000..c144ca75d455 --- /dev/null +++ b/projects/app/src/pageComponents/model/Channel/index.tsx @@ -0,0 +1,341 @@ +import { + deleteChannel, + getChannelList, + getChannelProviders, + putChannel, + putChannelStatus +} from '@/web/core/ai/channel'; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import React, { useState } from 'react'; +import { + Table, + Thead, + Tbody, + Tr, + Th, + Td, + TableContainer, + Box, + Button, + HStack, + Flex, + Spinner, + Switch +} from '@chakra-ui/react'; +import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; +import MyBox from '@fastgpt/web/components/common/MyBox'; +import MyIconButton from '@fastgpt/web/components/common/Icon/button'; +import { useUserStore } from '@/web/support/user/useUserStore'; +import { type ChannelInfoType } from '@/global/aiproxy/type'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; +import { ChannelStatusEnum, defaultChannel } from '@/global/aiproxy/constants'; +import dynamic from 'next/dynamic'; +import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; +import MyNumberInput from '@fastgpt/web/components/common/Input/NumberInput'; +import { useConfirm } from '@fastgpt/web/hooks/useConfirm'; +import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; +import Avatar from '@fastgpt/web/components/common/Avatar'; +import ModelTabHeader from '../ModelTabHeader'; +import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; +import { useFixedTableHeader } from '@fastgpt/web/hooks/useFixedTableHeader'; +import { useLockFn, useSet } from 'ahooks'; +import { useToast } from '@fastgpt/web/hooks/useToast'; + +const EditChannelModal = dynamic(() => import('./EditChannelModal'), { ssr: false }); +const ModelTest = dynamic(() => import('./ModelTest'), { ssr: false }); + +const ChannelTable = ({ Tab }: { Tab: React.ReactNode }) => { + const { t, i18n } = useClientTranslation('config_model'); + const { toast } = useToast(); + const { userInfo } = useUserStore(); + const { aiproxyChannels } = useSystemStore(); + + const isRoot = userInfo?.username === 'root'; + + const { + data: channelList = [], + runAsync: refreshChannelList, + loading: loadingChannelList + } = useRequest(getChannelList, { + manual: false + }); + + const { data: _channelProviders = {} } = useRequest(getChannelProviders, { + manual: false + }); + + const [editChannel, setEditChannel] = useState(); + const [channelMutationLoading, setChannelMutationLoading] = useState(false); + const runChannelMutation = useLockFn(async (operation: () => Promise) => { + setChannelMutationLoading(true); + try { + return await operation(); + } finally { + setChannelMutationLoading(false); + } + }); + + const { runAsync: updateChannelRequest, loading: loadingUpdateChannel } = useRequest(putChannel, { + manual: true, + onSuccess: () => { + refreshChannelList(); + } + }); + const updateChannel = (data: Parameters[0]) => + runChannelMutation(() => updateChannelRequest(data)); + const [updatingChannelIds, updatingChannelIdsDispatch] = useSet(); + const { runAsync: updateChannelStatusRequest } = useRequest( + async ({ + channelId, + channelName, + status + }: { + channelId: number; + channelName: string; + status: ChannelStatusEnum; + }) => { + updatingChannelIdsDispatch.add(channelId); + try { + await putChannelStatus(channelId, status); + toast({ + status: 'success', + title: t( + status === ChannelStatusEnum.ChannelStatusEnabled + ? 'config_model:status_enabled' + : 'config_model:status_disabled', + { name: channelName } + ) + }); + // 状态写入已经成功;列表刷新失败由其自身提示,不能把成功操作再次报成失败。 + await refreshChannelList().catch(() => {}); + } finally { + updatingChannelIdsDispatch.remove(channelId); + } + } + ); + const updateChannelStatus = (data: Parameters[0]) => + runChannelMutation(() => updateChannelStatusRequest(data)); + + const { openConfirm, ConfirmModal } = useConfirm({ + type: 'delete' + }); + const { runAsync: deleteChannelRequest, loading: loadingDeleteChannel } = useRequest( + deleteChannel, + { + manual: true, + onSuccess: () => { + refreshChannelList(); + } + } + ); + const onDeleteChannel = (channelId: number) => + runChannelMutation(() => deleteChannelRequest(channelId)); + + const [modelTestData, setTestModelData] = useState<{ channelId: number; models: string[] }>(); + + const isLoading = + loadingChannelList || loadingUpdateChannel || loadingDeleteChannel || channelMutationLoading; + const { headerContainerRef, bodyContainerRef, headerTableWidth } = useFixedTableHeader(); + + return ( + <> + {isRoot && ( + + + + )} + + + + + + + + + + + + + + + + + + + + + +
{t('common:Name')}{t('config_model:channel_type')}{t('config_model:model_count')}{t('config_model:model.active')} + + {t('config_model:channel_priority')} + + + {t('common:Operation')}
+
+ + + + + + + + + + + + {!loadingChannelList && channelList.length === 0 && ( + + + + )} + {channelList.map((item) => { + const providerData = aiproxyChannels.find( + (channel) => channel.channelId === item.type + ) || { + name: 'Invalid provider', + avatar: 'model/huggingface' + }; + return ( + + + + + + + + + ); + })} + +
+ +
{item.name} + + + {parseI18nString(providerData.name, i18n.language)} + + {item.models.length} + + {updatingChannelIds.has(item.id) ? ( + + ) : ( + + updateChannelStatus({ + channelId: item.id, + channelName: item.name, + status: e.target.checked + ? ChannelStatusEnum.ChannelStatusEnabled + : ChannelStatusEnum.ChannelStatusDisabled + }) + } + colorScheme={'myBlue'} + /> + )} + + + { + const val = (() => { + if (!e) return 1; + return e; + })(); + updateChannel({ + ...item, + priority: val + }); + }} + /> + + + + setTestModelData({ + channelId: item.id, + models: item.models + }) + } + /> + setEditChannel(item)} + /> + + openConfirm({ + onConfirm: () => onDeleteChannel(item.id), + customContent: t('config_model:confirm_delete_channel', { + name: item.name + }) + })() + } + /> + +
+
+
+ + {!!editChannel && ( + setEditChannel(undefined)} + onSuccess={() => refreshChannelList().catch(() => {})} + /> + )} + {!!modelTestData && ( + setTestModelData(undefined)} /> + )} + + + ); +}; + +export default ChannelTable; diff --git a/projects/app/src/pageComponents/model/DefaultModelModal.tsx b/projects/app/src/pageComponents/model/DefaultModelModal.tsx new file mode 100644 index 000000000000..b5b456dbe058 --- /dev/null +++ b/projects/app/src/pageComponents/model/DefaultModelModal.tsx @@ -0,0 +1,217 @@ +import AIModelSelector from '@/components/Select/AIModelSelector'; +import { putUpdateDefaultModels } from '@/web/core/ai/config'; +import { Box, Button, Flex } from '@chakra-ui/react'; +import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import type { ModelDefaultIds } from '@fastgpt/global/core/ai/defaultModel'; +import type { + LLMSystemModelDataType, + SystemModelDataType +} from '@fastgpt/global/core/ai/model.schema'; +import MyDivider from '@fastgpt/web/components/common/MyDivider'; +import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; +import MyModal from '@fastgpt/web/components/v2/common/MyModal'; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; +import type { ReactNode } from 'react'; +import { useMemo, useState } from 'react'; + +type DefaultModelState = Record; + +const labelStyles = { + fontSize: 'sm', + color: 'myGray.900', + mb: 0.5 +}; + +const DefaultModelField = ({ + label, + tip, + modelType, + models, + value, + mt = 4, + canBeUnset, + unsetLabel, + onChange +}: { + label: ReactNode; + tip?: string; + modelType: ModelTypeEnum; + models: SystemModelDataType[]; + value?: string; + mt?: number; + canBeUnset?: boolean; + unsetLabel?: string; + onChange: (modelId: string) => void; +}) => ( + + + {label} + {tip && } + + ({ value: model.modelId, label: model.name }))} + onChange={onChange} + /> + +); + +/** 管理所有默认模型槽位,并将重复选择交互收敛到一个字段模块。 */ +const DefaultModelModal = ({ + models, + defaultModelIds, + onSuccess, + onClose +}: { + models: SystemModelDataType[]; + defaultModelIds: ModelDefaultIds; + onSuccess: () => void | Promise; + onClose: () => void; +}) => { + const { t } = useClientTranslation('config_model'); + const activeModels = useMemo(() => models.filter((model) => model.isActive), [models]); + const modelsByType = useMemo( + () => + new Map( + Object.values(ModelTypeEnum).map((type) => [ + type, + activeModels.filter((model) => model.type === type) + ]) + ), + [activeModels] + ); + const llmModels = activeModels.filter( + (model): model is LLMSystemModelDataType => model.type === ModelTypeEnum.llm + ); + const visionModels = llmModels.filter((model) => !!model.config.vision); + const [defaultData, setDefaultData] = useState( + () => + Object.fromEntries( + Object.entries(defaultModelIds).map(([key, modelId]) => [ + key, + models.find((model) => model.modelId === modelId) + ]) + ) as DefaultModelState + ); + + const setDefaultModel = ({ + slot, + candidates, + modelId + }: { + slot: keyof ModelDefaultIds; + candidates: SystemModelDataType[]; + modelId: string; + }) => { + setDefaultData((state) => ({ + ...state, + [slot]: candidates.find((model) => model.modelId === modelId) + })); + }; + + const { runAsync, loading } = useRequest(putUpdateDefaultModels, { + onSuccess: () => { + onClose(); + void Promise.resolve(onSuccess()).catch(() => {}); + }, + successToast: t('common:update_success') + }); + + const typeFields = [ + { slot: ModelTypeEnum.llm, label: t('common:model.type.chat') }, + { slot: ModelTypeEnum.embedding, label: t('common:model.type.embedding') }, + { slot: ModelTypeEnum.tts, label: t('common:model.type.tts') }, + { slot: ModelTypeEnum.stt, label: t('common:model.type.stt') }, + { slot: ModelTypeEnum.rerank, label: t('common:model.type.reRank') } + ] as const; + + return ( + + + + + } + > + + {typeFields.map(({ slot, label }, index) => { + const candidates = modelsByType.get(slot) ?? []; + return ( + setDefaultModel({ slot, candidates, modelId })} + /> + ); + })} + + + + setDefaultModel({ slot: 'datasetTextLLM', candidates: llmModels, modelId }) + } + /> + + setDefaultModel({ slot: 'datasetImageLLM', candidates: visionModels, modelId }) + } + /> + + setDefaultModel({ slot: 'chatTitleLLM', candidates: llmModels, modelId }) + } + /> + + + ); +}; + +export default DefaultModelModal; diff --git a/projects/app/src/pageComponents/model/JsonModelConfigModal.tsx b/projects/app/src/pageComponents/model/JsonModelConfigModal.tsx new file mode 100644 index 000000000000..777c95902082 --- /dev/null +++ b/projects/app/src/pageComponents/model/JsonModelConfigModal.tsx @@ -0,0 +1,62 @@ +import { getModelConfigJson, putUpdateWithJson } from '@/web/core/ai/config'; +import { Button } from '@chakra-ui/react'; +import MyModal from '@fastgpt/web/components/v2/common/MyModal'; +import PopoverConfirm from '@fastgpt/web/components/common/MyPopover/PopoverConfirm'; +import JsonEditor from '@fastgpt/web/components/common/Textarea/JsonEditor'; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import { useToast } from '@fastgpt/web/hooks/useToast'; +import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; +import { useState } from 'react'; + +/** 管理系统模型 JSON 的加载、确认覆盖和成功反馈。 */ +const JsonModelConfigModal = ({ + onClose, + onSuccess +}: { + onClose: () => void; + onSuccess: () => Promise; +}) => { + const { t } = useClientTranslation('config_model'); + const { toast } = useToast(); + const [data, setData] = useState(''); + const { loading } = useRequest(getModelConfigJson, { + manual: false, + onSuccess: setData + }); + + return ( + + + {t('common:Confirm')}} + type="info" + closeOnBlur={false} + content={t('config_model:model.json_config_confirm')} + onConfirm={async () => { + await putUpdateWithJson({ config: data }); + toast({ title: t('common:update_success'), status: 'success' }); + onClose(); + void onSuccess().catch(() => {}); + }} + /> + + } + > + + + ); +}; + +export default JsonModelConfigModal; diff --git a/projects/app/src/pageComponents/account/model/Log/index.tsx b/projects/app/src/pageComponents/model/Log/index.tsx similarity index 66% rename from projects/app/src/pageComponents/account/model/Log/index.tsx rename to projects/app/src/pageComponents/model/Log/index.tsx index 4e377d078d34..46a8447f3584 100644 --- a/projects/app/src/pageComponents/account/model/Log/index.tsx +++ b/projects/app/src/pageComponents/model/Log/index.tsx @@ -12,7 +12,6 @@ import { Flex, Button, HStack, - ModalBody, Grid, GridItem, type BoxProps @@ -29,13 +28,13 @@ import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; import React, { useMemo, useRef, useState } from 'react'; import MyIcon from '@fastgpt/web/components/common/Icon'; import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time'; -import MyModal from '@fastgpt/web/components/common/MyModal'; +import MyModal from '@fastgpt/web/components/v2/common/MyModal'; import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; import SearchInput from '@fastgpt/web/components/common/Input/SearchInput'; import type { ChannelLogListItemType } from '@/global/aiproxy/type'; -import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useAdminModelConfig } from '@/web/core/ai/model/useAdminModelConfig'; import ModelTabHeader from '../ModelTabHeader'; +import { useFixedTableHeader } from '@fastgpt/web/hooks/useFixedTableHeader'; type LogDetailType = Omit & { channelName: string | number; @@ -121,6 +120,7 @@ const ChannelLog = ({ Tab }: { Tab: React.ReactNode }) => { }, scrollContainerRef }); + const { headerContainerRef, headerTableWidth } = useFixedTableHeader(scrollContainerRef); const formatData = useMemo(() => { return data.map((item) => { @@ -159,8 +159,16 @@ const ChannelLog = ({ Tab }: { Tab: React.ReactNode }) => { flexDirection={['column', 'row']} flexWrap={['nowrap', 'wrap']} alignItems={['stretch', 'flex-start']} - gap={[3, 4]} + gap={2} > + + setFilterProps({ ...filterProps, request_id: e.target.value })} + /> + { ]} onChange={(val) => setFilterProps({ ...filterProps, code_type: val })} /> - - setFilterProps({ ...filterProps, request_id: e.target.value })} - /> - { flexDirection={'column'} isLoading={isLoading} > - - + +
+ + + + + + + + + @@ -230,6 +238,27 @@ const ChannelLog = ({ Tab }: { Tab: React.ReactNode }) => { +
{t('config_model:channel_name')}
+
+ + + + + + + + + + + {formatData.map((item, index) => ( @@ -320,100 +349,92 @@ const LogDetail = ({ data, onClose }: { data: LogDetailType; onClose: () => void return ( {detailData && ( - - {/* 基本信息表格 */} - - {/* 第一行 */} - - RequestID - {detailData?.request_id} - - - Request IP - {detailData?.ip} - - - {t('config_model:channel_status')} - - {detailData?.code} - - - - Endpoint - {detailData?.endpoint} - - - {t('config_model:channel_name')} - {detailData?.channelName} - - - {t('config_model:model')} - {detailData?.model} - - - {t('config_model:request_at')} - {detailData?.request_at} + + {/* 第一行 */} + + RequestID + {detailData?.request_id} + + + Request IP + {detailData?.ip} + + + {t('config_model:channel_status')} + + {detailData?.code} + + + + Endpoint + {detailData?.endpoint} + + + {t('config_model:channel_name')} + {detailData?.channelName} + + + {t('config_model:model')} + {detailData?.model} + + + {t('config_model:request_at')} + {detailData?.request_at} + + + {t('config_model:duration')} + {detailData?.duration.toFixed(2)}s + + + {t('config_model:model_ttfb_time')} + + {detailData.ttfb_milliseconds ? `${detailData.ttfb_milliseconds}ms` : '-'} + + + + {t('config_model:model_tokens')} + + {detailData?.usage?.input_tokens} / {detailData?.usage?.output_tokens} + + + {detailData?.retry_times !== undefined && ( + + {t('config_model:retry_times')} + {detailData?.retry_times} - - {t('config_model:duration')} - {detailData?.duration.toFixed(2)}s + )} + {detailData?.content && ( + + Content + {detailData?.content} - - - {t('config_model:model_ttfb_time')} - - - {detailData.ttfb_milliseconds ? `${detailData.ttfb_milliseconds}ms` : '-'} - + )} + {detailData?.request_body && ( + + Request Body + {detailData?.request_body} - - {t('config_model:model_tokens')} - - {detailData?.usage?.input_tokens} / {detailData?.usage?.output_tokens} - + )} + {detailData?.response_body && ( + + Response Body + {detailData?.response_body} - {detailData?.retry_times !== undefined && ( - - {t('config_model:retry_times')} - {detailData?.retry_times} - - )} - {detailData?.content && ( - - Content - {detailData?.content} - - )} - {detailData?.request_body && ( - - Request Body - - {detailData?.request_body} - - - )} - {detailData?.response_body && ( - - Response Body - {detailData?.response_body} - - )} - - + )} + )} ); diff --git a/projects/app/src/pageComponents/model/ModelChannelCount.tsx b/projects/app/src/pageComponents/model/ModelChannelCount.tsx new file mode 100644 index 000000000000..4af4c16c50c8 --- /dev/null +++ b/projects/app/src/pageComponents/model/ModelChannelCount.tsx @@ -0,0 +1,89 @@ +import { ChannelStautsMap } from '@/global/aiproxy/constants'; +import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; +import type { AdminModelChannel } from '@fastgpt/global/openapi/admin/core/ai/model/api'; +import { Box, HStack, VStack } from '@chakra-ui/react'; +import Avatar from '@fastgpt/web/components/common/Avatar'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import MyPopover from '@fastgpt/web/components/common/MyPopover'; +import MyTag from '@fastgpt/web/components/common/Tag'; +import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; + +/** 展示模型关联的渠道数量,并在悬浮时列出具体渠道。 */ +const ModelChannelCount = ({ + channels, + onClick +}: { + channels: AdminModelChannel[]; + onClick?: () => void; +}) => { + const { t, i18n } = useClientTranslation('config_model'); + const label = + channels.length > 0 + ? t('config_model:channel_count', { count: channels.length }) + : t('config_model:no_channel_configured'); + const trigger = ( + { + if (!onClick || (event.key !== 'Enter' && event.key !== ' ')) return; + event.preventDefault(); + onClick(); + }} + > + + {label} + + ); + + if (channels.length === 0) return trigger; + + return ( + + {() => ( + + {channels.map((channel) => { + const status = ChannelStautsMap[channel.status as keyof typeof ChannelStautsMap]; + return ( + + + {channel.name} + + + + {parseI18nString(channel.protocol.name, i18n.language)} + + + {status ? t(status.label) : t('config_model:channel_status_unknown')} + + + ); + })} + + )} + + ); +}; + +export default ModelChannelCount; diff --git a/projects/app/src/pageComponents/model/ModelChannelModal.tsx b/projects/app/src/pageComponents/model/ModelChannelModal.tsx new file mode 100644 index 000000000000..500668e155f6 --- /dev/null +++ b/projects/app/src/pageComponents/model/ModelChannelModal.tsx @@ -0,0 +1,349 @@ +import { useModelChannelTest } from './useModelChannelTest'; +import { ChannelStautsMap } from '@/global/aiproxy/constants'; +import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; +import type { SystemModelDocumentDataType } from '@fastgpt/global/core/ai/model.schema'; +import type { AdminModelChannel } from '@fastgpt/global/openapi/admin/core/ai/model/api'; +import { + Box, + Button, + Checkbox, + Flex, + HStack, + Table, + TableContainer, + Tbody, + Td, + Th, + Thead, + Tr +} from '@chakra-ui/react'; +import Avatar from '@fastgpt/web/components/common/Avatar'; +import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import MyIconButton from '@fastgpt/web/components/common/Icon/button'; +import MyTag, { type ColorSchemaType } from '@fastgpt/web/components/common/Tag'; +import MyModal from '@fastgpt/web/components/v2/common/MyModal'; +import { useFixedTableHeader } from '@fastgpt/web/hooks/useFixedTableHeader'; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; +import { useLockFn } from 'ahooks'; +import { useMemo, useState } from 'react'; + +/** 固定行内基线,并禁用 Chakra 默认的勾选图标动画。 */ +const channelCheckboxStyles = { + verticalAlign: 'middle', + '&, & *': { + animation: 'none !important', + transition: 'none !important' + }, + '& .chakra-checkbox__control': { + transform: 'none !important' + } +}; + +export type ModelChannelModalModel = { + model: string; + modelId?: string; + modelData?: SystemModelDocumentDataType; + getModelData?: () => SystemModelDocumentDataType | undefined; + avatar?: string; +}; + +/** + * 渲染模型渠道选择内容,不包含 Modal 外壳和提交行为。 + * + * 由调用方持有选择草稿,因此既可用于独立关联弹窗,也可嵌入模板创建的第二步, + * 避免步骤切换时卸载并重新创建 Modal 产生动画抖动。 + */ +export const ModelChannelSelector = ({ + models, + channels, + selectedChannelIds, + onChange, + onCreate, + showCurrentModel = true, + showSelectedModelCount = false, + showTest = true +}: { + models: ModelChannelModalModel[]; + channels: AdminModelChannel[]; + selectedChannelIds: number[]; + onChange: (channelIds: number[]) => void; + onCreate?: () => void; + showCurrentModel?: boolean; + showSelectedModelCount?: boolean; + showTest?: boolean; +}) => { + const { t, i18n } = useClientTranslation('config_model'); + const selectedIds = useMemo(() => new Set(selectedChannelIds), [selectedChannelIds]); + const { headerContainerRef, bodyContainerRef, headerTableWidth } = useFixedTableHeader(); + const selectedChannelCount = channels.filter((channel) => selectedIds.has(channel.id)).length; + const isAllSelected = channels.length > 0 && selectedChannelCount === channels.length; + const testModel = showTest && models.length === 1 ? models[0] : undefined; + const { testingChannelIds, testModelChannel } = useModelChannelTest({ + target: (() => { + if (!testModel) return; + if (testModel.getModelData || testModel.modelData) { + return { + source: 'draft' as const, + getModelData: testModel.getModelData ?? (() => testModel.modelData) + }; + } + if (testModel.modelId) { + return { source: 'installed' as const, modelId: testModel.modelId, model: testModel.model }; + } + })(), + channels + }); + + const toggleChannel = (channelId: number) => { + const next = new Set(selectedIds); + if (next.has(channelId)) next.delete(channelId); + else next.add(channelId); + onChange(channels.filter((channel) => next.has(channel.id)).map((channel) => channel.id)); + }; + + return ( + <> + {showCurrentModel && ( + + + {t('config_model:current_model')} + + {models.length <= 3 ? ( + + {models.map((model, index) => ( + + + {model.model} + + ))} + + ) : ( + {t('config_model:included_models', { count: models.length })} + )} + + )} + {(showSelectedModelCount || onCreate) && ( + + {showSelectedModelCount && ( + + {t('config_model:selected_model_count', { count: models.length })} + + )} + {onCreate && ( + + )} + + )} + + + +
+ + + + + + {testModel && } + + + + + + + + {testModel && ( + + )} + + +
+ + 0 && !isAllSelected} + onChange={() => + onChange(isAllSelected ? [] : channels.map((channel) => channel.id)) + } + /> + {t('common:Select_all')} + + + {t('config_model:channel_name')} + + {t('config_model:channel_type')} + + {t('config_model:channel_status')} + + {t('config_model:test')} +
+
+ + + + + + + + {testModel && } + + + {channels.map((channel) => { + const status = ChannelStautsMap[channel.status as keyof typeof ChannelStautsMap]; + return ( + toggleChannel(channel.id)} + > + + + + + {testModel && ( + + )} + + ); + })} + {channels.length === 0 && ( + + + + )} + +
+ {/* 仅复选框区域拦截冒泡,单元格空白仍由行处理点击。 */} + event.stopPropagation()} + > + toggleChannel(channel.id)} + /> + + + {channel.name} + + + + + {parseI18nString(channel.protocol.name, i18n.language)} + + + + + {status ? t(status.label) : t('config_model:channel_status_unknown')} + + + + { + event.stopPropagation(); + void testModelChannel(channel.id); + }} + /> + +
+ +
+
+ + + ); +}; + +/** 独立的已有渠道关联弹窗;选择内容与模板创建第二步共享。 */ +const ModelChannelModal = ({ + models, + channels, + selectedChannelIds, + onConfirm, + onClose, + showCurrentModel = true, + showTest = true +}: { + models: ModelChannelModalModel[]; + channels: AdminModelChannel[]; + selectedChannelIds: number[]; + onConfirm: (channelIds: number[]) => unknown | Promise; + onClose: () => void; + showCurrentModel?: boolean; + showTest?: boolean; +}) => { + const { t } = useClientTranslation('config_model'); + const [selection, setSelection] = useState(selectedChannelIds); + const { runAsync: confirmRequest, loading: confirming } = useRequest(async () => { + await onConfirm(selection); + }); + const confirm = useLockFn(confirmRequest); + + return ( + + + + + } + > + + + ); +}; + +export default ModelChannelModal; diff --git a/projects/app/src/pageComponents/model/ModelConfigForm.tsx b/projects/app/src/pageComponents/model/ModelConfigForm.tsx new file mode 100644 index 000000000000..21078c7805dc --- /dev/null +++ b/projects/app/src/pageComponents/model/ModelConfigForm.tsx @@ -0,0 +1,877 @@ +import MyTextarea from '@/components/common/Textarea/MyTextarea'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; +import { Box, Flex, Grid, GridItem, HStack, Input, Switch } from '@chakra-ui/react'; +import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import type { SystemModelDocumentDataType } from '@fastgpt/global/core/ai/model.schema'; +import type { ModelProviderItemType } from '@fastgpt/global/core/ai/provider'; +import { + getRuntimeResolvedPriceTiers, + normalizeModelPricingForRead, + normalizeModelPricingForSave +} from '@fastgpt/global/core/ai/pricing'; +import Avatar from '@fastgpt/web/components/common/Avatar'; +import MyNumberInput from '@fastgpt/web/components/common/Input/NumberInput'; +import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel'; +import MySelect from '@fastgpt/web/components/common/MySelect'; +import MultipleSelect from '@fastgpt/web/components/common/MySelect/MultipleSelect'; +import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; +import JsonEditor from '@fastgpt/web/components/common/Textarea/JsonEditor'; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; +import { useLockFn } from 'ahooks'; +import React, { useEffect, useMemo, useState, type MutableRefObject } from 'react'; +import { + useController, + useForm, + useWatch, + type Control, + type FieldPath, + type UseFormRegister, + type UseFormSetValue +} from 'react-hook-form'; +import ModelPriceTiersTable, { emptyPriceTier } from './ModelPriceTiersTable'; + +const ControlHeight = '32px'; + +const InputStyles = { + maxW: '100%', + bg: 'white', + w: '100%', + h: ControlHeight, + minH: ControlHeight, + fontSize: 'sm' +}; + +const NumberInputStyles = { + ...InputStyles, + inputFieldProps: { + bg: 'transparent', + h: ControlHeight, + minH: ControlHeight, + px: 3, + fontSize: 'sm' + } +}; + +const MultilineInputStyles = { + maxW: '100%', + bg: 'white', + w: '100%', + rows: 3 +}; + +const defaultResponseFormatOptions = ['text', 'json_schema', 'json_object']; + +/** + * 删除表单控件产生的 NaN,避免 JSON 序列化把嵌套 NaN 变成 null 后再被接口 schema 拒绝。 + * 数组中的 NaN 使用 null 保留位置,模型配置对象中的 NaN 则直接视为未填写。 + */ +const removeNaNValues = (value: unknown): void => { + if (!value || typeof value !== 'object') return; + + if (Array.isArray(value)) { + value.forEach((item, index) => { + if (typeof item === 'number' && Number.isNaN(item)) { + value[index] = null; + } else { + removeNaNValues(item); + } + }); + return; + } + + Object.entries(value).forEach(([key, item]) => { + if (typeof item === 'number' && Number.isNaN(item)) { + delete (value as Record)[key]; + } else { + removeNaNValues(item); + } + }); +}; + +const Section = ({ + title, + children, + showBorder = true +}: { + title: string; + children: React.ReactNode; + showBorder?: boolean; +}) => ( + + + {title} + + {children} + +); + +const Field = ({ + label, + tip, + children, + colSpan = 1, + required = false +}: { + label: string; + tip?: string; + children: React.ReactNode; + colSpan?: number | number[]; + required?: boolean; +}) => ( + + + + {label} + + {tip && } + + {children} + +); + +const SwitchField = ({ + label, + tip, + field, + register +}: { + label: string; + tip?: string; + field: FieldPath; + register: UseFormRegister; +}) => ( + + + + {label} + + {tip && } + + + +); + +const ProviderField = React.memo(function ProviderField({ + control, + providerList, + t +}: { + control: Control; + providerList: { label: React.ReactNode; value: string }[]; + t: ReturnType['t']; +}) { + const { + field: { value, onChange, onBlur, ref }, + fieldState, + formState: { isSubmitted } + } = useController({ + control, + name: 'provider', + rules: { required: true } + }); + + return ( + + + + ); +}); + +const ResponseFormatField = React.memo(function ResponseFormatField({ + control, + setValue, + t +}: { + control: Control; + setValue: UseFormSetValue; + t: ReturnType['t']; +}) { + const responseFormatList = useWatch({ + control, + name: 'config.responseFormatList' + }); + const responseFormatOptions = useMemo(() => { + const valueSet = new Set([ + ...defaultResponseFormatOptions, + ...(Array.isArray(responseFormatList) ? responseFormatList : []) + ]); + + return Array.from(valueSet).map((item) => ({ + value: item, + label: item + })); + }, [responseFormatList]); + + return ( + + + list={responseFormatOptions} + value={Array.isArray(responseFormatList) ? responseFormatList : []} + onSelect={(value) => setValue('config.responseFormatList', value, { shouldDirty: true })} + placeholder={t('config_model:model.response_format')} + {...InputStyles} + borderRadius={'md'} + tagStyle={{ + bg: 'transparent', + color: 'myGray.700', + borderColor: 'myGray.200', + borderWidth: '1px', + borderRadius: '6px', + px: 2, + py: 1, + fontSize: '10px' + }} + /> + + ); +}); + +const DefaultConfigField = React.memo(function DefaultConfigField({ + control, + setValue, + label, + tip, + onDraftChange +}: { + control: Control; + setValue: UseFormSetValue; + label: string; + tip: string; + onDraftChange?: () => void; +}) { + const defaultConfig = useWatch({ + control, + name: 'config.defaultConfig' + }); + + return ( + + { + onDraftChange?.(); + if (!e) { + setValue('config.defaultConfig', {}, { shouldDirty: true }); + return; + } + try { + setValue('config.defaultConfig', JSON.parse(e.trim()), { shouldDirty: true }); + } catch (error) { + console.error(error); + } + }} + {...MultilineInputStyles} + pr={2.5} + /> + + ); +}); + +const VoicesField = React.memo(function VoicesField({ + control, + t, + onDraftChange +}: { + control: Control; + t: ReturnType['t']; + onDraftChange?: () => void; +}) { + const [isValidJson, setIsValidJson] = useState(true); + const { field, fieldState } = useController({ + control, + name: 'config.voices', + rules: { + validate: (value) => + (isValidJson && + Array.isArray(value) && + value.length > 0 && + value.every( + (voice) => voice && typeof voice.label === 'string' && typeof voice.value === 'string' + )) || + t('config_model:voices_array_required') + } + }); + + return ( + + { + onDraftChange?.(); + try { + const value = JSON.parse(e); + setIsValidJson(true); + field.onChange(value); + } catch { + setIsValidJson(false); + } + }} + {...MultilineInputStyles} + /> + {fieldState.error && ( + + {fieldState.error.message} + + )} + + ); +}); + +export type ModelConfigFormGetValues = () => SystemModelDocumentDataType; + +type ModelConfigFormProps = { + modelData: SystemModelDocumentDataType; + providers: ModelProviderItemType[]; + formId: string; + onSubmit: (modelData: SystemModelDocumentDataType) => Promise; + isModelIdReadOnly?: boolean; + channelSection?: { + title: string; + content: React.ReactNode; + }; + onModelChange?: (model: string) => void; + onSuccess?: () => void; + onSubmittingChange?: (loading: boolean) => void; + onDirtyChange?: (isDirty: boolean) => void; + /** 暴露当前未保存草稿读取器,供渠道测试等不触发表单提交的动作使用。 */ + getValuesRef?: MutableRefObject; +}; + +const ModelConfigForm = ({ + modelData, + providers, + formId, + onSubmit, + channelSection, + isModelIdReadOnly = false, + onModelChange, + onSuccess, + onSubmittingChange, + onDirtyChange, + getValuesRef +}: ModelConfigFormProps) => { + const { t } = useClientTranslation('config_model'); + const { feConfigs } = useSystemStore(); + const initialModelData = normalizeModelPricingForRead(modelData); + const [hasJsonDraftChanges, setHasJsonDraftChanges] = useState(false); + + const { + control, + register, + getValues, + setValue, + handleSubmit, + formState: { isDirty } + } = useForm({ + defaultValues: { + // 空白草稿的引用上限不向输入框写入 NaN,保持视觉上未填写。 + ...(initialModelData.type === ModelTypeEnum.llm && + Number.isNaN(initialModelData.config.quoteMaxToken) + ? { + ...initialModelData, + config: { ...initialModelData.config, quoteMaxToken: undefined } + } + : initialModelData), + priceTiers: (() => { + if (modelData.type !== ModelTypeEnum.llm) return undefined; + const tiers = initialModelData.priceTiers ?? []; + if (tiers.length === 0) return [emptyPriceTier]; + + const last = tiers[tiers.length - 1]; + if (!last.maxInputTokens) return tiers; + + return [ + ...tiers, + { + ...emptyPriceTier, + minInputTokens: last.maxInputTokens + } + ]; + })() + } + }); + + useEffect(() => { + if (!getValuesRef) return; + getValuesRef.current = getValues; + return () => { + getValuesRef.current = null; + }; + }, [getValues, getValuesRef]); + const reasoningEnabled = useWatch({ control, name: 'config.reasoning' }); + const model = useWatch({ control, name: 'model' }); + + useEffect(() => { + onModelChange?.(model); + }, [model, onModelChange]); + + useEffect(() => { + // 仅在 reasoning 关闭且 reasoningEffort 实际为 true 时才清,避免挂载即把表单标 dirty + if (!reasoningEnabled && getValues('config.reasoningEffort')) { + setValue('config.reasoningEffort', false, { shouldDirty: false }); + } + }, [reasoningEnabled, getValues, setValue]); + + const isLLMModel = modelData?.type === ModelTypeEnum.llm; + const isEmbeddingModel = modelData?.type === ModelTypeEnum.embedding; + const isTTSModel = modelData?.type === ModelTypeEnum.tts; + const isSTTModel = modelData?.type === ModelTypeEnum.stt; + const isRerankModel = modelData?.type === ModelTypeEnum.rerank; + + const providerList = useMemo( + () => + providers.map((item) => ({ + label: ( + + + {item.name} + + ), + value: item.id + })), + [providers] + ); + + const priceUnit = useMemo(() => { + if (isLLMModel || isEmbeddingModel || isRerankModel) return '/ 1k Tokens'; + if (isTTSModel) return `/ 1k ${t('common:unit.character')}`; + if (isSTTModel) return `/ 60 ${t('common:unit.seconds')}`; + return ''; + }, [isLLMModel, isEmbeddingModel, isTTSModel, t, isSTTModel, isRerankModel]); + + const { runAsync: submitModelRequest, loading: submittingModel } = useRequest( + async (data: SystemModelDocumentDataType) => { + data.name = data.name?.trim() || data.model; + if (data.type === ModelTypeEnum.llm) { + // 数字输入留空会产生 NaN;仅未填写时按上下文计算,保留显式填写的 0。 + if (data.config.quoteMaxToken == null || Number.isNaN(data.config.quoteMaxToken)) { + data.config.quoteMaxToken = Math.floor(data.config.maxContext * 0.8); + } + + // 空数字输入会被 react-hook-form 解析为 NaN;显式转成协议允许的 null, + // 避免依赖 JSON.stringify 将 NaN 隐式转换成 null。 + if (Number.isNaN(data.config.maxTemperature)) { + data.config.maxTemperature = null; + } + + const priceTiers = getRuntimeResolvedPriceTiers({ priceTiers: data.priceTiers }); + + let currentLowerExclusiveBound = 0; + + for (let index = 0; index < priceTiers.length; index++) { + const tier = priceTiers[index]; + const hasPrice = + typeof tier.inputPrice === 'number' || typeof tier.outputPrice === 'number'; + + if (!hasPrice) { + return Promise.reject(t('config_model:model.price_tier_price_required')); + } + + if (index < priceTiers.length - 1 && typeof tier.maxInputTokens !== 'number') { + return Promise.reject(t('config_model:model.price_tier_max_required')); + } + + if ( + typeof tier.maxInputTokens === 'number' && + tier.maxInputTokens <= currentLowerExclusiveBound + ) { + return Promise.reject(t('config_model:model.price_tier_range_invalid')); + } + + if (typeof tier.maxInputTokens === 'number') { + currentLowerExclusiveBound = tier.maxInputTokens; + } + } + + data.priceTiers = priceTiers; + } + + const modelData = data as Record; + removeNaNValues(modelData); + for (const key of Object.keys(modelData)) { + const val = modelData[key]; + if (val === null || val === undefined) delete modelData[key]; + } + + return onSubmit(normalizeModelPricingForSave(data)); + }, + { + onSuccess: () => { + onSuccess?.(); + }, + successToast: t('common:Success') + } + ); + const submitModel = useLockFn(submitModelRequest); + + useEffect(() => { + onSubmittingChange?.(submittingModel); + }, [onSubmittingChange, submittingModel]); + + useEffect(() => { + onDirtyChange?.(isDirty || hasJsonDraftChanges); + }, [hasJsonDraftChanges, isDirty, onDirtyChange]); + + const CustomApi = useMemo( + () => ( + <> + + + + {t('config_model:model.request_url')} + + + + + + + + + {t('config_model:model.request_auth')} + + + + + + + ), + [register, t] + ); + + return ( + +
+ + + + + + + + + + + + +
+ + {channelSection &&
{channelSection.content}
} + + {isLLMModel && ( +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ )} + + {isEmbeddingModel && ( +
+ + + + + + + + + + + + +
+ )} + + {isRerankModel && ( +
+ + + + + +
+ )} + + {isLLMModel && ( +
+ + + + + + + {reasoningEnabled && ( + + )} + {feConfigs?.isPlus && ( + + )} + +
+ )} + + {isEmbeddingModel && ( +
+ + + +
+ )} + + {priceUnit && feConfigs?.isPlus && ( +
+ {isLLMModel ? ( + + ) : ( + + + + + + {priceUnit} + + + + + )} +
+ )} + +
+ + {isLLMModel && ( + + + + )} + {(isLLMModel || isEmbeddingModel || isRerankModel) && ( + setHasJsonDraftChanges(true)} + /> + )} + {isTTSModel && ( + setHasJsonDraftChanges(true)} + /> + )} + {CustomApi} + + +
+
+ ); +}; + +export default ModelConfigForm; diff --git a/projects/app/src/pageComponents/model/ModelConfigTable.tsx b/projects/app/src/pageComponents/model/ModelConfigTable.tsx new file mode 100644 index 000000000000..cfb6b5e23235 --- /dev/null +++ b/projects/app/src/pageComponents/model/ModelConfigTable.tsx @@ -0,0 +1,812 @@ +import { + Box, + Flex, + Grid, + HStack, + Table, + TableContainer, + Tbody, + Td, + Th, + Thead, + Tr, + Switch, + Spinner, + Checkbox, + Button, + useDisclosure +} from '@chakra-ui/react'; +import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import Avatar from '@fastgpt/web/components/common/Avatar'; +import MyTag from '@fastgpt/web/components/common/Tag/index'; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import { + deleteSystemModel, + getAdminModelConfig, + getTestModel, + deleteSystemModels, + putSystemModelsStatus, + putReplaceSystemModelChannels +} from '@/web/core/ai/config'; +import MyBox from '@fastgpt/web/components/common/MyBox'; +import MyIconButton from '@fastgpt/web/components/common/Icon/button'; +import { useUserStore } from '@/web/support/user/useUserStore'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; +import CopyBox from '@fastgpt/web/components/common/String/CopyBox'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; +import AddModel from './AddModel'; +import PopoverConfirm from '@fastgpt/web/components/common/MyPopover/PopoverConfirm'; +import PriceTiersLabel from '@/components/core/ai/PriceTiersLabel'; +import TestModeBetaTag from '@/components/core/ai/TestModeBetaTag'; +import ModelCapabilityTags from '@/components/core/ai/ModelCapabilityTags'; +import { accountContentScrollStyles, accountPageRootStyles } from '@/pageComponents/account/styles'; +import ModelTabHeader from './ModelTabHeader'; +import { useUserModelStore } from '@/web/core/ai/model/useUserModelStore'; +import { + formatModelProviders, + getModelProviderFromCache, + getModelProviderListFromCache, + type ModelProviderItemType +} from '@fastgpt/global/core/ai/provider'; +import type { AdminSystemModelListItem } from '@fastgpt/global/openapi/admin/core/ai/model/api'; +import { useLockFn, useSet } from 'ahooks'; +import ModelChannelCount from './ModelChannelCount'; +import ModelChannelModal from './ModelChannelModal'; +import ModelEditModal from './ModelEditModal'; +import { useStaticVirtualList } from '@fastgpt/web/hooks/useVirtualList'; +import { useTableMultipleSelect } from '@fastgpt/web/hooks/useTableMultipleSelect'; +import { useConfirm } from '@fastgpt/web/hooks/useConfirm'; +import { useFixedTableHeader } from '@fastgpt/web/hooks/useFixedTableHeader'; +import JsonModelConfigModal from './JsonModelConfigModal'; +import DefaultModelModal from './DefaultModelModal'; +import ModelListFilters from '@/components/core/ai/ModelListFilters'; +import { useToast } from '@fastgpt/web/hooks/useToast'; + +const modelRowHeight = 80; +const modelTableColumnWidth = { + selection: '48px', + billing: '250px', + channels: '180px', + active: '128px', + actions: '160px' +} as const; + +/** 将编辑弹窗状态隔离在单行操作中,避免打开弹窗时重渲染整张大模型表。 */ +const ModelEditButton = React.memo( + ({ + model, + providers, + onSuccess, + isDisabled + }: { + model: AdminSystemModelListItem; + providers: ModelProviderItemType[]; + onSuccess: () => Promise; + isDisabled?: boolean; + }) => { + const { t } = useClientTranslation('config_model'); + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + setIsOpen(true)} + /> + {isOpen && ( + setIsOpen(false)} + /> + )} + + ); + } +); +ModelEditButton.displayName = 'ModelEditButton'; + +const ModelTable = ({ Tab }: { Tab: React.ReactNode }) => { + const { t, i18n } = useClientTranslation('config_model'); + const { toast } = useToast(); + const { userInfo } = useUserStore(); + const { feConfigs } = useSystemStore(); + const showBilling = !!feConfigs?.isPlus; + const tableColumnCount = showBilling ? 6 : 5; + + const { + data: adminConfig, + runAsync: refreshSystemModelList, + loading: loadingModels + } = useRequest(getAdminModelConfig, { manual: false }); + const systemModelList = useMemo(() => adminConfig?.models ?? [], [adminConfig?.models]); + const channelList = useMemo(() => adminConfig?.channels ?? [], [adminConfig?.channels]); + const providerCache = useMemo( + () => formatModelProviders(adminConfig?.providers ?? []), + [adminConfig?.providers] + ); + const getModelProviders = useCallback( + (language?: string) => + getModelProviderListFromCache(providerCache.ModelProviderListCache, language), + [providerCache.ModelProviderListCache] + ); + const getModelProvider = useCallback( + (provider?: string, language?: string) => + getModelProviderFromCache({ cache: providerCache.ModelProviderMapCache, provider, language }), + [providerCache.ModelProviderMapCache] + ); + const modelProviders = useMemo( + () => getModelProviders(i18n.language), + [getModelProviders, i18n.language] + ); + + const isRoot = userInfo?.username === 'root'; + + const [provider, setProvider] = useState(''); + const [modelType, setModelType] = useState(''); + const [search, setSearch] = useState(''); + const [showActive, setShowActive] = useState(false); + + const refreshModels = useCallback(async () => { + useUserModelStore.getState().clearMemory(); + await refreshSystemModelList(); + }, [refreshSystemModelList]); + + const modelList = useMemo(() => { + const formatLLMModelList = systemModelList + .filter((item) => item.type === ModelTypeEnum.llm) + .map((item) => ({ + ...item, + typeLabel: t('common:model.type.chat'), + priceLabel: ( + + ), + tagColor: 'blue' + })); + const formatVectorModelList = systemModelList + .filter((item) => item.type === ModelTypeEnum.embedding) + .map((item) => ({ + ...item, + typeLabel: t('common:model.type.embedding'), + priceLabel: item.charsPointsPrice ? ( + + {`${t('common:Input')}: `} + + {item.charsPointsPrice} + + {` ${t('common:support.wallet.subscription.point')} / 1K Tokens`} + + ) : ( + '-' + ), + tagColor: 'yellow' + })); + const formatAudioSpeechModelList = systemModelList + .filter((item) => item.type === ModelTypeEnum.tts) + .map((item) => ({ + ...item, + typeLabel: t('common:model.type.tts'), + priceLabel: item.charsPointsPrice ? ( + + + {item.charsPointsPrice} + + {` ${t('common:support.wallet.subscription.point')} / 1K ${t('common:unit.character')}`} + + ) : ( + '-' + ), + tagColor: 'green' + })); + const formatWhisperModel = systemModelList + .filter((item) => item.type === ModelTypeEnum.stt) + .map((item) => ({ + ...item, + typeLabel: t('common:model.type.stt'), + priceLabel: item.charsPointsPrice ? ( + + + {item.charsPointsPrice} + + {` ${t('common:support.wallet.subscription.point')} / 60${t('common:unit.seconds')}`} + + ) : ( + '-' + ), + tagColor: 'purple' + })); + const formatRerankModelList = systemModelList + .filter((item) => item.type === ModelTypeEnum.rerank) + .map((item) => ({ + ...item, + typeLabel: t('common:model.type.reRank'), + priceLabel: item.charsPointsPrice ? ( + + {`${t('common:Input')}: `} + + {item.charsPointsPrice} + + {` ${t('common:support.wallet.subscription.point')} / 1K Tokens`} + + ) : ( + '-' + ), + tagColor: 'red' + })); + + const formattedModelMap = new Map( + [ + ...formatLLMModelList, + ...formatVectorModelList, + ...formatAudioSpeechModelList, + ...formatWhisperModel, + ...formatRerankModelList + ].map((item) => [item.modelId, item] as const) + ); + // 格式化不能改变服务端返回的 MongoDB 新建时间倒序。 + const list = systemModelList.flatMap((item) => { + if (modelType && item.type !== modelType) return []; + const formattedModel = formattedModelMap.get(item.modelId); + return formattedModel ? [formattedModel] : []; + }); + + const formatList = list.map((item) => { + const provider = getModelProvider(item.provider, i18n.language); + return { + ...item, + avatar: provider.avatar, + providerId: provider.id, + providerName: provider.name, + contextToken: + item.type === ModelTypeEnum.llm + ? item.config.maxContext + : item.type === ModelTypeEnum.embedding || item.type === ModelTypeEnum.rerank + ? item.config.maxToken + : undefined, + vision: + item.type === ModelTypeEnum.llm || item.type === ModelTypeEnum.embedding + ? item.config.vision + : undefined, + audio: item.type === ModelTypeEnum.llm ? item.config.audio : undefined, + video: item.type === ModelTypeEnum.llm ? item.config.video : undefined, + reasoning: item.type === ModelTypeEnum.llm ? item.config.reasoning : undefined + }; + }); + + const filterList = formatList.filter((item) => { + const providerFilter = provider ? item.providerId === provider : true; + + const normalizedSearch = search.trim().toLowerCase(); + const nameFilter = normalizedSearch + ? item.name.toLowerCase().includes(normalizedSearch) || + item.model.toLowerCase().includes(normalizedSearch) + : true; + + const activeFilter = showActive ? item.isActive : true; + + return providerFilter && nameFilter && activeFilter; + }); + + return filterList; + }, [ + systemModelList, + t, + modelType, + getModelProvider, + i18n.language, + provider, + search, + showActive + ]); + const activeModelLength = useMemo(() => { + return modelList.filter((item) => item.isActive).length; + }, [modelList]); + const getModelId = useCallback((model: AdminSystemModelListItem) => model.modelId, []); + const { + selectedItems, + setSelectedItems, + toggleSelect, + isSelected, + getRowSelectionProps, + FloatingActionBar, + isSelecteAll, + selectAllTrigger + } = useTableMultipleSelect({ + list: modelList, + getItemId: getModelId + }); + const { + containerRef: modelListContainerRef, + virtualDataList: virtualModelList, + topPlaceholderHeight, + bottomPlaceholderHeight, + scrollToTop: scrollModelListToTop + } = useStaticVirtualList({ + data: modelList, + itemHeight: modelRowHeight, + overscan: 10 + }); + const { headerContainerRef: modelTableHeaderRef, headerTableWidth: modelTableHeaderWidth } = + useFixedTableHeader(modelListContainerRef); + + useEffect(() => { + scrollModelListToTop(); + }, [modelType, provider, scrollModelListToTop, search, showActive]); + + const [testingModelIds, testingModelIdsDispatch] = useSet(); + const { runAsync: onTestModel } = useRequest( + async (data: Parameters[0]) => { + testingModelIdsDispatch.add(data.modelId); + try { + return await getTestModel(data); + } finally { + testingModelIdsDispatch.remove(data.modelId); + } + }, + { + manual: true, + successToast: t('common:Success') + } + ); + const [updatingModelIds, updatingModelIdsDispatch] = useSet(); + const { runAsync: updateModelStatus } = useRequest( + async ({ modelId, model, isActive }: { modelId: string; model: string; isActive: boolean }) => { + updatingModelIdsDispatch.add(modelId); + try { + await putSystemModelsStatus({ modelIds: [modelId], isActive }); + toast({ + status: 'success', + title: t(isActive ? 'config_model:status_enabled' : 'config_model:status_disabled', { + name: model + }) + }); + // 状态写入已经成功;列表刷新失败由其自身提示,不能把成功操作再次报成失败。 + await refreshModels().catch(() => {}); + } finally { + updatingModelIdsDispatch.remove(modelId); + } + } + ); + + const [channelMutationLoading, setChannelMutationLoading] = useState(false); + const runChannelMutation = useLockFn(async (operation: () => Promise) => { + setChannelMutationLoading(true); + try { + return await operation(); + } finally { + setChannelMutationLoading(false); + } + }); + + const { runAsync: deleteModelRequest } = useRequest(deleteSystemModel, { + onSuccess: () => void refreshModels().catch(() => {}), + successToast: t('common:delete_success') + }); + const deleteModel = (data: Parameters[0]) => + runChannelMutation(() => deleteModelRequest(data)); + const clearSelection = useCallback(() => { + setSelectedItems([]); + }, [setSelectedItems]); + const { runAsync: updateModelsStatus, loading: updatingModelsStatus } = useRequest( + async (data: Parameters[0]) => { + await putSystemModelsStatus(data); + clearSelection(); + toast({ + status: 'success', + title: t( + data.isActive + ? 'config_model:model.batch_status_enabled' + : 'config_model:model.batch_status_disabled', + { count: data.modelIds.length } + ) + }); + await refreshModels().catch(() => {}); + } + ); + const { runAsync: deleteModelsRequest, loading: deletingModels } = useRequest( + deleteSystemModels, + { + manual: true, + onSuccess: () => { + clearSelection(); + void refreshModels().catch(() => {}); + }, + successToast: t('common:delete_success') + } + ); + const deleteModels = (data: Parameters[0]) => + runChannelMutation(() => deleteModelsRequest(data)); + const { openConfirm: openBatchDeleteConfirm, ConfirmModal: BatchDeleteConfirmModal } = useConfirm( + { + type: 'delete' + } + ); + + const [channelModel, setChannelModel] = useState(); + + const { + isOpen: isOpenJsonConfig, + onOpen: onOpenJsonConfig, + onClose: onCloseJsonConfig + } = useDisclosure(); + const { + onOpen: onOpenDefaultModel, + onClose: onCloseDefaultModel, + isOpen: isOpenDefaultModel + } = useDisclosure(); + + // 渠道是列表的补充数据,模型详情和更新也都有独立操作反馈;只有模型首次加载阻塞整表。 + const isInitialLoading = loadingModels && adminConfig === undefined; + + const [showModelId, setShowModelId] = useState(true); + + return ( + <> + {isRoot && ( + + + + + + + + )} + + + + + + + + + + {showBilling && } + + + + + + + + + {showBilling && } + + + + + +
+ 0 && !isSelecteAll} + onChange={selectAllTrigger} + /> + + setShowModelId(!showModelId)} + > + + {showModelId ? t('config_model:model.model_id') : t('common:model.name')} + + + + {t('common:model.billing')}{t('config_model:model.channels')} + setShowActive(!showActive)} + color={showActive ? 'primary.600' : 'myGray.600'} + > + {t('config_model:model.active')}({activeModelLength}) + + {t('common:Operation')}
+
+ + + + + + {showBilling && } + + + + + + {!isInitialLoading && modelList.length === 0 && ( + + + + )} + {topPlaceholderHeight > 0 && ( + + + )} + {virtualModelList.map(({ data: item }) => ( + + + + {showBilling && } + + + + + ))} + {bottomPlaceholderHeight > 0 && ( + + + )} + +
+ +
+
+ toggleSelect(item)} + /> + + + + + + {showModelId ? item.model : item.name} + + {item.testMode && } + + + + + {item.typeLabel} + + + + {item.priceLabel} + + setChannelModel(item)} + /> + + + + {updatingModelIds.has(item.modelId) ? ( + + ) : ( + + updateModelStatus({ + modelId: item.modelId, + model: item.model, + isActive: e.target.checked + }) + } + colorScheme={'myBlue'} + /> + )} + + + + onTestModel({ modelId: item.modelId })} + /> + + + + + } + type="delete" + content={t('config_model:model.delete_model_confirm')} + onConfirm={() => deleteModel({ modelId: item.modelId })} + /> + +
+
+
+ + + + + + } + /> +
+
+
+ + {!!channelModel && ( + channel.id)} + onClose={() => setChannelModel(undefined)} + onConfirm={async (channelIds) => { + await runChannelMutation(() => + putReplaceSystemModelChannels({ modelId: channelModel.modelId, channelIds }) + ); + toast({ status: 'success', title: t('config_model:associate_success') }); + setChannelModel(undefined); + await refreshModels().catch(() => {}); + }} + /> + )} + {isOpenJsonConfig && ( + + )} + {isOpenDefaultModel && ( + + )} + + + ); +}; + +export default ModelTable; diff --git a/projects/app/src/pageComponents/account/model/ModelDashboard/DataTableComponent.tsx b/projects/app/src/pageComponents/model/ModelDashboard/DataTableComponent.tsx similarity index 84% rename from projects/app/src/pageComponents/account/model/ModelDashboard/DataTableComponent.tsx rename to projects/app/src/pageComponents/model/ModelDashboard/DataTableComponent.tsx index 9e8d16ef0314..a5262fea939b 100644 --- a/projects/app/src/pageComponents/account/model/ModelDashboard/DataTableComponent.tsx +++ b/projects/app/src/pageComponents/model/ModelDashboard/DataTableComponent.tsx @@ -9,6 +9,7 @@ import type { DashboardDataItemType } from '@/global/aiproxy/type'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import { calculateModelPrice } from '@fastgpt/global/core/ai/pricing'; import type { ModelPriceTierType } from '@fastgpt/global/core/ai/model.schema'; +import { useFixedTableHeader } from '@fastgpt/web/hooks/useFixedTableHeader'; export type DashboardDataEntry = { timestamp: number; @@ -50,8 +51,10 @@ const DataTableComponent = ({ }: DataTableComponentProps) => { const { t } = useClientTranslation('config_model'); const { feConfigs } = useSystemStore(); + const showBilling = !!feConfigs?.isPlus; const [sortField, setSortField] = useState('totalCalls'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + const { headerContainerRef, bodyContainerRef, headerTableWidth } = useFixedTableHeader(); // Create a mapping from channel ID to channel name const channelIdToNameMap = useMemo(() => { @@ -69,6 +72,13 @@ const DataTableComponent = ({ // display the channel column const showChannelColumn = !!filterProps.model; + const tableMinWidth = showChannelColumn + ? showBilling + ? '1250px' + : '1140px' + : showBilling + ? '1110px' + : '1000px'; const tableData = useMemo(() => { if (data.length === 0) { @@ -269,9 +279,26 @@ const DataTableComponent = ({ }; return ( - - - + + +
+ + + {showChannelColumn && } + + + {showBilling && } + + + + + @@ -290,7 +317,7 @@ const DataTableComponent = ({ > {t('config_model:volunme_of_failed_calls')} {getSortIcon('errorCalls')} - {feConfigs?.isPlus && ( + {showBilling && ( +
{t('config_model:dashboard_model')} handleSort('totalCost')} @@ -311,6 +338,21 @@ const DataTableComponent = ({
+
+ + + + + {showChannelColumn && } + + + {showBilling && } + + + + + {tableData.map((item, index) => ( @@ -318,7 +360,7 @@ const DataTableComponent = ({ {showChannelColumn && } - {feConfigs?.isPlus && } + {showBilling && } diff --git a/projects/app/src/pageComponents/account/model/ModelDashboard/index.tsx b/projects/app/src/pageComponents/model/ModelDashboard/index.tsx similarity index 99% rename from projects/app/src/pageComponents/account/model/ModelDashboard/index.tsx rename to projects/app/src/pageComponents/model/ModelDashboard/index.tsx index 2a33a8a9c6ec..54b22f33a653 100644 --- a/projects/app/src/pageComponents/account/model/ModelDashboard/index.tsx +++ b/projects/app/src/pageComponents/model/ModelDashboard/index.tsx @@ -386,13 +386,13 @@ const ModelDashboard = ({ Tab }: { Tab: React.ReactNode }) => { flexWrap={['nowrap', 'wrap']} alignItems={['stretch', 'flex-start']} justifyContent={'space-between'} - gap={[3, 4]} + gap={2} > import('./Channel/EditChannelModal'), { ssr: false }); +const ModelChannelModal = dynamic(() => import('./ModelChannelModal'), { ssr: false }); + +const formId = 'system-model-edit-form'; + +/** 编辑弹窗只持有稳定 modelId,参数字段统一交给通用表单渲染和校验。 */ +const ModelEditModal = ({ + model, + providers, + onSuccess, + onClose +}: { + model: AdminSystemModelListItem; + providers: ModelProviderItemType[]; + onSuccess: () => void | Promise; + onClose: () => void; +}) => { + const { + t, + detail, + loadingModelData, + submitting, + setSubmitting, + modelFormGetValuesRef, + selectedChannelIds, + setSelectedChannelIds, + showCreateChannel, + setShowCreateChannel, + showAssociateChannel, + setShowAssociateChannel, + goToChannelManagement, + testModelChannel, + testingChannelIds, + setIsFormDirty, + submitModel, + refreshAfterChannelCreated, + LeaveConfirmModal + } = useModelEditWorkflow({ model, onSuccess, onClose }); + + return ( + <> + + + + + } + > + {detail && ( + { + const { modelId: _modelId, avatar: _avatar, ...documentData } = detail.model; + return documentData; + })()} + providers={providers} + isModelIdReadOnly + channelSection={{ + title: t('config_model:associated_channels', { + count: detail.channels.filter((channel) => selectedChannelIds.has(channel.id)) + .length + }), + content: ( + setShowCreateChannel(true)} + onAssociate={() => setShowAssociateChannel(true)} + onManage={goToChannelManagement} + onTest={(channelId) => void testModelChannel(channelId)} + testingChannelIds={testingChannelIds} + onRemove={(channelId) => + setSelectedChannelIds((current) => { + const next = new Set(current); + next.delete(channelId); + return next; + }) + } + /> + ) + }} + onSubmittingChange={setSubmitting} + onDirtyChange={setIsFormDirty} + onSuccess={() => { + onClose(); + void Promise.resolve(onSuccess()).catch(() => {}); + }} + onSubmit={submitModel} + /> + )} + + + {detail && showAssociateChannel && ( + modelFormGetValuesRef.current?.(), + avatar: detail.model.avatar + } + ]} + channels={detail.channels} + selectedChannelIds={[...selectedChannelIds]} + onConfirm={(channelIds) => { + setSelectedChannelIds(new Set(channelIds)); + setShowAssociateChannel(false); + }} + onClose={() => setShowAssociateChannel(false)} + /> + )} + + {detail && showCreateChannel && ( + setShowCreateChannel(false)} + /> + )} + + + + ); +}; + +export default ModelEditModal; diff --git a/projects/app/src/pageComponents/model/ModelLinkedChannels.tsx b/projects/app/src/pageComponents/model/ModelLinkedChannels.tsx new file mode 100644 index 000000000000..953394e97218 --- /dev/null +++ b/projects/app/src/pageComponents/model/ModelLinkedChannels.tsx @@ -0,0 +1,187 @@ +import { ChannelStautsMap } from '@/global/aiproxy/constants'; +import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; +import type { AdminModelChannel } from '@fastgpt/global/openapi/admin/core/ai/model/api'; +import { + Box, + Button, + HStack, + Table, + TableContainer, + Tbody, + Td, + Th, + Thead, + Tr +} from '@chakra-ui/react'; +import Avatar from '@fastgpt/web/components/common/Avatar'; +import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import MyIconButton from '@fastgpt/web/components/common/Icon/button'; +import MyTag, { type ColorSchemaType } from '@fastgpt/web/components/common/Tag'; +import { useFixedTableHeader } from '@fastgpt/web/hooks/useFixedTableHeader'; +import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; + +const ChannelTableColumns = () => ( + + + + + + +); + +/** 新增与编辑模型共用的渠道入口和已关联渠道概览;关联变更在模型保存时统一提交。 */ +const ModelLinkedChannels = ({ + channels, + selectedIds, + onCreate, + onAssociate, + onManage, + onTest, + testingChannelIds, + onRemove +}: { + channels: AdminModelChannel[]; + selectedIds: Set; + onCreate?: () => void; + onAssociate: () => void; + onManage: () => void; + onTest: (channelId: number) => void; + testingChannelIds: ReadonlySet; + onRemove: (channelId: number) => void; +}) => { + const { t, i18n } = useClientTranslation('config_model'); + const linkedChannels = channels.filter((channel) => selectedIds.has(channel.id)); + const { headerContainerRef, bodyContainerRef, headerTableWidth } = useFixedTableHeader(); + + return ( + + + {onCreate && ( + + )} + + + + + + +
{item.channelName}{formatNumber(item.totalCalls).toLocaleString()} {formatNumber(item.errorCalls)}{formatNumber(item.totalCost).toLocaleString()}{formatNumber(item.totalCost).toLocaleString()} 10 ? 'yellow.700' : ''}> {item.avgResponseTime > 0 ? `${item.avgResponseTime.toFixed(2)}` : '-'}
+ + + + + + + + + +
+ {t('config_model:channel_name')} + + {t('config_model:channel_type')} + + {t('config_model:channel_status')} + + {t('common:Operation')} +
+
+ + + + + + {linkedChannels.map((channel) => { + const status = ChannelStautsMap[channel.status as keyof typeof ChannelStautsMap]; + + return ( + + + + + + + ); + })} + {linkedChannels.length === 0 && ( + + + + )} + +
+ {channel.name} + + + + + {parseI18nString(channel.protocol.name, i18n.language)} + + + + + {status ? t(status.label) : t('config_model:channel_status_unknown')} + + + + onTest(channel.id)} + /> + onRemove(channel.id)} + /> + +
+ +
+
+ + + ); +}; + +export default ModelLinkedChannels; diff --git a/projects/app/src/pageComponents/model/ModelPriceTiersTable.tsx b/projects/app/src/pageComponents/model/ModelPriceTiersTable.tsx new file mode 100644 index 000000000000..8eb6209b9d09 --- /dev/null +++ b/projects/app/src/pageComponents/model/ModelPriceTiersTable.tsx @@ -0,0 +1,365 @@ +import { Box, Button, Flex, Input, Table, Tbody, Td, Th, Thead, Tr } from '@chakra-ui/react'; +import type { SystemModelDocumentDataType } from '@fastgpt/global/core/ai/model.schema'; +import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; +import React, { useCallback, useState } from 'react'; +import { + useFieldArray, + useWatch, + type Control, + type UseFormGetValues, + type UseFormRegister, + type UseFormSetValue +} from 'react-hook-form'; + +const PriceInputStyles = { + bg: 'transparent', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + h: '24px', + minH: '24px', + py: '4px', + lineHeight: '16px' +}; + +const BorderlessPriceInputStyles = { + variant: 'unstyled' as const, + bg: 'transparent', + border: 'none', + boxShadow: 'none', + _focus: { boxShadow: 'none' }, + _focusVisible: { boxShadow: 'none' } +}; + +const FixedPriceValueInputStyles = { + boxSizing: 'border-box' as const, + appearance: 'textfield' as const, + sx: { + '&::-webkit-outer-spin-button, &::-webkit-inner-spin-button': { + appearance: 'none', + margin: 0 + } + } +}; + +const InvalidPriceInputStyles = { + borderColor: 'red.500', + _hover: { borderColor: 'red.500' }, + _focus: { + borderColor: 'red.500', + boxShadow: '0 0 0 1px var(--chakra-colors-red-500)' + }, + _focusVisible: { + borderColor: 'red.500', + boxShadow: '0 0 0 1px var(--chakra-colors-red-500)' + } +}; + +export const emptyPriceTier = { + minInputTokens: 0, + maxInputTokens: undefined, + inputPrice: undefined, + outputPrice: undefined +}; + +const getOptionalNumber = (value: unknown) => { + if (value === '' || value === null || value === undefined) return undefined; + if (typeof value === 'number') return Number.isFinite(value) ? value : undefined; + + if (typeof value === 'string') { + const trimmedValue = value.trim(); + if (!trimmedValue) return undefined; + const parsedValue = Number(trimmedValue); + return Number.isFinite(parsedValue) ? parsedValue : undefined; + } + + return undefined; +}; + +/** 管理模型计费梯度的动态空行、范围校验和清空行为。 */ +const ModelPriceTiersTable = React.memo(function ModelPriceTiersTable({ + control, + register, + getValues, + setValue +}: { + control: Control; + register: UseFormRegister; + getValues: UseFormGetValues; + setValue: UseFormSetValue; +}) { + const { t } = useClientTranslation('config_model'); + const [invalidMaxInputMap, setInvalidMaxInputMap] = useState>({}); + const { + fields: priceTierFields, + append: appendPriceTier, + remove: removePriceTier + } = useFieldArray({ control, name: 'priceTiers' as never }); + const watchedPriceTiers = useWatch({ control, name: 'priceTiers' }); + + const ensureNextEmptyPriceTier = useCallback( + (index: number, value?: number, inputEl?: HTMLInputElement | null, lowerBound?: number) => { + if (typeof value !== 'number' || Number.isNaN(value)) return; + if (typeof lowerBound === 'number' && value <= lowerBound) return; + + const tiers = getValues('priceTiers') ?? []; + if (index !== tiers.length - 1) return; + + appendPriceTier(emptyPriceTier as never); + if (!inputEl) return; + + const selectionStart = inputEl.selectionStart; + const selectionEnd = inputEl.selectionEnd; + requestAnimationFrame(() => { + inputEl.focus(); + if (selectionStart !== null && selectionEnd !== null) { + inputEl.setSelectionRange(selectionStart, selectionEnd); + } + }); + }, + [appendPriceTier, getValues] + ); + + const clearPriceTier = useCallback( + (index: number) => { + if (priceTierFields.length === 1) { + setValue(`priceTiers.${index}.maxInputTokens` as never, undefined as never, { + shouldDirty: true + }); + setValue(`priceTiers.${index}.inputPrice` as never, undefined as never, { + shouldDirty: true + }); + setValue(`priceTiers.${index}.outputPrice` as never, undefined as never, { + shouldDirty: true + }); + return; + } + removePriceTier(index); + }, + [priceTierFields.length, removePriceTier, setValue] + ); + + return ( + + + + + + + + + + + + {priceTierFields.map((field, index) => { + const currentTier = watchedPriceTiers?.[index]; + const previousTier = watchedPriceTiers?.[index - 1]; + const previousTierMax = + index === 0 + ? 0 + : typeof previousTier?.maxInputTokens === 'number' && + Number.isFinite(previousTier.maxInputTokens) + ? previousTier.maxInputTokens + : 0; + const lowerBound = index === 0 ? 0 : previousTierMax; + const isLastTier = index === priceTierFields.length - 1; + const isInvalidMaxInput = + invalidMaxInputMap[index] ?? + (typeof currentTier?.maxInputTokens === 'number' && + currentTier.maxInputTokens <= lowerBound); + const isEmptyAction = + !currentTier?.maxInputTokens && !currentTier?.inputPrice && !currentTier?.outputPrice; + const maxInputTokensRegister = register(`priceTiers.${index}.maxInputTokens`, { + min: lowerBound, + setValueAs: getOptionalNumber + }); + const inputPriceRegister = register(`priceTiers.${index}.inputPrice`, { + setValueAs: getOptionalNumber + }); + const outputPriceRegister = register(`priceTiers.${index}.outputPrice`, { + setValueAs: getOptionalNumber + }); + + return ( + + + + + + + ); + })} + +
+ {t('common:model.price_tier_range')} + + {t('common:model.input_price')} + + {t('common:model.output_price')} + + {t('config_model:model.action')} +
+ + + {` < ${t('common:Input')} <= `} + { + maxInputTokensRegister.onChange(event); + const nextValue = getOptionalNumber(event.target.value); + setInvalidMaxInputMap((state) => ({ + ...state, + [index]: typeof nextValue === 'number' ? nextValue <= lowerBound : false + })); + }} + onBlur={(event) => { + maxInputTokensRegister.onBlur(event); + const nextValue = getOptionalNumber(event.target.value); + setInvalidMaxInputMap((state) => ({ + ...state, + [index]: typeof nextValue === 'number' ? nextValue <= lowerBound : false + })); + ensureNextEmptyPriceTier(index, nextValue, event.currentTarget, lowerBound); + }} + isInvalid={isInvalidMaxInput} + {...(isInvalidMaxInput ? InvalidPriceInputStyles : {})} + /> + + + + + + {t('common:support.wallet.subscription.point')} + + + + + + + {t('common:support.wallet.subscription.point')} + + + + +
+
+ ); +}); + +export default ModelPriceTiersTable; diff --git a/projects/app/src/pageComponents/account/model/ModelTabHeader.tsx b/projects/app/src/pageComponents/model/ModelTabHeader.tsx similarity index 100% rename from projects/app/src/pageComponents/account/model/ModelTabHeader.tsx rename to projects/app/src/pageComponents/model/ModelTabHeader.tsx diff --git a/projects/app/src/pageComponents/model/submit.ts b/projects/app/src/pageComponents/model/submit.ts new file mode 100644 index 000000000000..dabf39dfb571 --- /dev/null +++ b/projects/app/src/pageComponents/model/submit.ts @@ -0,0 +1,55 @@ +import type { + SystemModelDataType, + SystemModelDocumentDataType +} from '@fastgpt/global/core/ai/model.schema'; +import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import { postSystemModel, putSystemModel } from '@/web/core/ai/config'; +import { UpdateSystemModelBodySchema } from '@fastgpt/global/openapi/admin/core/ai/model/api'; +import { normalizeModelPricingForSave } from '@fastgpt/global/core/ai/pricing'; + +/** 保留完整未保存草稿,仅规范测试接口要求的模型标识和回退别名。 */ +export const prepareDraftSystemModelForTest = ( + modelData: SystemModelDocumentDataType +): SystemModelDocumentDataType => { + const model = modelData.model.trim(); + const draft = { ...modelData, model, name: modelData.name?.trim() || model }; + if (draft.type === ModelTypeEnum.llm) { + draft.config = { + ...draft.config, + quoteMaxToken: + draft.config.quoteMaxToken == null || Number.isNaN(draft.config.quoteMaxToken) + ? Math.floor(draft.config.maxContext * 0.8) + : draft.config.quoteMaxToken + }; + } + return draft; +}; + +/** 新建模型只调用创建接口,入参类型从结构上排除 modelId。 */ +export const submitCreatedSystemModel = ({ + modelData, + channelIds +}: { + modelData: SystemModelDocumentDataType; + channelIds: number[]; +}) => postSystemModel({ modelData: normalizeModelPricingForSave(modelData), channelIds }); + +/** 编辑参数与渠道作为同一次请求预检,服务端统一编排外部绑定和模型写入。 */ +export const submitUpdatedSystemModel = async ({ + modelId, + modelData, + channelIds +}: { + modelId: SystemModelDataType['modelId']; + modelData: SystemModelDocumentDataType; + channelIds: number[]; +}) => { + const { model: _model, ...editableModelData } = normalizeModelPricingForSave(modelData); + + const input = UpdateSystemModelBodySchema.parse({ + modelId, + modelData: editableModelData, + channelIds + }); + await putSystemModel(input); +}; diff --git a/projects/app/src/pageComponents/model/useModelChannelTest.ts b/projects/app/src/pageComponents/model/useModelChannelTest.ts new file mode 100644 index 000000000000..6f103988dbfb --- /dev/null +++ b/projects/app/src/pageComponents/model/useModelChannelTest.ts @@ -0,0 +1,77 @@ +import { getTestModel, postTestDraftModel } from '@/web/core/ai/config'; +import { getErrText } from '@fastgpt/global/common/error/utils'; +import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import type { SystemModelDocumentDataType } from '@fastgpt/global/core/ai/model.schema'; +import type { AdminModelChannel } from '@fastgpt/global/openapi/admin/core/ai/model/api'; +import { useToast } from '@fastgpt/web/hooks/useToast'; +import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; +import { useRef, useState } from 'react'; +import { prepareDraftSystemModelForTest } from './submit'; + +export type ModelChannelTestTarget = + | { source: 'draft'; getModelData: () => SystemModelDocumentDataType | undefined } + | { source: 'installed'; modelId: string; model: string }; + +/** + * 渠道测试统一读取点击时的草稿,或显式测试已保存实例;两种来源不能隐式回退。 + * 同一渠道只允许一个在途请求,不同渠道独立反馈;不会保存草稿或更新渠道关联。 + */ +export const useModelChannelTest = ({ + target, + channels +}: { + target?: ModelChannelTestTarget; + channels: Pick[]; +}) => { + const { t } = useClientTranslation('config_model'); + const { toast } = useToast(); + const inFlight = useRef(new Set()); + const [testingChannelIds, setTestingChannelIds] = useState>(new Set()); + + const testModelChannel = async (channelId: number) => { + if (!target || inFlight.current.has(channelId)) return; + const draft = target.source === 'draft' ? target.getModelData() : undefined; + const modelData = draft ? prepareDraftSystemModelForTest(draft) : undefined; + const model = target.source === 'installed' ? target.model : modelData?.model; + if (!model) { + toast({ status: 'warning', title: t('config_model:fill_model_id_before_test') }); + return; + } + if ( + modelData?.type === ModelTypeEnum.tts && + (!Array.isArray(modelData.config.voices) || modelData.config.voices.length === 0) + ) { + toast({ status: 'warning', title: t('config_model:fill_voice_before_test') }); + return; + } + + const channel = channels.find((item) => item.id === channelId)?.name ?? ''; + inFlight.current.add(channelId); + setTestingChannelIds(new Set(inFlight.current)); + try { + if (target.source === 'installed') { + await getTestModel({ modelId: target.modelId, channelId }); + } else if (modelData) { + await postTestDraftModel({ modelData, channelId }); + } + toast({ + status: 'success', + title: t('config_model:model_channel_test_success', { model, channel }) + }); + } catch (error) { + toast({ + status: 'error', + title: t('config_model:model_channel_test_failed', { + model, + channel, + reason: getErrText(error) + }) + }); + } finally { + inFlight.current.delete(channelId); + setTestingChannelIds(new Set(inFlight.current)); + } + }; + + return { testingChannelIds, testModelChannel }; +}; diff --git a/projects/app/src/pageComponents/model/useModelEditWorkflow.ts b/projects/app/src/pageComponents/model/useModelEditWorkflow.ts new file mode 100644 index 000000000000..330804d2535c --- /dev/null +++ b/projects/app/src/pageComponents/model/useModelEditWorkflow.ts @@ -0,0 +1,137 @@ +import { getSystemModelDetail } from '@/web/core/ai/config'; +import type { SystemModelDocumentDataType } from '@fastgpt/global/core/ai/model.schema'; +import type { AdminSystemModelListItem } from '@fastgpt/global/openapi/admin/core/ai/model/api'; +import { useConfirm } from '@fastgpt/web/hooks/useConfirm'; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; +import { useRouter } from 'next/router'; +import { useEffect, useRef, useState } from 'react'; +import type { ModelConfigFormGetValues } from './ModelConfigForm'; +import { submitUpdatedSystemModel } from './submit'; +import { useModelChannelTest } from './useModelChannelTest'; + +export type ModelEditWorkflowProps = { + model: AdminSystemModelListItem; + onSuccess: () => void | Promise; + onClose: () => void; +}; + +/** 编辑工作流统一持有详情、渠道草稿、测试和离开确认;UI 仅消费状态与操作。 */ +export const useModelEditWorkflow = ({ model, onSuccess, onClose }: ModelEditWorkflowProps) => { + const { t } = useClientTranslation('config_model'); + const router = useRouter(); + const [submitting, setSubmitting] = useState(false); + const modelFormGetValuesRef = useRef(null); + const [isFormDirty, setIsFormDirty] = useState(false); + const [selectedChannelIds, setSelectedChannelIds] = useState>(new Set()); + const [persistedChannelIds, setPersistedChannelIds] = useState>(new Set()); + const [showCreateChannel, setShowCreateChannel] = useState(false); + const [showAssociateChannel, setShowAssociateChannel] = useState(false); + const hasInitializedChannels = useRef(false); + const { openConfirm: openLeaveConfirm, ConfirmModal: LeaveConfirmModal } = useConfirm(); + + // 详情一次返回模型参数和渠道展示数据,避免编辑弹窗依赖列表快照或再次请求渠道接口。 + const { + data: detail, + runAsync: refreshDetail, + loading: loadingModelData + } = useRequest(() => getSystemModelDetail(model.modelId), { manual: false }); + const { testingChannelIds, testModelChannel } = useModelChannelTest({ + target: { source: 'draft', getModelData: () => modelFormGetValuesRef.current?.() }, + channels: detail?.channels ?? [] + }); + + useEffect(() => { + if (!detail || hasInitializedChannels.current) return; + + const associatedChannelIds = new Set( + detail.channels.filter((channel) => channel.isAssociated).map((channel) => channel.id) + ); + setSelectedChannelIds(associatedChannelIds); + setPersistedChannelIds(associatedChannelIds); + hasInitializedChannels.current = true; + }, [detail]); + + const hasUnsavedChannelChanges = + selectedChannelIds.size !== persistedChannelIds.size || + [...selectedChannelIds].some((channelId) => !persistedChannelIds.has(channelId)); + + const submitModel = async (data: SystemModelDocumentDataType) => { + await submitUpdatedSystemModel({ + modelId: model.modelId, + modelData: data, + channelIds: [...selectedChannelIds] + }); + }; + + /** 新建渠道会立即写入 AI Proxy;使用创建响应中的精确 ID 合并选择,避免列表差集误判。 */ + const refreshAfterChannelCreated = async (createdChannelId?: number) => { + if (createdChannelId !== undefined) { + setSelectedChannelIds((current) => new Set([...current, createdChannelId])); + } + + // 渠道已经创建成功,详情或列表刷新失败不能把写入结果误报为创建失败。 + await Promise.all([ + refreshDetail() + .then((refreshedDetail) => { + setPersistedChannelIds( + new Set( + refreshedDetail.channels + .filter((channel) => channel.isAssociated) + .map((channel) => channel.id) + ) + ); + }) + .catch(() => {}), + Promise.resolve(onSuccess()).catch(() => {}) + ]); + }; + + const navigateToChannelManagement = () => { + onClose(); + void router.push( + { + pathname: router.pathname, + query: { ...router.query, modelTab: 'channel' } + }, + undefined, + { shallow: true } + ); + }; + + const goToChannelManagement = () => { + if (!isFormDirty && !hasUnsavedChannelChanges) { + navigateToChannelManagement(); + return; + } + + openLeaveConfirm({ + title: t('config_model:confirm_go_to_channel_management'), + customContent: t('config_model:unsaved_model_config_leave_tip'), + confirmButtonVariant: 'dangerFill', + onConfirm: navigateToChannelManagement + })(); + }; + + return { + t, + detail, + loadingModelData, + submitting, + setSubmitting, + modelFormGetValuesRef, + selectedChannelIds, + setSelectedChannelIds, + showCreateChannel, + setShowCreateChannel, + showAssociateChannel, + setShowAssociateChannel, + goToChannelManagement, + testModelChannel, + testingChannelIds, + setIsFormDirty, + submitModel, + refreshAfterChannelCreated, + LeaveConfirmModal + }; +}; diff --git a/projects/app/src/pages/api/admin/settings/model/channel/replace.ts b/projects/app/src/pages/api/admin/settings/model/channel/replace.ts new file mode 100644 index 000000000000..2355770f4b21 --- /dev/null +++ b/projects/app/src/pages/api/admin/settings/model/channel/replace.ts @@ -0,0 +1,40 @@ +import type { ApiRequestProps } from '@fastgpt/next/type'; +import { NextAPI } from '@/service/middleware/entry'; +import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; +import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { MongoAIModel } from '@fastgpt/service/core/ai/config/schema'; +import { replaceModelInAIProxyChannels } from '@fastgpt/service/thirdProvider/aiproxy/channel'; +import { ModelScopeEnum } from '@fastgpt/global/core/ai/constants'; +import { ModelErrEnum } from '@fastgpt/global/common/error/code/model'; +import { + ReplaceSystemModelChannelsBodySchema, + type ReplaceSystemModelChannelsBody +} from '@fastgpt/global/openapi/admin/core/ai/model/api'; + +/** + * 以提交的完整渠道集合替换模型绑定。 + * + * 模型标识创建后不可变,因此只需按稳定 modelId 查询当前标识并替换其渠道集合。 + */ +async function handler(req: ApiRequestProps): Promise { + await authSystemAdmin({ req }); + const { modelId, channelIds } = parseApiInput({ + req, + bodySchema: ReplaceSystemModelChannelsBodySchema + }).body; + + const existingModel = await MongoAIModel.findOne({ + _id: modelId, + scope: ModelScopeEnum.system + }) + .select({ model: 1 }) + .lean(); + if (!existingModel) return Promise.reject(ModelErrEnum.unExist); + + await replaceModelInAIProxyChannels({ + model: existingModel.model, + channelIds + }); +} + +export default NextAPI(handler); diff --git a/projects/app/src/pages/api/admin/settings/model/create.ts b/projects/app/src/pages/api/admin/settings/model/create.ts index bbbae8ad9fe0..a165c40b2b5f 100644 --- a/projects/app/src/pages/api/admin/settings/model/create.ts +++ b/projects/app/src/pages/api/admin/settings/model/create.ts @@ -1,17 +1,12 @@ import type { ApiRequestProps } from '@fastgpt/next/type'; import { NextAPI } from '@/service/middleware/entry'; import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; -import { MongoAIModel } from '@fastgpt/service/core/ai/config/schema'; -import { - assertSystemModelTypesMatchPluginTemplates, - refreshModelTemplates, - updatedReloadSystemModel -} from '@fastgpt/service/core/ai/config/utils'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { createSystemModel } from '@/service/core/ai/model/service'; import { CreateSystemModelBodySchema, - CreateSystemModelResponseSchema, type CreateSystemModelBody, + CreateSystemModelResponseSchema, type CreateSystemModelResponse } from '@fastgpt/global/openapi/admin/core/ai/model/api'; @@ -19,15 +14,12 @@ async function handler( req: ApiRequestProps ): Promise { await authSystemAdmin({ req }); - const { modelData } = parseApiInput({ req, bodySchema: CreateSystemModelBodySchema }).body; - - // 插件不可用时不提交数据库更新,保持数据库与当前运行时 active 集合一致。 - const pluginDocuments = await refreshModelTemplates(); - assertSystemModelTypesMatchPluginTemplates({ models: [modelData], pluginDocuments }); - const model = await MongoAIModel.create(modelData); - await updatedReloadSystemModel({ pluginDocuments }); + const { modelData, channelIds } = parseApiInput({ + req, + bodySchema: CreateSystemModelBodySchema + }).body; - return CreateSystemModelResponseSchema.parse({ modelId: String(model._id) }); + return CreateSystemModelResponseSchema.parse(await createSystemModel({ modelData, channelIds })); } export default NextAPI(handler); diff --git a/projects/app/src/pages/api/admin/settings/model/createFromTemplates.ts b/projects/app/src/pages/api/admin/settings/model/createFromTemplates.ts new file mode 100644 index 000000000000..11d2442a87cc --- /dev/null +++ b/projects/app/src/pages/api/admin/settings/model/createFromTemplates.ts @@ -0,0 +1,27 @@ +import type { ApiRequestProps } from '@fastgpt/next/type'; +import { NextAPI } from '@/service/middleware/entry'; +import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; +import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { createSystemModelsFromTemplates } from '@/service/core/ai/model/service'; +import { + CreateSystemModelsFromTemplatesBodySchema, + type CreateSystemModelsFromTemplatesBody, + CreateSystemModelsFromTemplatesResponseSchema, + type CreateSystemModelsFromTemplatesResponse +} from '@fastgpt/global/openapi/admin/core/ai/model/api'; + +async function handler( + req: ApiRequestProps +): Promise { + await authSystemAdmin({ req }); + const { templates, channelIds } = parseApiInput({ + req, + bodySchema: CreateSystemModelsFromTemplatesBodySchema + }).body; + + return CreateSystemModelsFromTemplatesResponseSchema.parse( + await createSystemModelsFromTemplates({ templates, channelIds }) + ); +} + +export default NextAPI(handler); diff --git a/projects/app/src/pages/api/admin/settings/model/delete.ts b/projects/app/src/pages/api/admin/settings/model/delete.ts index f281461c370a..c10dbf4c7ca6 100644 --- a/projects/app/src/pages/api/admin/settings/model/delete.ts +++ b/projects/app/src/pages/api/admin/settings/model/delete.ts @@ -1,52 +1,28 @@ +import { deleteSystemModels } from '@/service/core/ai/model/service'; import type { ApiRequestProps } from '@fastgpt/next/type'; import { NextAPI } from '@/service/middleware/entry'; -import { MongoAIModel } from '@fastgpt/service/core/ai/config/schema'; import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; -import { findModelData } from '@fastgpt/service/core/ai/model'; -import { - refreshModelTemplates, - updatedReloadSystemModel -} from '@fastgpt/service/core/ai/config/utils'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; import { AdminSystemModelReferenceSchema, - type AdminSystemModelReference + DeleteSystemModelsBodySchema, + type AdminSystemModelReference, + type DeleteSystemModelsBody } from '@fastgpt/global/openapi/admin/core/ai/model/api'; -import { ModelErrEnum } from '@fastgpt/global/common/error/code/model'; -import { ModelScopeEnum } from '@fastgpt/global/core/ai/constants'; -import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; -import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema'; -import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant'; async function handler( - req: ApiRequestProps, AdminSystemModelReference> + req: ApiRequestProps ): Promise { await authSystemAdmin({ req }); - const { modelId } = parseApiInput({ req, querySchema: AdminSystemModelReferenceSchema }).query; - const pluginDocuments = await refreshModelTemplates(); - const modelData = findModelData({ modelId }); - - if (!modelData) return Promise.reject(ModelErrEnum.unExist); - if ( - pluginDocuments.some( - (model) => model.model === modelData.model && model.type === modelData.type - ) - ) { - return Promise.reject('Plugin model cannot be deleted'); - } - - await mongoSessionRun(async (session) => { - await MongoAIModel.deleteOne({ _id: modelId, scope: ModelScopeEnum.system }, { session }); - await MongoResourcePermission.deleteMany( - { - resourceType: PerResourceTypeEnum.model, - resourceId: modelId - }, - { session } - ); - }); + const modelIds = (() => { + if (Array.isArray(req.body?.modelIds)) { + return parseApiInput({ req, bodySchema: DeleteSystemModelsBodySchema }).body.modelIds; + } + const { modelId } = parseApiInput({ req, querySchema: AdminSystemModelReferenceSchema }).query; + return [modelId]; + })(); - await updatedReloadSystemModel({ pluginDocuments }); + return deleteSystemModels({ modelIds }); } export default NextAPI(handler); diff --git a/projects/app/src/pages/api/admin/settings/model/detail.ts b/projects/app/src/pages/api/admin/settings/model/detail.ts index 640f14a20d64..72ac848c0203 100644 --- a/projects/app/src/pages/api/admin/settings/model/detail.ts +++ b/projects/app/src/pages/api/admin/settings/model/detail.ts @@ -1,7 +1,9 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; + import type { ApiRequestProps } from '@fastgpt/next/type'; import { NextAPI } from '@/service/middleware/entry'; import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; -import { findModelData } from '@fastgpt/service/core/ai/model'; + import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; import { AdminSystemModelReferenceSchema, @@ -10,6 +12,7 @@ import { type GetAdminSystemModelDetailResponse } from '@fastgpt/global/openapi/admin/core/ai/model/api'; import { ModelErrEnum } from '@fastgpt/global/common/error/code/model'; +import { getAdminAIProxyChannelItems } from '@fastgpt/service/thirdProvider/aiproxy/channel'; async function handler( req: ApiRequestProps, AdminSystemModelReference> @@ -17,11 +20,20 @@ async function handler( await authSystemAdmin({ req }); const reference = parseApiInput({ req, querySchema: AdminSystemModelReferenceSchema }).query; - const modelItem = findModelData(reference); + const modelHandle = await getModelHandle(); + const modelItem = modelHandle.findModelData(reference); if (!modelItem) return Promise.reject(ModelErrEnum.unExist); - // 管理员详情用于编辑完整配置;仅列表接口脱敏,避免详情 round-trip 丢失鉴权和类型配置。 - return GetAdminSystemModelDetailResponseSchema.parse(modelItem); + const channelItems = await getAdminAIProxyChannelItems(); + + // 详情一次返回完整参数和渠道关系,避免编辑弹窗依赖列表快照或再次查询渠道。 + return GetAdminSystemModelDetailResponseSchema.parse({ + model: modelItem, + channels: channelItems.map((channel) => ({ + ...channel.summary, + isAssociated: channel.models.includes(modelItem.model) + })) + }); } export default NextAPI(handler); diff --git a/projects/app/src/pages/api/admin/settings/model/getConfigJson.ts b/projects/app/src/pages/api/admin/settings/model/getConfigJson.ts index 273527d24924..2c06acb8eca7 100644 --- a/projects/app/src/pages/api/admin/settings/model/getConfigJson.ts +++ b/projects/app/src/pages/api/admin/settings/model/getConfigJson.ts @@ -12,6 +12,7 @@ import { async function handler(req: ApiRequestProps): Promise { await authSystemAdmin({ req }); + const models = await MongoAIModel.find({ scope: ModelScopeEnum.system }).lean(); return GetSystemModelConfigJsonResponseSchema.parse( diff --git a/projects/app/src/pages/api/admin/settings/model/getDefaultConfig.ts b/projects/app/src/pages/api/admin/settings/model/getDefaultConfig.ts deleted file mode 100644 index 856fd826f9b8..000000000000 --- a/projects/app/src/pages/api/admin/settings/model/getDefaultConfig.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ApiRequestProps } from '@fastgpt/next/type'; -import { NextAPI } from '@/service/middleware/entry'; -import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; -import { getSystemModelConfig } from '@fastgpt/service/core/ai/config/utils'; -import { - AdminSystemModelReferenceSchema, - GetAdminSystemModelDefaultConfigResponseSchema, - type AdminSystemModelReference, - type GetAdminSystemModelDefaultConfigResponse -} from '@fastgpt/global/openapi/admin/core/ai/model/api'; -import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; - -async function handler( - req: ApiRequestProps, AdminSystemModelReference> -): Promise { - await authSystemAdmin({ req }); - const { modelId } = parseApiInput({ req, querySchema: AdminSystemModelReferenceSchema }).query; - - return GetAdminSystemModelDefaultConfigResponseSchema.parse(await getSystemModelConfig(modelId)); -} - -export default NextAPI(handler); diff --git a/projects/app/src/pages/api/admin/settings/model/list.ts b/projects/app/src/pages/api/admin/settings/model/list.ts index 6fea4142ce20..290af72c9252 100644 --- a/projects/app/src/pages/api/admin/settings/model/list.ts +++ b/projects/app/src/pages/api/admin/settings/model/list.ts @@ -1,3 +1,6 @@ +import { getModelProviderMetadata } from '@fastgpt/service/core/app/provider/controller'; +import { getModelHandle } from '@fastgpt/service/core/ai/model'; + import type { ApiRequestProps } from '@fastgpt/next/type'; import { NextAPI } from '@/service/middleware/entry'; import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; @@ -6,15 +9,31 @@ import { type GetAdminSystemModelListResponse } from '@fastgpt/global/openapi/admin/core/ai/model/api'; import { desensitizeSystemModel } from '@fastgpt/service/core/ai/config/utils'; +import { getAdminAIProxyChannelItems } from '@fastgpt/service/thirdProvider/aiproxy/channel'; async function handler(req: ApiRequestProps): Promise { await authSystemAdmin({ req }); + const channelItems = await getAdminAIProxyChannelItems(); + const channelsByModel = new Map(); + for (const channel of channelItems) { + // 每条关系只遍历一次,同渠道重复 model 不应造成重复展示。 + for (const model of new Set(channel.models)) { + const summaries = channelsByModel.get(model) ?? []; + summaries.push(channel.summary); + channelsByModel.set(model, summaries); + } + } + const modelHandle = await getModelHandle(); return GetAdminSystemModelListResponseSchema.parse({ - models: global.systemModelList.map(desensitizeSystemModel), - providers: global.ModelProviderRawCache, - defaultModelIds: global.systemConfiguredDefaultModelIds, - aiproxyChannels: global.aiproxyChannelsCache + models: modelHandle.getAllModels().map((model) => ({ + ...desensitizeSystemModel(model), + channels: channelsByModel.get(model.model) ?? [] + })), + channels: channelItems.map((channel) => channel.summary), + providers: getModelProviderMetadata().providers, + defaultModelIds: modelHandle.configuredDefaultModelIds, + aiproxyChannels: getModelProviderMetadata().aiproxyChannels }); } diff --git a/projects/app/src/pages/api/admin/settings/model/templates.ts b/projects/app/src/pages/api/admin/settings/model/templates.ts new file mode 100644 index 000000000000..e92cce8b5f18 --- /dev/null +++ b/projects/app/src/pages/api/admin/settings/model/templates.ts @@ -0,0 +1,25 @@ +import { getModelProviderMetadata } from '@fastgpt/service/core/app/provider/controller'; +import type { ApiRequestProps } from '@fastgpt/next/type'; +import { NextAPI } from '@/service/middleware/entry'; +import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; +import { preloadModelProviders } from '@fastgpt/service/core/app/provider/controller'; +import { refreshModelTemplates } from '@fastgpt/service/core/ai/config/utils'; +import { + GetAdminModelTemplatesResponseSchema, + type GetAdminModelTemplatesResponse +} from '@fastgpt/global/openapi/admin/core/ai/model/api'; + +/** 实时返回 Plugin 模型模板;响应不会写入任何运行时或持久化模型缓存。 */ +async function handler(req: ApiRequestProps): Promise { + await authSystemAdmin({ req }); + + await preloadModelProviders(); + const models = await refreshModelTemplates(); + + return GetAdminModelTemplatesResponseSchema.parse({ + models, + providers: getModelProviderMetadata().providers + }); +} + +export default NextAPI(handler); diff --git a/projects/app/src/pages/api/admin/settings/model/test.ts b/projects/app/src/pages/api/admin/settings/model/test.ts index bea406ab5735..02c66e3f24dc 100644 --- a/projects/app/src/pages/api/admin/settings/model/test.ts +++ b/projects/app/src/pages/api/admin/settings/model/test.ts @@ -1,12 +1,15 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; + import type { ApiRequestProps } from '@fastgpt/next/type'; import { NextAPI } from '@/service/middleware/entry'; import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; -import { findModelData } from '@fastgpt/service/core/ai/model'; + import { type EmbeddingSystemModelDataType, type LLMSystemModelDataType, type RerankSystemModelDataType, type STTSystemModelDataType, + type SystemModelDataType, type TTSSystemModelDataType } from '@fastgpt/global/core/ai/model.schema'; import { getAIApi } from '@fastgpt/service/core/ai/config'; @@ -20,60 +23,95 @@ import { createLLMResponse } from '@fastgpt/service/core/ai/llm/request'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; import { TestAdminSystemModelQuerySchema, + TestDraftAdminSystemModelBodySchema, TestAdminSystemModelResponseSchema, + type TestDraftAdminSystemModelBody, type TestAdminSystemModelQuery, type TestAdminSystemModelResponse } from '@fastgpt/global/openapi/admin/core/ai/model/api'; import { ModelErrEnum } from '@fastgpt/global/common/error/code/model'; +import { UserError } from '@fastgpt/global/common/error/utils'; +import { withTemporaryModelChannelBinding } from '@fastgpt/service/thirdProvider/aiproxy/channel'; const logger = getLogger(LogCategories.MODULE.AI.MODEL); async function handler( - req: ApiRequestProps, TestAdminSystemModelQuery> + req: ApiRequestProps ): Promise { const { teamId } = await authSystemAdmin({ req }); - const { modelId, channelId } = parseApiInput({ - req, - querySchema: TestAdminSystemModelQuerySchema - }).query; - const modelData = findModelData({ modelId }); - if (!modelData) return Promise.reject(ModelErrEnum.unExist); - - if (channelId) { - delete modelData.requestUrl; - delete modelData.requestAuth; - } + + const { modelData, channelId } = await (async () => { + if (req.method === 'POST') { + const { modelData: draftModelData, channelId } = parseApiInput({ + req, + bodySchema: TestDraftAdminSystemModelBodySchema + }).body; + + return { + modelData: { + ...draftModelData, + modelId: 'draft-model-test', + requestUrl: undefined, + requestAuth: undefined + } as SystemModelDataType, + channelId + }; + } + + const { modelId, channelId } = parseApiInput({ + req, + querySchema: TestAdminSystemModelQuerySchema + }).query; + const modelHandle = await getModelHandle(); + const installedModel = modelHandle.findModelData({ modelId }); + if (!installedModel) throw ModelErrEnum.unExist; + + return { + // 显式渠道只覆盖本次测试的连接配置,不能修改全局运行时模型缓存。 + modelData: channelId + ? { ...installedModel, requestUrl: undefined, requestAuth: undefined } + : installedModel, + channelId + }; + })(); const headers: Record = channelId ? { 'Aiproxy-Channel': String(channelId) } : {}; - logger.debug('Test model', modelData); - - if (modelData.type === 'llm') { - return TestAdminSystemModelResponseSchema.parse( - await testLLMModel({ model: modelData, headers, teamId }) - ); - } - if (modelData.type === 'embedding') { - return TestAdminSystemModelResponseSchema.parse( - await testEmbeddingModel({ model: modelData, headers }) - ); - } - if (modelData.type === 'tts') { - return TestAdminSystemModelResponseSchema.parse( - await testTTSModel({ model: modelData, headers }) - ); - } - if (modelData.type === 'stt') { - return TestAdminSystemModelResponseSchema.parse( - await testSTTModel({ model: modelData, headers }) - ); - } - if (modelData.type === 'rerank') { - return TestAdminSystemModelResponseSchema.parse( - await testReRankModel({ model: modelData, headers }) - ); - } - - return Promise.reject('Model type not supported'); + logger.debug('Test model', { model: modelData.model, type: modelData.type, channelId }); + + const runTest = async () => { + if (modelData.type === 'llm') { + return TestAdminSystemModelResponseSchema.parse( + await testLLMModel({ model: modelData, headers, teamId }) + ); + } + if (modelData.type === 'embedding') { + return TestAdminSystemModelResponseSchema.parse( + await testEmbeddingModel({ model: modelData, headers }) + ); + } + if (modelData.type === 'tts') { + return TestAdminSystemModelResponseSchema.parse( + await testTTSModel({ model: modelData, headers }) + ); + } + if (modelData.type === 'stt') { + return TestAdminSystemModelResponseSchema.parse( + await testSTTModel({ model: modelData, headers }) + ); + } + if (modelData.type === 'rerank') { + return TestAdminSystemModelResponseSchema.parse( + await testReRankModel({ model: modelData, headers }) + ); + } + + return Promise.reject('Model type not supported'); + }; + + // 草稿尚未持久化,测试时临时加入 AI Proxy 的目标渠道,结束后恢复原绑定。 + return req.method === 'POST' && channelId + ? withTemporaryModelChannelBinding({ model: modelData.model, channelId, run: runTest }) + : runTest(); } export default NextAPI(handler); @@ -122,11 +160,14 @@ const testTTSModel = async ({ model: TTSSystemModelDataType; headers: Record; }) => { + const voice = model.config.voices[0]?.value; + if (!voice) throw new UserError('TTS model test requires at least one voice'); + const { ai } = getAIApi({ timeout: 10000 }); await ai.audio.speech.create( { model: model.model, - voice: model.config.voices[0]?.value as any, + voice: voice as any, input: 'Hi', response_format: 'mp3', speed: 1 diff --git a/projects/app/src/pages/api/admin/settings/model/update.ts b/projects/app/src/pages/api/admin/settings/model/update.ts index 4f4ffae72fb4..1888c4e37ac8 100644 --- a/projects/app/src/pages/api/admin/settings/model/update.ts +++ b/projects/app/src/pages/api/admin/settings/model/update.ts @@ -1,37 +1,21 @@ import type { ApiRequestProps } from '@fastgpt/next/type'; import { NextAPI } from '@/service/middleware/entry'; import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; -import { MongoAIModel } from '@fastgpt/service/core/ai/config/schema'; -import { - assertSystemModelTypesMatchPluginTemplates, - refreshModelTemplates, - updatedReloadSystemModel -} from '@fastgpt/service/core/ai/config/utils'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; import { UpdateSystemModelBodySchema, type UpdateSystemModelBody } from '@fastgpt/global/openapi/admin/core/ai/model/api'; -import { ModelErrEnum } from '@fastgpt/global/common/error/code/model'; -import { ModelScopeEnum } from '@fastgpt/global/core/ai/constants'; +import { updateSystemModel } from '@/service/core/ai/model/service'; async function handler(req: ApiRequestProps): Promise { await authSystemAdmin({ req }); - const { modelId, modelData } = parseApiInput({ + const input = parseApiInput({ req, bodySchema: UpdateSystemModelBodySchema }).body; - // 插件不可用时不提交数据库更新,保持数据库与当前运行时 active 集合一致。 - const pluginDocuments = await refreshModelTemplates(); - assertSystemModelTypesMatchPluginTemplates({ models: [modelData], pluginDocuments }); - const result = await MongoAIModel.updateOne( - { _id: modelId, scope: ModelScopeEnum.system }, - { $set: modelData } - ); - if (result.matchedCount !== 1) return Promise.reject(ModelErrEnum.unExist); - - await updatedReloadSystemModel({ pluginDocuments }); + await updateSystemModel(input); } export default NextAPI(handler); diff --git a/projects/app/src/pages/api/admin/settings/model/updateDefault.ts b/projects/app/src/pages/api/admin/settings/model/updateDefault.ts index 8d404835cdb8..8d58449239c6 100644 --- a/projects/app/src/pages/api/admin/settings/model/updateDefault.ts +++ b/projects/app/src/pages/api/admin/settings/model/updateDefault.ts @@ -1,100 +1,17 @@ import type { ApiRequestProps } from '@fastgpt/next/type'; import { NextAPI } from '@/service/middleware/entry'; -import { MongoAIModel } from '@fastgpt/service/core/ai/config/schema'; -import { - refreshModelTemplates, - updatedReloadSystemModel -} from '@fastgpt/service/core/ai/config/utils'; import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { updateSystemDefaultModels } from '@/service/core/ai/model/service'; import { UpdateDefaultModelsBodySchema, type UpdateDefaultModelsBody } from '@fastgpt/global/openapi/admin/core/ai/model/api'; -import { ModelScopeEnum, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; -import { assertModelAvailable } from '@fastgpt/service/core/ai/model'; -import { upsertSystemDefaultModelIds } from '@fastgpt/service/core/ai/defaultModel/entity'; -/** - * 更新系统默认模型。所有非空引用会在写入系统作用域配置前一次性校验,避免错误 ID、禁用模型、 - * 类型不匹配或不支持视觉的模型让默认配置进入部分更新状态。 - */ async function handler(req: ApiRequestProps): Promise { await authSystemAdmin({ req }); const defaults = parseApiInput({ req, bodySchema: UpdateDefaultModelsBodySchema }).body; - const defaultFields = [ - { - modelId: defaults[ModelTypeEnum.llm], - expectedType: ModelTypeEnum.llm - }, - { - modelId: defaults[ModelTypeEnum.embedding], - expectedType: ModelTypeEnum.embedding - }, - { - modelId: defaults[ModelTypeEnum.tts], - expectedType: ModelTypeEnum.tts - }, - { - modelId: defaults[ModelTypeEnum.stt], - expectedType: ModelTypeEnum.stt - }, - { - modelId: defaults[ModelTypeEnum.rerank], - expectedType: ModelTypeEnum.rerank - }, - { - modelId: defaults.datasetTextLLMModelId, - expectedType: ModelTypeEnum.llm - }, - { - modelId: defaults.datasetImageLLMModelId, - expectedType: ModelTypeEnum.llm, - requiresVision: true - }, - { - modelId: defaults.chatTitleLLMModelId, - expectedType: ModelTypeEnum.llm - } - ].filter((item): item is typeof item & { modelId: string } => typeof item.modelId === 'string'); - - if (defaultFields.length > 0) { - // 全表读取后按 String(_id) 精确匹配,统一处理 API 层已校验的 ObjectId 字符串。 - const modelMap = new Map( - ( - await MongoAIModel.find( - { scope: ModelScopeEnum.system }, - '_id name model type isActive config.vision' - ).lean() - ).map((model) => [String(model._id), model]) - ); - - for (const { modelId, expectedType, requiresVision } of defaultFields) { - assertModelAvailable({ - model: modelMap.get(modelId), - type: expectedType, - vision: requiresVision - }); - } - } - - // 插件不可用时不提交数据库更新,保持数据库与当前运行时默认模型一致。 - const pluginDocuments = await refreshModelTemplates(); - - const configuredDefaultModelIds = { - [ModelTypeEnum.llm]: defaults[ModelTypeEnum.llm], - [ModelTypeEnum.embedding]: defaults[ModelTypeEnum.embedding], - [ModelTypeEnum.tts]: defaults[ModelTypeEnum.tts], - [ModelTypeEnum.stt]: defaults[ModelTypeEnum.stt], - [ModelTypeEnum.rerank]: defaults[ModelTypeEnum.rerank], - datasetTextLLM: defaults.datasetTextLLMModelId, - datasetImageLLM: defaults.datasetImageLLMModelId, - chatTitleLLM: defaults.chatTitleLLMModelId - }; - - await upsertSystemDefaultModelIds(configuredDefaultModelIds); - - await updatedReloadSystemModel({ pluginDocuments }); + return updateSystemDefaultModels(defaults); } export default NextAPI(handler); diff --git a/projects/app/src/pages/api/admin/settings/model/updateStatus.ts b/projects/app/src/pages/api/admin/settings/model/updateStatus.ts new file mode 100644 index 000000000000..28282d219648 --- /dev/null +++ b/projects/app/src/pages/api/admin/settings/model/updateStatus.ts @@ -0,0 +1,21 @@ +import type { ApiRequestProps } from '@fastgpt/next/type'; +import { NextAPI } from '@/service/middleware/entry'; +import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; +import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { + UpdateSystemModelStatusBodySchema, + type UpdateSystemModelStatusBody +} from '@fastgpt/global/openapi/admin/core/ai/model/api'; +import { updateSystemModelStatus } from '@fastgpt/service/core/ai/config/service'; + +async function handler(req: ApiRequestProps): Promise { + await authSystemAdmin({ req }); + const { modelIds, isActive } = parseApiInput({ + req, + bodySchema: UpdateSystemModelStatusBodySchema + }).body; + + await updateSystemModelStatus({ modelIds, isActive }); +} + +export default NextAPI(handler); diff --git a/projects/app/src/pages/api/admin/settings/model/updateWithJson.ts b/projects/app/src/pages/api/admin/settings/model/updateWithJson.ts index 9105305915ef..fa613b79ca7e 100644 --- a/projects/app/src/pages/api/admin/settings/model/updateWithJson.ts +++ b/projects/app/src/pages/api/admin/settings/model/updateWithJson.ts @@ -1,22 +1,12 @@ import type { ApiRequestProps } from '@fastgpt/next/type'; import { NextAPI } from '@/service/middleware/entry'; import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; -import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; -import { MongoAIModel } from '@fastgpt/service/core/ai/config/schema'; -import { - assertSystemModelTypesMatchPluginTemplates, - refreshModelTemplates, - updatedReloadSystemModel -} from '@fastgpt/service/core/ai/config/utils'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { importSystemModels } from '@/service/core/ai/model/service'; import { - ImportedSystemModelSchema, UpdateSystemModelsWithJsonBodySchema, - type ImportedSystemModel, type UpdateSystemModelsWithJsonBody } from '@fastgpt/global/openapi/admin/core/ai/model/api'; -import { ModelScopeEnum } from '@fastgpt/global/core/ai/constants'; -import { UserError } from '@fastgpt/global/common/error/utils'; async function handler(req: ApiRequestProps): Promise { await authSystemAdmin({ req }); @@ -25,66 +15,7 @@ async function handler(req: ApiRequestProps): Pr bodySchema: UpdateSystemModelsWithJsonBodySchema }).body; - const latestRecords = config.filter( - (item) => typeof item.modelId === 'string' && item.modelId.trim().length > 0 - ); - // 非空旧配置全部缺少 modelId 时按“过滤旧数据”处理,不能意外停用全部现有模型。 - if (config.length > 0 && latestRecords.length === 0) return; - - const importedModels = latestRecords.map((record, index) => { - const parsed = ImportedSystemModelSchema.safeParse(record); - if (!parsed.success) { - throw new UserError(`Invalid system model at index ${index}: ${parsed.error.message}`); - } - return parsed.data; - }); - - const assertNoDuplicates = (models: ImportedSystemModel[]) => { - const modelIds = new Set(); - const providerModels = new Set(); - for (const model of models) { - if (modelIds.has(model.modelId)) throw new UserError(`Duplicate modelId: ${model.modelId}`); - if (providerModels.has(model.model)) throw new UserError(`Duplicate model: ${model.model}`); - modelIds.add(model.modelId); - providerModels.add(model.model); - } - }; - assertNoDuplicates(importedModels); - - // 只把当前实例真实存在的 ID 交给 Mongoose,外部系统的任意 string ID 按 model 安装。 - const existingModelIds = new Set( - (await MongoAIModel.find({ scope: ModelScopeEnum.system }, '_id').lean()).map((model) => - String(model._id) - ) - ); - const configuredModels = importedModels.map((model) => model.model); - - // 插件不可用时不提交数据库更新,保持数据库与当前运行时 active 集合一致。 - const pluginDocuments = await refreshModelTemplates(); - assertSystemModelTypesMatchPluginTemplates({ models: importedModels, pluginDocuments }); - await mongoSessionRun(async (session) => { - await MongoAIModel.updateMany( - { scope: ModelScopeEnum.system, model: { $nin: configuredModels } }, - { $set: { isActive: false } }, - { session } - ); - - if (importedModels.length === 0) return; - await MongoAIModel.bulkWrite( - importedModels.map(({ modelId, ...modelData }) => ({ - updateOne: { - filter: existingModelIds.has(modelId) - ? { _id: modelId, scope: ModelScopeEnum.system } - : { scope: ModelScopeEnum.system, model: modelData.model }, - update: { $set: modelData }, - upsert: !existingModelIds.has(modelId) - } - })), - { session } - ); - }); - - await updatedReloadSystemModel({ pluginDocuments }); + return importSystemModels({ config }); } export default NextAPI(handler); diff --git a/projects/app/src/pages/api/aiproxy/[...path].ts b/projects/app/src/pages/api/aiproxy/[...path].ts index b6e8f8d2e9a9..adae467f17d3 100644 --- a/projects/app/src/pages/api/aiproxy/[...path].ts +++ b/projects/app/src/pages/api/aiproxy/[...path].ts @@ -4,6 +4,7 @@ import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; import { buildSameOriginUrl } from '@fastgpt/service/common/security/network'; import { Readable } from 'stream'; import { getAIProxyAdminConfig } from '@fastgpt/service/thirdProvider/aiproxy/config'; +import { withAIProxyChannelMutation } from '@fastgpt/service/thirdProvider/aiproxy/lease'; // 特殊路径映射,标记需要在末尾保留斜杠的路径 const endPathMap: Record = { @@ -31,7 +32,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const headers: Record = {}; for (const [key, value] of Object.entries(req.headers)) { - if (key === 'cookie' || key === 'host' || key === 'origin' || key === 'connection') continue; + const lowerKey = key.toLowerCase(); + // 管理端凭证只能由服务端注入,避免客户端 Authorization 与 AIProxy token 合并。 + if ( + lowerKey === 'authorization' || + lowerKey === 'cookie' || + lowerKey === 'host' || + lowerKey === 'origin' || + lowerKey === 'connection' + ) { + continue; + } if (value) { headers[key] = Array.isArray(value) ? value.join(', ') : value; } @@ -46,7 +57,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) body: req.method === 'GET' || req.method === 'HEAD' ? null : (req as any) }); - const response = await fetch(request); + const mutatesChannel = + !['GET', 'HEAD', 'OPTIONS'].includes(req.method ?? '') && + /^\/api\/channels?(?:\/|$)/.test(basePath); + const response = mutatesChannel + ? await withAIProxyChannelMutation(async ({ signal, assertValid }) => { + assertValid(); + return fetch(request, { signal: AbortSignal.any([signal, AbortSignal.timeout(30000)]) }); + }) + : await fetch(request); response.headers.forEach((value, key) => { const lowerKey = key.toLowerCase(); diff --git a/projects/app/src/pages/api/aiproxy/api/createChannel.ts b/projects/app/src/pages/api/aiproxy/api/createChannel.ts index 67e876b1996a..a7517153c23c 100644 --- a/projects/app/src/pages/api/aiproxy/api/createChannel.ts +++ b/projects/app/src/pages/api/aiproxy/api/createChannel.ts @@ -3,24 +3,72 @@ import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; import { axiosWithoutSSRF } from '@fastgpt/service/common/api/axios'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { getAIProxyAdminConfig } from '@fastgpt/service/thirdProvider/aiproxy/config'; +import { withAIProxyChannelMutation } from '@fastgpt/service/thirdProvider/aiproxy/lease'; +import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { z } from 'zod'; +import { i18nT } from '@fastgpt/global/common/i18n/utils'; +import { + CreateAdminAIProxyChannelBodySchema, + CreateAdminAIProxyChannelResponseSchema, + type CreateAdminAIProxyChannelBody, + type CreateAdminAIProxyChannelResponse +} from '@fastgpt/global/openapi/admin/core/ai/model/api'; -async function handler(req: ApiRequestProps, res: ApiResponseType) { +/** 在同一写租约内保证名称唯一并解析旧版创建结果,禁止猜测或返回空渠道 ID。 */ +async function handler( + req: ApiRequestProps, + res: ApiResponseType +): Promise { try { await authSystemAdmin({ req }); + const { body } = parseApiInput({ req, bodySchema: CreateAdminAIProxyChannelBodySchema }); const { baseUrl, token } = getAIProxyAdminConfig(); - const { data } = await axiosWithoutSSRF.post(`${baseUrl}/api/channel/`, req.body, { - headers: { - Authorization: `Bearer ${token}` + const result = await withAIProxyChannelMutation(async ({ signal }) => { + const config = { + headers: { + Authorization: `Bearer ${token}` + }, + signal, + timeout: 30000 + }; + const getNamedChannels = async () => { + const { data } = await axiosWithoutSSRF.get(`${baseUrl}/api/channels/all`, config); + const result = z + .object({ + success: z.literal(true), + data: z.array( + z.object({ id: z.number().int().positive(), name: z.string(), type: z.number() }) + ) + }) + .parse(data); + return result.data.filter((channel) => channel.name.trim() === body.name); + }; + if ((await getNamedChannels()).length > 0) { + throw new Error(i18nT('config_model:channel_name_duplicate')); } + const { data } = await axiosWithoutSSRF.post(`${baseUrl}/api/channel/`, body, config); + // v0.6.5 成功响应没有 data。已在写入前排除重名,按精确名称解析,不能用列表差集猜 ID。 + if (data?.success === true && data.data == null) { + const unresolvedMessage = + 'Channel created, but its ID could not be uniquely resolved. Refresh the channel list before retrying.'; + const channels = await getNamedChannels().catch(() => { + // 创建已生效,后续读取失败不能诱导用户直接重复创建。 + throw new Error(unresolvedMessage); + }); + if (channels.length !== 1 || channels[0].type !== body.type) { + throw new Error(unresolvedMessage); + } + return { success: true, data: { id: channels[0].id } }; + } + return data; }); - res.json(data); + res.json(CreateAdminAIProxyChannelResponseSchema.parse(result)); } catch (error) { res.json({ success: false, - message: getErrText(error), - data: error + message: getErrText(error) }); } } diff --git a/projects/app/src/pages/api/common/system/getInitData.ts b/projects/app/src/pages/api/common/system/getInitData.ts index e7b3fd7cac2f..9361e25b1c42 100644 --- a/projects/app/src/pages/api/common/system/getInitData.ts +++ b/projects/app/src/pages/api/common/system/getInitData.ts @@ -1,3 +1,4 @@ +import { getModelProviderMetadata } from '@fastgpt/service/core/app/provider/controller'; import type { NextApiResponse } from 'next'; import { type ApiRequestProps } from '@fastgpt/next/type'; import { NextAPI } from '@/service/middleware/entry'; @@ -38,7 +39,7 @@ async function handler( feConfigs: global.feConfigs, subPlans, systemVersion: global.systemVersion, - aiproxyChannels: global.aiproxyChannelsCache + aiproxyChannels: getModelProviderMetadata().aiproxyChannels }; } catch { const referer = req.headers.referer; @@ -46,7 +47,7 @@ async function handler( return { feConfigs: global.feConfigs, subPlans, - aiproxyChannels: global.aiproxyChannelsCache + aiproxyChannels: getModelProviderMetadata().aiproxyChannels }; } @@ -54,14 +55,14 @@ async function handler( if (bufferId && unAuthBufferId === bufferId) { return { bufferId: unAuthBufferId, - aiproxyChannels: global.aiproxyChannelsCache + aiproxyChannels: getModelProviderMetadata().aiproxyChannels }; } return { bufferId: unAuthBufferId, feConfigs: global.feConfigs, - aiproxyChannels: global.aiproxyChannelsCache + aiproxyChannels: getModelProviderMetadata().aiproxyChannels }; } })(); diff --git a/projects/app/src/pages/api/core/ai/agent/createQuestionGuide.ts b/projects/app/src/pages/api/core/ai/agent/createQuestionGuide.ts index ba1789306ad6..660d95da39e5 100644 --- a/projects/app/src/pages/api/core/ai/agent/createQuestionGuide.ts +++ b/projects/app/src/pages/api/core/ai/agent/createQuestionGuide.ts @@ -1,3 +1,4 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; import type { NextApiResponse } from 'next'; import { pushQuestionGuideUsage } from '@/service/support/wallet/usage/push'; @@ -9,7 +10,7 @@ import { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant'; import { authOutLinkValid } from '@fastgpt/service/support/permission/publish/authLink'; import { authOutLinkInit } from '@fastgpt/service/support/outLink/runtime/auth'; import { authCert } from '@fastgpt/service/support/permission/auth/common'; -import { getDefaultLLMModelData } from '@fastgpt/service/core/ai/model'; + import { CreateQuestionGuideBodySchema, CreateQuestionGuideResponseSchema, @@ -30,8 +31,8 @@ async function handler( authToken: true, authApiKey: true }); - - const qgModel = getDefaultLLMModelData(); + const modelHandle = await getModelHandle(); + const qgModel = modelHandle.getDefaultModelData('llm'); const { result, inputTokens, outputTokens } = await createQuestionGuide({ messages: messages as ChatCompletionMessageParam[], diff --git a/projects/app/src/pages/api/core/ai/agent/v2/createQuestionGuide.ts b/projects/app/src/pages/api/core/ai/agent/v2/createQuestionGuide.ts index e568f260cfa4..32e5c050c107 100644 --- a/projects/app/src/pages/api/core/ai/agent/v2/createQuestionGuide.ts +++ b/projects/app/src/pages/api/core/ai/agent/v2/createQuestionGuide.ts @@ -1,3 +1,4 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; import type { NextApiResponse } from 'next'; import { pushQuestionGuideUsage } from '@/service/support/wallet/usage/push'; import { createQuestionGuide } from '@fastgpt/service/core/ai/functions/createQuestionGuide'; @@ -7,7 +8,7 @@ import { NextAPI } from '@/service/middleware/entry'; import { getChatItems } from '@fastgpt/service/core/chat/controller'; import { chats2GPTMessages } from '@fastgpt/global/core/chat/adapt'; import { getAppLatestVersion } from '@fastgpt/service/core/app/version/controller'; -import { getDefaultLLMModelData, getLLMModelData } from '@fastgpt/service/core/ai/model'; + import { CreateQuestionGuideResponseSchema, CreateQuestionGuideV2BodySchema, @@ -67,10 +68,10 @@ async function handler( field: 'obj value time' }); const messages = chats2GPTMessages({ messages: histories, reserveId: false }); - + const modelHandle = await getModelHandle(); const qgModelData = (() => { if (inputQuestionGuide?.modelId !== undefined || inputQuestionGuide?.model !== undefined) { - return getLLMModelData({ + return modelHandle.getLLMModelData({ modelId: inputQuestionGuide.modelId, model: inputQuestionGuide.model }); @@ -79,12 +80,12 @@ async function handler( persistedQuestionGuide?.modelId !== undefined || persistedQuestionGuide?.model !== undefined ) { - return getLLMModelData({ + return modelHandle.getLLMModelData({ modelId: persistedQuestionGuide.modelId, model: persistedQuestionGuide.model }); } - return getDefaultLLMModelData(); + return modelHandle.getDefaultModelData('llm'); })(); const { result, inputTokens, outputTokens } = await createQuestionGuide({ diff --git a/projects/app/src/pages/api/core/ai/model/catalog.ts b/projects/app/src/pages/api/core/ai/model/catalog.ts index 2e3b53429b2c..5b8009a56035 100644 --- a/projects/app/src/pages/api/core/ai/model/catalog.ts +++ b/projects/app/src/pages/api/core/ai/model/catalog.ts @@ -1,3 +1,5 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; +import { getModelProviderMetadata } from '@fastgpt/service/core/app/provider/controller'; import { authModelViewer } from '@/service/core/ai/model/auth'; import type { ApiRequestProps } from '@fastgpt/next/type'; import { NextAPI } from '@/service/middleware/entry'; @@ -22,8 +24,15 @@ export async function handler( }).query; const catalogIdentity = await authModelViewer({ req, outLinkAuthData }); - const permission = await getMemberModelCatalogPermission(catalogIdentity); - const version = `3:${global.systemModelCatalogVersion}:${permission.version}`; + const modelHandle = await getModelHandle(); + const activeModels = modelHandle.getActiveModels(); + const configuredDefaults = modelHandle.configuredDefaultModelIds; + const providers = getModelProviderMetadata().providers; + const permission = await getMemberModelCatalogPermission({ + ...catalogIdentity, + catalogSnapshot: { models: activeModels, revision: modelHandle.revision } + }); + const version = `3:${modelHandle.version}:${permission.version}`; if (clientVersion === version) { return GetModelCatalogResponseSchema.parse({ version }); @@ -31,18 +40,16 @@ export async function handler( const permittedModelIds = new Set(permission.modelIds); // 权限结果只决定可见性,目录顺序始终继承 plugin 排好的 active 模型列表。 - const models = global.systemActiveModelList.filter((model) => - permittedModelIds.has(model.modelId) - ); + const models = activeModels.filter((model) => permittedModelIds.has(model.modelId)); return GetModelCatalogResponseSchema.parse({ version, data: { models: models.map(desensitizeSystemModel), - providers: global.ModelProviderRawCache, + providers, defaultModelIds: resolveEffectiveDefaultModelIds({ models, - configuredDefaults: global.systemConfiguredDefaultModelIds + configuredDefaults }) } }); diff --git a/projects/app/src/pages/api/core/ai/model/list.ts b/projects/app/src/pages/api/core/ai/model/list.ts index 5a6905b89d99..bccc172090fe 100644 --- a/projects/app/src/pages/api/core/ai/model/list.ts +++ b/projects/app/src/pages/api/core/ai/model/list.ts @@ -1,3 +1,5 @@ +import { getModelProviderMetadata } from '@fastgpt/service/core/app/provider/controller'; +import { getModelHandle } from '@fastgpt/service/core/ai/model'; import { NextAPI } from '@/service/middleware/entry'; import { GetSystemModelsResponseSchema, @@ -6,9 +8,10 @@ import { /** 价格页公开模型接口,只通过响应 Schema 白名单返回最小字段。 */ async function handler(): Promise { + const modelHandle = await getModelHandle(); return GetSystemModelsResponseSchema.parse({ - models: global.systemActiveModelList, - providers: global.ModelProviderRawCache + models: modelHandle.getActiveModels(), + providers: getModelProviderMetadata().providers }); } diff --git a/projects/app/src/pages/api/core/ai/model/summary.ts b/projects/app/src/pages/api/core/ai/model/summary.ts index 3c9144d06a5b..2e673618b163 100644 --- a/projects/app/src/pages/api/core/ai/model/summary.ts +++ b/projects/app/src/pages/api/core/ai/model/summary.ts @@ -8,7 +8,7 @@ import { } from '@fastgpt/global/openapi/core/ai/model/summary'; import type { ApiRequestProps } from '@fastgpt/next/type'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; -import { findModelData } from '@fastgpt/service/core/ai/model'; +import { getModelHandle } from '@fastgpt/service/core/ai/model'; import { getMemberModelCatalogPermission } from '@fastgpt/service/support/permission/model/controller'; /** 只返回展示白名单字段;停用模型照常鉴权,无权限模型允许显示名称,但绝不泄露执行配置。 */ @@ -20,14 +20,16 @@ export async function handler( bodySchema: GetModelSummariesBodySchema }).body; const identity = await authModelViewer({ req, outLinkAuthData }); + const modelHandle = await getModelHandle(); const { modelIds: permittedIds } = await getMemberModelCatalogPermission({ ...identity, - includeInactive: true + includeInactive: true, + catalogSnapshot: { models: modelHandle.getAllModels(), revision: modelHandle.revision } }); const permitted = new Set(permittedIds); return GetModelSummariesResponseSchema.parse({ models: modelIds.map((modelId) => { - const model = findModelData({ modelId }); + const model = modelHandle.findModelData({ modelId }); if (!model) return { modelId, status: 'deleted' }; return { modelId, diff --git a/projects/app/src/pages/api/core/ai/optimizePrompt.ts b/projects/app/src/pages/api/core/ai/optimizePrompt.ts index 6282aa0dd9d0..eeedb1fc6463 100644 --- a/projects/app/src/pages/api/core/ai/optimizePrompt.ts +++ b/projects/app/src/pages/api/core/ai/optimizePrompt.ts @@ -1,3 +1,4 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; import type { ApiRequestProps, ApiResponseType } from '@fastgpt/next/type'; import { NextAPI } from '@/service/middleware/entry'; import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; @@ -16,7 +17,6 @@ import { type OptimizePromptBody } from '@fastgpt/global/openapi/core/ai/api'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; -import { getLLMModelData } from '@fastgpt/service/core/ai/model'; const getPromptOptimizerSystemPrompt = () => { return `# Role @@ -83,7 +83,8 @@ async function handler(req: ApiRequestProps, res: ApiRespons authToken: true, authApiKey: true }); - const modelData = getLLMModelData({ modelId }); + const modelHandle = await getModelHandle(); + const modelData = modelHandle.getLLMModelData({ modelId }); res.setHeader('Content-Type', 'text/event-stream;charset=utf-8'); res.setHeader('X-Accel-Buffering', 'no'); diff --git a/projects/app/src/pages/api/core/app/create.ts b/projects/app/src/pages/api/core/app/create.ts index bd5046ded38d..4433ffe5dbb9 100644 --- a/projects/app/src/pages/api/core/app/create.ts +++ b/projects/app/src/pages/api/core/app/create.ts @@ -1,3 +1,4 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; import { NextAPI } from '@/service/middleware/entry'; import type { ParentIdType } from '@fastgpt/global/common/parentFolder/type'; import { parseParentIdInMongo } from '@fastgpt/global/common/parentFolder/utils'; @@ -41,7 +42,6 @@ import { import { migrateWorkflowToCurrent } from '@fastgpt/global/core/workflow/migration'; import { copyAvatarImage } from '@fastgpt/service/common/file/image/controller'; import { extractAppResourceRefsFromNodes } from '@fastgpt/service/core/app/resourceRefs'; -import { getSystemDefaultModelIds } from '@fastgpt/service/core/ai/model'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; @@ -174,11 +174,12 @@ export const onCreateApp = async ({ edges: edges ?? [], chatConfig }); + const modelHandle = await getModelHandle(); formatModels({ nodes: normalizedWorkflow.nodes, chatConfig: normalizedWorkflow.chatConfig, - models: global.systemActiveModelList, - defaultModelIds: getSystemDefaultModelIds(), + models: modelHandle.getActiveModels(), + defaultModelIds: modelHandle.getSystemDefaultModelIds(), modelReferencePolicy: 'fallback' }); await beforeUpdateAppFormat({ nodes: normalizedWorkflow.nodes, teamId }); @@ -316,11 +317,12 @@ export const onUpdateAppWorkflow = async ({ edges: edges ?? [], chatConfig }); + const modelHandle = await getModelHandle(); formatModels({ nodes: workflow.nodes, chatConfig: workflow.chatConfig, - models: global.systemActiveModelList, - defaultModelIds: getSystemDefaultModelIds(), + models: modelHandle.getActiveModels(), + defaultModelIds: modelHandle.getSystemDefaultModelIds(), modelReferencePolicy: 'fallback' }); await beforeUpdateAppFormat({ nodes: workflow.nodes, teamId }); diff --git a/projects/app/src/pages/api/core/app/version/publish.ts b/projects/app/src/pages/api/core/app/version/publish.ts index 17e641b68995..078a4633b734 100644 --- a/projects/app/src/pages/api/core/app/version/publish.ts +++ b/projects/app/src/pages/api/core/app/version/publish.ts @@ -15,7 +15,7 @@ import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; import { type ApiRequestProps } from '@fastgpt/next/type'; import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; -import { getSystemDefaultModelIds } from '@fastgpt/service/core/ai/model'; +import { getModelHandle } from '@fastgpt/service/core/ai/model'; import { beforeUpdateAppFormat, updateParentFoldersUpdateTime, @@ -47,18 +47,19 @@ async function handler(req: ApiRequestProps) { }); const normalizedWorkflow = migrateWorkflowToCurrent({ nodes, edges, chatConfig }); + const modelHandle = await getModelHandle(); const models = await (async () => { - if (!isPublish) return global.systemActiveModelList; + if (!isPublish) return modelHandle.getActiveModels(); // 与客户端 catalog 使用相同身份和权限规则,不能用应用所有者替代当前发布者。 const identity = await authModelViewer({ req }); const permittedIds = new Set(await getMemberModelIds(identity)); - return global.systemActiveModelList.filter((model) => permittedIds.has(model.modelId)); + return modelHandle.getActiveModels().filter((model) => permittedIds.has(model.modelId)); })(); formatModels({ nodes: normalizedWorkflow.nodes, chatConfig: normalizedWorkflow.chatConfig, models, - defaultModelIds: getSystemDefaultModelIds(), + defaultModelIds: modelHandle.getSystemDefaultModelIds(), modelReferencePolicy: isPublish ? 'validate' : 'preserve' }); await beforeUpdateAppFormat({ diff --git a/projects/app/src/pages/api/core/chat/init.ts b/projects/app/src/pages/api/core/chat/init.ts index 3414327b5908..063b4dae6edd 100644 --- a/projects/app/src/pages/api/core/chat/init.ts +++ b/projects/app/src/pages/api/core/chat/init.ts @@ -197,7 +197,7 @@ async function handler(req: NextApiRequest): Promise { hasBeenRead: chat?.hasBeenRead, app: { chatConfig: appChatConfig, - chatModels: getChatModelNameListByModules(nodes), + chatModels: await getChatModelNameListByModules(nodes), name: app.name, avatar: app.avatar ?? '', intro: app.intro ?? '', diff --git a/projects/app/src/pages/api/core/chat/record/getSpeech.ts b/projects/app/src/pages/api/core/chat/record/getSpeech.ts index 6a5765d5fe81..e00ebacc031a 100644 --- a/projects/app/src/pages/api/core/chat/record/getSpeech.ts +++ b/projects/app/src/pages/api/core/chat/record/getSpeech.ts @@ -1,3 +1,4 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; import type { NextApiResponse } from 'next'; import { jsonRes } from '@fastgpt/service/common/response'; @@ -5,7 +6,7 @@ import { text2Speech } from '@fastgpt/service/core/ai/audio/speech'; import { pushAudioSpeechUsage } from '@/service/support/wallet/usage/push'; import { authChatTargetCrud } from '@/service/support/permission/auth/chat'; import { authType2UsageSource } from '@/service/support/wallet/usage/utils'; -import { getTTSModelData } from '@fastgpt/service/core/ai/model'; + import { MongoTTSBuffer } from '@fastgpt/service/common/buffer/tts/schema'; import { type ApiRequestProps } from '@fastgpt/next/type'; import { GetChatSpeechBodySchema } from '@fastgpt/global/openapi/core/chat/record/api'; @@ -35,8 +36,11 @@ async function handler(req: ApiRequestProps, res: NextApiResponse) { sourceId, outLinkAuthData }); - - const ttsModel = getTTSModelData({ modelId: ttsConfig.modelId, model: ttsConfig.model }); + const modelHandle = await getModelHandle(); + const ttsModel = modelHandle.getTTSModelData({ + modelId: ttsConfig.modelId, + model: ttsConfig.model + }); const voiceData = ttsModel.config.voices.find((item) => item.value === ttsConfig.voice); if (!voiceData) { diff --git a/projects/app/src/pages/api/core/dataset/collection/create/images.ts b/projects/app/src/pages/api/core/dataset/collection/create/images.ts index 7e0ba396f2dc..805dbd0cf1f3 100644 --- a/projects/app/src/pages/api/core/dataset/collection/create/images.ts +++ b/projects/app/src/pages/api/core/dataset/collection/create/images.ts @@ -1,3 +1,5 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; +import { getDatasetModelReference } from '@fastgpt/service/core/dataset/model'; import { authDataset } from '@fastgpt/service/support/permission/dataset/auth'; import { CreateImageCollectionFormSchema, @@ -20,7 +22,7 @@ import { multer } from '@fastgpt/service/common/file/multer'; import { datasetImageCollectionFileType } from '@fastgpt/global/common/file/constants'; import { parseAllowedExtensions } from '@fastgpt/service/common/s3/utils/uploadConstraints'; import { checkDatasetIndexLimit } from '@fastgpt/service/support/permission/teamLimit'; -import { getDatasetEmbeddingModel, getDatasetVlmModel } from '@fastgpt/service/core/dataset/model'; + import { getDatasetImageIndexCapability } from '@fastgpt/service/core/dataset/utils'; import { assertUploadRateLimit } from '@fastgpt/service/common/rateLimit/interface/upload'; import { getTeamPlanStatus } from '@fastgpt/service/support/wallet/sub/utils'; @@ -59,10 +61,14 @@ async function handler(req: ApiRequestProps): Promise { }); // check model valid + const modelHandle = await getModelHandle(); const vectorModelStore = - getOptionalEmbeddingModelData({ modelId: vectorModelId, model: vectorModel }) ?? - getDefaultEmbeddingModelData(); + modelHandle.getEmbeddingModelData( + { modelId: vectorModelId, model: vectorModel }, + { optional: true } + ) ?? modelHandle.getDefaultModelData('embedding'); const agentModelStore = - getOptionalLLMModelData({ modelId: agentModelId, model: agentModel }) ?? - getDefaultLLMModelData(); + modelHandle.getLLMModelData({ modelId: agentModelId, model: agentModel }, { optional: true }) ?? + modelHandle.getDefaultModelData('llm'); // 显式空值表示“不设置”,不能再补系统默认或按旧名称恢复;仅未传引用时沿用默认。 const vlmModelStore = (() => { - if (vlmModelId !== undefined) return getOptionalVlmModelData({ modelId: vlmModelId }); - if (vlmModel !== undefined) return getOptionalVlmModelData({ model: vlmModel }); - return getDefaultVLMModelData(); + if (vlmModelId !== undefined) + return modelHandle.getVlmModelData({ modelId: vlmModelId }, { optional: true }); + if (vlmModel !== undefined) + return modelHandle.getVlmModelData({ model: vlmModel }, { optional: true }); + return modelHandle.getDefaultModelData('datasetImageLLM'); })(); // check limit diff --git a/projects/app/src/pages/api/core/dataset/createWithFiles.ts b/projects/app/src/pages/api/core/dataset/createWithFiles.ts index 1cc7a0993f11..f154f9c0b2e1 100644 --- a/projects/app/src/pages/api/core/dataset/createWithFiles.ts +++ b/projects/app/src/pages/api/core/dataset/createWithFiles.ts @@ -1,3 +1,4 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; import { NextAPI } from '@/service/middleware/entry'; import { parseParentIdInMongo } from '@fastgpt/global/common/parentFolder/utils'; import { @@ -20,14 +21,7 @@ import { import { TeamDatasetCreatePermissionVal } from '@fastgpt/global/support/permission/user/constant'; import { pushTrack } from '@fastgpt/service/common/middle/tracks/utils'; import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; -import { - getDefaultEmbeddingModelData, - getDefaultLLMModelData, - getDefaultVLMModelData, - getOptionalEmbeddingModelData, - getOptionalLLMModelData, - getOptionalVlmModelData -} from '@fastgpt/service/core/ai/model'; + import { MongoDataset } from '@fastgpt/service/core/dataset/schema'; import { authDataset } from '@fastgpt/service/support/permission/dataset/auth'; import { checkTeamDatasetLimit } from '@fastgpt/service/support/permission/teamLimit'; @@ -49,16 +43,18 @@ async function handler(req: ApiRequestProps): Promise { const { dataId, type, text } = parseApiInput({ @@ -27,8 +28,10 @@ async function handler(req: ApiRequestProps): Promise dataId, per: WritePermissionVal }); - - const embeddingModel = getDatasetEmbeddingModel(collection.dataset); + const modelHandle = await getModelHandle(); + const embeddingModel = modelHandle.getEmbeddingModelData( + getDatasetModelReference(collection.dataset, 'embedding') + ); const { index, tokens } = await createDatasetDataIndex({ data: datasetData, type, diff --git a/projects/app/src/pages/api/core/dataset/data/index/update.ts b/projects/app/src/pages/api/core/dataset/data/index/update.ts index 5817cc59cef9..00ddb6335768 100644 --- a/projects/app/src/pages/api/core/dataset/data/index/update.ts +++ b/projects/app/src/pages/api/core/dataset/data/index/update.ts @@ -1,3 +1,5 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; +import { getDatasetModelReference } from '@fastgpt/service/core/dataset/model'; import { updateDatasetDataIndex } from '@/service/core/dataset/data/dataIndex'; import { pushGenerateVectorUsage } from '@/service/support/wallet/usage/push'; import { NextAPI } from '@/service/middleware/entry'; @@ -12,7 +14,6 @@ import { addAuditLog, getI18nDatasetType } from '@fastgpt/service/support/user/a import { authDatasetData } from '@fastgpt/service/support/permission/dataset/auth'; import type { ApiRequestProps } from '@fastgpt/next/type'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; -import { getDatasetEmbeddingModel } from '@fastgpt/service/core/dataset/model'; async function handler(req: ApiRequestProps): Promise { const { dataId, indexDataId, type, text } = parseApiInput({ @@ -27,8 +28,10 @@ async function handler(req: ApiRequestProps): Promise dataId, per: WritePermissionVal }); - - const embeddingModel = getDatasetEmbeddingModel(collection.dataset); + const modelHandle = await getModelHandle(); + const embeddingModel = modelHandle.getEmbeddingModelData( + getDatasetModelReference(collection.dataset, 'embedding') + ); const { index, tokens } = await updateDatasetDataIndex({ data: datasetData, indexDataId, diff --git a/projects/app/src/pages/api/core/dataset/data/insertData.ts b/projects/app/src/pages/api/core/dataset/data/insertData.ts index 0bc9ea0ef18a..ac547bb16b21 100644 --- a/projects/app/src/pages/api/core/dataset/data/insertData.ts +++ b/projects/app/src/pages/api/core/dataset/data/insertData.ts @@ -1,3 +1,5 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; +import { getDatasetModelReference } from '@fastgpt/service/core/dataset/model'; /* insert one data to dataset (immediately insert) manual input or mark data @@ -22,7 +24,6 @@ import { type InsertDataResponse } from '@fastgpt/global/openapi/core/dataset/data/api'; import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; -import { getDatasetEmbeddingModel } from '@fastgpt/service/core/dataset/model'; async function handler(req: ApiRequestProps): Promise { const { collectionId, q, a, indexes, metadata } = parseApiInput({ @@ -55,8 +56,10 @@ async function handler(req: ApiRequestProps): Promise { ...item, text: simpleText(item.text) })); - - const vectorModelData = getDatasetEmbeddingModel(dataset); + const modelHandle = await getModelHandle(); + const vectorModelData = modelHandle.getEmbeddingModelData( + getDatasetModelReference(dataset, 'embedding') + ); await hasSameValue({ teamId, diff --git a/projects/app/src/pages/api/core/dataset/data/insertImages.ts b/projects/app/src/pages/api/core/dataset/data/insertImages.ts index 32e4fb6c7cb0..85f9fc00cbad 100644 --- a/projects/app/src/pages/api/core/dataset/data/insertImages.ts +++ b/projects/app/src/pages/api/core/dataset/data/insertImages.ts @@ -1,3 +1,5 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; +import { getDatasetModelReference } from '@fastgpt/service/core/dataset/model'; import type { ApiRequestProps } from '@fastgpt/next/type'; import { NextAPI } from '@/service/middleware/entry'; import { addDays } from 'date-fns'; @@ -6,11 +8,7 @@ import { WritePermissionVal } from '@fastgpt/global/support/permission/constant' import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; import { createTrainingUsage } from '@fastgpt/service/support/wallet/usage/controller'; import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; -import { - getDatasetAgentModel, - getDatasetEmbeddingModel, - getDatasetVlmModel -} from '@fastgpt/service/core/dataset/model'; + import { pushDataListToTrainingQueue } from '@fastgpt/service/core/dataset/training/controller'; import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants'; import path from 'node:path'; @@ -49,9 +47,14 @@ async function handler(req: ApiRequestProps): Promise { authApiKey: true }); const dataset = collection.dataset; - const vectorModelData = getDatasetEmbeddingModel(dataset); - const agentModelData = getDatasetAgentModel(dataset); - const vlmModelData = getDatasetVlmModel(dataset); + const modelHandle = await getModelHandle(); + const vectorModelData = modelHandle.getEmbeddingModelData( + getDatasetModelReference(dataset, 'embedding') + ); + const agentModelData = modelHandle.getLLMModelData(getDatasetModelReference(dataset, 'agent')); + const vlmModelData = modelHandle.getVlmModelData(getDatasetModelReference(dataset, 'vlm'), { + optional: true + }); const { availableVlmModel, supportVlm, supportImageEmbedding } = getDatasetImageIndexCapability( { vectorModel: vectorModelData, diff --git a/projects/app/src/pages/api/core/dataset/data/pushData.ts b/projects/app/src/pages/api/core/dataset/data/pushData.ts index 63e16e559f76..f33516497b20 100644 --- a/projects/app/src/pages/api/core/dataset/data/pushData.ts +++ b/projects/app/src/pages/api/core/dataset/data/pushData.ts @@ -1,3 +1,5 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; +import { getDatasetModelReference } from '@fastgpt/service/core/dataset/model'; /* push data to training queue */ import { authDatasetCollection } from '@fastgpt/service/support/permission/dataset/auth'; import { checkDatasetIndexLimit } from '@fastgpt/service/support/permission/teamLimit'; @@ -13,11 +15,7 @@ import { type PushDataResponseType } from '@fastgpt/global/openapi/core/dataset/data/api'; import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; -import { - getDatasetAgentModel, - getDatasetEmbeddingModel, - getDatasetVlmModel -} from '@fastgpt/service/core/dataset/model'; + import { createTrainingUsage } from '@fastgpt/service/support/wallet/usage/controller'; import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; @@ -37,9 +35,17 @@ async function handler(req: ApiRequestProps): Promise { collectionId, per: WritePermissionVal }); - const vectorModelData = getDatasetEmbeddingModel(collection.dataset); - const agentModelData = getDatasetAgentModel(collection.dataset); - const vlmModelData = getDatasetVlmModel(collection.dataset); + const modelHandle = await getModelHandle(); + const vectorModelData = modelHandle.getEmbeddingModelData( + getDatasetModelReference(collection.dataset, 'embedding') + ); + const agentModelData = modelHandle.getLLMModelData( + getDatasetModelReference(collection.dataset, 'agent') + ); + const vlmModelData = modelHandle.getVlmModelData( + getDatasetModelReference(collection.dataset, 'vlm'), + { optional: true } + ); const mode = getTrainingModeByCollection({ ...collection, diff --git a/projects/app/src/pages/api/core/dataset/data/update.ts b/projects/app/src/pages/api/core/dataset/data/update.ts index d6a716e04a14..e3618e38dcf6 100644 --- a/projects/app/src/pages/api/core/dataset/data/update.ts +++ b/projects/app/src/pages/api/core/dataset/data/update.ts @@ -1,3 +1,5 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; +import { getDatasetModelReference } from '@fastgpt/service/core/dataset/model'; import { updateDatasetDataSystemIndexes, updateDatasetDataByIndexes @@ -18,7 +20,7 @@ import { } from '@fastgpt/global/openapi/core/dataset/data/api'; import { replaceS3KeyToPreviewUrl } from '@fastgpt/service/core/dataset/utils'; import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants'; -import { getDatasetEmbeddingModel } from '@fastgpt/service/core/dataset/model'; + import { addHours } from 'date-fns'; async function handler(req: ApiRequestProps): Promise { @@ -44,7 +46,10 @@ async function handler(req: ApiRequestProps): Promise }); const dataset = collection.dataset; - const vectorModel = getDatasetEmbeddingModel(dataset); + const modelHandle = await getModelHandle(); + const vectorModel = modelHandle.getEmbeddingModelData( + getDatasetModelReference(dataset, 'embedding') + ); const nextQ = q ?? datasetData.q ?? ''; const nextA = a ?? datasetData.a ?? ''; const pushUpdateDataAuditLog = () => { diff --git a/projects/app/src/pages/api/core/dataset/detail.ts b/projects/app/src/pages/api/core/dataset/detail.ts index 478f91fd28c2..25cf21fa97a3 100644 --- a/projects/app/src/pages/api/core/dataset/detail.ts +++ b/projects/app/src/pages/api/core/dataset/detail.ts @@ -1,3 +1,5 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; +import { getDatasetModelReference } from '@fastgpt/service/core/dataset/model'; import { desensitizeSystemModel } from '@fastgpt/service/core/ai/config/utils'; import { authDataset } from '@fastgpt/service/support/permission/dataset/auth'; import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; @@ -11,11 +13,6 @@ import { import { getDatasetSyncDatasetStatus } from '@fastgpt/service/core/dataset/datasetSync'; import { filterApiDatasetServerPublicData } from '@fastgpt/global/core/dataset/apiDataset/utils'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; -import { - findDatasetAgentModel, - findDatasetEmbeddingModel, - findDatasetVlmModel -} from '@fastgpt/service/core/dataset/model'; async function handler(req: ApiRequestProps): Promise { const { id: datasetId } = parseApiInput({ req, querySchema: GetDatasetDetailQuerySchema }).query; @@ -30,9 +27,17 @@ async function handler(req: ApiRequestProps): Promise }); const { status, errorMsg } = await getDatasetSyncDatasetStatus(datasetId); - const vectorModel = findDatasetEmbeddingModel(dataset); - const agentModel = findDatasetAgentModel(dataset); - const vlmModel = findDatasetVlmModel(dataset); + const modelHandle = await getModelHandle(); + const vectorModel = modelHandle.findModelData(getDatasetModelReference(dataset, 'embedding'), { + type: 'embedding' + }); + const agentModel = modelHandle.findModelData(getDatasetModelReference(dataset, 'agent'), { + type: 'llm' + }); + const vlmModel = modelHandle.findModelData(getDatasetModelReference(dataset, 'vlm'), { + type: 'llm', + vision: true + }); return GetDatasetDetailResponseSchema.parse({ ...dataset, diff --git a/projects/app/src/pages/api/core/dataset/file/getPreviewChunks.ts b/projects/app/src/pages/api/core/dataset/file/getPreviewChunks.ts index 9b208336bed5..5f2745599b77 100644 --- a/projects/app/src/pages/api/core/dataset/file/getPreviewChunks.ts +++ b/projects/app/src/pages/api/core/dataset/file/getPreviewChunks.ts @@ -1,3 +1,5 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; +import { getDatasetModelReference } from '@fastgpt/service/core/dataset/model'; import { DatasetSourceReadTypeEnum } from '@fastgpt/global/core/dataset/constants'; import { rawText2Chunks, readDatasetSourceRawText } from '@fastgpt/service/core/dataset/read'; import { NextAPI } from '@/service/middleware/entry'; @@ -12,10 +14,7 @@ import { maxPreviewChunkCount } from '@fastgpt/global/core/dataset/training/utils'; import { CommonErrEnum } from '@fastgpt/global/common/error/code/common'; -import { - getDatasetAgentModel, - getDatasetEmbeddingModel -} from '@fastgpt/service/core/dataset/model'; + import { replaceS3KeyToPreviewUrl } from '@fastgpt/service/core/dataset/utils'; import { addDays } from 'date-fns'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; @@ -73,11 +72,11 @@ async function handler( if (fileAuthRes && String(fileAuthRes.tmbId) !== String(tmbId) && !fileAuthRes.isRoot) { return Promise.reject(CommonErrEnum.unAuthFile); } - + const modelHandle = await getModelHandle(); const formatChunkSettings = computedCollectionChunkSettings({ ...chunkSettings, - llmModel: getDatasetAgentModel(dataset), - vectorModel: getDatasetEmbeddingModel(dataset) + llmModel: modelHandle.getLLMModelData(getDatasetModelReference(dataset, 'agent')), + vectorModel: modelHandle.getEmbeddingModelData(getDatasetModelReference(dataset, 'embedding')) }); const { rawText } = await readDatasetSourceRawText({ @@ -99,7 +98,9 @@ async function handler( chunkSize: formatChunkSettings.chunkSize, paragraphChunkDeep: formatChunkSettings.paragraphChunkDeep, paragraphChunkMinSize: formatChunkSettings.paragraphChunkMinSize, - maxSize: getLLMMaxChunkSize(getDatasetAgentModel(dataset)), + maxSize: getLLMMaxChunkSize( + modelHandle.getLLMModelData(getDatasetModelReference(dataset, 'agent')) + ), overlapRatio, customReg: formatChunkSettings.chunkSplitter ? [formatChunkSettings.chunkSplitter] : [], maxChunks: maxPreviewChunkCount diff --git a/projects/app/src/pages/api/core/dataset/file/getRawTextPreviewChunks.ts b/projects/app/src/pages/api/core/dataset/file/getRawTextPreviewChunks.ts index a5cf6623a351..5a5940157fb1 100644 --- a/projects/app/src/pages/api/core/dataset/file/getRawTextPreviewChunks.ts +++ b/projects/app/src/pages/api/core/dataset/file/getRawTextPreviewChunks.ts @@ -1,3 +1,5 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; +import { getDatasetModelReference } from '@fastgpt/service/core/dataset/model'; import { NextAPI } from '@/service/middleware/entry'; import type { ApiRequestProps } from '@fastgpt/next/type'; import { WritePermissionVal } from '@fastgpt/global/support/permission/constant'; @@ -8,10 +10,7 @@ import { getLLMMaxChunkSize, maxPreviewChunkCount } from '@fastgpt/global/core/dataset/training/utils'; -import { - getDatasetAgentModel, - getDatasetEmbeddingModel -} from '@fastgpt/service/core/dataset/model'; + import { replaceS3KeyToPreviewUrl } from '@fastgpt/service/core/dataset/utils'; import { addDays } from 'date-fns'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; @@ -37,11 +36,11 @@ async function handler( datasetId, per: WritePermissionVal }); - + const modelHandle = await getModelHandle(); const formatChunkSettings = computedCollectionChunkSettings({ ...chunkSettings, - llmModel: getDatasetAgentModel(dataset), - vectorModel: getDatasetEmbeddingModel(dataset) + llmModel: modelHandle.getLLMModelData(getDatasetModelReference(dataset, 'agent')), + vectorModel: modelHandle.getEmbeddingModelData(getDatasetModelReference(dataset, 'embedding')) }); const chunks = await rawText2Chunks({ @@ -51,7 +50,9 @@ async function handler( chunkSize: formatChunkSettings.chunkSize, paragraphChunkDeep: formatChunkSettings.paragraphChunkDeep, paragraphChunkMinSize: formatChunkSettings.paragraphChunkMinSize, - maxSize: getLLMMaxChunkSize(getDatasetAgentModel(dataset)), + maxSize: getLLMMaxChunkSize( + modelHandle.getLLMModelData(getDatasetModelReference(dataset, 'agent')) + ), overlapRatio, customReg: formatChunkSettings.chunkSplitter ? [formatChunkSettings.chunkSplitter] : [], maxChunks: maxPreviewChunkCount diff --git a/projects/app/src/pages/api/core/dataset/list.ts b/projects/app/src/pages/api/core/dataset/list.ts index 79b35f5be00a..a892381d042f 100644 --- a/projects/app/src/pages/api/core/dataset/list.ts +++ b/projects/app/src/pages/api/core/dataset/list.ts @@ -1,3 +1,5 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; +import { getDatasetModelReference } from '@fastgpt/service/core/dataset/model'; import { MongoDataset } from '@fastgpt/service/core/dataset/schema'; import { authUserPer } from '@fastgpt/service/support/permission/user/auth'; import { NextAPI } from '@/service/middleware/entry'; @@ -14,7 +16,7 @@ import { getGroupsByTmbId } from '@fastgpt/service/support/permission/memberGrou import { getOrgIdSetWithParentByTmbId } from '@fastgpt/service/support/permission/org/controllers'; import { addSourceMember } from '@fastgpt/service/support/user/utils'; import { desensitizeSystemModel } from '@fastgpt/service/core/ai/config/utils'; -import { findDatasetEmbeddingModel } from '@fastgpt/service/core/dataset/model'; + import { isPrivateResourceByCollaborators, sumPer } from '@fastgpt/global/support/permission/utils'; import { getResourcePermissionsByTeam } from '@fastgpt/service/support/permission/resourcePermissionService'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; @@ -109,8 +111,13 @@ async function handler(req: ApiRequestProps): Promise { return { updateTime: -1, _id: -1 }; })(); const myDatasets = await MongoDataset.find(findDatasetQuery).sort(datasetSort).lean(); + const modelHandle = await getModelHandle(); const formatDatasets = myDatasets .map((dataset) => { + const vectorModel = modelHandle.findModelData( + getDatasetModelReference(dataset, 'embedding'), + { type: 'embedding' } + ); const { Per, privateDataset } = (() => { const getPer = (datasetId: string) => { const tmbRole = myRoles.find( @@ -140,10 +147,7 @@ async function handler(req: ApiRequestProps): Promise { name: dataset.name, intro: dataset.intro, type: dataset.type, - vectorModel: (() => { - const vectorModel = findDatasetEmbeddingModel(dataset); - return vectorModel ? desensitizeSystemModel(vectorModel) : undefined; - })(), + vectorModel: vectorModel ? desensitizeSystemModel(vectorModel) : undefined, inheritPermission: dataset.inheritPermission, tmbId: dataset.tmbId, createTime: dataset.createTime ?? new Types.ObjectId(String(dataset._id)).getTimestamp(), diff --git a/projects/app/src/pages/api/core/dataset/listV2.ts b/projects/app/src/pages/api/core/dataset/listV2.ts index 5bdb90db885e..8aedb8ea8a69 100644 --- a/projects/app/src/pages/api/core/dataset/listV2.ts +++ b/projects/app/src/pages/api/core/dataset/listV2.ts @@ -14,7 +14,8 @@ import { getGroupsByTmbId } from '@fastgpt/service/support/permission/memberGrou import { getOrgIdSetWithParentByTmbId } from '@fastgpt/service/support/permission/org/controllers'; import { addSourceMember } from '@fastgpt/service/support/user/utils'; import { desensitizeSystemModel } from '@fastgpt/service/core/ai/config/utils'; -import { findDatasetEmbeddingModel } from '@fastgpt/service/core/dataset/model'; +import { getDatasetModelReference } from '@fastgpt/service/core/dataset/model'; +import { getModelHandle } from '@fastgpt/service/core/ai/model'; import { isPrivateResourceByCollaborators, sumPer } from '@fastgpt/global/support/permission/utils'; import { findResourceKeysByCollaboratorsPermission, @@ -130,6 +131,7 @@ async function handler( roleListMap.set(resourceId, list); }); + const modelHandle = await getModelHandle(); const formatDatasets = myDatasets.map((dataset) => { const { Per, privateDataset } = (() => { const resourceClbs = roleListMap.get(String(dataset._id)) ?? []; @@ -163,7 +165,10 @@ async function handler( intro: dataset.intro ?? '', type: dataset.type, vectorModel: (() => { - const vectorModel = findDatasetEmbeddingModel(dataset); + const vectorModel = modelHandle.findModelData( + getDatasetModelReference(dataset, 'embedding'), + { type: 'embedding' } + ); return vectorModel ? desensitizeSystemModel(vectorModel) : undefined; })(), inheritPermission: dataset.inheritPermission, diff --git a/projects/app/src/pages/api/core/dataset/searchTest.ts b/projects/app/src/pages/api/core/dataset/searchTest.ts index bee12a4b073b..78c755f34fd1 100644 --- a/projects/app/src/pages/api/core/dataset/searchTest.ts +++ b/projects/app/src/pages/api/core/dataset/searchTest.ts @@ -1,3 +1,5 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; +import { getDatasetModelReference } from '@fastgpt/service/core/dataset/model'; import { authDataset } from '@fastgpt/service/support/permission/dataset/auth'; import { pushDatasetTestUsage } from '@/service/support/wallet/usage/push'; import { deepRagSearch, defaultSearchDatasetData } from '@fastgpt/service/core/dataset/search'; @@ -8,7 +10,6 @@ import { NextAPI } from '@/service/middleware/entry'; import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; import { type ApiRequestProps } from '@fastgpt/next/type'; import type { NextApiResponse } from 'next'; -import { getLLMModelData } from '@fastgpt/service/core/ai/model'; import { getDatasetSearchAuxiliaryModels } from '@fastgpt/service/core/dataset/search/auxiliaryModels'; import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; @@ -23,7 +24,6 @@ import { } from '@fastgpt/global/openapi/core/dataset/api'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; import { LimitTypeEnum, teamFrequencyLimit } from '@fastgpt/service/common/api/frequencyLimit'; -import { getDatasetEmbeddingModel } from '@fastgpt/service/core/dataset/model'; import { findFirstDatasetSearchVlmModel } from '@fastgpt/service/core/dataset/search/vlm'; export async function handler( @@ -91,19 +91,28 @@ export async function handler( }) ); - const { rerankModelData, extensionModelData } = getDatasetSearchAuxiliaryModels({ - usingReRank, - rerankModelId, - rerankModel, - datasetSearchUsingExtensionQuery, - datasetSearchExtensionModelId, - datasetSearchExtensionModel - }); + const modelHandle = await getModelHandle(); + const { rerankModelData, extensionModelData } = getDatasetSearchAuxiliaryModels( + { + usingReRank, + rerankModelId, + rerankModel, + datasetSearchUsingExtensionQuery, + datasetSearchExtensionModelId, + datasetSearchExtensionModel + }, + modelHandle + ); const deepSearchModelData = datasetDeepSearch - ? getLLMModelData({ modelId: datasetDeepSearchModelId, model: datasetDeepSearchModel }) + ? modelHandle.getLLMModelData({ + modelId: datasetDeepSearchModelId, + model: datasetDeepSearchModel + }) : undefined; - const embeddingModelData = getDatasetEmbeddingModel(dataset); - const vlmModelData = findFirstDatasetSearchVlmModel([dataset]); + const embeddingModelData = modelHandle.getEmbeddingModelData( + getDatasetModelReference(dataset, 'embedding') + ); + const vlmModelData = findFirstDatasetSearchVlmModel([dataset], modelHandle); const searchData = { histories: [], diff --git a/projects/app/src/pages/api/core/dataset/training/rebuildEmbedding.ts b/projects/app/src/pages/api/core/dataset/training/rebuildEmbedding.ts index 8fd4b1ed25c0..59dfbf777579 100644 --- a/projects/app/src/pages/api/core/dataset/training/rebuildEmbedding.ts +++ b/projects/app/src/pages/api/core/dataset/training/rebuildEmbedding.ts @@ -1,3 +1,4 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; import { NextAPI } from '@/service/middleware/entry'; import { authDataset } from '@fastgpt/service/support/permission/dataset/auth'; import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; @@ -7,11 +8,7 @@ import { MongoDatasetCollection } from '@fastgpt/service/core/dataset/collection import { MongoDatasetTraining } from '@fastgpt/service/core/dataset/training/schema'; import { createTrainingUsage } from '@fastgpt/service/support/wallet/usage/controller'; import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; -import { - getLLMModelData, - getEmbeddingModelData, - getOptionalVlmModelData -} from '@fastgpt/service/core/ai/model'; + import { getDatasetImageIndexCapability } from '@fastgpt/service/core/dataset/utils'; import { type ApiRequestProps } from '@fastgpt/next/type'; import { OwnerPermissionVal } from '@fastgpt/global/support/permission/constant'; @@ -36,8 +33,8 @@ async function handler(req: ApiRequestProps): Promise datasetId, per: OwnerPermissionVal }); - - const vectorModelData = getEmbeddingModelData({ modelId: vectorModelId }); + const modelHandle = await getModelHandle(); + const vectorModelData = modelHandle.getEmbeddingModelData({ modelId: vectorModelId }); // check vector model if (String(dataset.vectorModelId || '') === vectorModelData.modelId) { @@ -54,10 +51,13 @@ async function handler(req: ApiRequestProps): Promise return Promise.reject('数据集正在训练或者重建中,请稍后再试'); } - const vlmModelData = getOptionalVlmModelData({ - modelId: dataset.vlmModelId ? String(dataset.vlmModelId) : undefined, - model: dataset.vlmModel - }); + const vlmModelData = modelHandle.getVlmModelData( + { + modelId: dataset.vlmModelId ? String(dataset.vlmModelId) : undefined, + model: dataset.vlmModel + }, + { optional: true } + ); const { availableVlmModel, supportImageIndex } = getDatasetImageIndexCapability({ vectorModel: vectorModelData, vlmModel: vlmModelData @@ -69,7 +69,7 @@ async function handler(req: ApiRequestProps): Promise appName: '切换索引模型', billSource: UsageSourceEnum.training, vectorModelId: vectorModelData.modelId!, - agentModelId: getLLMModelData({ + agentModelId: modelHandle.getLLMModelData({ modelId: dataset.agentModelId ? String(dataset.agentModelId) : undefined, model: dataset.agentModel }).modelId, diff --git a/projects/app/src/pages/api/core/dataset/update.ts b/projects/app/src/pages/api/core/dataset/update.ts index c5204f814e25..1933c7f80bc1 100644 --- a/projects/app/src/pages/api/core/dataset/update.ts +++ b/projects/app/src/pages/api/core/dataset/update.ts @@ -1,3 +1,4 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; import { MongoDataset } from '@fastgpt/service/core/dataset/schema'; import { authDataset } from '@fastgpt/service/support/permission/dataset/auth'; import { NextAPI } from '@/service/middleware/entry'; @@ -33,12 +34,7 @@ import { isEqual } from 'lodash-es'; import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; import { getI18nDatasetType } from '@fastgpt/service/support/user/audit/util'; -import { - getEmbeddingModelData, - getLLMModelData, - getOptionalLLMModelData, - getOptionalVlmModelData -} from '@fastgpt/service/core/ai/model'; + import { computedCollectionChunkSettings } from '@fastgpt/global/core/dataset/training/utils'; import { getResourceOwnedClbs } from '@fastgpt/service/support/permission/controller'; import { getS3AvatarSource } from '@fastgpt/service/common/s3/sources/avatar'; @@ -67,7 +63,9 @@ async function handler(req: ApiRequestProps) { avatar, intro, agentModelId, + agentModel, vlmModelId, + vlmModel, websiteConfig, externalReadUrl, apiDatasetServer, @@ -96,26 +94,32 @@ async function handler(req: ApiRequestProps) { }); let targetName = ''; - + const modelHandle = await getModelHandle(); const chunkSettings = rawChunkSettings ? computedCollectionChunkSettings({ ...rawChunkSettings, - llmModel: getLLMModelData({ + llmModel: modelHandle.getLLMModelData({ modelId: dataset.agentModelId, model: dataset.agentModel }), - vectorModel: getEmbeddingModelData({ + vectorModel: modelHandle.getEmbeddingModelData({ modelId: dataset.vectorModelId, model: dataset.vectorModel }) }) : undefined; - const agentModelData = getOptionalLLMModelData({ modelId: agentModelId }); + const agentModelData = modelHandle.getLLMModelData( + { modelId: agentModelId, model: agentModel }, + { optional: true } + ); + // 新 ID(包括显式清空)优先,只有未传 ID 才兼容旧名称。 + const vlmReference = vlmModelId !== undefined ? { modelId: vlmModelId } : { model: vlmModel }; + const vlmValue = vlmModelId !== undefined ? vlmModelId : vlmModel; // undefined 表示不修改;显式 null/空字符串才是清空请求。 - const clearVlmModel = vlmModelId !== undefined && isEmptyModelValue(vlmModelId); + const clearVlmModel = vlmValue !== undefined && isEmptyModelValue(vlmValue); if (clearVlmModel && !permission.hasWritePer) return Promise.reject(DatasetErrEnum.unAuthDataset); - const vlmModelData = getOptionalVlmModelData({ modelId: vlmModelId }); + const vlmModelData = modelHandle.getVlmModelData(vlmReference, { optional: true }); if (isMove) { if (parentId) { diff --git a/projects/app/src/pages/api/core/workflow/optimizeCode.ts b/projects/app/src/pages/api/core/workflow/optimizeCode.ts index d953ba7ae4e9..b843c6a3e12a 100644 --- a/projects/app/src/pages/api/core/workflow/optimizeCode.ts +++ b/projects/app/src/pages/api/core/workflow/optimizeCode.ts @@ -1,3 +1,4 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; import { NextAPI } from '@/service/middleware/entry'; import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/llm/type'; import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; @@ -16,7 +17,7 @@ import { type OptimizeCodeBody } from '@fastgpt/global/openapi/core/workflow/api'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; -import { getLLMModelData } from '@fastgpt/service/core/ai/model'; + const logger = getLogger(LogCategories.MODULE.WORKFLOW.OPTIMIZE_CODE); const getPromptNodeCopilotSystemPrompt = () => { @@ -100,7 +101,8 @@ async function handler(req: ApiRequestProps, res: ApiResponseT authToken: true, authApiKey: true }); - const modelData = getLLMModelData({ modelId }); + const modelHandle = await getModelHandle(); + const modelData = modelHandle.getLLMModelData({ modelId }); res.setHeader('Content-Type', 'text/event-stream;charset=utf-8'); res.setHeader('X-Accel-Buffering', 'no'); diff --git a/projects/app/src/pages/api/v1/audio/transcriptions.ts b/projects/app/src/pages/api/v1/audio/transcriptions.ts index d320652810b9..ff52947e8ee1 100644 --- a/projects/app/src/pages/api/v1/audio/transcriptions.ts +++ b/projects/app/src/pages/api/v1/audio/transcriptions.ts @@ -1,3 +1,4 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; import type { NextApiRequest, NextApiResponse } from 'next'; import { jsonRes } from '@fastgpt/service/common/response'; import { pushWhisperUsage } from '@fastgpt/service/support/wallet/usage/controller'; @@ -8,7 +9,7 @@ import { assertMemberRateLimit, MemberRateLimitPolicy } from '@fastgpt/service/common/rateLimit/interface/member'; -import { getDefaultSTTModelData } from '@fastgpt/service/core/ai/model'; + import { multer } from '@fastgpt/service/common/file/multer'; import { AudioTranscriptionsDataSchema } from '@fastgpt/global/openapi/core/chat/record/api'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; @@ -52,14 +53,14 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { policy: MemberRateLimitPolicy.Transcriptions, memberId: String(tmbId) }); - + const modelHandle = await getModelHandle(); const transcriptionsResult = await aiTranscriptions({ - model: getDefaultSTTModelData(), + model: modelHandle.getDefaultModelData('stt'), fileStream: result.getReadStream(), filename: result.fileMetadata.originalname }); - pushWhisperUsage({ + await pushWhisperUsage({ teamId, tmbId, duration: transcriptionsResult?.usage?.total_tokens || duration, diff --git a/projects/app/src/pages/config/model/index.tsx b/projects/app/src/pages/config/model/index.tsx index 394f057a4726..fb6c9207259e 100644 --- a/projects/app/src/pages/config/model/index.tsx +++ b/projects/app/src/pages/config/model/index.tsx @@ -1,22 +1,19 @@ import { useEffect, useMemo } from 'react'; -import type React from 'react'; import { Box, Flex } from '@chakra-ui/react'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/router'; import ConfigContainer from '@/pageComponents/config/ConfigContainer'; -import ModelTable from '@/components/core/ai/ModelTable'; import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs'; import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import { accountPageRootStyles, accountTitleTextStyles } from '@/pageComponents/account/styles'; -import ModelTabHeader from '@/pageComponents/account/model/ModelTabHeader'; -const ModelConfigTable = dynamic(() => import('@/pageComponents/account/model/ModelConfigTable')); -const ChannelTable = dynamic(() => import('@/pageComponents/account/model/Channel')); -const ChannelLog = dynamic(() => import('@/pageComponents/account/model/Log')); -const ModelDashboard = dynamic(() => import('@/pageComponents/account/model/ModelDashboard')); +const ModelConfigTable = dynamic(() => import('@/pageComponents/model/ModelConfigTable')); +const ChannelTable = dynamic(() => import('@/pageComponents/model/Channel')); +const ChannelLog = dynamic(() => import('@/pageComponents/model/Log')); +const ModelDashboard = dynamic(() => import('@/pageComponents/model/ModelDashboard')); -type TabType = 'model' | 'config' | 'channel' | 'channel_log' | 'account_model'; +type TabType = 'config' | 'channel' | 'channel_log' | 'account_model'; const ModelProvider = () => { const { t } = useClientTranslation(['config_model', 'config']); @@ -25,7 +22,6 @@ const ModelProvider = () => { const modelTabList = useMemo<{ label: string; value: TabType }[]>( () => [ - { label: t('config_model:active_model'), value: 'model' }, { label: t('config_model:config_model'), value: 'config' }, ...(feConfigs.show_aiproxy ? [ @@ -38,19 +34,19 @@ const ModelProvider = () => { [feConfigs.show_aiproxy, t] ); const queryModelTab = router.query.modelTab; - const modelTab = modelTabList.find((item) => item.value === queryModelTab)?.value ?? 'model'; + const modelTab = modelTabList.find((item) => item.value === queryModelTab)?.value ?? 'config'; useEffect(() => { if (!router.isReady || queryModelTab === undefined) return; if (typeof queryModelTab === 'string' && queryModelTab === modelTab) return; - // 旧书签或手动输入可能指向已关闭的 AI Proxy 页面,统一回退到可用模型。 + // “可用模型”及已关闭的 AI Proxy 页面都统一回退到模型配置。 void router.replace( { pathname: router.pathname, query: { ...router.query, - modelTab: 'model' + modelTab: 'config' } }, undefined, @@ -108,7 +104,6 @@ const ModelProvider = () => { py={6} pt={[4, 6]} > - {modelTab === 'model' && } {modelTab === 'config' && } {modelTab === 'channel' && } {modelTab === 'channel_log' && } @@ -119,13 +114,4 @@ const ModelProvider = () => { ); }; -const ValidModelTable = ({ Tab }: { Tab: React.ReactNode }) => ( - <> - - - - - -); - export default ModelProvider; diff --git a/projects/app/src/pages/config/system/migrations.tsx b/projects/app/src/pages/config/system/migrations.tsx index 2468591fcc48..867d3a632c9d 100644 --- a/projects/app/src/pages/config/system/migrations.tsx +++ b/projects/app/src/pages/config/system/migrations.tsx @@ -385,7 +385,7 @@ const FailedRecordsModal = ({ minH: '520px', display: 'flex', flexDirection: 'column', - p: 5, + px: 5, overflowY: 'auto' }} > diff --git a/projects/app/src/service/common/system/cron.ts b/projects/app/src/service/common/system/cron.ts index ea5277647236..6c3f0aeda3ad 100644 --- a/projects/app/src/service/common/system/cron.ts +++ b/projects/app/src/service/common/system/cron.ts @@ -6,7 +6,6 @@ import { checkTimerLock } from '@fastgpt/service/common/system/timerLock/utils'; import { TimerIdEnum } from '@fastgpt/service/common/system/timerLock/constants'; import { addHours } from 'date-fns'; import { getScheduleTriggerApp } from '@/service/core/app/utils'; -import { cronRefreshModels } from '@fastgpt/service/core/ai/config/utils'; import { runSandboxArchiveCron as sandboxCronJob } from '@fastgpt/service/core/ai/sandbox/interface/admin'; import { clearExpiredS3FilesCron } from '@fastgpt/service/common/s3/lifecycle/cleanup'; import { cleanStaleGeneratingChats } from '@fastgpt/service/core/chat/cleanStaleGeneratingChats'; @@ -84,7 +83,6 @@ export const startCron = () => { setClearTmpUploadFilesCron(); clearInvalidDataCron(); scheduleTriggerAppCron(); - cronRefreshModels(); clearExpiredS3FilesCron(); sandboxCronJob(); cleanStaleGeneratingChatCron(); diff --git a/projects/app/src/service/common/system/volumnMongoWatch.ts b/projects/app/src/service/common/system/volumnMongoWatch.ts index 458b1870814e..d09956b9f6b5 100644 --- a/projects/app/src/service/common/system/volumnMongoWatch.ts +++ b/projects/app/src/service/common/system/volumnMongoWatch.ts @@ -4,24 +4,19 @@ import { MongoSystemConfigs } from '@fastgpt/service/common/system/config/schema import { debounce } from 'lodash-es'; import { MongoAppTemplate } from '@fastgpt/service/core/app/templates/templateSchema'; import { getAppTemplatesAndLoadThem } from '@fastgpt/service/core/app/templates/register'; -import { - watchSystemDefaultModelUpdate, - watchSystemModelUpdate -} from '@fastgpt/service/core/ai/config/utils'; import { SystemConfigsTypeEnum } from '@fastgpt/global/common/system/config/constants'; import { getLogger, LogCategories } from '@fastgpt/service/common/logger'; let changeStreams: any[] = []; const logger = getLogger(LogCategories.INFRA.MONGO); +/** 只监听系统配置、训练和模板;模型目录通过 revision 按需刷新,不订阅集合变化。 */ export const startMongoWatch = async () => { cleanupMongoWatch(); logger.info('Mongo change stream watch started'); changeStreams.push(reloadConfigWatch()); changeStreams.push(createDatasetTrainingMongoWatch()); changeStreams.push(refetchAppTemplates()); - changeStreams.push(watchSystemModelUpdate()); - changeStreams.push(watchSystemDefaultModelUpdate()); }; const reloadConfigWatch = () => { @@ -39,7 +34,7 @@ const reloadConfigWatch = () => { await initSystemConfig(); logger.info('System config refreshed via Mongo change stream'); } - } catch (error) {} + } catch {} }); }; @@ -48,11 +43,11 @@ const refetchAppTemplates = () => { return changeStream.on( 'change', - debounce(async (change) => { + debounce(async () => { setTimeout(() => { try { getAppTemplatesAndLoadThem(true); - } catch (error) {} + } catch {} }, 5000); }, 500) ); diff --git a/projects/app/src/service/core/ai/model/service.ts b/projects/app/src/service/core/ai/model/service.ts new file mode 100644 index 000000000000..86a09cde4297 --- /dev/null +++ b/projects/app/src/service/core/ai/model/service.ts @@ -0,0 +1,375 @@ +import { assertModelAvailable } from '@fastgpt/service/core/ai/utils'; +import { MongoAIModel } from '@fastgpt/service/core/ai/config/schema'; +import { runSystemModelTransaction } from '@fastgpt/service/core/ai/config/entity'; +import { + refreshModelTemplates, + updatedReloadSystemModel +} from '@fastgpt/service/core/ai/config/utils'; +import { + getSystemModelConfigUpdate, + updateSystemModelConfig +} from '@fastgpt/service/core/ai/config/service'; +import { + appendModelsToAIProxyChannels, + removeModelsFromAIProxyChannels, + replaceModelInAIProxyChannels +} from '@fastgpt/service/thirdProvider/aiproxy/channel'; +import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema'; +import { upsertSystemDefaultModelIds } from '@fastgpt/service/core/ai/defaultModel/entity'; +import { ModelScopeEnum, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import { ModelErrEnum } from '@fastgpt/global/common/error/code/model'; +import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant'; +import { UserError } from '@fastgpt/global/common/error/utils'; +import { + normalizeModelPricingForRead, + normalizeModelPricingForSave +} from '@fastgpt/global/core/ai/pricing'; +import { + ImportedSystemModelSchema, + CreateSystemModelResponseSchema, + CreateSystemModelsFromTemplatesResponseSchema, + type CreateSystemModelBody, + type CreateSystemModelResponse, + type CreateSystemModelsFromTemplatesBody, + type CreateSystemModelsFromTemplatesResponse, + type DeleteSystemModelsBody, + type ParsedSystemModelsWithJsonBody, + type UpdateDefaultModelsBody, + type UpdateSystemModelBody +} from '@fastgpt/global/openapi/admin/core/ai/model/api'; + +/** 配置和渠道由同一已校验请求提交;外部写入前检查目标实例与不可变类型。 */ +export const updateSystemModel = async ({ + modelId, + modelData, + channelIds +}: UpdateSystemModelBody): Promise => { + if (channelIds !== undefined) { + const existing = await MongoAIModel.findOne({ _id: modelId, scope: ModelScopeEnum.system }) + .select({ model: 1, type: 1 }) + .lean(); + if (!existing) throw ModelErrEnum.unExist; + if (existing.type !== modelData.type) + throw new UserError('System model type cannot be changed'); + await replaceModelInAIProxyChannels({ model: existing.model, channelIds }); + } + await updateSystemModelConfig({ modelId, modelData }); +}; + +/** 预检重名后先绑定渠道,再事务创建模型;数据库唯一索引负责并发兜底。 */ +export const createSystemModel = async ({ + modelData, + channelIds +}: CreateSystemModelBody): Promise => { + // 可提前识别的重名必须在 AI Proxy 写入前拒绝;数据库唯一索引继续作为并发兜底。 + const existingModel = await MongoAIModel.exists({ + scope: ModelScopeEnum.system, + model: modelData.model + }); + if (existingModel) { + throw new UserError(ModelErrEnum.alreadyExists); + } + + await appendModelsToAIProxyChannels({ channelIds, models: [modelData.model] }); + + const [model] = await runSystemModelTransaction((session) => + MongoAIModel.create( + [ + { + ...modelData, + isActive: modelData.isActive ?? false + } + ], + { session } + ) + ); + await updatedReloadSystemModel(); + + return CreateSystemModelResponseSchema.parse({ modelId: String(model._id) }); +}; + +/** 提交时重新读取模板,预检后绑定渠道,再批量创建停用实例。 */ +export const createSystemModelsFromTemplates = async ({ + templates, + channelIds +}: CreateSystemModelsFromTemplatesBody): Promise => { + const latestTemplates = await refreshModelTemplates(); + const latestTemplateMap = new Map( + latestTemplates.map((template) => [`${template.type}:${template.model}`, template]) + ); + const selectedTemplates = templates.map((reference) => { + const key = `${reference.type}:${reference.model}`; + const template = latestTemplateMap.get(key); + if (!template) throw new UserError(`Model template no longer exists: ${key}`); + return template; + }); + + const existingModels = await MongoAIModel.find({ + scope: ModelScopeEnum.system, + model: { $in: selectedTemplates.map(({ model }) => model) } + }) + .select({ model: 1 }) + .lean(); + const existingModelNames = new Set(existingModels.map((model) => model.model)); + const modelsToCreate = selectedTemplates + .filter((template) => !existingModelNames.has(template.model)) + .map((template) => ({ ...template, isActive: false })); + + await appendModelsToAIProxyChannels({ + channelIds, + models: modelsToCreate.map((model) => model.model) + }); + + const createdModels = await runSystemModelTransaction(async (session) => { + if (modelsToCreate.length === 0) return []; + return MongoAIModel.insertMany(modelsToCreate, { session }); + }); + + await updatedReloadSystemModel(); + + return CreateSystemModelsFromTemplatesResponseSchema.parse({ + models: createdModels.map((model) => ({ + modelId: String(model._id), + type: model.type, + model: model.model + })) + }); +}; + +/** 按稳定 ID 先事务删除模型与权限并刷新缓存,再解绑渠道;解绑失败不回退删除。 */ +export const deleteSystemModels = async ({ modelIds }: DeleteSystemModelsBody): Promise => { + const models = await MongoAIModel.find({ _id: { $in: modelIds }, scope: ModelScopeEnum.system }) + .select({ model: 1 }) + .lean(); + if (models.length !== modelIds.length) throw ModelErrEnum.unExist; + + await runSystemModelTransaction(async (session) => { + const result = await MongoAIModel.deleteMany( + { _id: { $in: modelIds }, scope: ModelScopeEnum.system }, + { session } + ); + if (result.deletedCount !== modelIds.length) return Promise.reject(ModelErrEnum.unExist); + + await MongoResourcePermission.deleteMany( + { + resourceType: PerResourceTypeEnum.model, + resourceId: { $in: modelIds } + }, + { session } + ); + }); + + await updatedReloadSystemModel(); + + // 模型删除已经提交;渠道解绑失败向调用方报错,但不恢复模型、权限和缓存。 + await removeModelsFromAIProxyChannels({ models: models.map((model) => model.model) }); +}; + +/** 替换系统模型配置,保留命中实例身份;事务删除缺失模型及权限,不修改 AI Proxy 渠道关联。 */ +export const importSystemModels = async ({ + config +}: ParsedSystemModelsWithJsonBody): Promise => { + const latestRecords = config.flatMap((record) => { + const modelId = record.modelId; + return typeof modelId === 'string' && modelId.trim().length > 0 + ? [{ record, modelId: modelId.trim() }] + : []; + }); + // 非空旧配置全部缺少 modelId 时按“过滤旧数据”处理,不能意外删除全部现有模型。 + if (config.length > 0 && latestRecords.length === 0) return; + + const assertNoDuplicateIds = (models: Array<{ modelId: string }>) => { + const modelIds = new Set(); + for (const model of models) { + if (modelIds.has(model.modelId)) throw new UserError(`Duplicate modelId: ${model.modelId}`); + modelIds.add(model.modelId); + } + }; + assertNoDuplicateIds(latestRecords); + + await runSystemModelTransaction(async (session) => { + const existingModels = await MongoAIModel.find( + { scope: ModelScopeEnum.system }, + '_id model type' + ) + .session(session) + .lean(); + const existingModelMap = new Map( + existingModels.map((model) => [ + String(model._id), + { modelId: String(model._id), model: model.model, type: model.type } + ]) + ); + const existingModelNameMap = new Map( + existingModels.map((model) => [ + model.model, + { modelId: String(model._id), model: model.model, type: model.type } + ]) + ); + const importedModels = latestRecords.map(({ record, modelId }, index) => { + const existingModel = + existingModelMap.get(modelId) ?? + (typeof record.model === 'string' ? existingModelNameMap.get(record.model) : undefined); + // 本地已有模型的调用标识与类型均不可变,导入时在完整校验前使用持久化值覆盖输入。 + const parsed = ImportedSystemModelSchema.safeParse( + existingModel + ? { + ...record, + modelId, + model: existingModel.model, + type: existingModel.type + } + : { ...record, modelId } + ); + if (!parsed.success) { + throw new UserError(`Invalid system model at index ${index}: ${parsed.error.message}`); + } + return { data: parsed.data, existingModel }; + }); + + const resolvedModels = importedModels.map( + ({ data: { modelId: importedModelId, ...importedData }, existingModel }) => { + // JSON 和编辑器共用“读取时转换 -> 保存新格式”;导出本身不改写历史价格。 + const modelData = normalizeModelPricingForSave(normalizeModelPricingForRead(importedData)); + if (!existingModel) { + return { + modelId: importedModelId, + model: modelData.model, + modelData, + isExistingModel: false + }; + } + + // 本实例已存在的模型按持久化 ID 更新,导入的 model/type 均不参与写入。 + const { model: _importedModel, ...editableModelData } = modelData; + return { + modelId: existingModel.modelId, + model: existingModel.model, + modelData: editableModelData, + isExistingModel: true + }; + } + ); + + const modelNames = new Set(); + for (const model of resolvedModels) { + if (modelNames.has(model.model)) throw new UserError(`Duplicate model: ${model.model}`); + modelNames.add(model.model); + } + const configuredModels = resolvedModels.map((model) => model.model); + + const removedModelIds = existingModels + .filter(({ model }) => !configuredModels.includes(model)) + .map(({ _id }) => _id); + if (removedModelIds.length > 0) { + // 配置替换只删除本地模型和权限,保留渠道配置供后续重新导入使用。 + await MongoAIModel.deleteMany( + { _id: { $in: removedModelIds }, scope: ModelScopeEnum.system }, + { session } + ); + await MongoResourcePermission.deleteMany( + { + resourceType: PerResourceTypeEnum.model, + resourceId: { $in: removedModelIds } + }, + { session } + ); + } + + if (importedModels.length === 0) return; + await MongoAIModel.bulkWrite( + resolvedModels.map(({ modelId, model, modelData, isExistingModel }) => ({ + updateOne: { + filter: isExistingModel + ? { _id: modelId, scope: ModelScopeEnum.system } + : { scope: ModelScopeEnum.system, model }, + update: isExistingModel ? getSystemModelConfigUpdate(modelData) : { $set: modelData }, + upsert: !isExistingModel + } + })), + { session } + ); + }); + + await updatedReloadSystemModel(); +}; + +/** 校验默认模型引用并提交配置,不接受失效或类型不匹配的引用。 */ +export const updateSystemDefaultModels = async ( + defaults: UpdateDefaultModelsBody +): Promise => { + await runSystemModelTransaction(async (session) => { + const defaultFields = [ + { + modelId: defaults[ModelTypeEnum.llm], + expectedType: ModelTypeEnum.llm + }, + { + modelId: defaults[ModelTypeEnum.embedding], + expectedType: ModelTypeEnum.embedding + }, + { + modelId: defaults[ModelTypeEnum.tts], + expectedType: ModelTypeEnum.tts + }, + { + modelId: defaults[ModelTypeEnum.stt], + expectedType: ModelTypeEnum.stt + }, + { + modelId: defaults[ModelTypeEnum.rerank], + expectedType: ModelTypeEnum.rerank + }, + { + modelId: defaults.datasetTextLLMModelId, + expectedType: ModelTypeEnum.llm + }, + { + modelId: defaults.datasetImageLLMModelId, + expectedType: ModelTypeEnum.llm, + requiresVision: true + }, + { + modelId: defaults.chatTitleLLMModelId, + expectedType: ModelTypeEnum.llm + } + ].filter((item): item is typeof item & { modelId: string } => typeof item.modelId === 'string'); + + if (defaultFields.length > 0) { + // 全表读取后按 String(_id) 精确匹配,统一处理 API 层已校验的 ObjectId 字符串。 + const modelMap = new Map( + ( + await MongoAIModel.find( + { scope: ModelScopeEnum.system }, + '_id name model type isActive config.vision' + ) + .session(session) + .lean() + ).map((model) => [String(model._id), model]) + ); + + for (const { modelId, expectedType, requiresVision } of defaultFields) { + assertModelAvailable({ + model: modelMap.get(modelId), + type: expectedType, + vision: requiresVision + }); + } + } + + const configuredDefaultModelIds = { + [ModelTypeEnum.llm]: defaults[ModelTypeEnum.llm], + [ModelTypeEnum.embedding]: defaults[ModelTypeEnum.embedding], + [ModelTypeEnum.tts]: defaults[ModelTypeEnum.tts], + [ModelTypeEnum.stt]: defaults[ModelTypeEnum.stt], + [ModelTypeEnum.rerank]: defaults[ModelTypeEnum.rerank], + datasetTextLLM: defaults.datasetTextLLMModelId, + datasetImageLLM: defaults.datasetImageLLMModelId, + chatTitleLLM: defaults.chatTitleLLMModelId + }; + + await upsertSystemDefaultModelIds(configuredDefaultModelIds, session); + }); + + await updatedReloadSystemModel(); +}; diff --git a/projects/app/src/service/core/app/workflow.ts b/projects/app/src/service/core/app/workflow.ts index 4cceef7e480a..b2dcbbbb601c 100644 --- a/projects/app/src/service/core/app/workflow.ts +++ b/projects/app/src/service/core/app/workflow.ts @@ -1,9 +1,13 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node'; import { isWorkflowSystemModelInput } from '@fastgpt/global/core/workflow/utils'; -import { getOptionalLLMModelData } from '@fastgpt/service/core/ai/model'; -export const getChatModelNameListByModules = (nodes: StoreNodeItemType[]): string[] => { +/** 获取一次目录后同步投影模型名称,缺失或停用引用只影响展示,不改变工作流本身。 */ +export const getChatModelNameListByModules = async ( + nodes: StoreNodeItemType[] +): Promise => { + const modelHandle = await getModelHandle(); const modelList = nodes .map((item) => { const modelIdInput = item.inputs.find( @@ -20,7 +24,7 @@ export const getChatModelNameListByModules = (nodes: StoreNodeItemType[]): strin const model = modelInput?.value; try { - return getOptionalLLMModelData({ modelId, model })?.name ?? ''; + return modelHandle.getLLMModelData({ modelId, model }, { optional: true })?.name ?? ''; } catch { // chatModels 仅用于标题栏展示。动态、缺失或已停用模型不应阻断聊天初始化。 return ''; diff --git a/projects/app/src/service/core/dataset/queues/datasetParse.ts b/projects/app/src/service/core/dataset/queues/datasetParse.ts index f293853c28b1..50438f2a2a84 100644 --- a/projects/app/src/service/core/dataset/queues/datasetParse.ts +++ b/projects/app/src/service/core/dataset/queues/datasetParse.ts @@ -1,3 +1,6 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; +import { getDatasetModelReference } from '@fastgpt/service/core/dataset/model'; + /* Dataset collection source parse, not max size. */ import { ParagraphChunkAIModeEnum } from '@fastgpt/global/core/dataset/constants'; @@ -18,11 +21,6 @@ import { checkTeamAiPointsAndLock } from './utils'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { delay } from '@fastgpt/global/common/system/utils'; import { rawText2Chunks, readDatasetSourceRawText } from '@fastgpt/service/core/dataset/read'; -import { - findDatasetAgentModel, - getDatasetEmbeddingModel, - findDatasetVlmModel -} from '@fastgpt/service/core/dataset/model'; import { getLLMMaxChunkSize } from '@fastgpt/global/core/dataset/training/utils'; import { checkDatasetIndexLimit } from '@fastgpt/service/support/permission/teamLimit'; import { predictDataLimitLength } from '@fastgpt/global/core/dataset/utils'; @@ -243,9 +241,18 @@ export const datasetParseQueue = async (): Promise => { try { // 解析阶段只严格校验向量模型;辅助模型仅取分块元数据,不校验启用或可调用状态。 - const embeddingModelData = getDatasetEmbeddingModel(dataset); - const agentModelData = findDatasetAgentModel(dataset); - const vlmModelData = findDatasetVlmModel(dataset); + const modelHandle = await getModelHandle(); + const embeddingModelData = modelHandle.getEmbeddingModelData( + getDatasetModelReference(dataset, 'embedding') + ); + const agentModelData = modelHandle.findModelData( + getDatasetModelReference(dataset, 'agent'), + { type: 'llm' } + ); + const vlmModelData = modelHandle.findModelData(getDatasetModelReference(dataset, 'vlm'), { + type: 'llm', + vision: true + }); const vlmModelConfigured = !isEmptyModelValue( getModelReferenceValue({ modelId: dataset.vlmModelId, model: dataset.vlmModel }) ); diff --git a/projects/app/src/service/core/dataset/queues/generateQA.ts b/projects/app/src/service/core/dataset/queues/generateQA.ts index c3786b447ebf..ecf415d4d061 100644 --- a/projects/app/src/service/core/dataset/queues/generateQA.ts +++ b/projects/app/src/service/core/dataset/queues/generateQA.ts @@ -1,3 +1,6 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; +import { getDatasetModelReference } from '@fastgpt/service/core/dataset/model'; + import { MongoDatasetTraining } from '@fastgpt/service/core/dataset/training/schema'; import { pushLLMTrainingUsage } from '@fastgpt/service/support/wallet/usage/controller'; import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants'; @@ -6,11 +9,7 @@ import { getLogger, LogCategories } from '@fastgpt/service/common/logger'; import { replaceVariable } from '@fastgpt/service/common/string/replaceVariable'; import { Prompt_AgentQA } from '@fastgpt/global/core/ai/prompt/agent'; import type { PushDataChunkType } from '@fastgpt/global/openapi/core/dataset/data/api'; -import { - getDatasetAgentModel, - getDatasetEmbeddingModel, - getDatasetVlmModel -} from '@fastgpt/service/core/dataset/model'; + import { checkTeamAiPointsAndLock } from './utils'; import { addMinutes } from 'date-fns'; import type { LLMSystemModelDataType } from '@fastgpt/global/core/ai/model.schema'; @@ -133,9 +132,17 @@ export async function generateQA(): Promise { }); try { - const modelData = getDatasetAgentModel(data.dataset); - const embeddingModelData = getDatasetEmbeddingModel(data.dataset); - const vlmModelData = getDatasetVlmModel(data.dataset); + const modelHandle = await getModelHandle(); + const modelData = modelHandle.getLLMModelData( + getDatasetModelReference(data.dataset, 'agent') + ); + const embeddingModelData = modelHandle.getEmbeddingModelData( + getDatasetModelReference(data.dataset, 'embedding') + ); + const vlmModelData = modelHandle.getVlmModelData( + getDatasetModelReference(data.dataset, 'vlm'), + { optional: true } + ); const prompt = `${data.collection.qaPrompt || Prompt_AgentQA.description} ${replaceVariable(Prompt_AgentQA.fixedText, { text })}`; diff --git a/projects/app/src/service/core/dataset/queues/generateVector.ts b/projects/app/src/service/core/dataset/queues/generateVector.ts index d6f2eb7cfd12..1a8aa4ee2037 100644 --- a/projects/app/src/service/core/dataset/queues/generateVector.ts +++ b/projects/app/src/service/core/dataset/queues/generateVector.ts @@ -1,3 +1,6 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; +import { getDatasetModelReference } from '@fastgpt/service/core/dataset/model'; + import { createDatasetData, updateDatasetDataByIndexes } from '@/service/core/dataset/data/data'; import { MongoDatasetTraining } from '@fastgpt/service/core/dataset/training/schema'; import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants'; @@ -5,7 +8,7 @@ import { pushGenerateVectorUsage } from '@/service/support/wallet/usage/push'; import { checkTeamAiPointsAndLock } from './utils'; import { addMinutes } from 'date-fns'; import { getLogger, LogCategories } from '@fastgpt/service/common/logger'; -import { getDatasetEmbeddingModel, getDatasetVlmModel } from '@fastgpt/service/core/dataset/model'; + import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { getMaxIndexSize } from '@fastgpt/global/core/dataset/training/utils'; @@ -49,13 +52,18 @@ type TrainingDataType = DatasetTrainingSchemaType & PopulateType; * 重新生成;这里仅保留 custom/question/summary/image 等外部索引。其中 image 是 VLM * 生成的文本描述索引,只有当前集合仍开启图片索引且 VLM 可用时才保留。 */ -export const getRebuildBaseIndexes = (trainingData: TrainingDataType) => { +export const getRebuildBaseIndexes = async (trainingData: TrainingDataType) => { const sourceIndexes = trainingData.indexes?.length ? trainingData.indexes.map((index) => ({ ...index })) : trainingData.data?.indexes || []; + const modelHandle = await getModelHandle(); const { supportVlm } = getDatasetImageIndexCapability({ - vectorModel: getDatasetEmbeddingModel(trainingData.dataset), - vlmModel: getDatasetVlmModel(trainingData.dataset) + vectorModel: modelHandle.getEmbeddingModelData( + getDatasetModelReference(trainingData.dataset, 'embedding') + ), + vlmModel: modelHandle.getVlmModelData(getDatasetModelReference(trainingData.dataset, 'vlm'), { + optional: true + }) }); return sourceIndexes.filter((index) => { @@ -75,14 +83,14 @@ export const getRebuildBaseIndexes = (trainingData: TrainingDataType) => { /** * 获取完整 rebuild 最终写入的数据,优先使用本轮图片和自动索引训练产物。 */ -export const getRebuildUpdateInput = (trainingData: TrainingDataType) => { +export const getRebuildUpdateInput = async (trainingData: TrainingDataType) => { if (!trainingData.data) return; return { q: trainingData.q ? trainingData.q : trainingData.data.q, a: trainingData.a ?? trainingData.data.a, imageId: trainingData.data.imageId, - indexes: getRebuildBaseIndexes(trainingData), + indexes: await getRebuildBaseIndexes(trainingData), imageDescMap: trainingData.imageDescMap }; }; @@ -199,11 +207,14 @@ export async function generateVector(): Promise { })(); // push usage + const modelHandle = await getModelHandle(); pushGenerateVectorUsage({ teamId: data.teamId, tmbId: data.tmbId, inputTokens: tokens, - model: getDatasetEmbeddingModel(data.dataset), + model: modelHandle.getEmbeddingModelData( + getDatasetModelReference(data.dataset, 'embedding') + ), usageId: data.billId }); @@ -251,18 +262,24 @@ const enqueueFollowingDatasetRebuild = async ({ trainingData }: { trainingData: TrainingDataType; -}) => - retryFn(() => +}) => { + const modelHandle = await getModelHandle(); + return retryFn(() => enqueueNextDatasetRebuildTask({ teamId: String(trainingData.teamId), tmbId: String(trainingData.tmbId), datasetId: String(trainingData.datasetId), billId: trainingData.billId, - vectorModel: getDatasetEmbeddingModel(trainingData.dataset), - vlmModel: getDatasetVlmModel(trainingData.dataset), + vectorModel: modelHandle.getEmbeddingModelData( + getDatasetModelReference(trainingData.dataset, 'embedding') + ), + vlmModel: modelHandle.getVlmModelData(getDatasetModelReference(trainingData.dataset, 'vlm'), { + optional: true + }), synonymVersion: trainingData.synonymVersion }) ); +}; const rebuildData = async ({ trainingData }: { trainingData: TrainingDataType }) => { // 同义词重建需要可靠续接;普通模型重建保持原有的尽力续接语义。 @@ -278,9 +295,11 @@ const rebuildData = async ({ trainingData }: { trainingData: TrainingDataType }) return Promise.reject('Not data'); } const datasetData = trainingData.data; - - const embModel = getDatasetEmbeddingModel(trainingData.dataset); - const rebuildUpdateInput = getRebuildUpdateInput(trainingData); + const modelHandle = await getModelHandle(); + const embModel = modelHandle.getEmbeddingModelData( + getDatasetModelReference(trainingData.dataset, 'embedding') + ); + const rebuildUpdateInput = await getRebuildUpdateInput(trainingData); const { tokens } = await updateDatasetDataByIndexes({ dataId: String(datasetData._id), @@ -302,8 +321,12 @@ const rebuildData = async ({ trainingData }: { trainingData: TrainingDataType }) }; const insertData = async ({ trainingData }: { trainingData: TrainingDataType }) => { + // 在业务事务开始前获取目录,避免刷新等待延长持锁时间。 + const modelHandle = await getModelHandle(); return mongoSessionRun(async (session) => { - const embModel = getDatasetEmbeddingModel(trainingData.dataset); + const embModel = modelHandle.getEmbeddingModelData( + getDatasetModelReference(trainingData.dataset, 'embedding') + ); // insert new data to dataset const { tokens } = await createDatasetData({ diff --git a/projects/app/src/service/core/dataset/synonym/mutation.ts b/projects/app/src/service/core/dataset/synonym/mutation.ts index 5828efa59b2c..b495242475ee 100644 --- a/projects/app/src/service/core/dataset/synonym/mutation.ts +++ b/projects/app/src/service/core/dataset/synonym/mutation.ts @@ -1,3 +1,5 @@ +import { getModelHandle } from '@fastgpt/service/core/ai/model'; +import { getDatasetModelReference } from '@fastgpt/service/core/dataset/model'; import type { ApiRequestProps } from '@fastgpt/next/type'; import path from 'node:path'; import { Types } from '@fastgpt/service/common/mongo'; @@ -14,11 +16,7 @@ import { MongoDatasetSynonym, MongoDatasetSynonymMapping } from '@fastgpt/service/core/dataset/synonym/schema'; -import { - getDatasetAgentModel, - getDatasetEmbeddingModel, - getDatasetVlmModel -} from '@fastgpt/service/core/dataset/model'; + import { createTrainingUsage } from '@fastgpt/service/support/wallet/usage/controller'; import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; import { MongoDatasetTraining } from '@fastgpt/service/core/dataset/training/schema'; @@ -98,9 +96,14 @@ export const createDatasetSynonymMutation = async ({ const fileVersion = (current?.version ?? 0) + 1; const now = new Date(); const normalizedFileName = path.basename(fileName) || 'synonyms.csv'; - const vectorModelData = getDatasetEmbeddingModel(dataset); - const agentModelData = getDatasetAgentModel(dataset); - const vlmModelData = getDatasetVlmModel(dataset); + const modelHandle = await getModelHandle(); + const vectorModelData = modelHandle.getEmbeddingModelData( + getDatasetModelReference(dataset, 'embedding') + ); + const agentModelData = modelHandle.getLLMModelData(getDatasetModelReference(dataset, 'agent')); + const vlmModelData = modelHandle.getVlmModelData(getDatasetModelReference(dataset, 'vlm'), { + optional: true + }); const { usageId } = await createTrainingUsage({ teamId, tmbId, diff --git a/projects/app/src/web/core/ai/channel.ts b/projects/app/src/web/core/ai/channel.ts index e3bc8fa94181..0f33f4b7f6d1 100644 --- a/projects/app/src/web/core/ai/channel.ts +++ b/projects/app/src/web/core/ai/channel.ts @@ -109,16 +109,8 @@ export function DELETE(url: string, data = {}): Promise { // ====== API ====== export const getChannelList = () => - GET('/channels/all', { - page: 1, - perPage: 10 - }).then((res) => { - res.sort((a, b) => { - if (a.status !== b.status) { - return a.status - b.status; - } - return b.priority - a.priority; - }); + GET('/channels/all').then((res) => { + res.sort((a, b) => b.created_at - a.created_at || b.id - a.id); return res; }); @@ -134,32 +126,59 @@ export const getChannelProviders = () => > >('/channels/type_metas'); -export const postCreateChannel = (data: CreateChannelProps) => - POST(`/createChannel`, { +/** FastGPT 所有渠道创建入口的统一提交方法,创建前按展示名称检查重复。 */ +export const postCreateChannel = async (data: CreateChannelProps) => { + const name = data.name.trim(); + const channels = await getChannelList(); + if (channels.some((channel) => channel.name.trim() === name)) { + return Promise.reject(i18nT('config_model:channel_name_duplicate')); + } + + return POST<{ id: number }>(`/createChannel`, { type: data.type, - name: data.name, + name, base_url: data.base_url, models: data.models, model_mapping: data.model_mapping, key: data.key, priority: 1 }); +}; export const putChannelStatus = (id: number, status: ChannelStatusEnum) => POST(`/channel/${id}/status`, { status }); -export const putChannel = (data: ChannelInfoType) => - PUT(`/channel/${data.id}`, { +/** + * AI Proxy v0.6.5 仅提供渠道完整更新接口,因此必须把列表快照中的高级配置原样带回。 + * balance_threshold 无法通过该版本接口可靠往返,非零时在写入前拒绝,避免静默归零。 + */ +export const putChannel = (data: ChannelInfoType) => { + if (data.balance_threshold !== undefined && data.balance_threshold !== 0) { + return Promise.reject( + new Error(`AI Proxy v0.6.5 cannot preserve balance_threshold for channel: ${data.id}`) + ); + } + + return PUT(`/channel/${data.id}`, { type: data.type, name: data.name, base_url: data.base_url, + proxy_url: data.proxy_url, models: data.models, model_mapping: data.model_mapping, + configs: data.configs, key: data.key, status: data.status, - priority: data.priority ? Math.max(data.priority, 1) : undefined + priority: Math.max(data.priority ?? 1, 1), + sets: data.sets, + enabled_auto_balance_check: data.enabled_auto_balance_check, + skip_tls_verify: data.skip_tls_verify, + enabled_no_permission_ban: data.enabled_no_permission_ban, + warn_error_rate: data.warn_error_rate, + max_error_rate: data.max_error_rate }); +}; export const deleteChannel = (id: number) => DELETE(`/channel/${id}`); diff --git a/projects/app/src/web/core/ai/config.ts b/projects/app/src/web/core/ai/config.ts index 134b214e866a..a1e39db31338 100644 --- a/projects/app/src/web/core/ai/config.ts +++ b/projects/app/src/web/core/ai/config.ts @@ -1,38 +1,47 @@ import { DELETE, GET, POST, PUT } from '@/web/common/api/request'; -import type { - SystemModelDataType, - SystemModelDocumentDataType -} from '@fastgpt/global/core/ai/model.schema'; import type { AdminSystemModelReference, CreateSystemModelBody, CreateSystemModelResponse, + CreateSystemModelsFromTemplatesBody, + CreateSystemModelsFromTemplatesResponse, + DeleteSystemModelsBody, + GetAdminModelTemplatesResponse, + GetAdminSystemModelDetailResponse, GetAdminSystemModelListResponse, + ReplaceSystemModelChannelsBody, TestAdminSystemModelQuery, + TestDraftAdminSystemModelBody, UpdateDefaultModelsBody, UpdateSystemModelBody, + UpdateSystemModelStatusBody, UpdateSystemModelsWithJsonBody } from '@fastgpt/global/openapi/admin/core/ai/model/api'; const adminModelPath = '/admin/settings/model'; -export const getSystemModelList = () => - GET(`${adminModelPath}/list`).then((res) => res.models); export const getAdminModelConfig = () => GET(`${adminModelPath}/list`); export const getSystemModelDetail = (modelId: string) => - GET(`${adminModelPath}/detail`, { modelId }); - -export const getSystemModelDefaultConfig = (modelId: string) => - GET(`${adminModelPath}/getDefaultConfig`, { modelId }); + GET(`${adminModelPath}/detail`, { modelId }); export const postSystemModel = (data: CreateSystemModelBody) => POST(`${adminModelPath}/create`, data); +export const getAdminModelTemplates = () => + GET(`${adminModelPath}/templates`); +export const postSystemModelsFromTemplates = (data: CreateSystemModelsFromTemplatesBody) => + POST(`${adminModelPath}/createFromTemplates`, data); +export const putReplaceSystemModelChannels = (data: ReplaceSystemModelChannelsBody) => + PUT(`${adminModelPath}/channel/replace`, data); export const putSystemModel = (data: UpdateSystemModelBody) => PUT(`${adminModelPath}/update`, data); +export const putSystemModelsStatus = (data: UpdateSystemModelStatusBody) => + PUT(`${adminModelPath}/updateStatus`, data); export const deleteSystemModel = (data: AdminSystemModelReference) => DELETE(`${adminModelPath}/delete`, data); +export const deleteSystemModels = (data: DeleteSystemModelsBody) => + DELETE(`${adminModelPath}/delete`, data, { dataAsBody: true }); export const getModelConfigJson = () => GET(`${adminModelPath}/getConfigJson`); export const putUpdateWithJson = (data: UpdateSystemModelsWithJsonBody) => @@ -40,6 +49,8 @@ export const putUpdateWithJson = (data: UpdateSystemModelsWithJsonBody) => export const getTestModel = (data: TestAdminSystemModelQuery) => GET(`${adminModelPath}/test`, data); +export const postTestDraftModel = (data: TestDraftAdminSystemModelBody) => + POST(`${adminModelPath}/test`, data); export const putUpdateDefaultModels = (data: UpdateDefaultModelsBody) => PUT(`${adminModelPath}/updateDefault`, data); diff --git a/projects/app/src/web/core/ai/model/useAdminModelConfig.ts b/projects/app/src/web/core/ai/model/useAdminModelConfig.ts index ac90b3f3fbf3..6476a2109dc6 100644 --- a/projects/app/src/web/core/ai/model/useAdminModelConfig.ts +++ b/projects/app/src/web/core/ai/model/useAdminModelConfig.ts @@ -10,6 +10,16 @@ import { /** 管理员模型页面的独立数据源,不读取普通成员 useUserModelStore。 */ export const useAdminModelConfig = () => { const request = useRequest(getAdminModelConfig, { manual: false }); + // 加载中和失败时保持空集合引用稳定,避免消费方 effect -> setState 形成渲染循环。 + const systemModelList = useMemo(() => request.data?.models ?? [], [request.data?.models]); + const defaultModelIds = useMemo( + () => request.data?.defaultModelIds ?? {}, + [request.data?.defaultModelIds] + ); + const aiproxyChannels = useMemo( + () => request.data?.aiproxyChannels ?? [], + [request.data?.aiproxyChannels] + ); const providerCache = useMemo( () => formatModelProviders(request.data?.providers ?? []), [request.data?.providers] @@ -27,9 +37,9 @@ export const useAdminModelConfig = () => { return { ...request, - systemModelList: request.data?.models ?? [], - defaultModelIds: request.data?.defaultModelIds ?? {}, - aiproxyChannels: request.data?.aiproxyChannels ?? [], + systemModelList, + defaultModelIds, + aiproxyChannels, getModelProvider, getModelProviders }; diff --git a/projects/app/test/api/admin/settings/model/auth.test.ts b/projects/app/test/api/admin/settings/model/auth.test.ts new file mode 100644 index 000000000000..be8417e037a1 --- /dev/null +++ b/projects/app/test/api/admin/settings/model/auth.test.ts @@ -0,0 +1,55 @@ +import { Call } from '@test/utils/request'; +import { getUser } from '@test/datas/users'; +import { describe, expect, it } from 'vitest'; +import listApi from '@/pages/api/admin/settings/model/list'; +import detailApi from '@/pages/api/admin/settings/model/detail'; +import createApi from '@/pages/api/admin/settings/model/create'; +import templatesApi from '@/pages/api/admin/settings/model/templates'; +import createFromTemplatesApi from '@/pages/api/admin/settings/model/createFromTemplates'; +import replaceChannelsApi from '@/pages/api/admin/settings/model/channel/replace'; +import deleteApi from '@/pages/api/admin/settings/model/delete'; +import testApi from '@/pages/api/admin/settings/model/test'; +import updateApi from '@/pages/api/admin/settings/model/update'; +import updateStatusApi from '@/pages/api/admin/settings/model/updateStatus'; +import getConfigJsonApi from '@/pages/api/admin/settings/model/getConfigJson'; +import updateWithJsonApi from '@/pages/api/admin/settings/model/updateWithJson'; +import updateDefaultApi from '@/pages/api/admin/settings/model/updateDefault'; + +describe('admin model API authorization', () => { + const adminModelApis = [ + ['list', listApi], + ['detail', detailApi], + ['create', createApi], + ['templates', templatesApi], + ['createFromTemplates', createFromTemplatesApi], + ['channel/replace', replaceChannelsApi], + ['delete', deleteApi], + ['test', testApi], + ['update', updateApi], + ['updateStatus', updateStatusApi], + ['getConfigJson', getConfigJsonApi], + ['updateWithJson', updateWithJsonApi], + ['updateDefault', updateDefaultApi] + ] as const; + + it.each(adminModelApis)( + 'rejects an unauthenticated %s request before handling model data', + async (_name, api) => { + const response = await Call(api); + + expect(response.code).not.toBe(200); + expect(response.error).toBeDefined(); + } + ); + + it('rejects every administrator model endpoint for an authenticated non-root user', async () => { + const user = await getUser('non-root-admin-model-api'); + + const responses = await Promise.all(adminModelApis.map(([, api]) => Call(api, { auth: user }))); + + for (const response of responses) { + expect(response.code).not.toBe(200); + expect(response.error).toBeDefined(); + } + }); +}); diff --git a/projects/app/test/api/admin/settings/model/getConfigJson.test.ts b/projects/app/test/api/admin/settings/model/getConfigJson.test.ts new file mode 100644 index 000000000000..2527c2579745 --- /dev/null +++ b/projects/app/test/api/admin/settings/model/getConfigJson.test.ts @@ -0,0 +1,85 @@ +import { getRootUser } from '@test/datas/users'; +import { Call } from '@test/utils/request'; +import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import { MongoAIModel } from '@fastgpt/service/core/ai/config/schema'; +import { describe, expect, it } from 'vitest'; +import getConfigJsonApi from '@/pages/api/admin/settings/model/getConfigJson'; + +describe('GET /api/admin/settings/model/getConfigJson', () => { + it('exports an empty JSON array when no system model is installed', async () => { + const root = await getRootUser(); + + const response = await Call(getConfigJsonApi, { auth: root }); + + expect(response.code).toBe(200); + expect(JSON.parse(response.data)).toEqual([]); + }); + + it('exports canonical model data with its stable modelId', async () => { + const root = await getRootUser(); + const model = await MongoAIModel.create({ + model: 'exported-model', + type: ModelTypeEnum.llm, + provider: 'OpenAI', + name: 'Exported model', + scope: 'system', + isActive: true, + requestUrl: 'https://example.com/v1', + requestAuth: 'secret-token', + inputPrice: 1, + outputPrice: 2, + config: { + maxContext: 16000, + maxResponse: 8000, + quoteMaxToken: 12000, + toolChoice: true + }, + metadata: { legacyOnly: true }, + unknownTopLevel: 'strip-me' + }); + + const response = await Call(getConfigJsonApi, { auth: root }); + + expect(response.code).toBe(200); + expect(JSON.parse(response.data)).toEqual([ + expect.objectContaining({ + modelId: String(model._id), + model: 'exported-model', + type: ModelTypeEnum.llm, + provider: 'OpenAI', + requestUrl: 'https://example.com/v1', + requestAuth: 'secret-token', + config: expect.objectContaining({ maxContext: 16000, toolChoice: true }) + }) + ]); + expect(JSON.parse(response.data)[0]).not.toHaveProperty('_id'); + expect(JSON.parse(response.data)[0]).not.toHaveProperty('metadata'); + expect(JSON.parse(response.data)[0]).not.toHaveProperty('unknownTopLevel'); + expect(JSON.parse(response.data)[0]).toMatchObject({ inputPrice: 1, outputPrice: 2 }); + expect(JSON.parse(response.data)[0].priceTiers).toEqual([]); + // 导出保留数据库中的旧价格,不触发格式转换或回写。 + expect(await MongoAIModel.findById(model._id).lean()).toMatchObject({ + inputPrice: 1, + outputPrice: 2 + }); + }); + + it('rejects unauthenticated configuration exports', async () => { + await MongoAIModel.create({ + model: 'private-model', + type: ModelTypeEnum.llm, + provider: 'OpenAI', + name: 'Private model', + scope: 'system', + isActive: true, + requestAuth: 'must-not-leak', + config: { maxContext: 16000, maxResponse: 8000, quoteMaxToken: 12000 } + }); + + const response = await Call(getConfigJsonApi); + + expect(response.code).not.toBe(200); + expect(response.data).toBeUndefined(); + expect(JSON.stringify(response.error)).not.toContain('must-not-leak'); + }); +}); diff --git a/projects/app/test/api/admin/settings/model/list.test.ts b/projects/app/test/api/admin/settings/model/list.test.ts new file mode 100644 index 000000000000..a749a590c942 --- /dev/null +++ b/projects/app/test/api/admin/settings/model/list.test.ts @@ -0,0 +1,125 @@ +import { setModelTestSnapshot } from '@test/modelCache'; +import { ModelScopeEnum, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + authSystemAdmin: vi.fn(), + getAdminAIProxyChannelItems: vi.fn() +})); + +vi.mock('@/service/middleware/entry', () => ({ NextAPI: (handler: unknown) => handler })); +vi.mock('@fastgpt/service/support/permission/user/auth', () => ({ + authSystemAdmin: mocks.authSystemAdmin +})); +vi.mock('@fastgpt/service/thirdProvider/aiproxy/channel', () => ({ + getAdminAIProxyChannelItems: mocks.getAdminAIProxyChannelItems +})); + +import handler from '@/pages/api/admin/settings/model/list'; + +describe('GET /api/admin/settings/model/list', () => { + beforeEach(() => { + vi.clearAllMocks(); + setModelTestSnapshot({ + models: [ + { + modelId: '68ad85a7463006c963799a05', + model: 'gpt-test', + name: 'GPT Test', + provider: 'OpenAI', + scope: ModelScopeEnum.system, + type: ModelTypeEnum.llm, + isActive: true, + requestAuth: 'secret', + config: { maxContext: 128000, maxResponse: 16000, quoteMaxToken: 30000 } + } + ] + }); + global.ModelProviderRawCache = [ + { + provider: 'OpenAI', + value: { en: 'OpenAI', 'zh-CN': 'OpenAI', 'zh-Hant': 'OpenAI' }, + avatar: 'model/openai' + } + ]; + global.aiproxyChannelsCache = [ + { + channelId: 1, + name: { en: 'OpenAI', 'zh-CN': 'OpenAI', 'zh-Hant': 'OpenAI' }, + avatar: 'model/openai' + } + ]; + setModelTestSnapshot({ configuredDefaultModelIds: {} }); + mocks.getAdminAIProxyChannelItems.mockResolvedValue([ + { + models: ['other-model'], + summary: { + id: 2, + name: 'disabled-channel', + protocol: { + name: { en: '9', 'zh-CN': '9', 'zh-Hant': '9' }, + avatar: '' + }, + status: 2 + } + }, + { + models: ['gpt-test'], + summary: { + id: 1, + name: 'enabled-channel', + protocol: { + name: { en: 'OpenAI', 'zh-CN': 'OpenAI', 'zh-Hant': 'OpenAI' }, + avatar: 'model/openai' + }, + status: 1 + } + } + ]); + }); + + it('returns one channel snapshot enriched at model and list levels', async () => { + const result = await handler({} as never); + + expect(mocks.authSystemAdmin).toHaveBeenCalledOnce(); + expect(mocks.getAdminAIProxyChannelItems).toHaveBeenCalledOnce(); + expect(result.channels.map((channel) => channel.name)).toEqual([ + 'disabled-channel', + 'enabled-channel' + ]); + expect(result.models[0].channels).toEqual([result.channels[1]]); + expect(result.models[0].requestAuth).toBeUndefined(); + expect(result.channels[1]).toEqual({ + id: 1, + name: 'enabled-channel', + protocol: { + name: { en: 'OpenAI', 'zh-CN': 'OpenAI', 'zh-Hant': 'OpenAI' }, + avatar: 'model/openai' + }, + status: 1 + }); + expect(result.channels[0].protocol).toEqual({ + name: { en: '9', 'zh-CN': '9', 'zh-Hant': '9' }, + avatar: '' + }); + }); + + it('returns an empty model and channel snapshot when neither is configured', async () => { + setModelTestSnapshot({ models: [] }); + mocks.getAdminAIProxyChannelItems.mockResolvedValue([]); + + const result = await handler({} as never); + + expect(result.models).toEqual([]); + expect(result.channels).toEqual([]); + expect(result.providers).toEqual(global.ModelProviderRawCache); + expect(result.aiproxyChannels).toEqual(global.aiproxyChannelsCache); + }); + + it('does not hide an AIProxy channel query failure behind a partial model list', async () => { + const error = new Error('aiproxy unavailable'); + mocks.getAdminAIProxyChannelItems.mockRejectedValue(error); + + await expect(handler({} as never)).rejects.toBe(error); + }); +}); diff --git a/projects/app/test/api/core/ai/model/catalog.test.ts b/projects/app/test/api/core/ai/model/catalog.test.ts index e06252429d90..c5ffd3c9859e 100644 --- a/projects/app/test/api/core/ai/model/catalog.test.ts +++ b/projects/app/test/api/core/ai/model/catalog.test.ts @@ -1,3 +1,6 @@ +import { getCachedModelHandle } from '@fastgpt/service/core/ai/config/handle'; +import type { getModelTestMap } from '@test/modelCache'; +import { setModelTestSnapshot, setModelTestMap } from '@test/modelCache'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; @@ -32,7 +35,6 @@ const model = { type: ModelTypeEnum.llm, scope: 'system' as const, isActive: true, - isCustom: false, requestAuth: 'secret', config: { maxContext: 4096, maxResponse: 1024, quoteMaxToken: 1024 } }; @@ -56,13 +58,16 @@ describe('GET /api/core/ai/model/catalog', () => { mocks.findTeamMember.mockReturnValue({ lean: vi.fn().mockResolvedValue({ role: 'member' }) }); - global.systemModelCatalogVersion = 'catalog-version'; - global.systemModelMap = new Map([ - [`id:${model.modelId}`, model] - ]) as typeof global.systemModelMap; - global.systemActiveModelList = [model] as typeof global.systemActiveModelList; - global.systemModelList = [model] as typeof global.systemModelList; - global.systemConfiguredDefaultModelIds = { llm: model.modelId }; + setModelTestSnapshot({ version: 'catalog-version' }); + setModelTestMap( + new Map([[`id:${model.modelId}`, model]]) as ReturnType + ); + setModelTestSnapshot({ + models: [model] as ReturnType< + NonNullable>['getActiveModels'] + > + }); + setModelTestSnapshot({ configuredDefaultModelIds: { llm: model.modelId } }); global.ModelProviderRawCache = [ { provider: 'provider', @@ -81,6 +86,41 @@ describe('GET /api/core/ai/model/catalog', () => { expect(result.data?.providers[0].provider).toBe('provider'); }); + it('keeps one snapshot when the catalog is published during permission resolution', async () => { + mocks.getMemberModelCatalogPermission.mockImplementationOnce(async () => { + setModelTestSnapshot({ version: 'new-catalog' }); + setModelTestSnapshot({ models: [] }); + setModelTestSnapshot({ configuredDefaultModelIds: {} }); + global.ModelProviderRawCache = []; + return { modelIds: [model.modelId], version: 'permission-version' }; + }); + const result = await handler({ query: {} } as any); + expect(result.version).toBe('3:catalog-version:permission-version'); + expect(result.data?.models.map((item) => item.modelId)).toEqual([model.modelId]); + expect(result.data?.defaultModelIds.llm).toBe(model.modelId); + expect(result.data?.providers[0].provider).toBe('provider'); + }); + + it.each(['authentication', 'permission query'])( + 'rejects %s failure even when the client has the current cached version', + async (stage) => { + const failure = new Error(`Injected ${stage} failure`); + if (stage === 'authentication') { + mocks.authUserPer.mockRejectedValueOnce(failure); + } else { + mocks.getMemberModelCatalogPermission.mockRejectedValueOnce(failure); + } + + await expect( + handler({ query: { version: '3:catalog-version:permission-version' } } as any) + ).rejects.toBe(failure); + expect(getCachedModelHandle()?.getActiveModels()).toHaveLength(1); + if (stage === 'authentication') { + expect(mocks.getMemberModelCatalogPermission).not.toHaveBeenCalled(); + } + } + ); + it('returns only the version when the client cache is current', async () => { const result = await handler({ query: { version: '3:catalog-version:permission-version' } @@ -102,7 +142,11 @@ describe('GET /api/core/ai/model/catalog', () => { expect(mocks.getMemberModelCatalogPermission).toHaveBeenCalledWith({ teamId: 'outlink-team', tmbId: 'outlink-member', - isTeamOwner: false + isTeamOwner: false, + catalogSnapshot: { + models: getCachedModelHandle()?.getActiveModels(), + revision: getCachedModelHandle()?.revision ?? 0 + } }); }); @@ -113,7 +157,11 @@ describe('GET /api/core/ai/model/catalog', () => { model: 'provider-model-2', name: 'Model 2' }; - global.systemActiveModelList = [model, secondModel] as typeof global.systemActiveModelList; + setModelTestSnapshot({ + models: [model, secondModel] as ReturnType< + NonNullable>['getActiveModels'] + > + }); mocks.getMemberModelCatalogPermission.mockResolvedValue({ modelIds: [secondModel.modelId, model.modelId], version: 'permission-version' @@ -128,11 +176,13 @@ describe('GET /api/core/ai/model/catalog', () => { }); it('does not include inactive model metadata in the candidate catalog', async () => { - global.systemModelList = [ - model, - { ...model, modelId: 'inactive', isActive: false }, - { ...model, modelId: 'hidden', isActive: false } - ] as typeof global.systemModelList; + setModelTestSnapshot({ + models: [ + model, + { ...model, modelId: 'inactive', isActive: false }, + { ...model, modelId: 'hidden', isActive: false } + ] as ReturnType>['getAllModels']> + }); mocks.getMemberModelCatalogPermission.mockResolvedValue({ modelIds: [model.modelId, 'inactive'], version: 'p' diff --git a/projects/app/test/api/core/ai/model/getDefaultConfig.test.ts b/projects/app/test/api/core/ai/model/getDefaultConfig.test.ts deleted file mode 100644 index 99082cb12584..000000000000 --- a/projects/app/test/api/core/ai/model/getDefaultConfig.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ApiRequestInputParseError } from '@fastgpt/service/common/zod/requestParseError'; - -const mocks = vi.hoisted(() => ({ - authSystemAdmin: vi.fn(), - getSystemModelConfig: vi.fn() -})); - -vi.mock('@/service/middleware/entry', () => ({ - NextAPI: (handler: unknown) => handler -})); - -vi.mock('@fastgpt/service/support/permission/user/auth', () => ({ - authSystemAdmin: mocks.authSystemAdmin -})); - -vi.mock('@fastgpt/service/core/ai/config/utils', () => ({ - getSystemModelConfig: mocks.getSystemModelConfig -})); - -import handler from '@/pages/api/admin/settings/model/getDefaultConfig'; - -describe('GET /api/admin/settings/model/getDefaultConfig', () => { - const modelId = '68ad85a7463006c963799a05'; - - beforeEach(() => { - vi.clearAllMocks(); - mocks.authSystemAdmin.mockResolvedValue(undefined); - mocks.getSystemModelConfig.mockResolvedValue({ - type: 'llm', - provider: 'openai', - model: 'gpt-4o', - name: 'GPT-4o', - scope: 'system' as const, - isActive: true, - config: { - maxContext: 128000, - maxResponse: 16384, - quoteMaxToken: 100000 - } - }); - }); - - it('loads the plugin template by modelId', async () => { - const result = await handler({ query: { modelId } } as any, {} as any); - - expect(mocks.getSystemModelConfig).toHaveBeenCalledWith(modelId); - expect(result).toMatchObject({ model: 'gpt-4o' }); - }); - - it('rejects a legacy model reference', async () => { - await expect(handler({ query: { model: 'gpt-4o' } } as any, {} as any)).rejects.toBeInstanceOf( - ApiRequestInputParseError - ); - expect(mocks.getSystemModelConfig).not.toHaveBeenCalled(); - }); -}); diff --git a/projects/app/test/api/core/ai/model/list.test.ts b/projects/app/test/api/core/ai/model/list.test.ts index 813da86d269d..fb4944ea7662 100644 --- a/projects/app/test/api/core/ai/model/list.test.ts +++ b/projects/app/test/api/core/ai/model/list.test.ts @@ -1,3 +1,4 @@ +import { setModelTestSnapshot } from '@test/modelCache'; import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -7,21 +8,22 @@ import handler from '@/pages/api/core/ai/model/list'; describe('GET /api/core/ai/model/list', () => { beforeEach(() => { - global.systemActiveModelList = [ - { - modelId: 'private-id', - model: 'private-provider-name', - name: 'GPT Public', - provider: 'openai', - type: ModelTypeEnum.llm, - scope: 'system', - isActive: true, - isCustom: false, - requestAuth: 'private-secret', - priceTiers: [{ minInputTokens: 0, inputPrice: 1, outputPrice: 2 }], - config: { maxContext: 128000, maxResponse: 16000, quoteMaxToken: 30000 } - } - ]; + setModelTestSnapshot({ + models: [ + { + modelId: 'private-id', + model: 'private-provider-name', + name: 'GPT Public', + provider: 'openai', + type: ModelTypeEnum.llm, + scope: 'system', + isActive: true, + requestAuth: 'private-secret', + priceTiers: [{ minInputTokens: 0, inputPrice: 1, outputPrice: 2 }], + config: { maxContext: 128000, maxResponse: 16000, quoteMaxToken: 30000 } + } + ] + }); global.ModelProviderRawCache = [ { provider: 'openai', diff --git a/projects/app/test/api/core/ai/model/summary.test.ts b/projects/app/test/api/core/ai/model/summary.test.ts index 1e39bacd9b1f..4a51937a9f55 100644 --- a/projects/app/test/api/core/ai/model/summary.test.ts +++ b/projects/app/test/api/core/ai/model/summary.test.ts @@ -1,3 +1,5 @@ +import type { getModelTestMap } from '@test/modelCache'; +import { getModelTestDefaults, setModelTestMap } from '@test/modelCache'; import { handler } from '@/pages/api/core/ai/model/summary'; import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -30,21 +32,23 @@ describe('POST /api/core/ai/model/summary', () => { }); mocks.permission.mockResolvedValue({ modelIds: ['active', 'disabled'], version: 'p' }); const base = { - ...global.systemDefaultModel.llm!, + ...getModelTestDefaults().llm!, name: 'Model', avatar: 'logo.svg', type: ModelTypeEnum.llm, isActive: true, requestAuth: 'secret', requestUrl: 'private', - config: { ...global.systemDefaultModel.llm!.config, defaultConfig: { private: true } } + config: { ...getModelTestDefaults().llm!.config, defaultConfig: { private: true } } }; - global.systemModelMap = new Map([ - ['id:active', { ...base, modelId: 'active' }], - ['id:disabled', { ...base, modelId: 'disabled', isActive: false }], - ['id:forbidden', { ...base, modelId: 'forbidden' }], - ['id:forbidden-disabled', { ...base, modelId: 'forbidden-disabled', isActive: false }] - ]) as typeof global.systemModelMap; + setModelTestMap( + new Map([ + ['id:active', { ...base, modelId: 'active' }], + ['id:disabled', { ...base, modelId: 'disabled', isActive: false }], + ['id:forbidden', { ...base, modelId: 'forbidden' }], + ['id:forbidden-disabled', { ...base, modelId: 'forbidden-disabled', isActive: false }] + ]) as ReturnType + ); }); it('returns all four states in requested order with only display fields', async () => { const result = await handler({ @@ -62,7 +66,11 @@ describe('POST /api/core/ai/model/summary', () => { teamId: 'team', tmbId: 'member', isTeamOwner: false, - includeInactive: true + includeInactive: true, + catalogSnapshot: expect.objectContaining({ + revision: expect.any(Number), + models: expect.any(Array) + }) }); }); it('authenticates before looking up even deleted model IDs', async () => { @@ -85,7 +93,11 @@ describe('POST /api/core/ai/model/summary', () => { teamId: 'link-team', tmbId: 'link-member', isTeamOwner: true, - includeInactive: true + includeInactive: true, + catalogSnapshot: expect.objectContaining({ + revision: expect.any(Number), + models: expect.any(Array) + }) }); }); it.each([[], [''], Array(101).fill('active')])( diff --git a/projects/app/test/api/core/ai/model/updateDefault.test.ts b/projects/app/test/api/core/ai/model/updateDefault.test.ts index bd40dbca85f2..9c022f4cbbc9 100644 --- a/projects/app/test/api/core/ai/model/updateDefault.test.ts +++ b/projects/app/test/api/core/ai/model/updateDefault.test.ts @@ -7,8 +7,8 @@ const mocks = vi.hoisted(() => ({ authSystemAdmin: vi.fn(), findLean: vi.fn(), upsertSystemDefaultModelIds: vi.fn(), - refreshModelTemplates: vi.fn(), - updatedReloadSystemModel: vi.fn() + updatedReloadSystemModel: vi.fn(), + session: { id: 'default-model-session' } })); vi.mock('@/service/middleware/entry', () => ({ @@ -21,7 +21,7 @@ vi.mock('@fastgpt/service/support/permission/user/auth', () => ({ vi.mock('@fastgpt/service/core/ai/config/schema', () => ({ MongoAIModel: { - find: vi.fn(() => ({ lean: mocks.findLean })) + find: vi.fn(() => ({ session: () => ({ lean: mocks.findLean }) })) } })); @@ -30,11 +30,13 @@ vi.mock('@fastgpt/service/core/ai/defaultModel/entity', () => ({ })); vi.mock('@fastgpt/service/core/ai/config/utils', () => ({ - refreshModelTemplates: mocks.refreshModelTemplates, updatedReloadSystemModel: mocks.updatedReloadSystemModel })); import handler from '@/pages/api/admin/settings/model/updateDefault'; +vi.mock('@fastgpt/service/core/ai/config/entity', () => ({ + runSystemModelTransaction: (fn: (session: unknown) => Promise) => fn(mocks.session) +})); describe('PUT /api/admin/settings/model/updateDefault', () => { beforeEach(() => { @@ -42,7 +44,6 @@ describe('PUT /api/admin/settings/model/updateDefault', () => { mocks.authSystemAdmin.mockResolvedValue(undefined); mocks.findLean.mockResolvedValue([]); mocks.upsertSystemDefaultModelIds.mockResolvedValue({ acknowledged: true }); - mocks.refreshModelTemplates.mockResolvedValue([]); mocks.updatedReloadSystemModel.mockResolvedValue(undefined); }); @@ -80,17 +81,20 @@ describe('PUT /api/admin/settings/model/updateDefault', () => { {} as any ); - expect(mocks.upsertSystemDefaultModelIds).toHaveBeenCalledWith({ - llm: ids.llm, - embedding: ids.embedding, - tts: undefined, - stt: undefined, - rerank: undefined, - datasetTextLLM: ids.datasetText, - datasetImageLLM: ids.datasetImage, - chatTitleLLM: ids.chatTitle - }); - expect(mocks.updatedReloadSystemModel).toHaveBeenCalledWith({ pluginDocuments: [] }); + expect(mocks.upsertSystemDefaultModelIds).toHaveBeenCalledWith( + { + llm: ids.llm, + embedding: ids.embedding, + tts: undefined, + stt: undefined, + rerank: undefined, + datasetTextLLM: ids.datasetText, + datasetImageLLM: ids.datasetImage, + chatTitleLLM: ids.chatTitle + }, + mocks.session + ); + expect(mocks.updatedReloadSystemModel).toHaveBeenCalledWith(); }); it.each([ @@ -139,7 +143,6 @@ describe('PUT /api/admin/settings/model/updateDefault', () => { } expect(mocks.upsertSystemDefaultModelIds).not.toHaveBeenCalled(); - expect(mocks.refreshModelTemplates).not.toHaveBeenCalled(); expect(mocks.updatedReloadSystemModel).not.toHaveBeenCalled(); }); }); diff --git a/projects/app/test/api/core/ai/optimizePrompt.test.ts b/projects/app/test/api/core/ai/optimizePrompt.test.ts index d68a6905c711..a2c6a99ec3ba 100644 --- a/projects/app/test/api/core/ai/optimizePrompt.test.ts +++ b/projects/app/test/api/core/ai/optimizePrompt.test.ts @@ -27,7 +27,7 @@ vi.mock('@fastgpt/service/core/ai/llm/request', () => ({ })); vi.mock('@fastgpt/service/core/ai/model', () => ({ - getLLMModelData: mocks.getLLMModelData + getModelHandle: async () => ({ getLLMModelData: mocks.getLLMModelData }) })); vi.mock('@fastgpt/service/common/logger', () => ({ diff --git a/projects/app/test/api/core/ai/skill/debugChat.test.ts b/projects/app/test/api/core/ai/skill/debugChat.test.ts index bc62a1b54a50..05db3e07698f 100644 --- a/projects/app/test/api/core/ai/skill/debugChat.test.ts +++ b/projects/app/test/api/core/ai/skill/debugChat.test.ts @@ -1,3 +1,4 @@ +import { setModelTestSnapshot, setModelTestMap } from '@test/modelCache'; import { buildDebugRuntimeNodes } from '@fastgpt/service/core/ai/skill/debugChat'; import * as debugChatApi from '@/pages/api/core/ai/skill/debugChat'; import { AgentSkillSourceEnum } from '@fastgpt/global/core/ai/skill/constants'; @@ -322,7 +323,6 @@ describe('debugChat handler — parameter validation', () => { type: ModelTypeEnum.llm, scope: 'system' as const, isActive: true, - isCustom: false, config: { maxContext: 32000, maxResponse: 4000, @@ -330,11 +330,13 @@ describe('debugChat handler — parameter validation', () => { } }; const runtimeModel = { ...modelData, ...modelData.config }; - global.systemModelMap = new Map([ - [`id:${modelData.modelId}`, modelData], - [`model:${modelData.model}`, modelData] - ]); - global.systemDefaultModel = { llm: runtimeModel }; + setModelTestMap( + new Map([ + [`id:${modelData.modelId}`, modelData], + [`model:${modelData.model}`, modelData] + ]) + ); + setModelTestSnapshot({ defaultModels: { llm: runtimeModel } }); debugChatMocks.preChatRound.mockResolvedValue({ chatId: 'prepared-debug-chat-id', responseChatItemId: 'prepared-debug-response-id', diff --git a/projects/app/test/api/core/ai/skill/list.test.ts b/projects/app/test/api/core/ai/skill/list.test.ts index 1ee8d35f2f06..092737e7f772 100644 --- a/projects/app/test/api/core/ai/skill/list.test.ts +++ b/projects/app/test/api/core/ai/skill/list.test.ts @@ -1,3 +1,5 @@ +import { getCachedModelHandle } from '@fastgpt/service/core/ai/config/handle'; + import { describe, expect, it } from 'vitest'; import handler from '@/pages/api/core/ai/skill/list'; import handlerV2 from '@/pages/api/core/ai/skill/listV2'; @@ -479,9 +481,9 @@ describe('POST /api/core/ai/skill/list', () => { resourceRefs: { skillIds: [String(publishedSkill._id)] } }); - const legacyModel = global.systemActiveModelList.find( - (model) => model.type === ModelTypeEnum.llm - )!; + const legacyModel = getCachedModelHandle() + ?.getActiveModels() + .find((model) => model.type === ModelTypeEnum.llm)!; const draftNode = createSkillNode(draftSkill); draftNode.inputs.push({ key: NodeInputKeyEnum.aiModel, diff --git a/projects/app/test/api/core/app/copy.test.ts b/projects/app/test/api/core/app/copy.test.ts index 3ba1bc206b4e..bf606e2222d7 100644 --- a/projects/app/test/api/core/app/copy.test.ts +++ b/projects/app/test/api/core/app/copy.test.ts @@ -1,3 +1,5 @@ +import { getCachedModelHandle } from '@fastgpt/service/core/ai/config/handle'; +import { getModelTestDefaults } from '@test/modelCache'; import * as copyapi from '@/pages/api/core/app/copy'; import * as createapi from '@/pages/api/core/app/create'; import { AppErrEnum } from '@fastgpt/global/common/error/code/app'; @@ -134,8 +136,10 @@ describe('Copy', () => { expect(res4.code).toBe(200); const copiedApp = await MongoApp.findById(res4.data?.appId).lean(); const expectedFallbackModelId = - global.systemDefaultModel.llm?.modelId ?? - global.systemActiveModelList.find((model) => model.type === ModelTypeEnum.llm)?.modelId; + getModelTestDefaults().llm?.modelId ?? + getCachedModelHandle() + ?.getActiveModels() + .find((model) => model.type === ModelTypeEnum.llm)?.modelId; expect(copiedApp?.modules[0].inputs).toEqual([ expect.objectContaining({ key: NodeInputKeyEnum.aiModelId, diff --git a/projects/app/test/api/core/app/version/publish.test.ts b/projects/app/test/api/core/app/version/publish.test.ts index fc708004459b..d773a79cef49 100644 --- a/projects/app/test/api/core/app/version/publish.test.ts +++ b/projects/app/test/api/core/app/version/publish.test.ts @@ -1,3 +1,5 @@ +import { getCachedModelHandle } from '@fastgpt/service/core/ai/config/handle'; +import { getModelTestDefaults, setModelTestSnapshot } from '@test/modelCache'; import handler from '@/pages/api/core/app/version/publish'; import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; import type { @@ -23,11 +25,13 @@ import { Call } from '@test/utils/request'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; describe('publish optional model defaults', () => { - let previousModels: typeof global.systemActiveModelList; - let previousDefaults: typeof global.systemDefaultModel; + let previousModels: ReturnType< + NonNullable>['getActiveModels'] + >; + let previousDefaults: ReturnType; beforeEach(() => { - previousModels = global.systemActiveModelList; - previousDefaults = global.systemDefaultModel; + previousModels = getCachedModelHandle()!.getActiveModels(); + previousDefaults = getModelTestDefaults(); const llm = previousDefaults.llm!; const rerank: RerankSystemModelDataType = { ...llm, @@ -43,12 +47,12 @@ describe('publish optional model defaults', () => { type: ModelTypeEnum.tts, config: { voices: [{ label: 'Voice', value: 'voice' }] } }; - global.systemActiveModelList = [llm, rerank, tts]; - global.systemDefaultModel = { llm, rerank, tts }; + setModelTestSnapshot({ models: [llm, rerank, tts] }); + setModelTestSnapshot({ defaultModels: { llm, rerank, tts } }); }); afterEach(() => { - global.systemActiveModelList = previousModels; - global.systemDefaultModel = previousDefaults; + setModelTestSnapshot({ models: previousModels }); + setModelTestSnapshot({ defaultModels: previousDefaults }); }); const makeBody = (enabled: boolean, value?: string | null): PublishAppBodyType => ({ @@ -109,8 +113,8 @@ describe('publish optional model defaults', () => { modelId: '68ad85a7463006c963799a02', model: 'available-model' }; - global.systemActiveModelList = [restrictedModel, availableModel]; - global.systemDefaultModel = { llm: restrictedModel }; + setModelTestSnapshot({ models: [restrictedModel, availableModel] }); + setModelTestSnapshot({ defaultModels: { llm: restrictedModel } }); await MongoResourcePermission.create({ teamId: owner.teamId, tmbId: owner.tmbId, @@ -159,7 +163,7 @@ describe('publish optional model defaults', () => { it('rejects publishing when no permitted fallback exists', async () => { const { member, app, restrictedModel } = await createRestrictedModelScenario(); - global.systemActiveModelList = [restrictedModel]; + setModelTestSnapshot({ models: [restrictedModel] }); const result = await Call(handler, { auth: member, query: { appId: String(app._id) }, @@ -239,7 +243,7 @@ describe('publish optional model defaults', () => { teamId: root.teamId, tmbId: root.tmbId }); - global.systemActiveModelList = []; + setModelTestSnapshot({ models: [] }); const result = await Call(handler, { auth: root, query: { appId: String(app._id) }, diff --git a/projects/app/test/api/core/dataset/collection/create/images.test.ts b/projects/app/test/api/core/dataset/collection/create/images.test.ts index dd4d7b895b77..0310d10f3636 100644 --- a/projects/app/test/api/core/dataset/collection/create/images.test.ts +++ b/projects/app/test/api/core/dataset/collection/create/images.test.ts @@ -83,9 +83,11 @@ vi.mock('@fastgpt/service/core/dataset/collection/controller', () => ({ createCollectionAndInsertData: mockCreateCollectionAndInsertData })); -vi.mock('@fastgpt/service/core/dataset/model', () => ({ - getDatasetEmbeddingModel: mockGetDatasetEmbeddingModel, - getDatasetVlmModel: mockGetDatasetVlmModel +vi.mock('@fastgpt/service/core/ai/model', () => ({ + getModelHandle: async () => ({ + getEmbeddingModelData: mockGetDatasetEmbeddingModel, + getVlmModelData: mockGetDatasetVlmModel + }) })); vi.mock('@fastgpt/service/core/dataset/utils', async (importOriginal) => { diff --git a/projects/app/test/api/core/dataset/create.test.ts b/projects/app/test/api/core/dataset/create.test.ts index 51369174d7b7..ec341791f86d 100644 --- a/projects/app/test/api/core/dataset/create.test.ts +++ b/projects/app/test/api/core/dataset/create.test.ts @@ -1,3 +1,4 @@ +import { getModelTestDefaults, setModelTestSnapshot } from '@test/modelCache'; import createHandler from '@/pages/api/core/dataset/create'; import type { CreateDatasetBody, @@ -24,14 +25,16 @@ describe('create dataset', () => { tmbId: owner.tmbId, permission: TeamDatasetCreatePermissionVal }); - const previousDefaults = global.systemDefaultModel; - global.systemDefaultModel = { - ...previousDefaults, - datasetImageLLM: { - ...previousDefaults.llm!, - config: { ...previousDefaults.llm!.config, vision: true } + const previousDefaults = getModelTestDefaults(); + setModelTestSnapshot({ + defaultModels: { + ...previousDefaults, + datasetImageLLM: { + ...previousDefaults.llm!, + config: { ...previousDefaults.llm!.config, vision: true } + } } - }; + }); try { const res = await Call, CreateDatasetResponse>( createHandler, @@ -52,7 +55,7 @@ describe('create dataset', () => { vlmModelId === undefined ? previousDefaults.llm!.modelId : undefined ); } finally { - global.systemDefaultModel = previousDefaults; + setModelTestSnapshot({ defaultModels: previousDefaults }); } } ); diff --git a/projects/app/test/api/core/dataset/createWithFiles.test.ts b/projects/app/test/api/core/dataset/createWithFiles.test.ts index 10207f20ee19..88893607007f 100644 --- a/projects/app/test/api/core/dataset/createWithFiles.test.ts +++ b/projects/app/test/api/core/dataset/createWithFiles.test.ts @@ -1,3 +1,4 @@ +import { getModelTestDefaults, setModelTestSnapshot } from '@test/modelCache'; import { describe, expect, it } from 'vitest'; import handler from '@/pages/api/core/dataset/createWithFiles'; import { MongoDataset } from '@fastgpt/service/core/dataset/schema'; @@ -23,11 +24,13 @@ describe('create dataset with files VLM selection', () => { tmbId: owner.tmbId, permission: TeamDatasetCreatePermissionVal }); - const previous = global.systemDefaultModel; - global.systemDefaultModel = { - ...previous, - datasetImageLLM: { ...previous.llm!, config: { ...previous.llm!.config, vision: true } } - }; + const previous = getModelTestDefaults(); + setModelTestSnapshot({ + defaultModels: { + ...previous, + datasetImageLLM: { ...previous.llm!, config: { ...previous.llm!.config, vision: true } } + } + }); try { const result = await Call< CreateDatasetWithFilesBody, @@ -43,7 +46,7 @@ describe('create dataset with files VLM selection', () => { vlmModelId === undefined ? previous.llm!.modelId : undefined ); } finally { - global.systemDefaultModel = previous; + setModelTestSnapshot({ defaultModels: previous }); } } ); diff --git a/projects/app/test/api/core/dataset/data/insertData.test.ts b/projects/app/test/api/core/dataset/data/insertData.test.ts index 12162abf972a..bad87300f1f5 100644 --- a/projects/app/test/api/core/dataset/data/insertData.test.ts +++ b/projects/app/test/api/core/dataset/data/insertData.test.ts @@ -35,7 +35,7 @@ vi.mock('@fastgpt/service/core/dataset/controller', () => ({ })); vi.mock('@fastgpt/service/core/ai/model', () => ({ - getEmbeddingModelData: mocks.getEmbeddingModelData + getModelHandle: async () => ({ getEmbeddingModelData: mocks.getEmbeddingModelData }) })); vi.mock('@/service/core/dataset/data/utils', () => ({ diff --git a/projects/app/test/api/core/dataset/data/insertImages.test.ts b/projects/app/test/api/core/dataset/data/insertImages.test.ts index 422a5feb1a5a..dd65c7830e1e 100644 --- a/projects/app/test/api/core/dataset/data/insertImages.test.ts +++ b/projects/app/test/api/core/dataset/data/insertImages.test.ts @@ -88,11 +88,13 @@ vi.mock('@fastgpt/service/core/dataset/training/controller', () => ({ pushDataListToTrainingQueue: mockPushDataListToTrainingQueue })); -vi.mock('@fastgpt/service/core/dataset/model', () => { +vi.mock('@fastgpt/service/core/ai/model', () => { return { - getDatasetEmbeddingModel: mockGetDatasetEmbeddingModel, - getDatasetAgentModel: mockGetDatasetAgentModel, - getDatasetVlmModel: mockGetDatasetVlmModel + getModelHandle: async () => ({ + getEmbeddingModelData: mockGetDatasetEmbeddingModel, + getLLMModelData: mockGetDatasetAgentModel, + getVlmModelData: mockGetDatasetVlmModel + }) }; }); diff --git a/projects/app/test/api/core/dataset/data/update.test.ts b/projects/app/test/api/core/dataset/data/update.test.ts index 2869367a4f85..aafd518b2125 100644 --- a/projects/app/test/api/core/dataset/data/update.test.ts +++ b/projects/app/test/api/core/dataset/data/update.test.ts @@ -55,8 +55,8 @@ vi.mock('@fastgpt/service/core/dataset/utils', () => ({ replaceS3KeyToPreviewUrl: mockReplaceS3KeyToPreviewUrl })); -vi.mock('@fastgpt/service/core/dataset/model', () => ({ - getDatasetEmbeddingModel: vi.fn(() => mockEmbeddingModel) +vi.mock('@fastgpt/service/core/ai/model', () => ({ + getModelHandle: async () => ({ getEmbeddingModelData: vi.fn(() => mockEmbeddingModel) }) })); import handler from '@/pages/api/core/dataset/data/update'; diff --git a/projects/app/test/api/core/dataset/detail.test.ts b/projects/app/test/api/core/dataset/detail.test.ts index 40d3a2ec04ce..43fafbfee582 100644 --- a/projects/app/test/api/core/dataset/detail.test.ts +++ b/projects/app/test/api/core/dataset/detail.test.ts @@ -1,3 +1,9 @@ +import { + getModelTestMap, + getModelTestDefaults, + setModelTestMap, + addModelTestModel +} from '@test/modelCache'; import { describe, expect, it } from 'vitest'; import handler from '@/pages/api/core/dataset/detail'; import { MongoDataset } from '@fastgpt/service/core/dataset/schema'; @@ -15,16 +21,16 @@ describe('GET /api/core/dataset/detail', () => { 'returns model IDs for selector state without exposing legacy fallback fields (%s)', async (state) => { const owner = await getUser(`dataset-model-display-${getNanoid(6)}`); - const originalMap = global.systemModelMap; + const originalMap = getModelTestMap(); const disabledModel = { - ...global.systemDefaultModel.llm!, + ...getModelTestDefaults().llm!, modelId: '68ad85a7463006c963799a77', model: 'disabled-vision', isActive: false, - config: { ...global.systemDefaultModel.llm!.config, vision: true } + config: { ...getModelTestDefaults().llm!.config, vision: true } }; - global.systemModelMap = new Map(originalMap); - global.systemModelMap.set(`id:${disabledModel.modelId}`, disabledModel); + setModelTestMap(new Map(originalMap)); + addModelTestModel(disabledModel); const modelConfig = (() => { if (state === 'deleted-legacy') return { vlmModel: 'deleted-vision' }; if (state === 'deleted-id') @@ -61,7 +67,7 @@ describe('GET /api/core/dataset/detail', () => { expect(result.data.vlmModel).toBeUndefined(); } } finally { - global.systemModelMap = originalMap; + setModelTestMap(originalMap); } } ); diff --git a/projects/app/test/api/core/dataset/training/rebuildEmbedding.test.ts b/projects/app/test/api/core/dataset/training/rebuildEmbedding.test.ts index 4da245c65b23..9b0daf9ce50b 100644 --- a/projects/app/test/api/core/dataset/training/rebuildEmbedding.test.ts +++ b/projects/app/test/api/core/dataset/training/rebuildEmbedding.test.ts @@ -1,3 +1,4 @@ +import { getModelTestDefaults, addModelTestModel } from '@test/modelCache'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import handler from '@/pages/api/core/dataset/training/rebuildEmbedding'; import { MongoDatasetCollection } from '@fastgpt/service/core/dataset/collection/schema'; @@ -11,11 +12,8 @@ import { import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants'; import { getRootUser } from '@test/datas/users'; import { Call } from '@test/utils/request'; -import { - getEmbeddingModelData, - getLLMModelData, - getVlmModelData -} from '@fastgpt/service/core/ai/model'; +import { getModelHandle } from '@fastgpt/service/core/ai/model'; +vi.unmock('@fastgpt/service/core/ai/model'); import type { EmbeddingSystemModelDataType, LLMSystemModelDataType @@ -64,8 +62,8 @@ describe('POST /api/core/dataset/training/rebuildEmbedding', () => { ...global.systemEnv, vectorMaxProcess: 1 }; - agentModel = global.systemDefaultModel.llm; - const defaultEmbeddingModel = global.systemDefaultModel.embedding; + agentModel = getModelTestDefaults().llm; + const defaultEmbeddingModel = getModelTestDefaults().embedding; visionEmbeddingModel = { ...defaultEmbeddingModel, modelId: '507f1f77bcf86cd799439012', @@ -98,17 +96,7 @@ describe('POST /api/core/dataset/training/rebuildEmbedding', () => { }; [visionEmbeddingModel, textOnlyEmbeddingModel, datasetVlmModel].forEach((model) => { - global.systemModelMap.set(`id:${model.modelId}`, model); - global.systemModelMap.set(`model:${model.model}`, model); - }); - - // 全局测试环境会固定 mock embedding 模型;本组用例需要验证按 ID 切换后的真实能力。 - vi.mocked(getEmbeddingModelData).mockImplementation(({ modelId, model }) => { - const modelData = global.systemModelMap.get(modelId ? `id:${modelId}` : `model:${model}`) as - | EmbeddingSystemModelDataType - | undefined; - if (!modelData) throw new Error('模型不存在'); - return modelData; + addModelTestModel(model); }); }); @@ -129,10 +117,12 @@ describe('POST /api/core/dataset/training/rebuildEmbedding', () => { ] }); - expect(getEmbeddingModelData({ modelId: visionEmbeddingModel.modelId })).toEqual( - visionEmbeddingModel + expect( + (await getModelHandle()).getEmbeddingModelData({ modelId: visionEmbeddingModel.modelId }) + ).toEqual(visionEmbeddingModel); + expect((await getModelHandle()).getLLMModelData({ modelId: agentModel.modelId })).toEqual( + agentModel ); - expect(getLLMModelData({ modelId: agentModel.modelId })).toEqual(agentModel); const res = await Call(handler, { auth: root, @@ -216,7 +206,9 @@ describe('POST /api/core/dataset/training/rebuildEmbedding', () => { imageId: 'dataset/team/main.png' }); - expect(getVlmModelData({ modelId: datasetVlmModel.modelId })).toEqual(datasetVlmModel); + expect((await getModelHandle()).getVlmModelData({ modelId: datasetVlmModel.modelId })).toEqual( + datasetVlmModel + ); const res = await Call(handler, { auth: root, diff --git a/projects/app/test/api/core/dataset/update.test.ts b/projects/app/test/api/core/dataset/update.test.ts index 3b58b328ea7a..6cb1f64fdac5 100644 --- a/projects/app/test/api/core/dataset/update.test.ts +++ b/projects/app/test/api/core/dataset/update.test.ts @@ -8,8 +8,81 @@ import { getFakeUsers } from '@test/datas/users'; import { Call } from '@test/utils/request'; import { describe, it, expect, beforeEach } from 'vitest'; import { RebuildEmbeddingBodySchema } from '@fastgpt/global/openapi/core/dataset/training/api'; +import { getModelTestDefaults, setModelTestSnapshot } from '@test/modelCache'; +import { getCachedModelHandle } from '@fastgpt/service/core/ai/config/handle'; describe('update dataset', () => { + it.each(['legacy', 'id', 'invalid-id', 'invalid-name', 'clear-id', 'clear-legacy'] as const)( + 'resolves legacy model updates without overriding canonical selections (%s)', + async (mode) => { + const owner = (await getFakeUsers(1)).members[0]; + const previousModels = getCachedModelHandle()!.getAllModels(); + const llm = getModelTestDefaults().llm!; + setModelTestSnapshot({ + models: previousModels.map((model) => + model.modelId === llm.modelId + ? { ...llm, config: { ...llm.config, vision: true } } + : model + ) + }); + try { + const dataset = await MongoDataset.create({ + teamId: owner.teamId, + tmbId: owner.tmbId, + name: 'legacy-update', + type: DatasetTypeEnum.dataset, + agentModelId: 'original-agent', + vlmModelId: 'original-vlm', + vlmModel: 'original-name' + }); + const body: UpdateDatasetBody = { + id: String(dataset._id), + agentModel: llm.model, + vlmModel: llm.model + }; + if (mode === 'id') { + Object.assign(body, { + agentModelId: llm.modelId, + vlmModelId: llm.modelId, + agentModel: 'missing-legacy', + vlmModel: 'missing-legacy' + }); + } else if (mode === 'invalid-id') { + body.agentModelId = 'missing-id'; + } else if (mode === 'invalid-name') { + body.agentModel = 'missing-legacy'; + } else if (mode === 'clear-id') { + body.vlmModelId = null; + } else if (mode === 'clear-legacy') { + body.vlmModel = ''; + } + const res = await Call, string>(updateHandler, { + auth: owner, + body + }); + const updated = await MongoDataset.findById(dataset._id).lean(); + if (mode === 'invalid-id' || mode === 'invalid-name') { + expect(res.code).not.toBe(200); + expect(updated).toMatchObject({ + agentModelId: 'original-agent', + vlmModelId: 'original-vlm' + }); + } else { + expect(res.code).toBe(200); + expect(updated?.agentModelId).toBe(llm.modelId); + if (mode === 'clear-id' || mode === 'clear-legacy') { + expect(updated).not.toHaveProperty('vlmModelId'); + expect(updated).not.toHaveProperty('vlmModel'); + } else { + expect(updated?.vlmModelId).toBe(llm.modelId); + } + } + } finally { + setModelTestSnapshot({ models: previousModels }); + } + } + ); + it.each([null, '', ' '])( 'clears only the VLM including its legacy field (%s)', async (vlmModelId) => { diff --git a/projects/app/test/integration/systemModelManagement.test.ts b/projects/app/test/integration/systemModelManagement.test.ts new file mode 100644 index 000000000000..c7df98f44588 --- /dev/null +++ b/projects/app/test/integration/systemModelManagement.test.ts @@ -0,0 +1,547 @@ +import { getCachedModelHandle, publishModelHandle } from '@fastgpt/service/core/ai/config/handle'; + +import { createServer, type Server } from 'node:http'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ModelErrEnum } from '@fastgpt/global/common/error/code/model'; +import { ModelScopeEnum, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import { type CreateSystemModelBody } from '@fastgpt/global/openapi/admin/core/ai/model/api'; + +// 全局测试配置提供 MongoMemoryReplSet;这里恢复真实 session,覆盖提交与回滚。 +vi.unmock('@fastgpt/service/common/mongo/sessionRun'); + +const external = vi.hoisted(() => ({ + baseUrl: '', + listModels: vi.fn() +})); +// 只替换外部服务的配置和 Plugin 边界,AI Proxy adapter/axios/模型目录均执行真实实现。 +vi.mock('@fastgpt/service/thirdProvider/aiproxy/config', () => ({ + getAIProxyAdminConfig: () => ({ baseUrl: external.baseUrl, token: 'local-integration-token' }) +})); +vi.mock('@fastgpt/service/thirdProvider/fastgptPlugin', () => ({ + pluginClient: { listModels: external.listModels } +})); +vi.mock('@fastgpt/service/core/app/provider/controller', () => ({ + getModelProviderMetadata: () => ({ providers: [], aiproxyChannels: [] }), + preloadModelProviders: vi.fn().mockResolvedValue(undefined), + getModelProvider: (provider: string) => ({ id: provider, name: provider, avatar: '', order: 0 }) +})); + +import { + createSystemModel, + createSystemModelsFromTemplates, + deleteSystemModels, + importSystemModels, + updateSystemDefaultModels, + updateSystemModel +} from '@/service/core/ai/model/service'; +import { updateSystemModelStatus } from '@fastgpt/service/core/ai/config/service'; +import { MongoAIModel } from '@fastgpt/service/core/ai/config/schema'; +import { connectionMongo } from '@fastgpt/service/common/mongo'; +import { MongoAIDefaultModel } from '@fastgpt/service/core/ai/defaultModel/schema'; +import * as catalogEntity from '@fastgpt/service/core/ai/config/entity'; +import { refreshModelHandle, loadInstalledModels } from '@fastgpt/service/core/ai/config/utils'; +import { appendModelsToAIProxyChannels } from '@fastgpt/service/thirdProvider/aiproxy/channel'; +import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema'; +import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant'; + +type LocalChannel = { id: number; type: number; name: string; models: string[] }; + +/** 确定性控制 HTTP 写入的暂停点,避免通过 sleep 猜测并发时序。 */ +const createGate = () => { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +}; + +/** 通过接口真实 schema 的推导类型构造完整草稿。 */ +const createDraft = (model: string): CreateSystemModelBody['modelData'] => ({ + model, + type: ModelTypeEnum.llm, + provider: 'OpenAI', + name: model, + scope: ModelScopeEnum.system, + isActive: true, + config: { maxContext: 32000, maxResponse: 16000, quoteMaxToken: 24000 } +}); + +describe('system model management integration: HTTP + MongoDB transactions + runtime catalog', () => { + let server: Server; + let channels: LocalChannel[]; + let requests: Array<{ method: string; url: string; authorization: string | undefined }>; + let failedChannelId: number | undefined; + let writeGate: ReturnType | undefined; + let writeStarted: ReturnType | undefined; + + beforeAll(async () => { + server = createServer(async (req, res) => { + requests.push({ + method: req.method ?? '', + url: req.url ?? '', + authorization: req.headers.authorization + }); + res.setHeader('Content-Type', 'application/json'); + if (req.headers.authorization !== 'Bearer local-integration-token') { + res.writeHead(401).end(JSON.stringify({ success: false })); + return; + } + if (req.method === 'GET' && req.url === '/api/channels/all') { + res.end(JSON.stringify({ success: true, data: channels })); + return; + } + const channelId = Number(req.url?.match(/^\/api\/channel\/(\d+)$/)?.[1]); + const channel = channels.find(({ id }) => id === channelId); + if (req.method !== 'PUT' || !channel) { + res.writeHead(404).end(JSON.stringify({ success: false })); + return; + } + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + writeStarted?.resolve(); + await writeGate?.promise; + if (channelId === failedChannelId) { + res.writeHead(503).end(JSON.stringify({ success: false })); + return; + } + const update: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')); + if ( + !update || + typeof update !== 'object' || + !('models' in update) || + !Array.isArray(update.models) || + !update.models.every((model) => typeof model === 'string') + ) { + res.writeHead(400).end(JSON.stringify({ success: false })); + return; + } + channel.models = update.models; + res.end(JSON.stringify({ success: true })); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP test server'); + external.baseUrl = `http://127.0.0.1:${address.port}`; + }); + + beforeEach(async () => { + channels = [ + { id: 1, type: 1, name: 'one', models: ['unrelated'] }, + { id: 2, type: 1, name: 'two', models: [] } + ]; + requests = []; + failedChannelId = undefined; + writeGate = undefined; + writeStarted = undefined; + external.listModels.mockReset().mockResolvedValue([]); + await Promise.all([ + MongoAIModel.deleteMany({}), + MongoAIDefaultModel.deleteMany({}), + MongoResourcePermission.deleteMany({}) + ]); + publishModelHandle(undefined); + await loadInstalledModels(); + }); + + afterEach(() => { + writeGate?.resolve(); + vi.restoreAllMocks(); + }); + + afterAll(async () => { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); + }); + + it('creates a model through real HTTP and publishes the committed catalog revision', async () => { + const { modelId } = await createSystemModel({ + modelData: createDraft('integration-new'), + channelIds: [1] + }); + + expect(channels[0].models).toEqual(['unrelated', 'integration-new']); + expect(await MongoAIModel.findById(modelId).lean()).toMatchObject({ + model: 'integration-new', + isActive: true + }); + expect(await catalogEntity.readSystemModelRevision()).toBe(1); + expect(getCachedModelHandle()?.revision).toBe(1); + expect(getCachedModelHandle()?.getAllModels()).toMatchObject([ + { modelId, model: 'integration-new' } + ]); + expect(requests.map(({ method }) => method)).toEqual(['GET', 'PUT']); + expect( + requests.every(({ authorization }) => authorization === 'Bearer local-integration-token') + ).toBe(true); + }); + + it('rejects duplicate creation before issuing any additional external request', async () => { + await createSystemModel({ modelData: createDraft('duplicate'), channelIds: [1] }); + requests = []; + + await expect( + createSystemModel({ modelData: createDraft('duplicate'), channelIds: [2] }) + ).rejects.toThrow(ModelErrEnum.alreadyExists); + + expect(requests).toEqual([]); + expect(channels[1].models).toEqual([]); + expect(await MongoAIModel.countDocuments({ model: 'duplicate' })).toBe(1); + expect(await catalogEntity.readSystemModelRevision()).toBe(1); + }); + + it('installs templates as inactive models and removes channel bindings and permissions on delete', async () => { + external.listModels.mockResolvedValue([createDraft('template-a'), createDraft('template-b')]); + const result = await createSystemModelsFromTemplates({ + templates: [ + { type: ModelTypeEnum.llm, model: 'template-a' }, + { type: ModelTypeEnum.llm, model: 'template-b' } + ], + channelIds: [1, 2] + }); + expect(result.models).toHaveLength(2); + expect(await MongoAIModel.countDocuments({ isActive: false })).toBe(2); + const modelIds = result.models.map(({ modelId }) => modelId); + // 原生 collection 写入只准备权限夹具;删除仍经过真实应用服务和事务。 + await MongoResourcePermission.collection.insertOne({ + resourceType: PerResourceTypeEnum.model, + resourceId: new connectionMongo.Types.ObjectId(modelIds[0]) + }); + + await deleteSystemModels({ modelIds }); + + expect(await MongoAIModel.countDocuments()).toBe(0); + expect(await MongoResourcePermission.countDocuments()).toBe(0); + expect(channels.map(({ models }) => models)).toEqual([['unrelated'], []]); + expect(getCachedModelHandle()?.getAllModels()).toEqual([]); + expect(await catalogEntity.readSystemModelRevision()).toBe(2); + }); + + it('keeps the accepted partial external success without writing MongoDB when a later channel fails', async () => { + failedChannelId = 2; + + await expect( + createSystemModel({ modelData: createDraft('partial'), channelIds: [1, 2] }) + ).rejects.toThrow(); + + expect(channels.map(({ models }) => models)).toEqual([['unrelated', 'partial'], []]); + expect(await MongoAIModel.countDocuments()).toBe(0); + expect(await catalogEntity.readSystemModelRevision()).toBe(0); + }); + + it('rolls back model deletion and revision when permission deletion fails inside the transaction', async () => { + const { modelId } = await createSystemModel({ + modelData: createDraft('rollback-delete'), + channelIds: [1] + }); + // 在事务内模型删除之后注入下一条数据库操作失败,验证真实 MongoDB 回滚。 + vi.spyOn(MongoResourcePermission, 'deleteMany').mockImplementationOnce(() => { + throw new Error('Injected permission delete failure'); + }); + + await expect(deleteSystemModels({ modelIds: [modelId] })).rejects.toThrow( + 'Injected permission delete failure' + ); + + expect(await MongoAIModel.findById(modelId).lean()).not.toBeNull(); + expect(await catalogEntity.readSystemModelRevision()).toBe(1); + // 数据库事务失败时尚未开始外部解绑。 + expect(channels[0].models).toEqual(['unrelated', 'rollback-delete']); + expect(getCachedModelHandle()?.revision).toBe(1); + }); + + it('commits model and permission deletion before unbinding and does not restore them on channel failure', async () => { + const { modelId } = await createSystemModel({ + modelData: createDraft('delete-first'), + channelIds: [1, 2] + }); + await MongoResourcePermission.collection.insertOne({ + resourceType: PerResourceTypeEnum.model, + resourceId: new connectionMongo.Types.ObjectId(modelId) + }); + failedChannelId = 2; + writeGate = createGate(); + writeStarted = createGate(); + const deletion = deleteSystemModels({ modelIds: [modelId] }); + const rejected = expect(deletion).rejects.toThrow(); + try { + await writeStarted.promise; + // 首次渠道写入尚未完成时,数据库和运行时目录已经完成删除。 + expect(await MongoAIModel.findById(modelId).lean()).toBeNull(); + expect(await MongoResourcePermission.countDocuments()).toBe(0); + expect(getCachedModelHandle()?.getAllModels()).toEqual([]); + expect(channels[0].models).toContain('delete-first'); + } finally { + writeGate.resolve(); + await rejected; + } + expect(await MongoAIModel.findById(modelId).lean()).toBeNull(); + expect(await MongoResourcePermission.countDocuments()).toBe(0); + expect(await catalogEntity.readSystemModelRevision()).toBe(2); + expect(channels.map(({ models }) => models)).toEqual([['unrelated'], ['delete-first']]); + expect(getCachedModelHandle()?.getAllModels()).toEqual([]); + }); + + it('rejects competing writers while a lease is held and preserves both changes after retry', async () => { + writeGate = createGate(); + writeStarted = createGate(); + const first = appendModelsToAIProxyChannels({ channelIds: [1], models: ['first'] }); + try { + await writeStarted.promise; + await expect( + appendModelsToAIProxyChannels({ channelIds: [1], models: ['second'] }) + ).rejects.toThrow('being updated'); + expect(requests).toHaveLength(2); + } finally { + writeGate.resolve(); + await first; + } + + await appendModelsToAIProxyChannels({ channelIds: [1], models: ['second'] }); + + expect(channels[0].models).toEqual(['unrelated', 'first', 'second']); + }); + + it('returns committed creation after reload failure and repairs the snapshot at the next read barrier', async () => { + const failure = vi + .spyOn(catalogEntity, 'readSystemModelSnapshot') + .mockRejectedValueOnce(new Error('Injected snapshot read failure')); + + const { modelId } = await createSystemModel({ + modelData: createDraft('reload-repair'), + channelIds: [1] + }); + + expect(await MongoAIModel.findById(modelId).lean()).not.toBeNull(); + expect(await catalogEntity.readSystemModelRevision()).toBe(1); + expect(getCachedModelHandle()?.revision).toBe(0); + failure.mockRestore(); + await refreshModelHandle(); + expect(getCachedModelHandle()?.revision).toBe(1); + expect(getCachedModelHandle()?.getAllModels()).toMatchObject([ + { modelId, model: 'reload-repair' } + ]); + }); + + it('rejects the entire template batch before external writes when one template disappears', async () => { + external.listModels.mockResolvedValue([createDraft('available')]); + await expect( + createSystemModelsFromTemplates({ + templates: [ + { type: ModelTypeEnum.llm, model: 'available' }, + { type: ModelTypeEnum.llm, model: 'removed' } + ], + channelIds: [1, 2] + }) + ).rejects.toThrow('no longer exists'); + expect(requests).toEqual([]); + expect(await MongoAIModel.countDocuments()).toBe(0); + expect(await catalogEntity.readSystemModelRevision()).toBe(0); + }); + + it('uses the latest template parameters, skips installed names and leaves instances unchanged later', async () => { + const installed = await createSystemModel({ + modelData: createDraft('installed'), + channelIds: [] + }); + external.listModels.mockResolvedValue([ + { ...createDraft('installed'), type: ModelTypeEnum.stt, config: {} }, + { + ...createDraft('fresh'), + name: 'Latest template', + config: { maxContext: 64000, maxResponse: 8000, quoteMaxToken: 32000 } + } + ]); + const result = await createSystemModelsFromTemplates({ + templates: [ + { type: ModelTypeEnum.stt, model: 'installed' }, + { type: ModelTypeEnum.llm, model: 'fresh' } + ], + channelIds: [] + }); + expect(result.models).toHaveLength(1); + expect(await MongoAIModel.findById(installed.modelId).lean()).toMatchObject({ + type: 'llm', + name: 'installed' + }); + expect(await MongoAIModel.findById(result.models[0].modelId).lean()).toMatchObject({ + name: 'Latest template', + isActive: false, + config: { maxContext: 64000 } + }); + external.listModels.mockResolvedValue([]); + await loadInstalledModels(); + expect(getCachedModelHandle()?.getAllModels()).toHaveLength(2); + expect(external.listModels).toHaveBeenCalledTimes(1); + expect(requests).toEqual([]); + }); + + it('rolls back a partially matched status update without advancing revision or snapshot', async () => { + const { modelId } = await createSystemModel({ + modelData: createDraft('status'), + channelIds: [] + }); + const missingId = new connectionMongo.Types.ObjectId().toString(); + await expect( + updateSystemModelStatus({ modelIds: [modelId, missingId], isActive: false }) + ).rejects.toBeDefined(); + expect(await MongoAIModel.findById(modelId).lean()).toMatchObject({ isActive: true }); + expect(await catalogEntity.readSystemModelRevision()).toBe(1); + expect(getCachedModelHandle()?.revision).toBe(1); + await updateSystemModelStatus({ modelIds: [modelId], isActive: false }); + expect(await MongoAIModel.findById(modelId).lean()).toMatchObject({ isActive: false }); + expect(await catalogEntity.readSystemModelRevision()).toBe(2); + }); + + it('preserves configured defaults when creating another model and rolls back invalid default changes', async () => { + const { modelId } = await createSystemModel({ + modelData: createDraft('default'), + channelIds: [] + }); + await updateSystemDefaultModels({ llm: modelId, chatTitleLLMModelId: modelId }); + const defaultsBefore = await MongoAIDefaultModel.find({}, { defaultModelIds: 1 }).lean(); + const second = await createSystemModel({ modelData: createDraft('second'), channelIds: [] }); + expect(second.modelId).not.toBe(modelId); + expect(await MongoAIDefaultModel.find({}, { defaultModelIds: 1 }).lean()).toEqual( + defaultsBefore + ); + const revision = await catalogEntity.readSystemModelRevision(); + await expect( + updateSystemDefaultModels({ llm: second.modelId, datasetImageLLMModelId: modelId }) + ).rejects.toBeDefined(); + expect(await MongoAIDefaultModel.find({}, { defaultModelIds: 1 }).lean()).toEqual( + defaultsBefore + ); + expect(await catalogEntity.readSystemModelRevision()).toBe(revision); + await updateSystemDefaultModels({}); + expect(await catalogEntity.readSystemModelRevision()).toBe(revision + 1); + }); + + it('prechecks immutable type before channel replacement and clears omitted request credentials on update', async () => { + const { modelId } = await createSystemModel({ + modelData: { + ...createDraft('editable'), + requestUrl: 'http://local.test', + requestAuth: 'test-secret' + }, + channelIds: [1] + }); + requests = []; + const { model: _model, ...editable } = createDraft('editable'); + await expect( + updateSystemModel({ + modelId, + modelData: { ...editable, type: ModelTypeEnum.stt, config: {} }, + channelIds: [2] + }) + ).rejects.toThrow('type cannot be changed'); + expect(requests).toEqual([]); + await updateSystemModel({ + modelId, + modelData: { ...editable, name: 'Renamed' }, + channelIds: [2] + }); + const updated = await MongoAIModel.findById(modelId).lean(); + expect(updated).toMatchObject({ name: 'Renamed', model: 'editable', type: 'llm' }); + expect(updated).not.toHaveProperty('requestUrl'); + expect(updated).not.toHaveProperty('requestAuth'); + expect(channels.map(({ models }) => models)).toEqual([['unrelated'], ['editable']]); + }); + + it('keeps JSON import atomic and distinguishes legacy no-ID records from deliberate empty configuration', async () => { + const { modelId } = await createSystemModel({ + modelData: createDraft('json-original'), + channelIds: [1] + }); + const before = await MongoAIModel.find({}).lean(); + await expect( + importSystemModels({ + config: [ + { ...createDraft('external'), modelId: 'external' }, + { ...createDraft('invalid'), modelId: 'invalid', config: { maxContext: 'bad' } } + ] + }) + ).rejects.toThrow('Invalid system model'); + expect(await MongoAIModel.find({}).lean()).toEqual(before); + expect(await catalogEntity.readSystemModelRevision()).toBe(1); + await importSystemModels({ config: [createDraft('legacy')] }); + expect(await MongoAIModel.find({}).lean()).toEqual(before); + expect(await catalogEntity.readSystemModelRevision()).toBe(1); + await importSystemModels({ + config: [ + { + ...createDraft('injected-name'), + modelId, + type: ModelTypeEnum.stt, + name: 'Imported', + inputPrice: 0, + outputPrice: 2 + } + ] + }); + const imported = await MongoAIModel.findById(modelId).lean(); + expect(imported).toMatchObject({ model: 'json-original', type: 'llm', name: 'Imported' }); + expect(imported).not.toHaveProperty('inputPrice'); + expect(imported).not.toHaveProperty('outputPrice'); + expect(imported?.priceTiers).toEqual( + expect.arrayContaining([expect.objectContaining({ inputPrice: 0, outputPrice: 2 })]) + ); + await MongoResourcePermission.collection.insertOne({ + resourceType: PerResourceTypeEnum.model, + resourceId: new connectionMongo.Types.ObjectId(modelId) + }); + const channelsBeforeImport = structuredClone(channels); + requests = []; + await importSystemModels({ config: [] }); + expect(await MongoAIModel.findById(modelId).lean()).toBeNull(); + expect(await MongoAIModel.countDocuments()).toBe(0); + expect(await MongoResourcePermission.countDocuments()).toBe(0); + expect(channels).toEqual(channelsBeforeImport); + expect(channels[0].models).toContain('json-original'); + expect(requests).toEqual([]); + }); + + it('rolls back MongoDB after successful channel writes and deduplicates external bindings on retry', async () => { + const beforeDefaults = await MongoAIDefaultModel.findOne().lean(); + // 在真实事务已增加 revision 后注入模型写入失败;HTTP 渠道写入已经完成。 + vi.spyOn(MongoAIModel, 'create').mockImplementationOnce(() => { + throw new Error('Injected model insert failure'); + }); + const input = { modelData: createDraft('retry-after-db-failure'), channelIds: [1, 2] }; + + await expect(createSystemModel(input)).rejects.toThrow('Injected model insert failure'); + + expect(await MongoAIModel.countDocuments()).toBe(0); + expect(await MongoAIDefaultModel.findOne().lean()).toEqual(beforeDefaults); + expect(await catalogEntity.readSystemModelRevision()).toBe(0); + expect(getCachedModelHandle()?.getAllModels()).toEqual([]); + expect(channels.map(({ models }) => models)).toEqual([ + ['unrelated', 'retry-after-db-failure'], + ['retry-after-db-failure'] + ]); + + await createSystemModel(input); + + expect(await MongoAIModel.countDocuments()).toBe(1); + expect(await catalogEntity.readSystemModelRevision()).toBe(1); + expect(channels.map(({ models }) => models)).toEqual([ + ['unrelated', 'retry-after-db-failure'], + ['retry-after-db-failure'] + ]); + }); + + it('rejects concurrent duplicate creation through the real unique index with one committed revision', async () => { + const results = await Promise.allSettled([ + createSystemModel({ modelData: createDraft('concurrent'), channelIds: [] }), + createSystemModel({ modelData: createDraft('concurrent'), channelIds: [] }) + ]); + expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(1); + expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1); + expect(await MongoAIModel.countDocuments({ model: 'concurrent' })).toBe(1); + expect(await catalogEntity.readSystemModelRevision()).toBe(1); + }); +}); diff --git a/projects/app/test/migration/runner.test.ts b/projects/app/test/migration/runner.test.ts index fb2007245c1b..67b78eae8486 100644 --- a/projects/app/test/migration/runner.test.ts +++ b/projects/app/test/migration/runner.test.ts @@ -360,15 +360,15 @@ describe('system migration runner', () => { let retryRunner: ReturnType | undefined; try { await runner.start(); - await vi.waitFor(async () => { - expect((await MongoSystemMigrationState.findById(migration.id).lean())?.status).toBe( - SystemMigrationStatusEnum.failed - ); - // 测试环境不启用 Mongo 事务,需等状态和独立错误明细都完成写入。 - await expect(getMigrationFailedRecordCounts([migration.id])).resolves.toEqual([ - { migrationId: migration.id, stageKey: 'migrating', count: 1 } - ]); - }); + // 非阻塞失败会结束本轮 tick。测试未启用事务,failed + 旧明细数量并不代表 + // context.fail 的“删除旧快照 -> 插入新快照”已完成,必须等待实际执行结束。 + await runner.tick(); + expect((await MongoSystemMigrationState.findById(migration.id).lean())?.status).toBe( + SystemMigrationStatusEnum.failed + ); + await expect(getMigrationFailedRecordCounts([migration.id])).resolves.toEqual([ + { migrationId: migration.id, stageKey: 'migrating', count: 1 } + ]); const state = await MongoSystemMigrationState.findById(migration.id).lean(); expect(state?.lastError).toMatchObject({ @@ -387,7 +387,6 @@ describe('system migration runner', () => { const [storedFailedRecord] = await getMigrationFailedRecords(migration.id); expect(storedFailedRecord?.reason).toEqual({ message: 'missing modelId' }); - await new Promise((resolve) => setTimeout(resolve, 100)); await runner.tick(); expect(executions).toBe(1); expect((await MongoSystemMigrationState.findById(migration.id).lean())?.status).toBe( @@ -401,15 +400,13 @@ describe('system migration runner', () => { logger }); await retryRunner.start(); - await vi.waitFor(async () => { - expect((await MongoSystemMigrationState.findById(migration.id).lean())?.status).toBe( - SystemMigrationStatusEnum.succeeded - ); - }); + // 同样等待成功状态与失败明细清理全部结束,再验证恢复结果。 + await retryRunner.tick(); + expect((await MongoSystemMigrationState.findById(migration.id).lean())?.status).toBe( + SystemMigrationStatusEnum.succeeded + ); expect(executions).toBe(2); - await vi.waitFor(async () => { - await expect(getMigrationFailedRecords(migration.id)).resolves.toEqual([]); - }); + await expect(getMigrationFailedRecords(migration.id)).resolves.toEqual([]); } finally { runner.stop(); retryRunner?.stop(); diff --git a/projects/app/test/migration/tasks/20260903_migrate_legacy_system_models/index.test.ts b/projects/app/test/migration/tasks/20260903_migrate_legacy_system_models/index.test.ts index c7eeae8021f5..89fdf0f80561 100644 --- a/projects/app/test/migration/tasks/20260903_migrate_legacy_system_models/index.test.ts +++ b/projects/app/test/migration/tasks/20260903_migrate_legacy_system_models/index.test.ts @@ -5,8 +5,8 @@ import type { SystemMigrationContext } from '@/migration/registry'; const mocks = vi.hoisted(() => ({ preloadModelProviders: vi.fn(), getPluginSystemModelDocuments: vi.fn(), - syncPreinstalledSystemModels: vi.fn(), loadInstalledModels: vi.fn(), + inspectLegacySystemModelMigration: vi.fn(), bootstrapAIModelsFromLegacy: vi.fn() })); @@ -15,10 +15,10 @@ vi.mock('@fastgpt/service/core/app/provider/controller', () => ({ })); vi.mock('@fastgpt/service/core/ai/config/utils', () => ({ getPluginSystemModelDocuments: mocks.getPluginSystemModelDocuments, - syncPreinstalledSystemModels: mocks.syncPreinstalledSystemModels, loadInstalledModels: mocks.loadInstalledModels })); vi.mock('@/migration/tasks/20260903_migrate_legacy_system_models/service', () => ({ + inspectLegacySystemModelMigration: mocks.inspectLegacySystemModelMigration, bootstrapAIModelsFromLegacy: mocks.bootstrapAIModelsFromLegacy })); @@ -40,8 +40,8 @@ describe('migrateLegacySystemModels', () => { vi.clearAllMocks(); mocks.preloadModelProviders.mockResolvedValue(undefined); mocks.getPluginSystemModelDocuments.mockResolvedValue([{ model: 'plugin-model' }]); - mocks.syncPreinstalledSystemModels.mockResolvedValue(undefined); mocks.loadInstalledModels.mockResolvedValue(undefined); + mocks.inspectLegacySystemModelMigration.mockResolvedValue({ sourceCount: 3, targetCount: 0 }); mocks.bootstrapAIModelsFromLegacy.mockResolvedValue({ status: 'migrated', sourceCount: 3, @@ -65,12 +65,7 @@ describe('migrateLegacySystemModels', () => { expect(mocks.bootstrapAIModelsFromLegacy).toHaveBeenCalledWith({ pluginDocuments: [{ model: 'plugin-model' }] }); - expect(mocks.syncPreinstalledSystemModels).toHaveBeenCalledWith({ - pluginDocuments: [{ model: 'plugin-model' }] - }); - expect(mocks.loadInstalledModels).toHaveBeenCalledWith({ - pluginDocuments: [{ model: 'plugin-model' }] - }); + expect(mocks.loadInstalledModels).toHaveBeenCalledWith(); expect(context.reportProgress).toHaveBeenNthCalledWith(1, { key: 'loading_templates', status: SystemMigrationStatusEnum.running @@ -104,34 +99,52 @@ describe('migrateLegacySystemModels', () => { }); }); - it('returns the normal result for an empty legacy collection and still publishes the cache', async () => { + it('still merges legacy data when system models already exist', async () => { const context = createContext(); - mocks.bootstrapAIModelsFromLegacy.mockResolvedValue({ - status: 'migrated', - sourceCount: 0, - targetCount: 4, - migratedCount: 0 - }); + mocks.inspectLegacySystemModelMigration.mockResolvedValue({ sourceCount: 3, targetCount: 4 }); await expect(migrateLegacySystemModels(context)).resolves.toEqual({ - sourceCount: 0, - targetCount: 4, - migratedCount: 0 + sourceCount: 3, + targetCount: 5, + migratedCount: 2 }); expect(mocks.preloadModelProviders).toHaveBeenCalledOnce(); expect(mocks.getPluginSystemModelDocuments).toHaveBeenCalledOnce(); - expect(mocks.bootstrapAIModelsFromLegacy).toHaveBeenCalledOnce(); - expect(mocks.syncPreinstalledSystemModels).toHaveBeenCalledOnce(); + expect(mocks.bootstrapAIModelsFromLegacy).toHaveBeenCalledWith({ + pluginDocuments: [{ model: 'plugin-model' }] + }); expect(mocks.loadInstalledModels).toHaveBeenCalledOnce(); expect(context.assertActive).toHaveBeenCalledTimes(2); expect(context.reportProgress).toHaveBeenCalledTimes(6); expect(context.logger.info).toHaveBeenCalledWith('Legacy system model migration completed', { status: 'migrated', + sourceCount: 3, + targetCount: 5, + migratedCount: 2 + }); + }); + + it('migrates an empty legacy collection without requesting templates', async () => { + const context = createContext(); + mocks.inspectLegacySystemModelMigration.mockResolvedValue({ sourceCount: 0, targetCount: 0 }); + mocks.bootstrapAIModelsFromLegacy.mockResolvedValue({ + status: 'migrated', + sourceCount: 0, + targetCount: 0, + migratedCount: 0 + }); + + await expect(migrateLegacySystemModels(context)).resolves.toEqual({ sourceCount: 0, - targetCount: 4, + targetCount: 0, migratedCount: 0 }); + + expect(mocks.preloadModelProviders).not.toHaveBeenCalled(); + expect(mocks.getPluginSystemModelDocuments).not.toHaveBeenCalled(); + expect(mocks.bootstrapAIModelsFromLegacy).toHaveBeenCalledWith({ pluginDocuments: [] }); + expect(mocks.loadInstalledModels).toHaveBeenCalledOnce(); }); it('propagates migration errors without reporting completion', async () => { @@ -141,7 +154,6 @@ describe('migrateLegacySystemModels', () => { await expect(migrateLegacySystemModels(context)).rejects.toBe(error); expect(context.reportProgress).toHaveBeenCalledTimes(3); - expect(mocks.syncPreinstalledSystemModels).not.toHaveBeenCalled(); expect(mocks.loadInstalledModels).not.toHaveBeenCalled(); expect(context.logger.info).not.toHaveBeenCalled(); }); @@ -152,7 +164,6 @@ describe('migrateLegacySystemModels', () => { mocks.loadInstalledModels.mockRejectedValue(error); await expect(migrateLegacySystemModels(context)).rejects.toBe(error); - expect(mocks.syncPreinstalledSystemModels).toHaveBeenCalledOnce(); expect(context.reportProgress).toHaveBeenCalledTimes(5); expect(context.logger.info).not.toHaveBeenCalled(); }); diff --git a/projects/app/test/migration/tasks/20260903_migrate_legacy_system_models/service.test.ts b/projects/app/test/migration/tasks/20260903_migrate_legacy_system_models/service.test.ts index 29a18cf67378..39923ba42850 100644 --- a/projects/app/test/migration/tasks/20260903_migrate_legacy_system_models/service.test.ts +++ b/projects/app/test/migration/tasks/20260903_migrate_legacy_system_models/service.test.ts @@ -2,7 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; import type { SystemModelDocumentDataType } from '@fastgpt/global/core/ai/model.schema'; import { LegacySystemModelCollectionName } from '@fastgpt/service/core/ai/config/constants'; -import { bootstrapAIModelsFromLegacy } from '@/migration/tasks/20260903_migrate_legacy_system_models/service'; +import { + bootstrapAIModelsFromLegacy, + inspectLegacySystemModelMigration +} from '@/migration/tasks/20260903_migrate_legacy_system_models/service'; import { MongoAIModel } from '@fastgpt/service/core/ai/config/schema'; import { MongoAIDefaultModel } from '@fastgpt/service/core/ai/defaultModel/schema'; @@ -47,6 +50,20 @@ describe('bootstrapAIModelsFromLegacy', () => { ]); }); + it('keeps a fresh installation empty when no legacy models exist', async () => { + await expect(bootstrapAIModelsFromLegacy({ pluginDocuments: [] })).resolves.toEqual({ + status: 'migrated', + sourceCount: 0, + targetCount: 0, + migratedCount: 0 + }); + await expect(MongoAIModel.countDocuments()).resolves.toBe(0); + await expect(MongoAIDefaultModel.findOne({ scope: 'system' }).lean()).resolves.toMatchObject({ + scope: 'system', + defaultModelIds: {} + }); + }); + it('preserves an existing valid default while merging a legacy model', async () => { const existingModel = await MongoAIModel.create(pluginLlm); await MongoAIDefaultModel.create({ @@ -117,7 +134,7 @@ describe('bootstrapAIModelsFromLegacy', () => { } }); - it('keeps auto-preinstalled models while inserting legacy-only models', async () => { + it('keeps target-only models while inserting legacy-only models', async () => { await MongoAIModel.create(pluginLlm); const legacy = await legacyCollection.insertOne(createLegacyLlm('legacy-model')); @@ -354,7 +371,7 @@ describe('bootstrapAIModelsFromLegacy', () => { }); }); - it('replaces a same-name preinstalled model with active legacy metadata', async () => { + it('replaces a same-name target with legacy metadata', async () => { const legacy = await legacyCollection.insertOne({ model: 'same-name-embedding', metadata: { @@ -482,4 +499,49 @@ describe('bootstrapAIModelsFromLegacy', () => { await expect(MongoAIModel.countDocuments()).resolves.toBe(0); await expect(MongoAIDefaultModel.exists({ scope: 'system' })).resolves.toBeNull(); }); + + it('reports only system scope target models in the migration inspection', async () => { + await legacyCollection.insertOne(createLegacyLlm('legacy-model')); + await MongoAIModel.collection.insertOne({ + ...pluginLlm, + scope: 'team', + teamId: 'team-id' + }); + + await expect(inspectLegacySystemModelMigration()).resolves.toEqual({ + sourceCount: 1, + targetCount: 0 + }); + }); + + it('reads the latest target snapshot inside the transaction and merges without deleting it', async () => { + await legacyCollection.insertOne(createLegacyLlm('legacy-race-model')); + const originalFind = legacyCollection.find.bind(legacyCollection); + vi.spyOn(legacyCollection, 'find').mockImplementationOnce((...args) => { + const cursor = originalFind(...args); + const originalToArray = cursor.toArray.bind(cursor); + vi.spyOn(cursor, 'toArray').mockImplementationOnce(async () => { + const records = await originalToArray(); + await MongoAIModel.create({ + ...pluginLlm, + model: 'concurrent-system-model', + name: 'Concurrent system model' + }); + return records; + }); + return cursor; + }); + + await expect(bootstrapAIModelsFromLegacy({ pluginDocuments: [] })).resolves.toEqual({ + status: 'migrated', + sourceCount: 1, + targetCount: 2, + migratedCount: 1 + }); + await expect( + MongoAIModel.findOne({ model: 'concurrent-system-model' }) + ).resolves.not.toBeNull(); + await expect(MongoAIModel.findOne({ model: 'legacy-race-model' })).resolves.not.toBeNull(); + await expect(MongoAIDefaultModel.exists({ scope: 'system' })).resolves.not.toBeNull(); + }); }); diff --git a/projects/app/test/pageComponents/model/submit.test.ts b/projects/app/test/pageComponents/model/submit.test.ts new file mode 100644 index 000000000000..c45d1b0ad356 --- /dev/null +++ b/projects/app/test/pageComponents/model/submit.test.ts @@ -0,0 +1,186 @@ +import { ModelScopeEnum, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + postSystemModel: vi.fn(), + putReplaceSystemModelChannels: vi.fn(), + putSystemModel: vi.fn() +})); + +vi.mock('@/web/core/ai/config', () => mocks); + +import { + prepareDraftSystemModelForTest, + submitCreatedSystemModel, + submitUpdatedSystemModel +} from '@/pageComponents/model/submit'; +import { + normalizeModelPricingForRead, + normalizeModelPricingForSave +} from '@fastgpt/global/core/ai/pricing'; + +const modelData = { + type: ModelTypeEnum.llm, + provider: 'OpenAI', + model: 'controller-test-model', + name: 'Controller test model', + scope: ModelScopeEnum.system, + isActive: false, + config: { maxContext: 16000, maxResponse: 8000, quoteMaxToken: 12000 } +}; + +describe('admin model submit controllers', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.postSystemModel.mockResolvedValue({ modelId: '68ad85a7463006c963799a05' }); + mocks.putReplaceSystemModelChannels.mockResolvedValue(undefined); + mocks.putSystemModel.mockResolvedValue(undefined); + }); + + it('uses only POST create for a new model and sends no modelId', async () => { + await submitCreatedSystemModel({ modelData, channelIds: [] }); + + expect(mocks.postSystemModel).toHaveBeenCalledWith({ + modelData: { ...modelData, priceTiers: [] }, + channelIds: [] + }); + expect(mocks.putSystemModel).not.toHaveBeenCalled(); + expect(mocks.putReplaceSystemModelChannels).not.toHaveBeenCalled(); + expect(mocks.postSystemModel.mock.calls[0]?.[0].modelData).not.toHaveProperty('modelId'); + }); + + it.each([undefined, null, Number.NaN])( + 'defaults an empty quote limit for draft tests: %s', + (quoteMaxToken) => { + const draft = { ...modelData, name: ' ', config: { ...modelData.config, quoteMaxToken } }; + const prepared = prepareDraftSystemModelForTest(draft as typeof modelData); + expect(prepared).toMatchObject({ name: modelData.model, config: { quoteMaxToken: 12800 } }); + expect(draft.config.quoteMaxToken).toBe(quoteMaxToken); + } + ); + + it('preserves an explicitly zero quote limit for draft tests', () => { + expect( + prepareDraftSystemModelForTest({ + ...modelData, + config: { ...modelData.config, quoteMaxToken: 0 } + }) + ).toMatchObject({ config: { quoteMaxToken: 0 } }); + }); + + it('preserves the complete current draft when preparing a channel test', () => { + const draft = { + type: ModelTypeEnum.tts, + provider: 'Custom provider', + model: ' draft-tts ', + name: 'Draft alias', + scope: ModelScopeEnum.system, + isActive: false, + requestUrl: 'https://draft.example.com/audio', + requestAuth: 'draft-secret', + config: { voices: [{ label: 'Alloy', value: 'alloy' }] } + } as const; + + expect(prepareDraftSystemModelForTest(draft)).toEqual({ + ...draft, + model: 'draft-tts' + }); + }); + + it('submits config and channels together without a separate external mutation', async () => { + const modelId = '68ad85a7463006c963799a05'; + + await submitUpdatedSystemModel({ modelId, modelData, channelIds: [2, 7] }); + + expect(mocks.postSystemModel).not.toHaveBeenCalled(); + expect(mocks.putReplaceSystemModelChannels).not.toHaveBeenCalled(); + expect(mocks.putSystemModel).toHaveBeenCalledWith({ + modelId, + channelIds: [2, 7], + modelData: expect.not.objectContaining({ model: expect.anything() }) + }); + }); + + it('rejects an invalid edited alias before sending any mutation', async () => { + await expect( + submitUpdatedSystemModel({ + modelId: '68ad85a7463006c963799a05', + modelData: { ...modelData, name: ' ' }, + channelIds: [2] + }) + ).rejects.toBeDefined(); + expect(mocks.putSystemModel).not.toHaveBeenCalled(); + expect(mocks.putReplaceSystemModelChannels).not.toHaveBeenCalled(); + }); +}); + +describe('normalizeModelPricingForRead', () => { + it.each([ + { inputPrice: 1, outputPrice: 3 }, + { inputPrice: 0, outputPrice: 3 }, + { charsPointsPrice: 2 } + ])('converts legacy LLM pricing without retaining old fields: %j', (pricing) => { + const original = { ...modelData, ...pricing }; + const result = normalizeModelPricingForRead(original); + expect(result.priceTiers).toEqual([ + { + minInputTokens: 0, + inputPrice: 'charsPointsPrice' in pricing ? pricing.charsPointsPrice : pricing.inputPrice, + outputPrice: 'charsPointsPrice' in pricing ? pricing.charsPointsPrice : pricing.outputPrice + } + ]); + for (const key of ['inputPrice', 'outputPrice', 'charsPointsPrice']) { + expect(result).not.toHaveProperty(key); + } + expect(original).toEqual({ ...modelData, ...pricing }); + }); + + it('keeps current tiers ahead of legacy fields', () => { + const priceTiers = [{ minInputTokens: 0, inputPrice: 2, outputPrice: 4 }]; + expect( + normalizeModelPricingForRead({ ...modelData, priceTiers, inputPrice: 10, outputPrice: 20 }) + .priceTiers + ).toEqual(priceTiers); + }); +}); + +describe('normalizeModelPricingForSave', () => { + it('persists a free edit without falling back to the legacy prices', async () => { + const form = normalizeModelPricingForRead({ ...modelData, inputPrice: 1, outputPrice: 3 }); + form.priceTiers = [{ minInputTokens: 0, inputPrice: 0, outputPrice: 0 }]; + const saved = normalizeModelPricingForSave(form); + expect(saved.priceTiers).toEqual([]); + expect(saved).not.toHaveProperty('inputPrice'); + expect(saved).not.toHaveProperty('outputPrice'); + + await submitUpdatedSystemModel({ + modelId: '68ad85a7463006c963799a05', + modelData: form, + channelIds: [] + }); + expect(mocks.putSystemModel.mock.calls.at(-1)?.[0].modelData).toMatchObject({ priceTiers: [] }); + expect(mocks.putSystemModel.mock.calls.at(-1)?.[0].modelData).not.toHaveProperty('inputPrice'); + }); + + it('ignores stale legacy fields even if a caller still includes them in the save input', () => { + const result = normalizeModelPricingForSave({ + ...modelData, + charsPointsPrice: 9, + inputPrice: 1, + outputPrice: 3, + priceTiers: [] + }); + expect(result).toEqual({ ...modelData, priceTiers: [] }); + }); + + it.each([ModelTypeEnum.embedding, ModelTypeEnum.tts, ModelTypeEnum.stt, ModelTypeEnum.rerank])( + 'preserves the current non-LLM pricing for %s', + (type) => { + const model = { ...modelData, type, charsPointsPrice: 5, config: {} } as Parameters< + typeof normalizeModelPricingForRead + >[0]; + expect(normalizeModelPricingForRead(model)).toBe(model); + expect(normalizeModelPricingForSave(model)).toBe(model); + } + ); +}); diff --git a/projects/app/test/pageComponents/model/useModelChannelTest.test.ts b/projects/app/test/pageComponents/model/useModelChannelTest.test.ts new file mode 100644 index 000000000000..cf637e380ee0 --- /dev/null +++ b/projects/app/test/pageComponents/model/useModelChannelTest.test.ts @@ -0,0 +1,277 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ModelScopeEnum, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import type { SystemModelDocumentDataType } from '@fastgpt/global/core/ai/model.schema'; + +const mocks = vi.hoisted(() => ({ + getTestModel: vi.fn(), + postTestDraftModel: vi.fn(), + toast: vi.fn(), + translate: vi.fn((key: string, _values?: Record) => key), + setTestingChannelIds: vi.fn<(value: ReadonlySet) => void>() +})); + +vi.mock('@/web/core/ai/config', () => ({ + getTestModel: mocks.getTestModel, + postTestDraftModel: mocks.postTestDraftModel, + postSystemModel: vi.fn(), + putReplaceSystemModelChannels: vi.fn(), + putSystemModel: vi.fn() +})); + +// 这里只隔离 React 调度;草稿规范化、异步请求编排及 in-flight Set 均运行真实实现。 +vi.mock('react', async (importOriginal) => ({ + ...(await importOriginal()), + useRef: (value: T) => ({ current: value }), + useState: (value: ReadonlySet) => [value, mocks.setTestingChannelIds] +})); + +vi.mock('@fastgpt/web/hooks/useToast', () => ({ + useToast: () => ({ toast: mocks.toast }) +})); + +vi.mock('@fastgpt/web/i18n/useClientTranslation', () => ({ + useClientTranslation: () => ({ t: mocks.translate }) +})); + +import { useModelChannelTest } from '@/pageComponents/model/useModelChannelTest'; + +describe('useModelChannelTest', () => { + const channels = [ + { id: 1, name: 'Primary channel' }, + { id: 2, name: 'Secondary channel' } + ]; + + /** 构造完整草稿,保证测试检查请求参数而不只检查模型标识。 */ + const createDraft = (): SystemModelDocumentDataType => ({ + type: ModelTypeEnum.llm, + scope: ModelScopeEnum.system, + model: 'initial-model', + name: 'Initial alias', + provider: 'OpenAI', + isActive: false, + config: { maxContext: 16000, maxResponse: 8000, quoteMaxToken: 12000 } + }); + + /** 手动完成请求,精确验证重叠请求的去重与完成顺序,不依赖计时器。 */ + const deferredRequest = () => { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.getTestModel.mockReset().mockResolvedValue(undefined); + mocks.postTestDraftModel.mockReset().mockResolvedValue(undefined); + }); + + it('reads the complete latest draft on every click and never calls the installed endpoint', async () => { + let draft = createDraft(); + const getModelData = vi.fn(() => draft); + const { testModelChannel, testingChannelIds } = useModelChannelTest({ + target: { source: 'draft', getModelData }, + channels + }); + + expect(testingChannelIds.size).toBe(0); + expect(getModelData).not.toHaveBeenCalled(); + + draft = { + ...draft, + model: ' edited-model ', + name: '', + provider: 'Custom provider', + requestUrl: 'https://draft.example.com/v1', + requestAuth: 'draft-token', + priceTiers: [{ inputPrice: 0, outputPrice: 5 }], + config: { maxContext: 32000, maxResponse: 4000, quoteMaxToken: 24000, vision: true } + }; + await testModelChannel(1); + + expect(mocks.postTestDraftModel).toHaveBeenLastCalledWith({ + modelData: { ...draft, model: 'edited-model', name: 'edited-model' }, + channelId: 1 + }); + expect(draft.model).toBe(' edited-model '); + expect(draft.name).toBe(''); + + draft = { ...draft, model: 'second-edit', name: 'New alias' }; + await testModelChannel(2); + + expect(getModelData).toHaveBeenCalledTimes(2); + expect(mocks.postTestDraftModel).toHaveBeenLastCalledWith({ modelData: draft, channelId: 2 }); + expect(mocks.getTestModel).not.toHaveBeenCalled(); + }); + + it('uses the stable persisted ID only for an explicitly installed target', async () => { + const { testModelChannel } = useModelChannelTest({ + target: { source: 'installed', modelId: 'persisted-model-id', model: 'installed-model' }, + channels + }); + + await testModelChannel(1); + + expect(mocks.getTestModel).toHaveBeenCalledExactlyOnceWith({ + modelId: 'persisted-model-id', + channelId: 1 + }); + expect(mocks.postTestDraftModel).not.toHaveBeenCalled(); + expect(mocks.translate).toHaveBeenCalledWith('config_model:model_channel_test_success', { + model: 'installed-model', + channel: 'Primary channel' + }); + expect(mocks.toast).toHaveBeenCalledWith({ + status: 'success', + title: 'config_model:model_channel_test_success' + }); + expect(mocks.setTestingChannelIds.mock.calls.map(([ids]) => [...ids])).toEqual([[1], []]); + }); + + it('does nothing while there is no test target', async () => { + await useModelChannelTest({ channels }).testModelChannel(1); + + expect(mocks.getTestModel).not.toHaveBeenCalled(); + expect(mocks.postTestDraftModel).not.toHaveBeenCalled(); + expect(mocks.toast).not.toHaveBeenCalled(); + expect(mocks.setTestingChannelIds).not.toHaveBeenCalled(); + }); + + it.each(['missing draft', 'empty model', 'whitespace model'])( + 'rejects %s without falling back to a saved model', + async (scenario) => { + const draft = (() => { + if (scenario === 'missing draft') return undefined; + return { ...createDraft(), model: scenario === 'empty model' ? '' : ' \t ' }; + })(); + const { testModelChannel } = useModelChannelTest({ + target: { source: 'draft', getModelData: () => draft }, + channels + }); + + await testModelChannel(1); + + expect(mocks.toast).toHaveBeenCalledExactlyOnceWith({ + status: 'warning', + title: 'config_model:fill_model_id_before_test' + }); + expect(mocks.getTestModel).not.toHaveBeenCalled(); + expect(mocks.postTestDraftModel).not.toHaveBeenCalled(); + expect(mocks.setTestingChannelIds).not.toHaveBeenCalled(); + } + ); + + it.each([null, {}, 'voice'])( + 'rejects invalid JSON voice value %j without an unhandled error', + async (voices) => { + const draft = { + ...createDraft(), + type: ModelTypeEnum.tts, + config: { voices } + } as unknown as SystemModelDocumentDataType; + const { testModelChannel } = useModelChannelTest({ + target: { source: 'draft', getModelData: () => draft }, + channels + }); + await expect(testModelChannel(1)).resolves.toBeUndefined(); + expect(mocks.toast).toHaveBeenCalledWith({ + status: 'warning', + title: 'config_model:fill_voice_before_test' + }); + expect(mocks.postTestDraftModel).not.toHaveBeenCalled(); + } + ); + + it('rejects empty TTS voices but accepts the newly entered draft voice on a subsequent click', async () => { + let draft: SystemModelDocumentDataType = { + type: ModelTypeEnum.tts, + scope: ModelScopeEnum.system, + provider: 'Custom provider', + model: 'draft-tts', + name: 'TTS draft', + config: { voices: [] } + }; + const { testModelChannel } = useModelChannelTest({ + target: { source: 'draft', getModelData: () => draft }, + channels + }); + + await testModelChannel(1); + + expect(mocks.toast).toHaveBeenCalledWith({ + status: 'warning', + title: 'config_model:fill_voice_before_test' + }); + expect(mocks.postTestDraftModel).not.toHaveBeenCalled(); + expect(mocks.setTestingChannelIds).not.toHaveBeenCalled(); + + draft = { ...draft, config: { voices: [{ label: 'New voice', value: 'new-voice' }] } }; + await testModelChannel(1); + + expect(mocks.postTestDraftModel).toHaveBeenCalledExactlyOnceWith({ + modelData: draft, + channelId: 1 + }); + expect(mocks.getTestModel).not.toHaveBeenCalled(); + }); + + it('deduplicates a channel in flight while other channels progress and cleans up after errors', async () => { + const first = deferredRequest(); + const second = deferredRequest(); + mocks.postTestDraftModel.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const getModelData = vi.fn(createDraft); + const { testModelChannel } = useModelChannelTest({ + target: { source: 'draft', getModelData }, + channels + }); + + const firstRun = testModelChannel(1); + await testModelChannel(1); + const secondRun = testModelChannel(2); + + expect(getModelData).toHaveBeenCalledTimes(2); + expect(mocks.postTestDraftModel).toHaveBeenCalledTimes(2); + expect(mocks.setTestingChannelIds.mock.calls.map(([ids]) => [...ids])).toEqual([[1], [1, 2]]); + + second.resolve(); + await secondRun; + expect(mocks.setTestingChannelIds).toHaveBeenLastCalledWith(new Set([1])); + + first.reject(new Error('channel unavailable')); + await expect(firstRun).resolves.toBeUndefined(); + + expect(mocks.translate).toHaveBeenCalledWith('config_model:model_channel_test_failed', { + model: 'initial-model', + channel: 'Primary channel', + reason: 'channel unavailable' + }); + expect(mocks.toast).toHaveBeenLastCalledWith({ + status: 'error', + title: 'config_model:model_channel_test_failed' + }); + expect(mocks.setTestingChannelIds).toHaveBeenLastCalledWith(new Set()); + + await testModelChannel(1); + expect(mocks.postTestDraftModel).toHaveBeenCalledTimes(3); + expect(mocks.setTestingChannelIds).toHaveBeenLastCalledWith(new Set()); + }); + + it('uses an empty channel label when channel metadata is unavailable', async () => { + await useModelChannelTest({ + target: { source: 'installed', modelId: 'persisted-model-id', model: 'installed-model' }, + channels: [] + }).testModelChannel(99); + + expect(mocks.getTestModel).toHaveBeenCalledExactlyOnceWith({ + modelId: 'persisted-model-id', + channelId: 99 + }); + expect(mocks.translate).toHaveBeenCalledWith('config_model:model_channel_test_success', { + model: 'installed-model', + channel: '' + }); + }); +}); diff --git a/projects/app/test/pageComponents/model/useModelEditWorkflow.test.ts b/projects/app/test/pageComponents/model/useModelEditWorkflow.test.ts new file mode 100644 index 000000000000..2d33c8696399 --- /dev/null +++ b/projects/app/test/pageComponents/model/useModelEditWorkflow.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ModelScopeEnum, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import type { SystemModelDocumentDataType } from '@fastgpt/global/core/ai/model.schema'; +import type { + AdminSystemModelListItem, + GetAdminSystemModelDetailResponse +} from '@fastgpt/global/openapi/admin/core/ai/model/api'; + +const mocks = vi.hoisted(() => ({ + getSystemModelDetail: vi.fn(), + getTestModel: vi.fn(), + postTestDraftModel: vi.fn(), + toast: vi.fn(), + refreshDetail: vi.fn(), + detail: undefined as GetAdminSystemModelDetailResponse | undefined, + loading: false +})); + +vi.mock('@/web/core/ai/config', () => ({ + getSystemModelDetail: mocks.getSystemModelDetail, + getTestModel: mocks.getTestModel, + postTestDraftModel: mocks.postTestDraftModel, + postSystemModel: vi.fn(), + putReplaceSystemModelChannels: vi.fn(), + putSystemModel: vi.fn() +})); + +vi.mock('react', async (importOriginal) => ({ + ...(await importOriginal()), + useRef: (value: T) => ({ current: value }), + useState: (value: T) => [value, vi.fn()], + useEffect: vi.fn() +})); + +vi.mock('@fastgpt/web/hooks/useRequest', () => ({ + useRequest: () => ({ + data: mocks.detail, + loading: mocks.loading, + runAsync: mocks.refreshDetail + }) +})); +vi.mock('@fastgpt/web/hooks/useToast', () => ({ + useToast: () => ({ toast: mocks.toast }) +})); +vi.mock('@fastgpt/web/hooks/useConfirm', () => ({ + useConfirm: () => ({ openConfirm: vi.fn(() => vi.fn()), ConfirmModal: () => null }) +})); +vi.mock('@fastgpt/web/i18n/useClientTranslation', () => ({ + useClientTranslation: () => ({ t: (key: string) => key }) +})); +vi.mock('next/router', () => ({ + useRouter: () => ({ pathname: '/config/model', query: {}, push: vi.fn() }) +})); + +import { useModelEditWorkflow } from '@/pageComponents/model/useModelEditWorkflow'; + +describe('useModelEditWorkflow draft test wiring', () => { + const model: AdminSystemModelListItem = { + modelId: '68ad85a7463006c963799a05', + scope: ModelScopeEnum.system, + type: ModelTypeEnum.tts, + provider: 'OpenAI', + model: 'saved-tts', + name: 'Saved model', + config: { voices: [{ label: 'Saved voice', value: 'saved-voice' }] }, + channels: [] + }; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.loading = false; + mocks.detail = { + model, + channels: [ + { + id: 7, + name: 'Edited model channel', + status: 1, + isAssociated: true, + protocol: { + name: { en: 'OpenAI', 'zh-CN': 'OpenAI', 'zh-Hant': 'OpenAI' }, + avatar: 'model/openai' + } + } + ] + }; + mocks.postTestDraftModel.mockReset().mockResolvedValue(undefined); + mocks.getTestModel.mockReset().mockResolvedValue(undefined); + }); + + it('exposes loaded detail and reads the attached current form rather than persisted model data', async () => { + const workflow = useModelEditWorkflow({ model, onClose: vi.fn(), onSuccess: vi.fn() }); + expect(workflow.detail).toBe(mocks.detail); + expect(workflow.loadingModelData).toBe(false); + + let draft: SystemModelDocumentDataType = { + scope: ModelScopeEnum.system, + type: ModelTypeEnum.tts, + provider: 'Edited provider', + model: ' form-model-id ', + name: 'Edited alias', + requestUrl: 'https://draft.example.com/audio', + requestAuth: 'edited-auth', + config: { voices: [{ label: 'Edited voice', value: 'edited-voice' }] } + }; + workflow.modelFormGetValuesRef.current = () => draft; + + await workflow.testModelChannel(7); + + expect(mocks.postTestDraftModel).toHaveBeenLastCalledWith({ + modelData: { ...draft, model: 'form-model-id' }, + channelId: 7 + }); + + draft = { + ...draft, + requestAuth: 'second-edit', + config: { voices: [{ label: 'New', value: 'new' }] } + }; + await workflow.testModelChannel(7); + + expect(mocks.postTestDraftModel).toHaveBeenCalledTimes(2); + expect(mocks.postTestDraftModel).toHaveBeenLastCalledWith({ + modelData: { ...draft, model: 'form-model-id' }, + channelId: 7 + }); + expect(mocks.getTestModel).not.toHaveBeenCalled(); + }); + + it('never falls back to a saved model while the form has not attached its reader', async () => { + const workflow = useModelEditWorkflow({ model, onClose: vi.fn(), onSuccess: vi.fn() }); + + await workflow.testModelChannel(7); + + expect(mocks.getTestModel).not.toHaveBeenCalled(); + expect(mocks.postTestDraftModel).not.toHaveBeenCalled(); + expect(mocks.toast).toHaveBeenCalledWith({ + status: 'warning', + title: 'config_model:fill_model_id_before_test' + }); + }); + + it('exposes loading detail without requiring channel metadata to exist', async () => { + mocks.detail = undefined; + mocks.loading = true; + const workflow = useModelEditWorkflow({ model, onClose: vi.fn(), onSuccess: vi.fn() }); + + expect(workflow.detail).toBeUndefined(); + expect(workflow.loadingModelData).toBe(true); + await workflow.testModelChannel(7); + expect(mocks.getTestModel).not.toHaveBeenCalled(); + expect(mocks.postTestDraftModel).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/app/test/pages/api/aiproxy/createChannel.test.ts b/projects/app/test/pages/api/aiproxy/createChannel.test.ts new file mode 100644 index 000000000000..8add76357de9 --- /dev/null +++ b/projects/app/test/pages/api/aiproxy/createChannel.test.ts @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + authSystemAdmin: vi.fn(), + getAIProxyAdminConfig: vi.fn(), + post: vi.fn(), + get: vi.fn() +})); + +vi.mock('@fastgpt/service/support/permission/user/auth', () => ({ + authSystemAdmin: mocks.authSystemAdmin +})); +vi.mock('@fastgpt/service/thirdProvider/aiproxy/config', () => ({ + getAIProxyAdminConfig: mocks.getAIProxyAdminConfig +})); +vi.mock('@fastgpt/service/common/api/axios', () => ({ + axiosWithoutSSRF: { post: mocks.post, get: mocks.get } +})); + +import handler from '@/pages/api/aiproxy/api/createChannel'; + +describe('POST /api/aiproxy/api/createChannel', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.get.mockReset().mockResolvedValue({ data: { success: true, data: [] } }); + mocks.authSystemAdmin.mockResolvedValue(undefined); + mocks.getAIProxyAdminConfig.mockReturnValue({ + baseUrl: 'https://aiproxy.example.com', + token: 'admin-token' + }); + }); + + it('creates a channel using only the server-side administrator credential', async () => { + const body = { name: 'channel-a', type: 1, key: 'test-key' }; + const upstreamResult = { success: true, data: { id: 10 } }; + const response = { json: vi.fn() }; + mocks.post.mockResolvedValue({ data: upstreamResult }); + + await handler({ body } as any, response as any); + + expect(mocks.authSystemAdmin).toHaveBeenCalledOnce(); + expect(mocks.post).toHaveBeenCalledWith('https://aiproxy.example.com/api/channel/', body, { + headers: { Authorization: 'Bearer admin-token' }, + signal: expect.any(AbortSignal), + timeout: 30000 + }); + expect(response.json).toHaveBeenCalledWith(upstreamResult); + }); + + it('rejects invalid channel parameters before acquiring a lease or contacting AIProxy', async () => { + const response = { json: vi.fn() }; + await handler({ body: { name: ' ', type: 1 } } as any, response as any); + expect(mocks.getAIProxyAdminConfig).not.toHaveBeenCalled(); + expect(mocks.post).not.toHaveBeenCalled(); + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ success: false })); + }); + + it('resolves the exact unique channel name when v0.6.5 returns success without data', async () => { + mocks.post.mockResolvedValue({ data: { success: true } }); + mocks.get.mockResolvedValueOnce({ data: { success: true, data: [] } }).mockResolvedValueOnce({ + data: { + success: true, + data: [ + { id: 99, name: 'unrelated-new-channel', type: 1 }, + { id: 10, name: 'channel-a', type: 1 } + ] + } + }); + const response = { json: vi.fn() }; + await handler({ body: { name: ' channel-a ', type: 1 } } as any, response as any); + expect(response.json).toHaveBeenCalledWith({ success: true, data: { id: 10 } }); + expect(mocks.get).toHaveBeenCalledTimes(2); + }); + + it('rejects duplicate names on the server before creating a channel', async () => { + mocks.get.mockResolvedValue({ + data: { success: true, data: [{ id: 10, name: ' channel-a ', type: 1 }] } + }); + const response = { json: vi.fn() }; + await handler({ body: { name: 'channel-a', type: 1 } } as any, response as any); + expect(mocks.post).not.toHaveBeenCalled(); + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ success: false })); + }); + + it.each([ + [], + [ + { id: 10, name: 'channel-a', type: 1 }, + { id: 11, name: 'channel-a', type: 1 } + ], + [{ id: 10, name: 'channel-a', type: 2 }] + ])( + 'does not invent an ID when the created channel cannot be uniquely resolved: %j', + async (...channels) => { + mocks.post.mockResolvedValue({ data: { success: true, data: null } }); + mocks.get.mockResolvedValueOnce({ data: { success: true, data: [] } }).mockResolvedValueOnce({ + data: { success: true, data: channels } + }); + const response = { json: vi.fn() }; + await handler({ body: { name: 'channel-a', type: 1 } } as any, response as any); + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: expect.stringContaining('Channel created') + }) + ); + expect(mocks.post).toHaveBeenCalledOnce(); + } + ); + + it('rejects multiple credentials before contacting AIProxy', async () => { + const response = { json: vi.fn() }; + await handler( + { body: { name: 'channel-a', type: 1, key: 'first\nsecond' } } as any, + response as any + ); + expect(mocks.get).not.toHaveBeenCalled(); + expect(mocks.post).not.toHaveBeenCalled(); + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ success: false })); + }); + + it('reports a committed creation when the follow-up read fails, without retrying the write', async () => { + mocks.post.mockResolvedValue({ data: { success: true } }); + mocks.get + .mockResolvedValueOnce({ data: { success: true, data: [] } }) + .mockRejectedValueOnce(new Error('connection reset')); + const response = { json: vi.fn() }; + await handler({ body: { name: 'channel-a', type: 1 } } as any, response as any); + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: expect.stringContaining('Channel created') + }) + ); + expect(mocks.post).toHaveBeenCalledOnce(); + }); + + it('preserves a rejected upstream business response without resolving an ID', async () => { + mocks.post.mockResolvedValue({ data: { success: false, message: 'provider rejected' } }); + const response = { json: vi.fn() }; + await handler({ body: { name: 'channel-a', type: 1 } } as any, response as any); + expect(response.json).toHaveBeenCalledWith({ success: false, message: 'provider rejected' }); + expect(mocks.get).toHaveBeenCalledOnce(); + }); + + it('does not call AIProxy when administrator authorization fails', async () => { + const response = { json: vi.fn() }; + mocks.authSystemAdmin.mockRejectedValue(new Error('unAuthorization')); + + await handler({ body: { name: 'channel-a', type: 1 } } as any, response as any); + + expect(mocks.getAIProxyAdminConfig).not.toHaveBeenCalled(); + expect(mocks.post).not.toHaveBeenCalled(); + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: expect.any(String) }) + ); + }); + + it('returns an explicit failure payload when AIProxy rejects channel creation', async () => { + const response = { json: vi.fn() }; + mocks.post.mockRejectedValue(new Error('duplicate channel')); + + await handler({ body: { name: 'channel-a', type: 1 } } as any, response as any); + + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'duplicate channel' }) + ); + }); +}); diff --git a/projects/app/test/pages/api/aiproxy/proxy.test.ts b/projects/app/test/pages/api/aiproxy/proxy.test.ts new file mode 100644 index 000000000000..b2572b351e5d --- /dev/null +++ b/projects/app/test/pages/api/aiproxy/proxy.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + authSystemAdmin: vi.fn(), + getAIProxyAdminConfig: vi.fn(), + jsonRes: vi.fn(), + fetch: vi.fn() +})); + +vi.mock('@fastgpt/service/support/permission/user/auth', () => ({ + authSystemAdmin: mocks.authSystemAdmin +})); +vi.mock('@fastgpt/service/thirdProvider/aiproxy/config', () => ({ + getAIProxyAdminConfig: mocks.getAIProxyAdminConfig +})); +vi.mock('@fastgpt/service/common/response', () => ({ jsonRes: mocks.jsonRes })); + +import handler from '@/pages/api/aiproxy/[...path]'; + +const createResponse = () => { + const response = { + setHeader: vi.fn(), + status: vi.fn(), + end: vi.fn() + }; + response.status.mockReturnValue(response); + return response; +}; + +describe('AIProxy administrator proxy', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', mocks.fetch); + mocks.authSystemAdmin.mockResolvedValue(undefined); + mocks.getAIProxyAdminConfig.mockReturnValue({ + baseUrl: 'https://aiproxy.example.com', + token: 'admin-token' + }); + mocks.fetch.mockResolvedValue( + new Response(null, { + status: 202, + headers: { + 'x-upstream': 'ok', + 'content-encoding': 'gzip', + 'transfer-encoding': 'chunked' + } + }) + ); + }); + + it('authenticates the administrator and safely forwards the request', async () => { + const response = createResponse(); + + await handler( + { + method: 'GET', + query: { path: ['api', 'channels', 'all'], keyword: 'a b' }, + headers: { + cookie: 'session=private', + host: 'localhost:3000', + origin: 'https://attacker.example.com', + connection: 'keep-alive', + authorization: 'Bearer client-token', + 'x-client': 'preserved' + } + } as any, + response as any + ); + + expect(mocks.authSystemAdmin).toHaveBeenCalledOnce(); + const request = mocks.fetch.mock.calls[0][0] as Request; + expect(request.url).toBe('https://aiproxy.example.com/api/channels/all?keyword=a+b'); + expect(request.headers.get('authorization')).toBe('Bearer admin-token'); + expect(request.headers.get('x-client')).toBe('preserved'); + expect(request.headers.get('cookie')).toBeNull(); + expect(request.headers.get('host')).toBeNull(); + expect(request.headers.get('origin')).toBeNull(); + expect(request.headers.get('connection')).toBeNull(); + expect(response.status).toHaveBeenCalledWith(202); + expect(response.setHeader).toHaveBeenCalledWith('x-upstream', 'ok'); + expect(response.setHeader).not.toHaveBeenCalledWith('content-encoding', expect.anything()); + expect(response.setHeader).not.toHaveBeenCalledWith('transfer-encoding', expect.anything()); + expect(response.end).toHaveBeenCalledOnce(); + }); + + it('never calls AIProxy when administrator authorization fails', async () => { + const error = new Error('unAuthorization'); + const response = createResponse(); + mocks.authSystemAdmin.mockRejectedValue(error); + + await handler({ query: {}, headers: {} } as any, response as any); + + expect(mocks.getAIProxyAdminConfig).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.jsonRes).toHaveBeenCalledWith(response, { code: 500, error }); + }); + + it('rejects an empty proxy path without issuing an upstream request', async () => { + const response = createResponse(); + + await handler({ query: {}, headers: {} } as any, response as any); + + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.jsonRes).toHaveBeenCalledWith( + response, + expect.objectContaining({ code: 500, error: expect.any(Error) }) + ); + }); +}); diff --git a/projects/app/test/pages/api/core/ai/model/delete.test.ts b/projects/app/test/pages/api/core/ai/model/delete.test.ts index 26f435a8ecd2..cdac0275655f 100644 --- a/projects/app/test/pages/api/core/ai/model/delete.test.ts +++ b/projects/app/test/pages/api/core/ai/model/delete.test.ts @@ -5,9 +5,9 @@ const modelId = '68ad85a7463006c963799a05'; const mocks = vi.hoisted(() => ({ authSystemAdmin: vi.fn(), - refreshModelTemplates: vi.fn(), findModelData: vi.fn(), - deleteModel: vi.fn(), + removeModelsFromAIProxyChannels: vi.fn(), + deleteModels: vi.fn(), deletePermissions: vi.fn(), updatedReloadSystemModel: vi.fn(), session: { id: 'session-1' } @@ -22,24 +22,34 @@ vi.mock('@fastgpt/service/support/permission/user/auth', () => ({ })); vi.mock('@fastgpt/service/core/ai/config/utils', () => ({ - refreshModelTemplates: mocks.refreshModelTemplates, updatedReloadSystemModel: mocks.updatedReloadSystemModel })); vi.mock('@fastgpt/service/core/ai/model', () => ({ - findModelData: mocks.findModelData + getModelHandle: async () => ({ findModelData: mocks.findModelData }) +})); + +vi.mock('@fastgpt/service/thirdProvider/aiproxy/channel', () => ({ + removeModelsFromAIProxyChannels: mocks.removeModelsFromAIProxyChannels })); vi.mock('@fastgpt/service/core/ai/config/schema', () => ({ - MongoAIModel: { deleteOne: mocks.deleteModel } + MongoAIModel: { + deleteMany: mocks.deleteModels, + find: ({ _id }: { _id: { $in: string[] } }) => ({ + select: () => ({ + lean: async () => _id.$in.map((modelId) => mocks.findModelData({ modelId })).filter(Boolean) + }) + }) + } })); vi.mock('@fastgpt/service/support/permission/schema', () => ({ MongoResourcePermission: { deleteMany: mocks.deletePermissions } })); -vi.mock('@fastgpt/service/common/mongo/sessionRun', () => ({ - mongoSessionRun: vi.fn(async (callback: (session: unknown) => Promise) => +vi.mock('@fastgpt/service/core/ai/config/entity', () => ({ + runSystemModelTransaction: vi.fn(async (callback: (session: unknown) => Promise) => callback(mocks.session) ) })); @@ -50,63 +60,104 @@ describe('DELETE /api/admin/settings/model/delete', () => { beforeEach(() => { vi.clearAllMocks(); mocks.authSystemAdmin.mockResolvedValue(undefined); - mocks.refreshModelTemplates.mockResolvedValue([]); - mocks.findModelData.mockReturnValue({ - modelId, - model: 'custom-model', + mocks.findModelData.mockImplementation(({ modelId: requestedModelId }) => ({ + modelId: requestedModelId, + model: `model-${requestedModelId}`, type: ModelTypeEnum.llm - }); - mocks.deleteModel.mockResolvedValue({ deletedCount: 1 }); + })); + mocks.removeModelsFromAIProxyChannels.mockResolvedValue(undefined); + mocks.deleteModels.mockResolvedValue({ deletedCount: 1 }); mocks.deletePermissions.mockResolvedValue({ deletedCount: 1 }); mocks.updatedReloadSystemModel.mockResolvedValue(undefined); }); - it('hard deletes a model absent from plugins together with its permission resources', async () => { + it('hard deletes an installed model without consulting Plugin templates', async () => { await handler({ query: { modelId } } as any); - expect(mocks.deleteModel).toHaveBeenCalledWith( - { _id: modelId, scope: 'system' }, + expect(mocks.removeModelsFromAIProxyChannels).toHaveBeenCalledWith({ + models: [`model-${modelId}`] + }); + expect(mocks.deleteModels).toHaveBeenCalledWith( + { _id: { $in: [modelId] }, scope: 'system' }, { session: mocks.session } ); expect(mocks.deletePermissions).toHaveBeenCalledWith( { resourceType: 'model', - resourceId: modelId + resourceId: { $in: [modelId] } }, { session: mocks.session } ); - expect(mocks.updatedReloadSystemModel).toHaveBeenCalledWith({ pluginDocuments: [] }); + expect(mocks.updatedReloadSystemModel).toHaveBeenCalledWith(); }); - it('rejects deletion when the latest plugin snapshot still contains the model', async () => { - mocks.refreshModelTemplates.mockResolvedValue([ - { model: 'custom-model', type: ModelTypeEnum.llm } - ]); + it('deletes the model regardless of whether Plugin is available', async () => { + await handler({ query: { modelId } } as any); - await expect(handler({ query: { modelId } } as any)).rejects.toBe( - 'Plugin model cannot be deleted' - ); + expect(mocks.deleteModels).toHaveBeenCalledOnce(); + }); + + it('deletes multiple models and permissions in one transaction', async () => { + const secondModelId = '68ad85a7463006c963799a06'; + mocks.deleteModels.mockResolvedValueOnce({ deletedCount: 2 }); + + await handler({ body: { modelIds: [modelId, secondModelId] } } as any); - expect(mocks.deleteModel).not.toHaveBeenCalled(); - expect(mocks.deletePermissions).not.toHaveBeenCalled(); + expect(mocks.removeModelsFromAIProxyChannels).toHaveBeenCalledWith({ + models: [`model-${modelId}`, `model-${secondModelId}`] + }); + expect(mocks.deleteModels).toHaveBeenCalledWith( + { _id: { $in: [modelId, secondModelId] }, scope: 'system' }, + { session: mocks.session } + ); + expect(mocks.deletePermissions).toHaveBeenCalledWith( + { + resourceType: 'model', + resourceId: { $in: [modelId, secondModelId] } + }, + { session: mocks.session } + ); + expect(mocks.updatedReloadSystemModel).toHaveBeenCalledOnce(); }); - it('does not delete data when plugin refresh fails', async () => { - mocks.refreshModelTemplates.mockRejectedValue(new Error('plugin unavailable')); + it('does not mutate channels or MongoDB when any model does not exist', async () => { + const missingModelId = '68ad85a7463006c963799a06'; + mocks.findModelData.mockImplementation(({ modelId: requestedModelId }) => + requestedModelId === missingModelId + ? undefined + : { + modelId: requestedModelId, + model: `model-${requestedModelId}`, + type: ModelTypeEnum.llm + } + ); - await expect(handler({ query: { modelId } } as any)).rejects.toThrow('plugin unavailable'); + await expect( + handler({ body: { modelIds: [modelId, missingModelId] } } as any) + ).rejects.toBeDefined(); - expect(mocks.deleteModel).not.toHaveBeenCalled(); - expect(mocks.updatedReloadSystemModel).not.toHaveBeenCalled(); + expect(mocks.removeModelsFromAIProxyChannels).not.toHaveBeenCalled(); + expect(mocks.deleteModels).not.toHaveBeenCalled(); }); - it('allows deleting a same-name model with a different type than the plugin template', async () => { - mocks.refreshModelTemplates.mockResolvedValue([ - { model: 'custom-model', type: ModelTypeEnum.embedding } - ]); + it('keeps committed model deletion when channel unbinding fails', async () => { + mocks.removeModelsFromAIProxyChannels.mockRejectedValueOnce(new Error('unbind failed')); + + await expect(handler({ query: { modelId } } as any)).rejects.toThrow('unbind failed'); + + expect(mocks.deleteModels).toHaveBeenCalledOnce(); + expect(mocks.deletePermissions).toHaveBeenCalledOnce(); + expect(mocks.updatedReloadSystemModel).toHaveBeenCalledOnce(); + }); + it('unbinds channels after the MongoDB deletion and cache refresh', async () => { await handler({ query: { modelId } } as any); - expect(mocks.deleteModel).toHaveBeenCalledOnce(); + expect(mocks.deleteModels.mock.invocationCallOrder[0]).toBeLessThan( + mocks.removeModelsFromAIProxyChannels.mock.invocationCallOrder[0] + ); + expect(mocks.updatedReloadSystemModel.mock.invocationCallOrder[0]).toBeLessThan( + mocks.removeModelsFromAIProxyChannels.mock.invocationCallOrder[0] + ); }); }); diff --git a/projects/app/test/pages/api/core/ai/model/test.test.ts b/projects/app/test/pages/api/core/ai/model/test.test.ts new file mode 100644 index 000000000000..360efcc5db19 --- /dev/null +++ b/projects/app/test/pages/api/core/ai/model/test.test.ts @@ -0,0 +1,334 @@ +import { ModelScopeEnum, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + authSystemAdmin: vi.fn(), + findModelData: vi.fn(), + createLLMResponse: vi.fn(), + getVectors: vi.fn(), + reRankRecall: vi.fn(), + aiTranscriptions: vi.fn(), + getAIApi: vi.fn(), + debug: vi.fn(), + withTemporaryModelChannelBinding: vi.fn() +})); + +vi.mock('@/service/middleware/entry', () => ({ NextAPI: (handler: unknown) => handler })); +vi.mock('@fastgpt/service/support/permission/user/auth', () => ({ + authSystemAdmin: mocks.authSystemAdmin +})); +vi.mock('@fastgpt/service/core/ai/model', () => ({ + getModelHandle: async () => ({ findModelData: mocks.findModelData }) +})); +vi.mock('@fastgpt/service/core/ai/llm/request', () => ({ + createLLMResponse: mocks.createLLMResponse +})); +vi.mock('@fastgpt/service/core/ai/embedding', () => ({ getVectors: mocks.getVectors })); +vi.mock('@fastgpt/service/core/ai/rerank', () => ({ reRankRecall: mocks.reRankRecall })); +vi.mock('@fastgpt/service/core/ai/audio/transcriptions', () => ({ + aiTranscriptions: mocks.aiTranscriptions +})); +vi.mock('@fastgpt/service/core/ai/config', () => ({ getAIApi: mocks.getAIApi })); +vi.mock('@fastgpt/service/thirdProvider/aiproxy/channel', () => ({ + withTemporaryModelChannelBinding: (options: { run: () => Promise }) => options.run() +})); +vi.mock('@fastgpt/service/common/logger', () => ({ + LogCategories: { MODULE: { AI: { MODEL: 'model' } } }, + getLogger: () => ({ debug: mocks.debug, info: vi.fn() }) +})); + +import handler from '@/pages/api/admin/settings/model/test'; + +const installedModel = { + modelId: '68ad85a7463006c963799a05', + type: ModelTypeEnum.llm, + provider: 'OpenAI', + model: 'test-routing-model', + name: 'Test routing model', + scope: ModelScopeEnum.system, + isActive: true, + requestUrl: 'https://model.example.com/v1/chat/completions', + requestAuth: 'model-secret', + config: { maxContext: 16000, maxResponse: 8000, quoteMaxToken: 12000 } +}; + +describe('admin model test routing', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authSystemAdmin.mockResolvedValue({ teamId: 'root-team' }); + mocks.findModelData.mockReturnValue(installedModel); + mocks.createLLMResponse.mockResolvedValue({ answerText: 'ok' }); + }); + + it('uses an explicit channel on a request-local copy without mutating model connection data', async () => { + const result = await handler( + { query: { modelId: installedModel.modelId, channelId: 7 } } as any, + {} as any + ); + + expect(result).toBe('ok'); + expect(mocks.createLLMResponse).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: 'root-team', + custonHeaders: { 'Aiproxy-Channel': '7' }, + body: expect.objectContaining({ + model: expect.objectContaining({ + modelId: installedModel.modelId, + requestUrl: undefined, + requestAuth: undefined + }) + }) + }) + ); + expect(installedModel.requestUrl).toBe('https://model.example.com/v1/chat/completions'); + expect(installedModel.requestAuth).toBe('model-secret'); + }); + + it('preserves model request configuration when no channel override is selected', async () => { + await handler({ query: { modelId: installedModel.modelId } } as any, {} as any); + + expect(mocks.createLLMResponse).toHaveBeenCalledWith( + expect.objectContaining({ + custonHeaders: {}, + body: expect.objectContaining({ model: installedModel }) + }) + ); + }); + + it('tests a draft model through the selected channel without resolving a persisted model', async () => { + await handler( + { + method: 'POST', + body: { + modelData: { + type: installedModel.type, + provider: installedModel.provider, + model: 'draft-routing-model', + name: 'Draft routing model', + scope: installedModel.scope, + isActive: installedModel.isActive, + requestUrl: installedModel.requestUrl, + requestAuth: installedModel.requestAuth, + config: installedModel.config + }, + channelId: 9 + } + } as any, + {} as any + ); + + expect(mocks.findModelData).not.toHaveBeenCalled(); + expect(mocks.createLLMResponse).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: 'root-team', + custonHeaders: { 'Aiproxy-Channel': '9' }, + body: expect.objectContaining({ + model: expect.objectContaining({ + model: 'draft-routing-model', + requestUrl: undefined, + requestAuth: undefined + }) + }) + }) + ); + }); + + it('ignores incomplete price fields when testing a draft model', async () => { + await handler( + { + method: 'POST', + body: { + modelData: { + type: installedModel.type, + provider: installedModel.provider, + model: 'draft-with-empty-price', + name: 'Draft with empty price', + scope: installedModel.scope, + config: installedModel.config, + priceTiers: [ + { + minInputTokens: 0, + inputPrice: undefined, + outputPrice: undefined + } + ] + }, + channelId: 9 + } + } as any, + {} as any + ); + + expect(mocks.createLLMResponse).toHaveBeenCalledWith( + expect.objectContaining({ custonHeaders: { 'Aiproxy-Channel': '9' } }) + ); + const testedModel = mocks.createLLMResponse.mock.calls[0]?.[0]?.body?.model; + expect(testedModel).toMatchObject({ model: 'draft-with-empty-price' }); + expect(testedModel).not.toHaveProperty('priceTiers'); + }); + + it('tests a draft text-to-speech model with its configured voice', async () => { + const createSpeech = vi.fn().mockResolvedValue({}); + mocks.getAIApi.mockReturnValue({ + ai: { audio: { speech: { create: createSpeech } } } + }); + + await handler( + { + method: 'POST', + body: { + modelData: { + type: ModelTypeEnum.tts, + provider: 'Custom provider', + model: 'draft-tts', + name: 'Draft TTS', + scope: ModelScopeEnum.system, + isActive: false, + config: { voices: [{ label: 'Alloy', value: 'alloy' }] } + }, + channelId: 10 + } + } as any, + {} as any + ); + + expect(createSpeech).toHaveBeenCalledWith( + { + model: 'draft-tts', + voice: 'alloy', + input: 'Hi', + response_format: 'mp3', + speed: 1 + }, + { headers: { 'Aiproxy-Channel': '10' } } + ); + }); + + it('rejects a draft text-to-speech model without a voice', async () => { + await expect( + handler( + { + method: 'POST', + body: { + modelData: { + type: ModelTypeEnum.tts, + provider: 'OpenAI', + model: 'draft-tts', + name: 'Draft TTS', + scope: ModelScopeEnum.system, + isActive: false, + config: { voices: [] } + }, + channelId: 10 + } + } as any, + {} as any + ) + ).rejects.toMatchObject({ name: 'ApiRequestInputParseError' }); + expect(mocks.getAIApi).not.toHaveBeenCalled(); + }); + + it('tests an embedding model through the selected channel', async () => { + const vectors = [{ embedding: [0.1, 0.2], index: 0 }]; + mocks.findModelData.mockReturnValue({ + ...installedModel, + type: ModelTypeEnum.embedding + }); + mocks.getVectors.mockResolvedValue(vectors); + + const result = await handler( + { query: { modelId: installedModel.modelId, channelId: 11 } } as any, + {} as any + ); + + expect(result).toEqual(vectors); + expect(mocks.getVectors).toHaveBeenCalledWith( + expect.objectContaining({ + inputs: [{ type: 'text', input: 'Hi' }], + headers: { 'Aiproxy-Channel': '11' }, + model: expect.objectContaining({ requestUrl: undefined, requestAuth: undefined }) + }) + ); + }); + + it('tests a rerank model through the selected channel', async () => { + mocks.findModelData.mockReturnValue({ + ...installedModel, + type: ModelTypeEnum.rerank + }); + mocks.reRankRecall.mockResolvedValue([{ id: '1', score: 1 }]); + + await expect( + handler({ query: { modelId: installedModel.modelId, channelId: 12 } } as any, {} as any) + ).resolves.toBeUndefined(); + expect(mocks.reRankRecall).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'Hi', + documents: [{ id: '1', text: 'Hi' }], + headers: { 'Aiproxy-Channel': '12' }, + model: expect.objectContaining({ requestUrl: undefined, requestAuth: undefined }) + }) + ); + }); + + it('tests a text-to-speech model through the selected channel', async () => { + const createSpeech = vi.fn().mockResolvedValue({}); + mocks.findModelData.mockReturnValue({ + ...installedModel, + type: ModelTypeEnum.tts, + config: { voices: [{ label: 'Alloy', value: 'alloy' }] } + }); + mocks.getAIApi.mockReturnValue({ + ai: { audio: { speech: { create: createSpeech } } } + }); + + await expect( + handler({ query: { modelId: installedModel.modelId, channelId: 13 } } as any, {} as any) + ).resolves.toBeUndefined(); + expect(createSpeech).toHaveBeenCalledWith( + { + model: installedModel.model, + voice: 'alloy', + input: 'Hi', + response_format: 'mp3', + speed: 1 + }, + { headers: { 'Aiproxy-Channel': '13' } } + ); + }); + + it('rejects an installed text-to-speech model without a voice', async () => { + mocks.findModelData.mockReturnValue({ + ...installedModel, + type: ModelTypeEnum.tts, + config: { voices: [] } + }); + + await expect( + handler({ query: { modelId: installedModel.modelId, channelId: 13 } } as any, {} as any) + ).rejects.toMatchObject({ + name: 'UserError', + message: 'TTS model test requires at least one voice' + }); + expect(mocks.getAIApi).not.toHaveBeenCalled(); + }); + + it('tests a speech-to-text model through the selected channel', async () => { + mocks.findModelData.mockReturnValue({ + ...installedModel, + type: ModelTypeEnum.stt + }); + mocks.aiTranscriptions.mockResolvedValue({ text: 'Hi' }); + + await expect( + handler({ query: { modelId: installedModel.modelId, channelId: 14 } } as any, {} as any) + ).resolves.toBeUndefined(); + expect(mocks.aiTranscriptions).toHaveBeenCalledWith( + expect.objectContaining({ + filename: 'test.mp3', + headers: { 'Aiproxy-Channel': '14' }, + model: expect.objectContaining({ requestUrl: undefined, requestAuth: undefined }) + }) + ); + }); +}); diff --git a/projects/app/test/pages/api/core/ai/model/update.test.ts b/projects/app/test/pages/api/core/ai/model/update.test.ts index bd2fd86249f6..acedeb7e8917 100644 --- a/projects/app/test/pages/api/core/ai/model/update.test.ts +++ b/projects/app/test/pages/api/core/ai/model/update.test.ts @@ -4,10 +4,17 @@ import { Call } from '@test/utils/request'; import { getRootUser } from '@test/datas/users'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +vi.unmock('@fastgpt/service/common/mongo/sessionRun'); + const configMocks = vi.hoisted(() => ({ refreshModelTemplates: vi.fn(), updatedReloadSystemModel: vi.fn() })); +const channelMocks = vi.hoisted(() => ({ + appendModelsToAIProxyChannels: vi.fn(), + replaceModelInAIProxyChannels: vi.fn() +})); +const providerMocks = vi.hoisted(() => ({ preloadModelProviders: vi.fn() })); vi.mock('@fastgpt/service/core/ai/config/utils', async (importOriginal) => { const actual = await importOriginal(); @@ -18,8 +25,16 @@ vi.mock('@fastgpt/service/core/ai/config/utils', async (importOriginal) => { updatedReloadSystemModel: configMocks.updatedReloadSystemModel }; }); +vi.mock('@fastgpt/service/thirdProvider/aiproxy/channel', () => channelMocks); +vi.mock('@fastgpt/service/core/app/provider/controller', async (importOriginal) => ({ + ...(await importOriginal()), + preloadModelProviders: providerMocks.preloadModelProviders +})); import createModelApi from '@/pages/api/admin/settings/model/create'; +import createModelsFromTemplatesApi from '@/pages/api/admin/settings/model/createFromTemplates'; +import replaceModelChannelsApi from '@/pages/api/admin/settings/model/channel/replace'; +import getModelTemplatesApi from '@/pages/api/admin/settings/model/templates'; import updateModelApi from '@/pages/api/admin/settings/model/update'; const buildLlmDocument = () => ({ @@ -37,6 +52,11 @@ const buildLlmDocument = () => ({ isActive: true }); +const buildLlmUpdateData = () => { + const { model: _model, ...modelData } = buildLlmDocument(); + return modelData; +}; + const callApi = async ({ handler, body }: { handler: any; body: unknown }) => { const root = await getRootUser(); return Call(handler, { auth: root, body }); @@ -44,8 +64,13 @@ const callApi = async ({ handler, body }: { handler: any; body: unknown }) => { describe('admin settings model create/update api', () => { beforeEach(() => { - configMocks.refreshModelTemplates.mockReset().mockResolvedValue([]); configMocks.updatedReloadSystemModel.mockReset().mockResolvedValue(undefined); + configMocks.refreshModelTemplates.mockReset().mockResolvedValue([]); + channelMocks.appendModelsToAIProxyChannels.mockReset().mockResolvedValue(undefined); + channelMocks.replaceModelInAIProxyChannels.mockReset().mockResolvedValue(undefined); + providerMocks.preloadModelProviders.mockReset().mockImplementation(async () => { + global.ModelProviderRawCache = []; + }); }); it('creates a custom model through the dedicated create endpoint', async () => { @@ -63,8 +88,152 @@ describe('admin settings model create/update api', () => { }); }); - it('rejects creating a model whose type conflicts with a same-name plugin template', async () => { - configMocks.refreshModelTemplates.mockResolvedValueOnce([buildLlmDocument()]); + it('defaults a newly created model to inactive', async () => { + const modelData = buildLlmDocument(); + delete (modelData as { isActive?: boolean }).isActive; + + const res = await callApi({ handler: createModelApi, body: { modelData } }); + + expect(res.error).toBeUndefined(); + await expect(MongoAIModel.findById(res.data?.modelId).lean()).resolves.toMatchObject({ + isActive: false + }); + }); + + it('saves a free LLM tier and removes persisted and submitted legacy prices', async () => { + const model = await MongoAIModel.create({ + ...buildLlmDocument(), + inputPrice: 1, + outputPrice: 3, + charsPointsPrice: 9 + }); + const res = await callApi({ + handler: updateModelApi, + body: { + modelId: String(model._id), + modelData: { + ...buildLlmUpdateData(), + inputPrice: 1, + outputPrice: 3, + charsPointsPrice: 9, + priceTiers: [{ minInputTokens: 0, inputPrice: 0, outputPrice: 0 }] + } + } + }); + expect(res.error).toBeUndefined(); + const updated = await MongoAIModel.findById(model._id).lean(); + expect(updated).not.toHaveProperty('inputPrice'); + expect(updated).not.toHaveProperty('outputPrice'); + expect(updated).not.toHaveProperty('charsPointsPrice'); + expect(updated?.priceTiers).toMatchObject([{ inputPrice: 0, outputPrice: 0 }]); + }); + + it('creates an active model with no channel or connection configuration', async () => { + const res = await callApi({ + handler: createModelApi, + body: { modelData: buildLlmDocument(), channelIds: [] } + }); + + expect(res.error).toBeUndefined(); + expect(channelMocks.appendModelsToAIProxyChannels).toHaveBeenCalledWith({ + channelIds: [], + models: ['test-llm'] + }); + const created = await MongoAIModel.findById(res.data?.modelId).lean(); + expect(created).toMatchObject({ isActive: true }); + expect(created).not.toHaveProperty('requestUrl'); + expect(created).not.toHaveProperty('requestAuth'); + }); + + it('preserves requestUrl and requestAuth across create and update', async () => { + const created = await callApi({ + handler: createModelApi, + body: { + modelData: { + ...buildLlmDocument(), + requestUrl: 'https://first.example.com/v1/chat/completions', + requestAuth: 'first-secret' + }, + channelIds: [] + } + }); + expect(created.error).toBeUndefined(); + await expect(MongoAIModel.findById(created.data?.modelId).lean()).resolves.toMatchObject({ + requestUrl: 'https://first.example.com/v1/chat/completions', + requestAuth: 'first-secret' + }); + + const updated = await callApi({ + handler: updateModelApi, + body: { + modelId: created.data?.modelId, + modelData: { + ...buildLlmUpdateData(), + requestUrl: 'https://second.example.com/v1/chat/completions', + requestAuth: 'second-secret' + } + } + }); + + expect(updated.error).toBeUndefined(); + await expect(MongoAIModel.findById(created.data?.modelId).lean()).resolves.toMatchObject({ + requestUrl: 'https://second.example.com/v1/chat/completions', + requestAuth: 'second-secret' + }); + }); + + it('binds channels before inserting the new model', async () => { + channelMocks.appendModelsToAIProxyChannels.mockImplementationOnce(async () => { + await expect(MongoAIModel.countDocuments()).resolves.toBe(0); + }); + + const res = await callApi({ + handler: createModelApi, + body: { modelData: buildLlmDocument(), channelIds: [7] } + }); + + expect(res.error).toBeUndefined(); + expect(channelMocks.appendModelsToAIProxyChannels).toHaveBeenCalledWith({ + channelIds: [7], + models: ['test-llm'] + }); + await expect(MongoAIModel.countDocuments()).resolves.toBe(1); + }); + + it('does not insert a model when channel binding fails', async () => { + channelMocks.appendModelsToAIProxyChannels.mockRejectedValueOnce( + new Error('channel update failed') + ); + + const res = await callApi({ + handler: createModelApi, + body: { modelData: buildLlmDocument(), channelIds: [7] } + }); + + expect(res.error).toBeDefined(); + await expect(MongoAIModel.countDocuments()).resolves.toBe(0); + expect(configMocks.updatedReloadSystemModel).not.toHaveBeenCalled(); + }); + + it('creates a second model without overwriting the existing default model document', async () => { + const existing = await MongoAIModel.create({ + ...buildLlmDocument(), + model: 'deepseek-v4-flash', + name: 'DeepSeek V4 Flash' + }); + + const res = await callApi({ handler: createModelApi, body: { modelData: buildLlmDocument() } }); + + expect(res.error).toBeUndefined(); + await expect(MongoAIModel.countDocuments()).resolves.toBe(2); + await expect(MongoAIModel.findById(existing._id).lean()).resolves.toMatchObject({ + model: 'deepseek-v4-flash', + name: 'DeepSeek V4 Flash' + }); + }); + + it('rejects a different type reusing the same model identifier', async () => { + await MongoAIModel.create(buildLlmDocument()); const res = await callApi({ handler: createModelApi, @@ -81,8 +250,54 @@ describe('admin settings model create/update api', () => { } }); + expect(res.error).toBeDefined(); + await expect(MongoAIModel.countDocuments()).resolves.toBe(1); + }); + + it('rejects an existing model before mutating any requested channel', async () => { + await MongoAIModel.create(buildLlmDocument()); + + const res = await callApi({ + handler: createModelApi, + body: { modelData: buildLlmDocument(), channelIds: [7] } + }); + expect(res.error?.name).toBe('UserError'); - await expect(MongoAIModel.countDocuments()).resolves.toBe(0); + expect(channelMocks.appendModelsToAIProxyChannels).not.toHaveBeenCalled(); + await expect(MongoAIModel.countDocuments()).resolves.toBe(1); + }); + + it('validates edited config before any channel mutation', async () => { + const existing = await MongoAIModel.create(buildLlmDocument()); + const res = await callApi({ + handler: updateModelApi, + body: { + modelId: String(existing._id), + channelIds: [7], + modelData: { ...buildLlmUpdateData(), name: ' ' } + } + }); + expect(res.error).toBeDefined(); + expect(channelMocks.replaceModelInAIProxyChannels).not.toHaveBeenCalled(); + expect((await MongoAIModel.findById(existing._id).lean())?.name).toBe('Test LLM'); + }); + + it('submits edited channels and model config through one validated operation', async () => { + const existing = await MongoAIModel.create(buildLlmDocument()); + const res = await callApi({ + handler: updateModelApi, + body: { + modelId: String(existing._id), + channelIds: [7], + modelData: { ...buildLlmUpdateData(), name: 'Updated alias' } + } + }); + expect(res.error).toBeUndefined(); + expect(channelMocks.replaceModelInAIProxyChannels).toHaveBeenCalledWith({ + model: existing.model, + channelIds: [7] + }); + expect((await MongoAIModel.findById(existing._id).lean())?.name).toBe('Updated alias'); }); it('updates an existing model only by modelId', async () => { @@ -92,7 +307,7 @@ describe('admin settings model create/update api', () => { body: { modelId: String(existing._id), modelData: { - ...buildLlmDocument(), + ...buildLlmUpdateData(), config: { ...buildLlmDocument().config, maxTemperature: 1.2 } } } @@ -104,6 +319,83 @@ describe('admin settings model create/update api', () => { }); }); + it('clears omitted optional model fields instead of keeping stale values', async () => { + const existing = await MongoAIModel.create({ + ...buildLlmDocument(), + requestUrl: 'https://old.example.com/v1', + requestAuth: 'old-secret', + testMode: true, + charsPointsPrice: 9, + inputPrice: 4, + outputPrice: 5, + priceTiers: [{ minInputTokens: 0, inputPrice: 1, outputPrice: 2 }] + }); + + const res = await callApi({ + handler: updateModelApi, + body: { + modelId: String(existing._id), + modelData: buildLlmUpdateData() + } + }); + + expect(res.error).toBeUndefined(); + const updated = await MongoAIModel.findById(existing._id).lean(); + expect(updated).not.toHaveProperty('requestUrl'); + expect(updated).not.toHaveProperty('requestAuth'); + expect(updated).not.toHaveProperty('testMode'); + expect(updated).not.toHaveProperty('charsPointsPrice'); + expect(updated).not.toHaveProperty('inputPrice'); + expect(updated).not.toHaveProperty('outputPrice'); + expect(updated).not.toHaveProperty('priceTiers'); + }); + + it('rejects changing an existing model type through the update endpoint', async () => { + const existing = await MongoAIModel.create(buildLlmDocument()); + + const res = await callApi({ + handler: updateModelApi, + body: { + modelId: String(existing._id), + modelData: { + type: ModelTypeEnum.embedding, + provider: 'OpenAI', + name: 'Changed type', + scope: 'system', + isActive: true, + config: { defaultToken: 512, maxToken: 8192, weight: 100 } + } + } + }); + + expect(res.error).toMatchObject({ + name: 'UserError', + message: 'System model type cannot be changed' + }); + await expect(MongoAIModel.findById(existing._id).lean()).resolves.toMatchObject({ + type: ModelTypeEnum.llm, + config: { maxContext: 16000 } + }); + expect(configMocks.updatedReloadSystemModel).not.toHaveBeenCalled(); + }); + + it('rejects attempts to change the immutable model identifier', async () => { + const existing = await MongoAIModel.create(buildLlmDocument()); + const res = await callApi({ + handler: updateModelApi, + body: { + modelId: String(existing._id), + modelData: { ...buildLlmUpdateData(), model: 'renamed-llm' } + } + }); + + expect(res.error?.name).toBe('ApiRequestInputParseError'); + await expect(MongoAIModel.findById(existing._id).lean()).resolves.toMatchObject({ + model: 'test-llm' + }); + expect(configMocks.updatedReloadSystemModel).not.toHaveBeenCalled(); + }); + it('accepts and persists a null max temperature', async () => { const existing = await MongoAIModel.create(buildLlmDocument()); const res = await callApi({ @@ -111,7 +403,7 @@ describe('admin settings model create/update api', () => { body: { modelId: String(existing._id), modelData: { - ...buildLlmDocument(), + ...buildLlmUpdateData(), config: { ...buildLlmDocument().config, maxTemperature: null } } } @@ -130,7 +422,7 @@ describe('admin settings model create/update api', () => { body: { modelId: String(existing._id), modelData: { - ...buildLlmDocument(), + ...buildLlmUpdateData(), config: { ...buildLlmDocument().config, maxTemperature: '1.2' } } } @@ -145,7 +437,7 @@ describe('admin settings model create/update api', () => { it('rejects update requests without modelId', async () => { const res = await callApi({ handler: updateModelApi, - body: { modelData: buildLlmDocument() } + body: { modelData: buildLlmUpdateData() } }); expect(res.error?.name).toBe('ApiRequestInputParseError'); @@ -155,36 +447,146 @@ describe('admin settings model create/update api', () => { it('rejects a non-ObjectId modelId at the API boundary', async () => { const res = await callApi({ handler: updateModelApi, - body: { modelId: 'not-an-object-id', modelData: buildLlmDocument() } + body: { modelId: 'not-an-object-id', modelData: buildLlmUpdateData() } }); expect(res.error?.name).toBe('ApiRequestInputParseError'); await expect(MongoAIModel.countDocuments()).resolves.toBe(0); }); - it('rejects a type that conflicts with a same-name plugin template before writing', async () => { - const existing = await MongoAIModel.create(buildLlmDocument()); - configMocks.refreshModelTemplates.mockResolvedValueOnce([buildLlmDocument()]); - const embeddingDocument = { - type: ModelTypeEnum.embedding, - provider: 'OpenAI', - model: buildLlmDocument().model, - name: 'Conflicting embedding', - scope: 'system' as const, - isActive: true, - config: { defaultToken: 512, maxToken: 8192, weight: 100 } - }; + it('rejects modelId anywhere in a create model payload', async () => { + const res = await callApi({ + handler: createModelApi, + body: { modelData: { ...buildLlmDocument(), modelId: '68ad85a7463006c963799a05' } } + }); + + expect(res.error?.name).toBe('ApiRequestInputParseError'); + await expect(MongoAIModel.countDocuments()).resolves.toBe(0); + expect(configMocks.updatedReloadSystemModel).not.toHaveBeenCalled(); + }); + + it('rejects the whole template batch when a selected template disappeared', async () => { + configMocks.refreshModelTemplates.mockResolvedValue([buildLlmDocument()]); const res = await callApi({ - handler: updateModelApi, - body: { modelId: String(existing._id), modelData: embeddingDocument } + handler: createModelsFromTemplatesApi, + body: { + templates: [ + { type: ModelTypeEnum.llm, model: 'test-llm' }, + { type: ModelTypeEnum.llm, model: 'removed-llm' } + ], + channelIds: [7] + } }); expect(res.error?.name).toBe('UserError'); - await expect(MongoAIModel.findById(existing._id).lean()).resolves.toMatchObject({ - type: ModelTypeEnum.llm, - config: { maxContext: 16000 } + expect(channelMocks.appendModelsToAIProxyChannels).not.toHaveBeenCalled(); + await expect(MongoAIModel.countDocuments()).resolves.toBe(0); + }); + + it('pulls model templates again for every templates request', async () => { + configMocks.refreshModelTemplates.mockResolvedValue([buildLlmDocument()]); + + const first = await callApi({ handler: getModelTemplatesApi, body: undefined }); + const second = await callApi({ handler: getModelTemplatesApi, body: undefined }); + + expect(first.error).toBeUndefined(); + expect(second.error).toBeUndefined(); + expect(configMocks.refreshModelTemplates).toHaveBeenCalledTimes(2); + expect(providerMocks.preloadModelProviders).toHaveBeenCalledTimes(2); + }); + + it('uses the latest template values, filters installed models, and creates inactive models', async () => { + await MongoAIModel.create(buildLlmDocument()); + configMocks.refreshModelTemplates.mockResolvedValue([ + buildLlmDocument(), + { ...buildLlmDocument(), model: 'new-llm', name: 'Latest template name' } + ]); + + const res = await callApi({ + handler: createModelsFromTemplatesApi, + body: { + templates: [ + { type: ModelTypeEnum.llm, model: 'test-llm' }, + { type: ModelTypeEnum.llm, model: 'new-llm' } + ], + channelIds: [7] + } + }); + + expect(res.error).toBeUndefined(); + expect(res.data?.models).toHaveLength(1); + expect(channelMocks.appendModelsToAIProxyChannels).toHaveBeenCalledWith({ + channelIds: [7], + models: ['new-llm'] + }); + await expect(MongoAIModel.findOne({ model: 'new-llm' }).lean()).resolves.toMatchObject({ + name: 'Latest template name', + isActive: false + }); + }); + + it('rolls back the whole Mongo batch on a concurrent unique-model conflict', async () => { + const firstTemplate = { ...buildLlmDocument(), model: 'batch-first' }; + const conflictingTemplate = { ...buildLlmDocument(), model: 'batch-conflict' }; + const channelModels = new Set(); + configMocks.refreshModelTemplates.mockResolvedValue([firstTemplate, conflictingTemplate]); + channelMocks.appendModelsToAIProxyChannels.mockImplementationOnce(async ({ models }) => { + models.forEach((model: string) => channelModels.add(model)); + await MongoAIModel.create(conflictingTemplate); + }); + + const res = await callApi({ + handler: createModelsFromTemplatesApi, + body: { + templates: [ + { type: ModelTypeEnum.llm, model: 'batch-first' }, + { type: ModelTypeEnum.llm, model: 'batch-conflict' } + ], + channelIds: [7] + } }); + + expect(res.error).toBeDefined(); + await expect(MongoAIModel.exists({ model: 'batch-first' })).resolves.toBeNull(); + await expect(MongoAIModel.countDocuments({ model: 'batch-conflict' })).resolves.toBe(1); + expect([...channelModels]).toEqual(['batch-first', 'batch-conflict']); expect(configMocks.updatedReloadSystemModel).not.toHaveBeenCalled(); }); + + it('replaces model channels by stable modelId without accepting a renamed identifier', async () => { + const existing = await MongoAIModel.create(buildLlmDocument()); + + const res = await callApi({ + handler: replaceModelChannelsApi, + body: { + modelId: String(existing._id), + channelIds: [2, 7] + } + }); + + expect(res.error).toBeUndefined(); + expect(channelMocks.replaceModelInAIProxyChannels).toHaveBeenCalledWith({ + model: 'test-llm', + channelIds: [2, 7] + }); + }); + + it('allows replacing an existing model association with zero channels', async () => { + const existing = await MongoAIModel.create(buildLlmDocument()); + + const res = await callApi({ + handler: replaceModelChannelsApi, + body: { + modelId: String(existing._id), + channelIds: [] + } + }); + + expect(res.error).toBeUndefined(); + expect(channelMocks.replaceModelInAIProxyChannels).toHaveBeenCalledWith({ + model: 'test-llm', + channelIds: [] + }); + }); }); diff --git a/projects/app/test/pages/api/core/ai/model/updateStatus.test.ts b/projects/app/test/pages/api/core/ai/model/updateStatus.test.ts new file mode 100644 index 000000000000..eac02b372be6 --- /dev/null +++ b/projects/app/test/pages/api/core/ai/model/updateStatus.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const modelIds = ['68ad85a7463006c963799a05', '68ad85a7463006c963799a06']; +const mocks = vi.hoisted(() => ({ + authSystemAdmin: vi.fn(), + updateModels: vi.fn(), + updatedReloadSystemModel: vi.fn(), + session: { id: 'session-1' } +})); + +vi.mock('@/service/middleware/entry', () => ({ + NextAPI: (handler: unknown) => handler +})); +vi.mock('@fastgpt/service/support/permission/user/auth', () => ({ + authSystemAdmin: mocks.authSystemAdmin +})); +vi.mock('@fastgpt/service/core/ai/config/schema', () => ({ + MongoAIModel: { updateMany: mocks.updateModels } +})); +vi.mock('@fastgpt/service/core/ai/config/utils', () => ({ + updatedReloadSystemModel: mocks.updatedReloadSystemModel +})); +vi.mock('@fastgpt/service/core/ai/config/entity', () => ({ + runSystemModelTransaction: vi.fn(async (callback: (session: unknown) => Promise) => + callback(mocks.session) + ) +})); + +import handler from '@/pages/api/admin/settings/model/updateStatus'; + +describe('PUT /api/admin/settings/model/updateStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.updateModels.mockResolvedValue({ matchedCount: modelIds.length }); + }); + + it('updates selected model statuses and reloads the model cache once', async () => { + await handler({ body: { modelIds, isActive: false } } as any); + + expect(mocks.updateModels).toHaveBeenCalledWith( + { _id: { $in: modelIds }, scope: 'system' }, + { $set: { isActive: false } }, + { session: mocks.session } + ); + expect(mocks.updatedReloadSystemModel).toHaveBeenCalledOnce(); + }); + + it('rejects the whole operation when any selected model no longer exists', async () => { + mocks.updateModels.mockResolvedValueOnce({ matchedCount: 1 }); + + await expect(handler({ body: { modelIds, isActive: true } } as any)).rejects.toBe( + 'modelUnExist' + ); + expect(mocks.updatedReloadSystemModel).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/app/test/pages/api/core/ai/model/updateWithJson.test.ts b/projects/app/test/pages/api/core/ai/model/updateWithJson.test.ts index 23ff8d16fc71..67a63d22d620 100644 --- a/projects/app/test/pages/api/core/ai/model/updateWithJson.test.ts +++ b/projects/app/test/pages/api/core/ai/model/updateWithJson.test.ts @@ -5,7 +5,6 @@ import { getRootUser } from '@test/datas/users'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const configMocks = vi.hoisted(() => ({ - refreshModelTemplates: vi.fn(), updatedReloadSystemModel: vi.fn() })); @@ -14,12 +13,12 @@ vi.mock('@fastgpt/service/core/ai/config/utils', async (importOriginal) => { return { ...actual, - refreshModelTemplates: configMocks.refreshModelTemplates, updatedReloadSystemModel: configMocks.updatedReloadSystemModel }; }); import updateWithJsonApi from '@/pages/api/admin/settings/model/updateWithJson'; +import getConfigJsonApi from '@/pages/api/admin/settings/model/getConfigJson'; const buildLlmConfig = ({ modelId, model = 'test-llm' }: { modelId: string; model?: string }) => ({ modelId, @@ -80,11 +79,67 @@ const callUpdateWithJson = async (config: string) => { describe('admin settings model updateWithJson api', () => { beforeEach(() => { - configMocks.refreshModelTemplates.mockReset().mockResolvedValue([]); configMocks.updatedReloadSystemModel.mockReset().mockResolvedValue(undefined); }); - it('updates matching IDs, creates external models by model and disables omitted records', async () => { + it('keeps legacy prices across an export/import round trip while removing old fields on save', async () => { + const existingModel = await MongoAIModel.create({ + ...buildStoredLlm('legacy-priced'), + inputPrice: 1, + outputPrice: 3 + }); + const root = await getRootUser(); + const exported = await Call(getConfigJsonApi, { auth: root }); + const result = await callUpdateWithJson(exported.data); + expect(result.code).toBe(200); + const saved = await MongoAIModel.findById(existingModel._id).lean(); + expect(saved?.priceTiers).toMatchObject([{ inputPrice: 1, outputPrice: 3 }]); + expect(saved).not.toHaveProperty('inputPrice'); + expect(saved).not.toHaveProperty('outputPrice'); + }); + + it('converts legacy imports like opening the editor and saves canonical free tiers without old fields', async () => { + const existingModel = await MongoAIModel.create(buildStoredLlm('import-priced')); + const input = { + ...buildLlmConfig({ modelId: String(existingModel._id) }), + inputPrice: 2, + outputPrice: 4 + }; + expect((await callUpdateWithJson(JSON.stringify([input]))).code).toBe(200); + expect((await MongoAIModel.findById(existingModel._id).lean())?.priceTiers).toMatchObject([ + { inputPrice: 2, outputPrice: 4 } + ]); + // 和编辑器一样,先转换并移除旧字段;之后的新价格为空即表示免费。 + const { inputPrice: _inputPrice, outputPrice: _outputPrice, ...newConfig } = input; + expect( + (await callUpdateWithJson(JSON.stringify([{ ...newConfig, priceTiers: [] }]))).code + ).toBe(200); + const saved = await MongoAIModel.findById(existingModel._id).lean(); + expect(saved?.priceTiers).toEqual([]); + expect(saved).not.toHaveProperty('inputPrice'); + expect(saved).not.toHaveProperty('outputPrice'); + }); + + it('canonicalizes newly imported LLM prices without changing non-LLM pricing', async () => { + const result = await callUpdateWithJson( + JSON.stringify([ + { ...buildLlmConfig({ modelId: 'external-llm', model: 'new-llm' }), charsPointsPrice: 3 }, + { + ...buildEmbeddingConfig({ modelId: 'external-embedding', model: 'new-embedding' }), + charsPointsPrice: 4 + } + ]) + ); + expect(result.code).toBe(200); + const llm = await MongoAIModel.findOne({ model: 'new-llm' }).lean(); + expect(llm?.priceTiers).toMatchObject([{ inputPrice: 3, outputPrice: 3 }]); + expect(llm).not.toHaveProperty('charsPointsPrice'); + expect(await MongoAIModel.findOne({ model: 'new-embedding' }).lean()).toMatchObject({ + charsPointsPrice: 4 + }); + }); + + it('updates matching IDs, creates external models by model and deletes omitted records', async () => { const oldModel = await MongoAIModel.create(buildStoredLlm('old-model')); const existingModel = await MongoAIModel.create(buildStoredLlm('test-llm')); @@ -96,8 +151,7 @@ describe('admin settings model updateWithJson api', () => { ); expect(res.code).toBe(200); - const disabledModel = await MongoAIModel.findById(oldModel._id).lean(); - expect(disabledModel?.isActive).toBe(false); + expect(await MongoAIModel.findById(oldModel._id).lean()).toBeNull(); const updatedModel = await MongoAIModel.findOne({ model: 'test-llm' }).lean(); expect(String(updatedModel?._id)).toBe(String(existingModel._id)); expect(updatedModel).toMatchObject({ @@ -109,7 +163,112 @@ describe('admin settings model updateWithJson api', () => { expect(String(externalModel?._id)).not.toBe('external-system-model-id'); }); - it('ignores old records without modelId and does not disable all models', async () => { + it('keeps the stored model identifier when a local modelId imports a different model', async () => { + const existingModel = await MongoAIModel.create(buildStoredLlm('stored-model')); + + const res = await callUpdateWithJson( + JSON.stringify([ + { + ...buildLlmConfig({ + modelId: String(existingModel._id), + model: 'accidental-renamed-model' + }), + name: 'Updated display name' + } + ]) + ); + + expect(res.code).toBe(200); + await expect(MongoAIModel.findById(existingModel._id).lean()).resolves.toMatchObject({ + model: 'stored-model', + name: 'Updated display name', + isActive: true, + config: { maxContext: 16000 } + }); + await expect( + MongoAIModel.findOne({ model: 'accidental-renamed-model' }).lean() + ).resolves.toBeNull(); + }); + + it('ignores an imported type when a local modelId already exists', async () => { + const existingModel = await MongoAIModel.create(buildStoredLlm('stored-model')); + + const res = await callUpdateWithJson( + JSON.stringify([ + { + ...buildLlmConfig({ modelId: String(existingModel._id), model: 'accidental-model' }), + type: ModelTypeEnum.embedding, + name: 'Imported as another type' + } + ]) + ); + + expect(res.code).toBe(200); + await expect(MongoAIModel.findById(existingModel._id).lean()).resolves.toMatchObject({ + model: 'stored-model', + type: ModelTypeEnum.llm, + name: 'Imported as another type', + config: { maxContext: 16000 } + }); + expect(configMocks.updatedReloadSystemModel).toHaveBeenCalledOnce(); + }); + + it('clears omitted optional fields when replacing a local model config', async () => { + const existingModel = await MongoAIModel.create({ + ...buildStoredLlm('stored-model'), + requestUrl: 'https://old.example.com/v1', + requestAuth: 'old-secret', + testMode: true, + charsPointsPrice: 8, + inputPrice: 2, + outputPrice: 3, + priceTiers: [{ minInputTokens: 0, inputPrice: 1, outputPrice: 2 }] + }); + + const res = await callUpdateWithJson( + JSON.stringify([buildLlmConfig({ modelId: String(existingModel._id) })]) + ); + + expect(res.code).toBe(200); + const updated = await MongoAIModel.findById(existingModel._id).lean(); + expect(updated).not.toHaveProperty('requestUrl'); + expect(updated).not.toHaveProperty('requestAuth'); + expect(updated).not.toHaveProperty('testMode'); + expect(updated).not.toHaveProperty('charsPointsPrice'); + expect(updated).not.toHaveProperty('inputPrice'); + expect(updated).not.toHaveProperty('outputPrice'); + expect(updated?.priceTiers).toEqual([]); + }); + + it('uses the stored model identifier when a local modelId omits model', async () => { + const existingModel = await MongoAIModel.create(buildStoredLlm('stored-model')); + const { model: _model, ...configWithoutModel } = buildLlmConfig({ + modelId: String(existingModel._id) + }); + + const res = await callUpdateWithJson( + JSON.stringify([{ ...configWithoutModel, name: 'Updated without model' }]) + ); + + expect(res.code).toBe(200); + await expect(MongoAIModel.findById(existingModel._id).lean()).resolves.toMatchObject({ + model: 'stored-model', + name: 'Updated without model', + config: { maxContext: 16000 } + }); + }); + + it('requires model when modelId does not match a local model', async () => { + const { model: _model, ...configWithoutModel } = buildLlmConfig({ modelId: 'external-id' }); + + const res = await callUpdateWithJson(JSON.stringify([configWithoutModel])); + + expect(res.error?.name).toBe('UserError'); + await expect(MongoAIModel.countDocuments()).resolves.toBe(0); + expect(configMocks.updatedReloadSystemModel).not.toHaveBeenCalled(); + }); + + it('ignores old records without modelId and does not delete all models', async () => { const existing = await MongoAIModel.create(buildStoredLlm('existing-model')); const res = await callUpdateWithJson( JSON.stringify([{ ...buildStoredLlm('legacy-model'), scope: undefined }]) @@ -123,7 +282,11 @@ describe('admin settings model updateWithJson api', () => { }); it('reuses a target model ID when an external ID points to an existing provider model', async () => { - const existing = await MongoAIModel.create(buildStoredLlm('test-llm')); + const existing = await MongoAIModel.create({ + ...buildStoredLlm('test-llm'), + requestAuth: 'stale-secret', + charsPointsPrice: 5 + }); const res = await callUpdateWithJson( JSON.stringify([buildLlmConfig({ modelId: 'another-system-id' })]) ); @@ -132,6 +295,8 @@ describe('admin settings model updateWithJson api', () => { const updated = await MongoAIModel.findOne({ model: 'test-llm' }).lean(); expect(String(updated?._id)).toBe(String(existing._id)); expect(updated?.config.maxContext).toBe(16000); + expect(updated).not.toHaveProperty('requestAuth'); + expect(updated).not.toHaveProperty('charsPointsPrice'); }); it('rejects malformed JSON as an input parse error', async () => { @@ -151,17 +316,18 @@ describe('admin settings model updateWithJson api', () => { await expect(MongoAIModel.countDocuments()).resolves.toBe(0); }); - it('rejects an imported model whose type conflicts with a same-name plugin template', async () => { - configMocks.refreshModelTemplates.mockResolvedValueOnce([buildStoredLlm('plugin-model')]); + it('rejects same-name models even when their types differ', async () => { + const llm = await MongoAIModel.create(buildStoredLlm('shared-model')); const res = await callUpdateWithJson( JSON.stringify([ - buildEmbeddingConfig({ modelId: 'external-model-id', model: 'plugin-model' }) + buildLlmConfig({ modelId: String(llm._id), model: 'shared-model' }), + buildEmbeddingConfig({ modelId: 'external-model-id', model: 'shared-model' }) ]) ); expect(res.error?.name).toBe('UserError'); - await expect(MongoAIModel.countDocuments()).resolves.toBe(0); + await expect(MongoAIModel.countDocuments({ model: 'shared-model' })).resolves.toBe(1); expect(configMocks.updatedReloadSystemModel).not.toHaveBeenCalled(); }); }); diff --git a/projects/app/test/pages/api/core/dataset/file/getPreviewChunks.test.ts b/projects/app/test/pages/api/core/dataset/file/getPreviewChunks.test.ts index f4dd47bc1912..82ffbc434388 100644 --- a/projects/app/test/pages/api/core/dataset/file/getPreviewChunks.test.ts +++ b/projects/app/test/pages/api/core/dataset/file/getPreviewChunks.test.ts @@ -29,8 +29,10 @@ vi.mock('@fastgpt/service/core/dataset/read', () => ({ })); vi.mock('@fastgpt/service/core/ai/model', () => ({ - getEmbeddingModelData: vi.fn(() => ({ modelId: 'embedding-id', config: {} })), - getLLMModelData: vi.fn(() => ({ modelId: 'llm-id', config: {} })) + getModelHandle: async () => ({ + getEmbeddingModelData: vi.fn(() => ({ modelId: 'embedding-id', config: {} })), + getLLMModelData: vi.fn(() => ({ modelId: 'llm-id', config: {} })) + }) })); vi.mock('@fastgpt/global/core/dataset/training/utils', () => ({ diff --git a/projects/app/test/pages/api/core/dataset/file/getRawTextPreviewChunks.test.ts b/projects/app/test/pages/api/core/dataset/file/getRawTextPreviewChunks.test.ts index 875fb0974d9f..68054261beff 100644 --- a/projects/app/test/pages/api/core/dataset/file/getRawTextPreviewChunks.test.ts +++ b/projects/app/test/pages/api/core/dataset/file/getRawTextPreviewChunks.test.ts @@ -20,8 +20,10 @@ vi.mock('@fastgpt/service/core/dataset/read', () => ({ })); vi.mock('@fastgpt/service/core/ai/model', () => ({ - getEmbeddingModelData: vi.fn(() => ({ modelId: 'embedding-id', config: {} })), - getLLMModelData: vi.fn(() => ({ modelId: 'llm-id', config: {} })) + getModelHandle: async () => ({ + getEmbeddingModelData: vi.fn(() => ({ modelId: 'embedding-id', config: {} })), + getLLMModelData: vi.fn(() => ({ modelId: 'llm-id', config: {} })) + }) })); vi.mock('@fastgpt/global/core/dataset/training/utils', () => ({ diff --git a/projects/app/test/pages/api/core/dataset/searchTest.test.ts b/projects/app/test/pages/api/core/dataset/searchTest.test.ts index d1bf578bf8e6..9fe4180545fa 100644 --- a/projects/app/test/pages/api/core/dataset/searchTest.test.ts +++ b/projects/app/test/pages/api/core/dataset/searchTest.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DatasetSearchModeEnum, DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants'; import { UserError } from '@fastgpt/global/common/error/utils'; import { ModelErrEnum } from '@fastgpt/global/common/error/code/model'; -import { getDefaultLLMModelData, getDefaultRerankModelData } from '@fastgpt/service/core/ai/model'; +const mockGetDefaultModelData = vi.hoisted(() => vi.fn()); const mockAuthDataset = vi.hoisted(() => vi.fn()); const mockCheckTeamAIPoints = vi.hoisted(() => vi.fn()); @@ -40,13 +40,14 @@ vi.mock('@fastgpt/service/support/openapi/tools', () => ({ })); vi.mock('@fastgpt/service/core/ai/model', () => ({ - getDefaultLLMModelData: vi.fn(), - getDefaultRerankModelData: vi.fn(), - getRerankModelData: mockGetRerankModelData, - getEmbeddingModelData: mockGetEmbeddingModelData, - getLLMModelData: mockGetLLMModelData, - findModelData: mockGetOptionalVlmModelData, - getOptionalVlmModelData: mockGetOptionalVlmModelData + getModelHandle: async () => ({ + getDefaultModelData: mockGetDefaultModelData, + getRerankModelData: mockGetRerankModelData, + getEmbeddingModelData: mockGetEmbeddingModelData, + getLLMModelData: mockGetLLMModelData, + findModelData: mockGetOptionalVlmModelData, + getOptionalVlmModelData: mockGetOptionalVlmModelData + }) })); vi.mock('@fastgpt/service/support/user/audit/util', () => ({ @@ -81,18 +82,12 @@ describe('searchTest query image auth', () => { mockGetRerankModelData.mockImplementationOnce(() => { throw new UserError(ModelErrEnum.unConfigured); }); - vi.mocked(getDefaultLLMModelData).mockReturnValueOnce({ - modelId: 'default-query', - name: 'Default query', - model: 'default-query', - config: {} - } as any); - vi.mocked(getDefaultRerankModelData).mockReturnValueOnce({ - modelId: 'default-rerank', - name: 'Default rerank', - model: 'default-rerank', + mockGetDefaultModelData.mockImplementation((slot) => ({ + modelId: slot === 'llm' ? 'default-query' : 'default-rerank', + name: 'Default model', + model: slot === 'llm' ? 'default-query' : 'default-rerank', config: {} - } as any); + })); await handler( { body: { diff --git a/projects/app/test/service/common/system/volumnMongoWatch.test.ts b/projects/app/test/service/common/system/volumnMongoWatch.test.ts new file mode 100644 index 000000000000..4b96f31f555e --- /dev/null +++ b/projects/app/test/service/common/system/volumnMongoWatch.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MongoAIModel } from '@fastgpt/service/core/ai/config/schema'; +import { MongoAIDefaultModel } from '@fastgpt/service/core/ai/defaultModel/schema'; + +const mocks = vi.hoisted(() => { + const stream = () => ({ on: vi.fn().mockReturnThis(), close: vi.fn() }); + return { + configStream: stream(), + datasetStream: stream(), + templateStream: stream(), + configWatch: vi.fn(), + datasetWatch: vi.fn(), + templateWatch: vi.fn() + }; +}); + +vi.mock('@fastgpt/service/common/system/config/schema', () => ({ + MongoSystemConfigs: { watch: mocks.configWatch } +})); +vi.mock('@/service/core/dataset/training/utils', () => ({ + createDatasetTrainingMongoWatch: mocks.datasetWatch +})); +vi.mock('@fastgpt/service/core/app/templates/templateSchema', () => ({ + MongoAppTemplate: { watch: mocks.templateWatch } +})); + +import { startMongoWatch } from '@/service/common/system/volumnMongoWatch'; + +afterEach(() => vi.restoreAllMocks()); + +describe('startMongoWatch', () => { + it('keeps config, training and template watches without opening model change streams', async () => { + mocks.configWatch.mockReturnValue(mocks.configStream); + mocks.datasetWatch.mockReturnValue(mocks.datasetStream); + mocks.templateWatch.mockReturnValue(mocks.templateStream); + const modelWatch = vi.spyOn(MongoAIModel, 'watch'); + const defaultsWatch = vi.spyOn(MongoAIDefaultModel, 'watch'); + + await startMongoWatch(); + await startMongoWatch(); + + for (const watch of [mocks.configWatch, mocks.datasetWatch, mocks.templateWatch]) { + expect(watch).toHaveBeenCalledTimes(2); + } + for (const stream of [mocks.configStream, mocks.datasetStream, mocks.templateStream]) { + expect(stream.close).toHaveBeenCalledOnce(); + } + expect(modelWatch).not.toHaveBeenCalled(); + expect(defaultsWatch).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/app/test/service/core/app/rewriteAppWorkflowToDetail.test.ts b/projects/app/test/service/core/app/rewriteAppWorkflowToDetail.test.ts index 33c4d2faa240..0e1d8ae6199e 100644 --- a/projects/app/test/service/core/app/rewriteAppWorkflowToDetail.test.ts +++ b/projects/app/test/service/core/app/rewriteAppWorkflowToDetail.test.ts @@ -1,7 +1,7 @@ +import { getModelTestDefaults } from '@test/modelCache'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MongoAgentSkills } from '@fastgpt/service/core/ai/skill/model/schema'; import { MongoDataset } from '@fastgpt/service/core/dataset/schema'; -import { getDatasetEmbeddingModel } from '@fastgpt/service/core/dataset/model'; import { AgentSkillSourceEnum, AgentSkillTypeEnum } from '@fastgpt/global/core/ai/skill/constants'; import { DatasetTypeEnum, DatasetTypeMap } from '@fastgpt/global/core/dataset/constants'; import { @@ -1282,7 +1282,7 @@ describe('rewriteAppWorkflowToDetail - agent skills', () => { it('刷新 ChatAgent 的知识库参数快照信息', async () => { const user = await getUser(`agent-dataset-params-${getNanoid(6)}`); - const embeddingModel = global.systemDefaultModel.embedding; + const embeddingModel = getModelTestDefaults().embedding; const dataset = await MongoDataset.create({ name: 'Current Dataset Name', avatar: '/icon/current-dataset.svg', @@ -1290,7 +1290,6 @@ describe('rewriteAppWorkflowToDetail - agent skills', () => { teamId: user.teamId, tmbId: user.tmbId }); - const resolvedEmbeddingModel = getDatasetEmbeddingModel(dataset); const datasetParamsInput = { key: NodeInputKeyEnum.datasetParams, value: { @@ -1338,8 +1337,8 @@ describe('rewriteAppWorkflowToDetail - agent skills', () => { name: 'Current Dataset Name', avatar: '/icon/current-dataset.svg', vectorModel: expect.objectContaining({ - modelId: resolvedEmbeddingModel.modelId, - model: resolvedEmbeddingModel.model + modelId: embeddingModel.modelId, + model: '' }), isDeleted: false } @@ -1348,7 +1347,7 @@ describe('rewriteAppWorkflowToDetail - agent skills', () => { it('兼容旧版单对象知识库选择项并补齐详情快照', async () => { const user = await getUser(`legacy-single-dataset-detail-${getNanoid(6)}`); - const embeddingModel = global.systemDefaultModel.embedding; + const embeddingModel = getModelTestDefaults().embedding; const dataset = await MongoDataset.create({ name: 'Legacy Dataset Name', avatar: '/icon/legacy-dataset.svg', @@ -1356,7 +1355,6 @@ describe('rewriteAppWorkflowToDetail - agent skills', () => { teamId: user.teamId, tmbId: user.tmbId }); - const resolvedEmbeddingModel = getDatasetEmbeddingModel(dataset); const datasetSelectInput = { key: NodeInputKeyEnum.datasetSelectList, value: { @@ -1387,8 +1385,8 @@ describe('rewriteAppWorkflowToDetail - agent skills', () => { name: 'Legacy Dataset Name', avatar: '/icon/legacy-dataset.svg', vectorModel: expect.objectContaining({ - modelId: resolvedEmbeddingModel.modelId, - model: resolvedEmbeddingModel.model + modelId: embeddingModel.modelId, + model: '' }), isDeleted: false } diff --git a/projects/app/test/service/core/app/workflow.test.ts b/projects/app/test/service/core/app/workflow.test.ts index ffd5c6b9a3eb..6c96c6bfefa2 100644 --- a/projects/app/test/service/core/app/workflow.test.ts +++ b/projects/app/test/service/core/app/workflow.test.ts @@ -1,3 +1,4 @@ +import { setModelTestMap, getModelTestMap } from '@test/modelCache'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; @@ -18,7 +19,6 @@ const activeModel = { name: 'GPT test', scope: 'system', isActive: true, - isCustom: false, config: {} } as SystemModelDataType; @@ -37,7 +37,7 @@ const createNode = ({ }) as StoreNodeItemType; describe('getChatModelNameListByModules', () => { - const originalModelMap = global.systemModelMap; + const originalModelMap = getModelTestMap(); beforeEach(() => { const disabledModel = { @@ -55,29 +55,31 @@ describe('getChatModelNameListByModules', () => { type: ModelTypeEnum.embedding } as SystemModelDataType; - global.systemModelMap = new Map([ - [`id:${activeModelId}`, activeModel], - [`model:${activeModel.model}`, activeModel], - [`id:${disabledModelId}`, disabledModel], - [`id:${embeddingModelId}`, embeddingModel] - ]); + setModelTestMap( + new Map([ + [`id:${activeModelId}`, activeModel], + [`model:${activeModel.model}`, activeModel], + [`id:${disabledModelId}`, disabledModel], + [`id:${embeddingModelId}`, embeddingModel] + ]) + ); }); afterEach(() => { - global.systemModelMap = originalModelMap; + setModelTestMap(originalModelMap); }); - it('returns deduplicated display names for valid static LLM references', () => { + it('returns deduplicated display names for valid static LLM references', async () => { const nodes = [ createNode({ value: activeModelId }), createNode({ value: activeModel.model, key: NodeInputKeyEnum.aiModel }), createNode({ value: activeModelId }) ]; - expect(getChatModelNameListByModules(nodes)).toEqual(['GPT test']); + expect(await getChatModelNameListByModules(nodes)).toEqual(['GPT test']); }); - it('skips unresolved references without blocking chat initialization', () => { + it('skips unresolved references without blocking chat initialization', async () => { const nodes = [ createNode({ value: 'missing-model-id' }), createNode({ value: disabledModelId }), @@ -88,7 +90,6 @@ describe('getChatModelNameListByModules', () => { createNode({ value: activeModelId, flowNodeType: FlowNodeTypeEnum.pluginModule }) ]; - expect(() => getChatModelNameListByModules(nodes)).not.toThrow(); - expect(getChatModelNameListByModules(nodes)).toEqual([]); + expect(await getChatModelNameListByModules(nodes)).toEqual([]); }); }); diff --git a/projects/app/test/service/core/dataset/data/data.test.ts b/projects/app/test/service/core/dataset/data/data.test.ts index 79489cc26d4b..dd8d84b78467 100644 --- a/projects/app/test/service/core/dataset/data/data.test.ts +++ b/projects/app/test/service/core/dataset/data/data.test.ts @@ -1,6 +1,5 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { Types } from '@fastgpt/service/common/mongo'; -import { getEmbeddingModelData } from '@fastgpt/service/core/ai/model'; import { jiebaSplit } from '@fastgpt/service/common/string/jieba/index'; import { MongoS3TTL } from '@fastgpt/service/common/s3/models/ttl'; import { S3Buckets } from '@fastgpt/service/common/s3/config/constants'; @@ -173,7 +172,6 @@ describe('Dataset data service', () => { mockDeleteDatasetFileByKey.mockReset(); mockGetDatasetBase64Image.mockClear(); mockCountPromptTokens.mockClear(); - vi.mocked(getEmbeddingModelData).mockReturnValue(embeddingModel); mockGetVectors.mockImplementation(async ({ inputs }) => createMockVectorsResponse(inputs.map((input) => input.input)) ); diff --git a/projects/app/test/service/core/dataset/data/dataIndex.test.ts b/projects/app/test/service/core/dataset/data/dataIndex.test.ts index 989c8330baa1..aed93bae41f8 100644 --- a/projects/app/test/service/core/dataset/data/dataIndex.test.ts +++ b/projects/app/test/service/core/dataset/data/dataIndex.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Types } from '@fastgpt/service/common/mongo'; -import { getEmbeddingModelData } from '@fastgpt/service/core/ai/model'; import { MongoDatasetCollection } from '@fastgpt/service/core/dataset/collection/schema'; import { MongoDatasetData } from '@fastgpt/service/core/dataset/data/schema'; import { MongoDataset } from '@fastgpt/service/core/dataset/schema'; @@ -163,7 +162,6 @@ describe('DatasetDataIndexOperation', () => { mockCountPromptTokens.mockImplementation(async (text: string) => countPromptTokensInWorker(text) ); - vi.mocked(getEmbeddingModelData).mockReturnValue(embeddingModel); mockGetVectors.mockImplementation(async ({ inputs }) => createMockVectorsResponse(inputs.map((input) => input.input)) ); diff --git a/projects/app/test/service/core/dataset/queues/datasetParse.test.ts b/projects/app/test/service/core/dataset/queues/datasetParse.test.ts index 24fec3a6baee..69bec1920d97 100644 --- a/projects/app/test/service/core/dataset/queues/datasetParse.test.ts +++ b/projects/app/test/service/core/dataset/queues/datasetParse.test.ts @@ -1,3 +1,9 @@ +import { + getModelTestDefaults, + getModelTestMap, + setModelTestMap, + addModelTestModel +} from '@test/modelCache'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DatasetCollectionDataProcessModeEnum, @@ -39,7 +45,7 @@ import { datasetParseQueue } from '@/service/core/dataset/queues/datasetParse'; const createTask = async ({ agentModelId, vlmModelId, - vectorModelId = global.systemDefaultModel.embedding!.modelId, + vectorModelId = getModelTestDefaults().embedding!.modelId, paragraphChunkAIMode = ParagraphChunkAIModeEnum.forbid, imageIndex = false, autoIndexes = false @@ -97,18 +103,18 @@ describe('datasetParseQueue model validation', () => { it.each(['missing', 'disabled', 'wrong-type', 'unconfigured'])( 'does not block parsing on %s auxiliary models', async (state) => { - const previousMap = global.systemModelMap; - global.systemModelMap = new Map(previousMap); + const previousMap = getModelTestMap(); + setModelTestMap(new Map(previousMap)); if (state === 'disabled') { const model = { - ...global.systemDefaultModel.llm!, + ...getModelTestDefaults().llm!, modelId: 'aux-model', isActive: false, - config: { ...global.systemDefaultModel.llm!.config, vision: true } + config: { ...getModelTestDefaults().llm!.config, vision: true } }; - global.systemModelMap.set('id:aux-model', model); + addModelTestModel(model); } else if (state === 'wrong-type') { - global.systemModelMap.set('id:aux-model', global.systemDefaultModel.embedding!); + addModelTestModel(getModelTestDefaults().embedding!); } try { const { task, collection } = await createTask({ @@ -128,7 +134,7 @@ describe('datasetParseQueue model validation', () => { expect(mocks.usage).not.toHaveBeenCalled(); expect(global.datasetParseQueueLen).toBe(0); } finally { - global.systemModelMap = previousMap; + setModelTestMap(previousMap); } } ); @@ -164,7 +170,7 @@ describe('datasetParseQueue model validation', () => { it('retries a paragraph request failure and bills the subsequent success', async () => { const { task, collection } = await createTask({ - agentModelId: global.systemDefaultModel.llm!.modelId, + agentModelId: getModelTestDefaults().llm!.modelId, paragraphChunkAIMode: ParagraphChunkAIModeEnum.force }); mocks.paragraph.mockRejectedValueOnce(new Error('temporary failure')).mockResolvedValue({ diff --git a/projects/app/test/service/core/dataset/queues/generateVector.test.ts b/projects/app/test/service/core/dataset/queues/generateVector.test.ts index 44ef53182e3d..2aad2fd86e98 100644 --- a/projects/app/test/service/core/dataset/queues/generateVector.test.ts +++ b/projects/app/test/service/core/dataset/queues/generateVector.test.ts @@ -1,3 +1,4 @@ +import { getModelTestDefaults, addModelTestModel } from '@test/modelCache'; import { beforeEach, describe, expect, it } from 'vitest'; import { getRebuildBaseIndexes, @@ -24,8 +25,8 @@ let vlmModel: LLMSystemModelDataType; beforeEach(() => { serviceEnv.DATASET_SYNONYM_ENABLED = true; - const defaultEmbeddingModel = global.systemDefaultModel.embedding; - const defaultLLMModel = global.systemDefaultModel.llm; + const defaultEmbeddingModel = getModelTestDefaults().embedding; + const defaultLLMModel = getModelTestDefaults().llm; visionEmbeddingModel = { ...defaultEmbeddingModel, modelId: '507f1f77bcf86cd799439021', @@ -48,14 +49,14 @@ beforeEach(() => { }; [visionEmbeddingModel, vlmModel].forEach((model) => { - global.systemModelMap.set(`id:${model.modelId}`, model); - global.systemModelMap.set(`model:${model.model}`, model); + addModelTestModel(model); + addModelTestModel(model); }); }); describe('generateVector image embedding helpers', () => { - it('should drop system indexes and keep supported external image description indexes when rebuilding', () => { - const result = getRebuildBaseIndexes({ + it('should drop system indexes and keep supported external image description indexes when rebuilding', async () => { + const result = await getRebuildBaseIndexes({ indexes: [ { type: DatasetDataIndexTypeEnum.default, text: 'old default', dataId: 'default_id' }, { type: DatasetDataIndexTypeEnum.custom, text: 'manual', dataId: 'manual_id' }, @@ -99,8 +100,8 @@ describe('generateVector image embedding helpers', () => { ]); }); - it('should drop VLM image description indexes when collection image index is disabled', () => { - const result = getRebuildBaseIndexes({ + it('should drop VLM image description indexes when collection image index is disabled', async () => { + const result = await getRebuildBaseIndexes({ indexes: [ { type: DatasetDataIndexTypeEnum.custom, text: 'manual', dataId: 'manual_id' }, { @@ -132,8 +133,8 @@ describe('generateVector image embedding helpers', () => { ]); }); - it('uses a newly generated pure-image description without requiring imageDescMap', () => { - const result = getRebuildUpdateInput({ + it('uses a newly generated pure-image description without requiring imageDescMap', async () => { + const result = await getRebuildUpdateInput({ q: 'new VLM description', indexes: [], dataset: { @@ -156,13 +157,13 @@ describe('generateVector image embedding helpers', () => { }); }); - it('keeps image, question and summary indexes generated by the current rebuild', () => { + it('keeps image, question and summary indexes generated by the current rebuild', async () => { const generatedIndexes = [ { type: DatasetDataIndexTypeEnum.image, text: 'new image description' }, { type: DatasetDataIndexTypeEnum.question, text: 'new generated question' }, { type: DatasetDataIndexTypeEnum.summary, text: 'new generated summary' } ]; - const result = getRebuildUpdateInput({ + const result = await getRebuildUpdateInput({ q: 'content', indexes: generatedIndexes, dataset: { diff --git a/projects/app/test/service/core/dataset/synonym/mutation.test.ts b/projects/app/test/service/core/dataset/synonym/mutation.test.ts index acee63a49bd6..60c6d0234d96 100644 --- a/projects/app/test/service/core/dataset/synonym/mutation.test.ts +++ b/projects/app/test/service/core/dataset/synonym/mutation.test.ts @@ -31,24 +31,27 @@ vi.mock('@fastgpt/service/support/permission/dataset/auth', () => ({ vi.mock('@fastgpt/service/support/wallet/usage/controller', () => ({ createTrainingUsage: mockCreateTrainingUsage })); -vi.mock('@fastgpt/service/core/dataset/model', () => ({ - getDatasetAgentModel: () => ({ - modelId: '507f1f77bcf86cd799439023', - name: 'Agent', - model: 'agent-model', - config: {} - }), - getDatasetEmbeddingModel: () => ({ - modelId: '507f1f77bcf86cd799439021', - name: 'Embedding', - model: 'embedding', - config: { maxToken: 8192 } - }), - getDatasetVlmModel: () => ({ - modelId: '507f1f77bcf86cd799439022', - name: 'VLM', - model: 'vlm-model', - config: { vision: true } +vi.mock('@fastgpt/service/core/ai/model', () => ({ + isImageEmbeddingModel: (model?: { config?: { vision?: boolean } }) => !!model?.config?.vision, + getModelHandle: async () => ({ + getLLMModelData: () => ({ + modelId: '507f1f77bcf86cd799439023', + name: 'Agent', + model: 'agent-model', + config: {} + }), + getEmbeddingModelData: () => ({ + modelId: '507f1f77bcf86cd799439021', + name: 'Embedding', + model: 'embedding', + config: { maxToken: 8192 } + }), + getVlmModelData: () => ({ + modelId: '507f1f77bcf86cd799439022', + name: 'VLM', + model: 'vlm-model', + config: { vision: true } + }) }) })); diff --git a/projects/app/test/service/middleware/entry.test.ts b/projects/app/test/service/middleware/entry.test.ts new file mode 100644 index 000000000000..d335d2cdb060 --- /dev/null +++ b/projects/app/test/service/middleware/entry.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + createApiEntry: vi.fn(() => vi.fn()), + withNextCors: vi.fn().mockResolvedValue(undefined) +})); +vi.mock('@fastgpt/service/common/http/entry', () => ({ createApiEntry: mocks.createApiEntry })); +vi.mock('@fastgpt/next/middle/cors', () => ({ withNextCors: mocks.withNextCors })); +await import('@/service/middleware/entry'); + +describe('NextAPI', () => { + it('only registers CORS, with no model refresh or request context wrapper', async () => { + const options = mocks.createApiEntry.mock.calls[0][0] as unknown as { + beforeCallback: Array<(req: unknown, res: unknown) => Promise>; + }; + expect(options.beforeCallback).toHaveLength(1); + const req = { method: 'OPTIONS', url: '/api/core/ai/model/list' }; + const res = {}; + await options.beforeCallback[0](req, res); + expect(mocks.withNextCors).toHaveBeenCalledWith(expect.objectContaining({ req, res })); + }); +}); diff --git a/projects/app/test/web/core/ai/channel.test.ts b/projects/app/test/web/core/ai/channel.test.ts new file mode 100644 index 000000000000..6aaab0745c46 --- /dev/null +++ b/projects/app/test/web/core/ai/channel.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + request: vi.fn(), + useResponseInterceptor: vi.fn() +})); + +vi.mock('axios', () => ({ + default: { + create: () => ({ + interceptors: { response: { use: mocks.useResponseInterceptor } }, + request: mocks.request + }) + } +})); + +vi.mock('@fastgpt/web/common/system/utils', () => ({ + getWebReqUrl: (path: string) => `/base${path}` +})); + +vi.mock('@fastgpt/global/common/i18n/utils', () => ({ + i18nT: (key: string) => key +})); + +import { postCreateChannel, putChannel } from '@/web/core/ai/channel'; + +const channelInput = { + type: 1, + name: ' Existing channel ', + base_url: 'https://example.com/v1', + key: 'secret', + models: ['model-a'], + model_mapping: {} +}; + +describe('postCreateChannel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('rejects a duplicate trimmed channel name before creating anything', async () => { + mocks.request.mockResolvedValueOnce({ + data: { + success: true, + data: [{ id: 1, name: 'Existing channel', created_at: 1 }] + } + }); + + await expect(postCreateChannel(channelInput)).rejects.toBe( + 'config_model:channel_name_duplicate' + ); + + expect(mocks.request).toHaveBeenCalledOnce(); + expect(mocks.request).toHaveBeenCalledWith({ + baseURL: '/base/api/aiproxy/api', + url: '/channels/all', + method: 'GET', + data: undefined, + params: {} + }); + }); + + it('trims a unique name and submits the canonical create payload', async () => { + mocks.request + .mockResolvedValueOnce({ data: { success: true, data: [] } }) + .mockResolvedValueOnce({ data: { success: true, data: { id: 2 } } }); + + await expect(postCreateChannel(channelInput)).resolves.toEqual({ id: 2 }); + + expect(mocks.request).toHaveBeenNthCalledWith(2, { + baseURL: '/base/api/aiproxy/api', + url: '/createChannel', + method: 'POST', + data: { + type: 1, + name: 'Existing channel', + base_url: 'https://example.com/v1', + models: ['model-a'], + model_mapping: {}, + key: 'secret', + priority: 1 + }, + params: undefined + }); + }); + + it('preserves advanced channel fields during a full channel update', async () => { + mocks.request.mockResolvedValueOnce({ data: { success: true } }); + + await putChannel({ + id: 7, + type: 1, + name: 'Advanced channel', + base_url: 'https://example.com/v1', + proxy_url: 'https://proxy.example.com', + models: ['model-a'], + model_mapping: { alias: 'model-a' }, + configs: { region: 'us-west' }, + key: 'secret', + status: 1, + priority: 0, + sets: ['production'], + enabled_auto_balance_check: true, + balance_threshold: 0, + skip_tls_verify: true, + enabled_no_permission_ban: true, + warn_error_rate: 0.2, + max_error_rate: 0.5, + created_at: 1 + }); + + expect(mocks.request).toHaveBeenCalledWith({ + baseURL: '/base/api/aiproxy/api', + url: '/channel/7', + method: 'PUT', + data: { + type: 1, + name: 'Advanced channel', + base_url: 'https://example.com/v1', + proxy_url: 'https://proxy.example.com', + models: ['model-a'], + model_mapping: { alias: 'model-a' }, + configs: { region: 'us-west' }, + key: 'secret', + status: 1, + priority: 1, + sets: ['production'], + enabled_auto_balance_check: true, + skip_tls_verify: true, + enabled_no_permission_ban: true, + warn_error_rate: 0.2, + max_error_rate: 0.5 + }, + params: undefined + }); + }); + + it('rejects a full update when balance threshold cannot be preserved', async () => { + await expect( + putChannel({ + id: 8, + type: 1, + name: 'Threshold channel', + base_url: '', + models: [], + model_mapping: {}, + key: '', + status: 1, + priority: 1, + balance_threshold: 10, + created_at: 1 + }) + ).rejects.toThrow('cannot preserve balance_threshold for channel: 8'); + expect(mocks.request).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/app/test/web/core/ai/model/modelData.test.ts b/projects/app/test/web/core/ai/model/modelData.test.ts index 93dd3011f9d8..ba819d84da57 100644 --- a/projects/app/test/web/core/ai/model/modelData.test.ts +++ b/projects/app/test/web/core/ai/model/modelData.test.ts @@ -134,6 +134,29 @@ describe('catalog consumers', () => { ).toBeUndefined(); }); + it('skips an unavailable cached or system default model before using the first candidate', async () => { + const unavailableDefaults = models.map((model) => + model.modelId === 'business' || model.modelId === 'system' + ? { ...model, isActive: false } + : model + ); + mocks.catalog.mockResolvedValueOnce({ + version: 'v2', + data: { + models: unavailableDefaults, + providers: [], + defaultModelIds: { llm: 'system' } + } + }); + + expect( + await getModelDefault({ + modelType: ModelTypeEnum.llm, + businessDefaultModelId: 'business' + }) + ).toMatchObject({ modelId: 'first' }); + }); + it('propagates network failure and retries instead of treating it as an empty catalog', async () => { mocks.catalog.mockRejectedValueOnce(new Error('offline')); await expect(getModelDetail({ modelId: 'first' })).rejects.toThrow('offline'); diff --git a/projects/app/test/web/core/ai/model/useAdminModelConfig.test.ts b/projects/app/test/web/core/ai/model/useAdminModelConfig.test.ts new file mode 100644 index 000000000000..1c6b4833a222 --- /dev/null +++ b/projects/app/test/web/core/ai/model/useAdminModelConfig.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ModelScopeEnum, ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import type { GetAdminSystemModelListResponse } from '@fastgpt/global/openapi/admin/core/ai/model/api'; + +const mocks = vi.hoisted(() => { + const slots: { dependencies: readonly unknown[]; value: unknown }[] = []; + const scheduler = { cursor: 0, slots }; + + /** 按 Hook 调用顺序和 Object.is 比较依赖,保留同一挂载实例的 memo/callback 结果。 */ + const memo = (factory: () => T, dependencies: readonly unknown[]): T => { + const index = scheduler.cursor++; + const previous = slots[index]; + if ( + previous && + previous.dependencies.length === dependencies.length && + dependencies.every((dependency, index) => Object.is(dependency, previous.dependencies[index])) + ) { + return previous.value as T; + } + + const value = factory(); + slots[index] = { dependencies: [...dependencies], value }; + return value; + }; + + return { + scheduler, + memo, + request: { + data: undefined as GetAdminSystemModelListResponse | undefined, + loading: false, + error: undefined as Error | undefined + }, + getAdminModelConfig: vi.fn() + }; +}); + +// 仅隔离 React 调度和远程请求;实际 Hook 与供应商格式化逻辑保持真实执行。 +vi.mock('react', async (importOriginal) => ({ + ...(await importOriginal()), + useMemo: mocks.memo, + useCallback: (callback: T, dependencies: readonly unknown[]) => + mocks.memo(() => callback, dependencies) +})); +vi.mock('@fastgpt/web/hooks/useRequest', () => ({ + useRequest: () => ({ ...mocks.request }) +})); +vi.mock('@/web/core/ai/config', () => ({ getAdminModelConfig: mocks.getAdminModelConfig })); + +import { useAdminModelConfig } from '@/web/core/ai/model/useAdminModelConfig'; + +describe('useAdminModelConfig', () => { + /** 模拟同一 Hook 实例重新渲染,不重置已保存的 memo 槽位。 */ + const render = () => { + mocks.scheduler.cursor = 0; + return useAdminModelConfig(); + }; + + const createResponse = (): GetAdminSystemModelListResponse => ({ + models: [ + { + modelId: '68ad85a7463006c963799a05', + model: 'whisper-test', + name: 'Speech recognition', + provider: 'OpenAI', + type: ModelTypeEnum.stt, + scope: ModelScopeEnum.system, + isActive: true, + config: {}, + channels: [] + } + ], + channels: [], + providers: [ + { + provider: 'OpenAI', + value: { en: 'OpenAI', 'zh-CN': '开放智能', 'zh-Hant': '開放智能' }, + avatar: 'model/openai' + } + ], + defaultModelIds: { stt: '68ad85a7463006c963799a05' }, + aiproxyChannels: [ + { + channelId: 1, + name: { en: 'OpenAI', 'zh-CN': '开放智能', 'zh-Hant': '開放智能' }, + avatar: 'model/openai' + } + ] + }); + + beforeEach(() => { + mocks.scheduler.cursor = 0; + mocks.scheduler.slots.length = 0; + mocks.request.data = undefined; + mocks.request.loading = false; + mocks.request.error = undefined; + }); + + it('preserves empty collection references while absent, loading, failed and retrying', () => { + const initial = render(); + expect(initial.systemModelList).toEqual([]); + expect(initial.defaultModelIds).toEqual({}); + expect(initial.aiproxyChannels).toEqual([]); + + for (const status of [ + { loading: true, error: undefined }, + { loading: false, error: new Error('AI Proxy unavailable') }, + { loading: true, error: undefined }, + { loading: false, error: undefined } + ]) { + Object.assign(mocks.request, status); + const rerendered = render(); + + expect(rerendered.loading).toBe(status.loading); + expect(rerendered.error).toBe(status.error); + expect(rerendered.systemModelList).toBe(initial.systemModelList); + expect(rerendered.defaultModelIds).toBe(initial.defaultModelIds); + expect(rerendered.aiproxyChannels).toBe(initial.aiproxyChannels); + expect(rerendered.getModelProvider).toBe(initial.getModelProvider); + expect(rerendered.getModelProviders).toBe(initial.getModelProviders); + } + }); + + it('publishes real collections and provider metadata without churning them during refresh', () => { + const initial = render(); + const response = createResponse(); + mocks.request.data = response; + const loaded = render(); + + expect(loaded.systemModelList).toBe(response.models); + expect(loaded.systemModelList).not.toBe(initial.systemModelList); + expect(loaded.defaultModelIds).toBe(response.defaultModelIds); + expect(loaded.aiproxyChannels).toBe(response.aiproxyChannels); + expect(loaded.getModelProvider('OpenAI', 'zh-CN').name).toBe('开放智能'); + expect(loaded.getModelProviders('en').map((provider) => provider.id)).toEqual(['OpenAI']); + + mocks.request.loading = true; + const refreshing = render(); + expect(refreshing.systemModelList).toBe(loaded.systemModelList); + expect(refreshing.defaultModelIds).toBe(loaded.defaultModelIds); + expect(refreshing.aiproxyChannels).toBe(loaded.aiproxyChannels); + expect(refreshing.getModelProvider).toBe(loaded.getModelProvider); + + mocks.request.data = { ...response, models: [] }; + const refreshed = render(); + expect(refreshed.systemModelList).toBe(mocks.request.data.models); + expect(refreshed.systemModelList).not.toBe(loaded.systemModelList); + expect(refreshed.defaultModelIds).toBe(loaded.defaultModelIds); + expect(refreshed.aiproxyChannels).toBe(loaded.aiproxyChannels); + expect(refreshed.getModelProvider).toBe(loaded.getModelProvider); + }); +}); diff --git a/projects/app/test/web/core/ai/model/useUserModelStore.test.ts b/projects/app/test/web/core/ai/model/useUserModelStore.test.ts index da0d5ce29ec6..7a6ae6669315 100644 --- a/projects/app/test/web/core/ai/model/useUserModelStore.test.ts +++ b/projects/app/test/web/core/ai/model/useUserModelStore.test.ts @@ -34,7 +34,6 @@ const catalogData = { type: ModelTypeEnum.llm, scope: 'system' as const, isActive: true, - isCustom: false, config: { maxContext: 4096, maxResponse: 1024, quoteMaxToken: 1024 } } ], diff --git a/projects/app/vitest.config.ts b/projects/app/vitest.config.ts index dae686a67815..60ca043db0c0 100644 --- a/projects/app/vitest.config.ts +++ b/projects/app/vitest.config.ts @@ -36,6 +36,8 @@ export default defineConfig({ }, test: { env: { + AIPROXY_API_ENDPOINT: process.env.AIPROXY_API_ENDPOINT ?? 'http://127.0.0.1:3000', + AIPROXY_API_TOKEN: process.env.AIPROXY_API_TOKEN ?? 'test-aiproxy-token', FILE_TOKEN_KEY: process.env.FILE_TOKEN_KEY ?? 'bfd697e7e798f75deaf2d31210bc93a2e41ad4eed9e7831071d77821b7b97cff', diff --git a/test/mocks/core/ai/embedding.ts b/test/mocks/core/ai/embedding.ts index e6a969be0af3..57986a0211e2 100644 --- a/test/mocks/core/ai/embedding.ts +++ b/test/mocks/core/ai/embedding.ts @@ -123,24 +123,18 @@ vi.mock('@fastgpt/service/core/ai/model', async (importOriginal) => { const actual = (await importOriginal()) as any; return { ...actual, - getEmbeddingModel: vi.fn().mockReturnValue({ - type: ModelTypeEnum.embedding, - model: 'text-embedding-ada-002', - name: 'text-embedding-ada-002', - provider: 'OpenAI', - defaultToken: 1, - maxToken: 100 - }), - getEmbeddingModelData: vi.fn().mockReturnValue({ - modelId: '68ad85a7463006c963799a68', - model: 'text-embedding-ada-002', - name: 'text-embedding-ada-002', - provider: 'openai', - type: 'embedding', - scope: 'system' as const, - isActive: true, - isCustom: false, - config: { defaultToken: 100, maxToken: 100, weight: 0 } + getModelHandle: async () => ({ + ...(await actual.getModelHandle()), + getEmbeddingModelData: vi.fn().mockReturnValue({ + modelId: '68ad85a7463006c963799a68', + model: 'text-embedding-ada-002', + name: 'text-embedding-ada-002', + provider: 'openai', + type: 'embedding', + scope: 'system' as const, + isActive: true, + config: { defaultToken: 100, maxToken: 100, weight: 0 } + }) }) }; }); diff --git a/test/modelCache.ts b/test/modelCache.ts new file mode 100644 index 000000000000..727401107eb8 --- /dev/null +++ b/test/modelCache.ts @@ -0,0 +1,75 @@ +import { + createModelHandle, + getCachedModelHandle, + publishModelHandle +} from '@fastgpt/service/core/ai/config/handle'; +import type { SystemDefaultModelType } from '@fastgpt/service/core/ai/type'; +import type { SystemModelDataType } from '@fastgpt/global/core/ai/model.schema'; + +/** 测试显式注入目录,不再依赖 Node 全局变量。默认值按当前真实 handle 的槽位读取。 */ +export const getModelTestDefaults = (): SystemDefaultModelType => { + const handle = getCachedModelHandle(); + const defaults: SystemDefaultModelType = {}; + if (!handle) return defaults; + for (const slot of [ + 'llm', + 'embedding', + 'rerank', + 'tts', + 'stt', + 'datasetTextLLM', + 'datasetImageLLM', + 'chatTitleLLM' + ] as const) { + try { + Object.assign(defaults, { [slot]: handle.getDefaultModelData(slot) }); + } catch {} + } + return defaults; +}; + +/** 局部覆盖测试目录并发布新快照;未指定的数据保留,避免测试回写冻结的共享对象。 */ +export const setModelTestSnapshot = (patch: Partial[0]>) => { + const previous = getCachedModelHandle(); + publishModelHandle( + createModelHandle({ + defaultModels: getModelTestDefaults(), + configuredDefaultModelIds: previous?.configuredDefaultModelIds ?? {}, + revision: previous?.revision ?? 0, + version: previous?.version ?? 'test-catalog', + ...patch, + // 某些目录/权限测试只提供 ID;旧 active-list fixture 的省略状态在测试中仍表示启用。 + models: (patch.models ?? previous?.getAllModels() ?? []).map((model) => ({ + ...model, + isActive: model.isActive ?? true + })) + }) + ); +}; + +/** 旧测试的 ID/名称索引转换为唯一模型列表;仅作为 fixture 输入,不公开生产 Map。 */ +export const setModelTestMap = (map?: Map) => { + if (!map) return publishModelHandle(undefined); + setModelTestSnapshot({ + models: [...new Map([...map.values()].map((model) => [model.modelId, model])).values()] + }); +}; + +export const getModelTestMap = () => + new Map( + (getCachedModelHandle()?.getAllModels() ?? []).flatMap((model) => [ + [`id:${model.modelId}`, model] as const, + [`model:${model.model}`, model] as const + ]) + ); + +/** 新增/替换 fixture 模型,显式重新发布而不是修改 handle 内部 Map。 */ +export const addModelTestModel = (model: SystemModelDataType) => + setModelTestSnapshot({ + models: [ + ...(getCachedModelHandle()?.getAllModels() ?? []).filter( + (item) => item.modelId !== model.modelId + ), + model + ] + }); diff --git a/test/setup.ts b/test/setup.ts index 355d4f23a357..a18999ef4654 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,5 +1,4 @@ import './mocks'; -import { existsSync, readFileSync } from 'fs'; import { connectMongo } from '@fastgpt/service/common/mongo/init'; import { initGlobalVariables } from '@/service/common/system'; @@ -35,20 +34,6 @@ beforeAll(async () => { initGlobalVariables(); global.systemEnv = {} as any; - // await getInitConfig(); - if (existsSync('projects/app/.env.local')) { - const str = readFileSync('projects/app/.env.local', 'utf-8'); - const lines = str.split('\n'); - const systemEnv: Record = {}; - for (const line of lines) { - const [key, value] = line.split('='); - if (key && value && !key.startsWith('#')) { - systemEnv[key] = value; - } - } - global.systemEnv.oneapiUrl = systemEnv['OPENAI_BASE_URL']; - global.systemEnv.chatApiKey = systemEnv['CHAT_API_KEY']; - } global.feConfigs = { isPlus: false } as any; diff --git a/test/setupModels.ts b/test/setupModels.ts index 662f65e9f783..6d2d547bb5e9 100644 --- a/test/setupModels.ts +++ b/test/setupModels.ts @@ -1,6 +1,8 @@ import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import { setModelTestSnapshot } from './modelCache'; export default async function setupModels() { + // 测试静态目录对应空数据库的初始修订号;目录集成测试显式写入并刷新真实快照。 const llmModel = { modelId: '68ad85a7463006c963799a68', type: ModelTypeEnum.llm, @@ -10,7 +12,6 @@ export default async function setupModels() { isActive: true, isDefault: true, scope: 'system' as const, - isCustom: false, requestUrl: undefined, requestAuth: undefined, provider: 'OpenAI', @@ -34,7 +35,6 @@ export default async function setupModels() { isActive: true, isDefault: true, scope: 'system' as const, - isCustom: false, requestUrl: undefined, requestAuth: undefined, provider: 'OpenAI', @@ -46,16 +46,14 @@ export default async function setupModels() { } }; - global.systemDefaultModel = { - llm: llmModel, - embedding: embeddingModel - }; - global.systemModelList = [llmModel, embeddingModel]; - global.systemActiveModelList = [llmModel, embeddingModel]; - global.systemModelMap = new Map([ - [`id:${llmModel.modelId}`, llmModel], - [`model:${llmModel.model}`, llmModel], - [`id:${embeddingModel.modelId}`, embeddingModel], - [`model:${embeddingModel.model}`, embeddingModel] - ]); + setModelTestSnapshot({ + models: [llmModel, embeddingModel], + revision: 0, + defaultModels: { + llm: llmModel, + embedding: embeddingModel + }, + configuredDefaultModelIds: {}, + version: 'test-catalog' + }); } diff --git a/vitest.config.mts b/vitest.config.mts index 46b901a00a4c..fbf3f00a9576 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -18,6 +18,8 @@ export default defineConfig({ }, test: { env: { + AIPROXY_API_ENDPOINT: process.env.AIPROXY_API_ENDPOINT ?? 'http://127.0.0.1:3000', + AIPROXY_API_TOKEN: process.env.AIPROXY_API_TOKEN ?? 'test-aiproxy-token', FILE_TOKEN_KEY: process.env.FILE_TOKEN_KEY ?? 'bfd697e7e798f75deaf2d31210bc93a2e41ad4eed9e7831071d77821b7b97cff',