AI 模型接入
+ {needsStatusRefresh &&
}
{draft.legacyAvailable &&
}
@@ -149,7 +195,7 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
}
- 用户可选模型
{dirty && 有未保存修改}
+ 用户可选模型
{dirty && 有未保存修改}
{!!rows.length && <>
-
- 用户可自行选择已开放的模型;不支持的用途不可勾选。
- 模型 / 上游开放给用户用户可选用途接口兼容
+
+ 先测试用途,再勾选开放并保存。{dirty ? ' 测试前请先保存或撤销修改。' : ''}
+ 模型 / 上游开放给用户用户可选用途实际测试
{!visibleRows.length &&
没有匹配模型
}
- {pageRows.map(({ provider, model }) =>
+ {pageRows.map(({ provider, model }) => {
+ const testableStages = stages.filter((stage) => model.capabilities.allowedStages.includes(stage.id))
+ const selectedStage = testableStages.find((stage) => ['unknown', 'running'].includes(model.verification[stage.id].status)) || testableStages.find((stage) => stage.id === testStages[model.key]) || testableStages.find((stage) => model.verification[stage.id].status !== 'passed') || testableStages[0]
+ const result = selectedStage ? model.verification[selectedStage.id] : null
+ const protocol = selectedStage ? model.capabilities.protocols?.[selectedStage.id] : null
+ const detailsId = `test-details-${model.key}-${selectedStage?.id}`
+ const canInspect = !!protocol || !!result && !!(result.message || result.checkedAt || result.requestId)
+ const unsettled = Object.values(model.verification).some((entry) => entry.status === 'running' || entry.status === 'unknown')
+ const testing = busy.startsWith(`verify:${model.key}:`)
+ const blockedTest = providerBlocked || !provider.enabled || !provider.hasApiKey || !model.present || !selectedStage || unsettled
+ return
{model.id}
{capabilityLabels[model.capabilities.kind]}{model.capabilities.kind !== 'unknown' && !model.capabilities.allowedStages.length ? ' · 待适配' : ''} · {provider.name}{!provider.enabled ? ' · 上游已停用' : ''}{!model.present ? ' · 已离开上游目录' : ''}
{model.capabilities.reason}
-
+
-
-
)}
+
+ {selectedStage && result ? <>
+
+
+ {result.canResume && result.id ? : }
+ {canInspect && }
+
+
{testing ? '正在调用模型,请稍候' : result.status === 'unknown' ? result.canResume ? '等待上游结果,可查询' : '结果未知,暂停核查' : result.status === 'running' ? '测试进行中,可刷新状态' : `${selectedStage.shortLabel} · ${verificationLabels[result.status]}`}
+ {canInspect &&
+ {protocol && <>
协议:{protocol.id}
接口:{protocol.path}
{(protocol.scopeId || protocol.routeId || protocol.operationId) &&
路由:{[protocol.scopeId, protocol.routeId, protocol.operationId].filter(Boolean).join(' / ')}
}>}
+ {result.message &&
{result.message}
}{result.checkedAt &&
{new Date(result.checkedAt).toLocaleString('zh-CN')}
}{result.requestId &&
请求编号:{result.requestId}
}
+
}
+ > :
{model.capabilities.kind === 'unknown' ? '能力待识别,暂不可测试' : '暂未接入详情页用途'}
}
+
+
})}
>}
+ {confirmActionDialog}
}
diff --git a/docs/ai-model-management.md b/docs/ai-model-management.md
index d175782..632beaa 100644
--- a/docs/ai-model-management.md
+++ b/docs/ai-model-management.md
@@ -6,30 +6,54 @@
1. 已有详情页上游可用“迁入当前连接”显式转为数据库配置;迁入操作只保存配置,不调用模型。凭据继续引用固定的服务端 `DETAIL_GENERATOR_API_KEY`,不复制到浏览器。
2. 新增上游填写名称、HTTPS API 地址和 API Key。新增或替换凭据需已有专用 `API_KEY_ENCRYPTION_KEY`,最少 32 字节。凭据使用 AES-256-GCM 加密,接口只返回配置状态;编辑时密钥留空保持现状。
-3. “同步目录”请求上游 `/v1/models`。当前上游还读取其公开的 `/api/v2/catalog/models` 结构化能力目录,只保留当前 Key 目录内的模型。新发现模型默认未启用;重复同步保留仍符合能力限制的人工用途,上游已移除的模型不可用。
-4. 先按对话、多模态、生图、其他及待核验筛选,再勾选允许用户使用的用途并启用。用途由服务端能力证据限制:纯对话不能勾选生图;无图片输入能力不能勾选资料分析;仅支持文生图、专用路由或异步接口的模型不能直接用于当前产品参考图生图。
-5. 为三个阶段选择默认模型并保存。用户可在详情页“模型选择”中自行选择管理员开放的模型,默认值只是新项目的初始选择。不同上游的同名模型以独立编号区分,实际请求仍使用上游原生模型 ID。详情页正式验收前仍只向超级管理员开放。
+3. “同步目录”请求上游 `/v1/models`。当前接入的 `api.vip98k.com` 还读取其公开的 `/api/v2/catalog/models` 结构化能力目录,只保留当前 Key 有权使用的模型。模型数量取决于上游目录、Key 权限和同步结果,不固定为某个数量。新发现模型默认未开放;同步不会调用生成接口,也不会自动批量测试。
+4. 搜索模型或上游,按对话、视觉理解、生图、语音合成、语音识别、音乐、视频、向量、图像工具、3D、其他或待识别筛选。分类由服务端读取能力及协议证据,不按模型名字猜测。类型已识别不代表当前详情页已接入该类型。
+5. 在模型行选择**测试用途**,点击“测试”。确认“将实际调用模型,可能产生上游费用”后,只测试当前模型的当前用途。资料分析、卖点文案、图片生成分别记录结果;一个用途通过,不会替另一个用途背书。存在未保存的配置时,先保存或撤销,再测试。
+6. 用途实际测试通过后,勾选允许用户使用的用途,再勾选“开放”。测试成功不会自动开放模型。为三个阶段选择默认模型,点击“保存配置”后生效;没有有效测试结果、能力不符或尚未适配的用途不能开放。
+7. 用户可在详情页“模型选择”中自行选择管理员已开放的模型,默认值只是新项目的初始选择。不同上游的同名模型以独立编号区分,实际请求仍使用上游原生模型 ID。详情页正式验收前仍只向超级管理员开放。
-管理员“确认接口兼容”是配置确认,不能增加模型的服务端能力。后端在保存、默认值、用户候选列表及每次模型执行前共同复核,直接提交请求也无法越界。上游能力停用后,旧项目和已排队任务不能继续调用该能力,已有内容仍可编辑、保存及导出。
+不再提供“确认接口兼容”人工勾选,配置接口也不接受 `compatibility`。是否可用由服务端的协议适配和当前用途的实际测试结果共同决定。后端在保存配置、设置默认值、提供用户候选列表及执行模型前共同复核,直接提交请求也无法越界。上游停用、能力撤销或验证失效后,旧项目和已排队任务也不能继续调用失效能力;已有内容仍可编辑、保存及导出。
+
+## 测试状态与查询
+
+| 界面状态 | 含义与操作 |
+| --- | --- |
+| 待测试 | 已有可执行的用途适配,尚无该用途的实际测试结果。 |
+| 测试中 | 测试正在执行,禁止重复提交;“刷新测试状态”只重新读取平台记录。 |
+| 已通过 | 当前连接、密钥、模型能力、协议路由和适配版本对应的用途测试通过,可由管理员开放。 |
+| 未通过 | 已得到明确失败结果;查看测试详情,处理原因后再显式测试。 |
+| 暂停核查 | 请求是否完成尚不明确,禁止直接重测,避免重复计费。 |
+| 需重新测试 | 连接、密钥、能力、路由或适配版本已改变,原测试结果不能继续作为开放依据。 |
+
+异步生图先持久化上游任务编号,再查询任务结果。仅当已有任务编号且当前连接及路由仍匹配时,显示“查询结果”;该操作只查询同一个上游任务,不重新发送生成请求。没有已知任务编号时,保持暂停,按测试详情中的请求编号核查上游记录。
+
+浏览器丢失测试响应后只查询状态,不自动重发付费请求;如果状态仍是旧记录,当前页面保留暂停状态。重复的同一幂等请求也不会新建模型调用。真实测试与详情页生成共用执行额度:每人同时 1 个,全局同时 2 个。
+
+模型行的详情按钮默认折叠。展开后可查看当前用途实际绑定的协议、接口路径,以及目录提供的服务范围、路由和操作标识;已有结果还显示测试时间、消息和请求编号。这里显示服务端绑定,不允许前端随意修改路由。
## 当前适配协议
-| 用途 | 输入与接口 | 输出要求 |
+| 用途 | 已实现的协议适配 | 实际测试检查 |
| --- | --- | --- |
-| 资料分析 | `/chat/completions`,文本和 `image_url` 产品图 | 可用的文本分析 |
-| 卖点文案 | `/chat/completions`,文本 | 可解析且符合项目校验的大纲 JSON |
-| 图片生成 | `/images/edits`,multipart 产品及参考图片 | `data[0].b64_json` 中的有效图片 |
+| 资料分析 | 支持图片输入的 OpenAI Chat Completions 或 Anthropic Messages | 能读取测试图片,并正确返回指定位置的颜色 JSON。 |
+| 卖点文案 | OpenAI Chat Completions 或 Anthropic Messages | 返回符合详情页大纲校验的一屏 JSON。 |
+| 图片生成 | OpenAI Images Edits;明确支持私有参考图内联输入的 Images Generations 或异步图片任务 | 请求可接受参考图,结果可解码、尺寸有效且非空白。 |
-音频、视频及图片处理工具归为其他;专用接口需要另行开发适配后才能分配给详情页阶段。模型目录里的名称本身不证明适配能力。能力以明确的输入、输出、默认接口协议为依据;没有元数据时只保留精确上游地址和模型的既有真实验证记录(Gemini 2.5 Flash 分析/文案、Nano Banana 2 参考图生图),不把同名其他上游视为已验证。上游明确停用、撤销模态或接口时优先拒绝。目录同步不等于真实生成验收,不自动批量发送付费测试。
+具体接口以每个模型的服务端协议绑定为准。同步时在目录明确的服务范围内选择可执行路由,并保留对应标识;执行时不会静默换路由。专用协议或异步接口只有符合已实现适配及私有参考图输入要求时,才可进入相应用途测试;纯文生图不因此获得产品参考图能力。
+
+当前业务工作台仍只有详情页的三个用途。语音、音乐、视频、向量、图像工具及 3D 可以在目录中分类查看,但本次没有为它们增加生成工作台,也不能因出现在目录就勾选为生图用途。无可靠能力信息的模型保持“待识别”;明确不满足用途输入输出要求的模型不能通过人工确认绕过。
+
+界面借鉴家具项目的连接编辑、获取目录和就近反馈方式。家具项目“测试并获取模型”的目录 GET 只证明目录连接,本项目另设的按用途真实调用才产生用途验证记录。目录同步、接口测试通过、真实产品生成质量和完整详情页验收是不同证据;这里不代表所有目录模型均已实测,也不代表品牌风格、PSD 可编辑性或 Photoshop 验收已经完成。
## 安全与数据
- 只接受 HTTPS 标准端口、无嵌入凭据或查询参数的服务地址;解析的全部 IP 都须为公网地址,并将已检查地址固定到实际 HTTPS 连接。拒绝跳转,防止 API Key 被带到其他地址。
-- 修改 API 地址必须同时填写该服务的密钥,并清除原地址的目录兼容配置。密钥不会进入响应、审计元数据或项目快照。
-- 上游、模型目录、阶段默认值及审计记录保存在 `ai_model_providers`、`ai_model_catalog`、`ai_model_configuration`、`ai_model_audit`。配置事务串行并核对版本,旧配置不能覆盖新修改。
+- 修改 API 地址必须同时填写该服务的密钥,并清除原地址的目录配置。验证上下文包括连接、密钥、能力、协议路由及适配版本;任何相关变化都会使旧通过记录失效。密钥不会进入响应、审计元数据或项目快照。
+- 上游、模型目录、阶段默认值及审计记录保存在 `ai_model_providers`、`ai_model_catalog`、`ai_model_configuration`、`ai_model_audit`。用途测试记录及任务检查点保存在 `ai_model_verifications`。配置事务串行并核对版本,旧配置不能覆盖新修改。
+- 调用上游前先持久化测试记录并复核数据库超级管理员权限。正在执行或结果未知的同一模型用途禁止重新提交;已知异步任务只恢复查询,不重新生成。
- `capability_metadata` 只持久化服务端归一化的能力证据,管理接口不接受客户端修改。公开能力目录无密钥请求,使用与私有请求相同的公网 DNS 固定、拒绝重定向和限时保护;公开目录限 8 MiB,私有模型列表保持 2 MiB。同步任一目录失败时不保存部分结果。
- 详情项目保存可信的上游及模型绑定,模型执行前重新核对可用性、权限和绑定。旧裸模型名只对应显式迁入的原服务,不能静默改到另一供应商。
- 缺少阶段默认模型时暂停生成;已有项目仍可修改文案、保存和导出。
-- 受控迁移:`20261012_ai_model_registry` 新增表,`20261013_ai_model_capabilities` 仅新增可空的能力证据列;不包含真实凭据或自动上游导入。旧行无能力证据时默认不可开放,只有准确匹配的既有实际验证可用;同步后恢复有证据的候选范围。生产发布仍遵循标准备份、受控迁移及精确提交验证;禁止对生产运行 `prisma db push`。
+- 受控迁移:`20261012_ai_model_registry` 新增配置表,`20261013_ai_model_capabilities` 新增能力证据列,`20261013_ai_model_verifications` 新增用途测试记录及唯一约束,并替换原人工兼容检查约束。历史 `compatibility` 值保留但不再参与可用性判定;迁移不会将其转换为“已通过”,也不含真实凭据、自动上游导入或付费测试。生产发布仍遵循标准备份、受控迁移及精确提交验证;禁止对生产运行 `prisma db push`。
-本地真实 HTTP 与隔离数据库验收脚本:`scripts/verify-ai-model-admin.ts`,仅允许 `127.0.0.1:55441/ai_model_test`。目录同步为只读查询,脚本不提交生成任务。
+是否可向用户开放,以当前服务端用途验证记录为准。实际执行过哪些模型及产品验收项目,须另附对应记录,不从目录数量、适配协议数量或部署成功推断。
diff --git a/docs/ai-model-verification-acceptance-2026-10-13.md b/docs/ai-model-verification-acceptance-2026-10-13.md
new file mode 100644
index 0000000..7865b31
--- /dev/null
+++ b/docs/ai-model-verification-acceptance-2026-10-13.md
@@ -0,0 +1,24 @@
+# AI 模型接入实测记录(2026-10-13)
+
+本轮以家具项目的上游连接、目录同步和就近反馈方式为参考,移除人工兼容确认。目录连接成功不等于模型用途通过;管理端和详情页共同读取服务端实际验证记录。
+
+## 已验证
+
+- 本地隔离数据库 `ai_model_test`,端口 55441;未使用生产用户、任务或数据库作为测试夹具。
+- 真实上游 `api.vip98k.com`:`gemini-3-flash` 资料分析和卖点文案通过,`nano-banana-2` 私有参考图生图通过。资料分析检查图片指定位置的颜色,文案检查大纲结构,生图检查实际可解码输出。
+- Gemini 分析请求编号 `c6893b1f-c2fa-4248-8bc6-485f2b543b98`,文案 `cb014e22-1f31-499f-8c80-2594051d919e`,Nano `25905516-3c9c-42f0-8906-3a829a481e83`。图片结果通过同一已知任务查询取回,未因等待或解析问题重复提交生成。
+- 实际 HTTP 权限检查:匿名 401;普通用户、普通管理员、撤权前签发的旧会话 403;伪造 `compatibility` 和不匹配的生图用途 400;拒绝路径没有新增模型验证记录。
+- 内置浏览器实测桌面及 390px 手机布局、能力/状态筛选、用途禁选、测试费用确认与取消、未保存配置禁测、撤销和协议详情。没有人工兼容复选框;手机模型控制无横向溢出,顶部既有导航保持横向滚动。
+- 11 组相关 Jest 共 225 项通过(最后一处用途说明调整后单独复跑前端 27 项);迁移及发布门禁 32 项通过;TypeScript 和隔离源码的 Next.js 生产构建通过。
+- 独立审查修复异步任务恢复期间再次撤权的竞争条件:保留已受理的上游任务,标为待核查,不允许重发生成。
+- 真实素材案例:朴美 `GBPM-AD0358`,沿用素材库同款两张参考图。项目 `8b89c9ae-4531-4558-b21f-426f404600d5` 完成分析、人工审阅大纲、首屏生成及单屏重生成;共 2 次文本和 2 次生图 POST,没有重复提交。原始大纲保留;审阅删除缺尺寸依据的“小巧”描述,并把未确认材质改为木纹外观。
+- 中文多行文案编辑、图片替换、保存恢复通过;PNG/JPG/长 PNG 均为 750×1200,已查看实际导出像素,无乱码、遮挡或裁断。PSD 2,338,309 字节,按屏分组,背景和产品场景独立,标题和正文是 2 个文本层,字体 `NotoSansSC-Regular`。这些是文件结构验证,不等同于 Photoshop 应用内验收。
+
+最新目录同步发现 Gemini 2.5 Flash/Pro 已下架;目录保留历史条目并明确标注不可用。本地实测默认更新为 Gemini 3 Flash 和 Nano Banana 2,不把下架模型或历史人工确认视为有效验证。
+
+## 证据与边界
+
+本机原始回执、权限结果和界面截图位于忽略目录 `output/ai-model-integration-v2/`。密钥仅从服务端配置读取,回执不保存密钥、登录 Cookie 或私有图片签名地址。
+
+本轮没有批量测试全部目录模型,也没有增加 TTS、音乐、视频或 3D 业务工作台。品牌规范确认、Photoshop 中改字与保存重开仍需单独验收,不能从接口测试、PSD 解析或部署成功推断。
+本次真实产品只验证一屏及其重生成,不据此宣称完整多屏的品牌风格一致性已验收。
diff --git a/docs/生产数据库安全增量迁移SOP-2026-08-12.md b/docs/生产数据库安全增量迁移SOP-2026-08-12.md
index 30d0b2e..c9b37e8 100644
--- a/docs/生产数据库安全增量迁移SOP-2026-08-12.md
+++ b/docs/生产数据库安全增量迁移SOP-2026-08-12.md
@@ -2,7 +2,7 @@
## 结论
-当前应用发布工具验证三十五份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。目标名称以该精确提交的 `TARGET_MIGRATIONS` 为准,不能沿用历史批次的数量或凭证。
+当前应用发布工具验证三十六份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。目标名称以该精确提交的 `TARGET_MIGRATIONS` 为准,不能沿用历史批次的数量或凭证。
目标 migration:
@@ -41,12 +41,15 @@
- `20261012_detail_generator`
- `20261012_ai_model_registry`
- `20261013_ai_model_capabilities`
+- `20261013_ai_model_verifications`
`20261012_detail_generator` 仅新增个人详情页项目、私有文件、持久任务和品牌风格草案四张表及其约束/索引,不回填素材库或修改用户权限。正式验收前,启用配置仍只开放给当前有效超级管理员;私有文件目录独立持久挂载。
-`20261012_ai_model_registry` 仅新增模型上游、目录、默认配置、审计四张表及约束/索引;`20261013_ai_model_capabilities` 仅新增目录的可空 `capability_metadata` JSONB 列。受控迁移核对列类型、空值限制、安全默认值、模型唯一性、原连接唯一性、引用限制和凭据完整性约束;切流前复核这两份 SQL 的精确校验和及四表字段。迁移不写入真实密钥、不自动导入或开放模型。
+`20261012_ai_model_registry` 仅新增模型上游、目录、默认配置、审计四张表及约束/索引;`20261013_ai_model_capabilities` 仅新增目录的可空 `capability_metadata` JSONB 列。`20261013_ai_model_verifications` 新增 `ai_model_verifications`,持久保存按用途的真实测试结果、幂等请求及异步任务检查点;它将 `ai_model_enabled_compatible` 替换为 `ai_model_enabled_stages`,不删除历史 `compatibility` 列,也不将历史人工确认值转成已通过测试。
-部署后由当前有效超级管理员显式执行“迁入当前连接”,再“同步目录”。迁入继续引用现有 `DETAIL_GENERATOR_API_KEY` 和精确原地址,保留已验证的 Gemini 2.5 Flash 分析/文案及 Nano Banana 2 生图默认;同步不会开放新发现模型,也不调用付费生成。应核对迁入审计、目录数量、三阶段默认和普通用户拒绝状态后完成验收。现有环境连接不依赖加密主密钥;新增上游或替换密钥才要求已配置至少 32 字节的专用 `API_KEY_ENCRYPTION_KEY`,不要替换已有主密钥或把凭据写进迁移、命令参数、日志。
+受控迁移核对列类型、空值限制、安全默认值、模型唯一性、原连接唯一性、引用限制和凭据完整性约束。验证表还须核对 `stage`、`context_hash`、`status` 检查约束,`task_id` 可空文本及 `evidence` 非空 JSONB,模型外键、幂等唯一索引、未决测试唯一索引和模型用途时间索引;旧人工兼容启用约束必须已移除,新用途启用约束必须存在。切流前复核三份 AI SQL 的精确校验和及五表字段。迁移不写入真实密钥、不自动导入、开放或付费测试模型。
+
+部署后,尚未迁入的连接由当前有效超级管理员显式执行“迁入当前连接”,然后“同步目录”;已迁入的连接直接核对配置并同步,不能用导入覆盖现有设置。迁入继续引用现有 `DETAIL_GENERATOR_API_KEY` 和精确原地址。新版本不再把旧人工确认或旧默认值视为用途测试证据;需分别实际测试计划开放的分析、文案和生图用途,通过后再由管理员勾选开放并保存三阶段默认。同步目录只读获取目录,不调用付费生成;实际测试需管理员显式发起,已有异步任务只查询结果,不重新生成。应核对迁入/配置/测试审计、动态目录范围、三阶段可用默认和普通用户拒绝状态。现有环境连接不依赖加密主密钥;新增上游或替换密钥才要求已配置至少 32 字节的专用 `API_KEY_ENCRYPTION_KEY`,不要替换已有主密钥或把凭据写进迁移、命令参数、日志。
已有标准 drain 能力和稳定代理的生产实例,继续使用当前 `direct` 发布策略。维护、备份和迁移期间同时持有发布锁与代理 reconcile 锁,验证三个入口 503、作业及连接排空、旧实例停止后再取备份。完成迁移审计才交接给同 SHA 的标准发布 payload;交接前释放锁,避免 payload 重取时自锁。恢复旧实例时按其实际 IP 生成恢复代理配置,保留原配置并验证三个入口的旧 SHA。
@@ -79,7 +82,7 @@
1. 构建并固定同一 exact SHA/digest 的 runner 与 migrator;
2. legacy bootstrap `Audit`;
3. legacy bootstrap `Apply`,外部验收 503,确认旧容器停止且保留;
-4. 在同一维护窗内运行本文的真实备份、隔离恢复、三十五条目标 migration 和迁移后审计;
+4. 在同一维护窗内运行本文的真实备份、隔离恢复、三十六条目标 migration 和迁移后审计;
5. 仅在所有门禁通过后,运行普通同 SHA 应用部署。
顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。
@@ -405,7 +408,7 @@ docker run --rm \
"$MIGRATOR_IMAGE" --apply
```
-工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对三十三条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、业绩转化经营数据快照及同步状态表、字段类型与可空性、检测报告原五个可空字段和独立检测日期字段、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下,同时确认品牌知识预览切片列为非空文本数组且默认空数组;最后原子写审计 JSON。
+工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对三十六条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、业绩转化经营数据快照及同步状态表、字段类型与可空性、检测报告原五个可空字段和独立检测日期字段、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下,同时确认品牌知识预览切片列为非空文本数组且默认空数组;AI 模型五表、能力证据及用途验证约束/索引也须通过前述结构检查,最后原子写审计 JSON。
apply 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写:
@@ -421,9 +424,10 @@ test -n "$MIGRATION_AUDIT_SHA256"
只有以下全部成立,才进入应用首次安全上线步骤:
- `applied.json` 的 `verified=true`;
-- 三十三条 target migration 均 `completed=true`,source/database checksum 一致;
+- 三十六条 target migration 均 `completed=true`,source/database checksum 一致;
- `_prisma_migrations.finished_at` 非空、`rolled_back_at` 为空;
- 视频租约列/索引、任务详情作业租约列/索引、active-only `uploadId` 唯一索引、品牌默认值精确匹配;
+- AI 模型配置及验证五表、用途/上下文/状态约束、幂等与未决测试唯一索引和任务检查点字段精确匹配;旧人工兼容启用约束已由新用途约束替换;
- 再次运行 `--audit` 仍通过;
- 备份 receipt、迁移审计文件、目标 SHA 和 migrator image ID 被记录在当次发布记录中。
diff --git a/lib/__tests__/ai-model-capabilities.test.ts b/lib/__tests__/ai-model-capabilities.test.ts
index 0e3b095..7e635f3 100644
--- a/lib/__tests__/ai-model-capabilities.test.ts
+++ b/lib/__tests__/ai-model-capabilities.test.ts
@@ -9,7 +9,7 @@ const base = 'https://api.vip98k.com/v1'
const generic = 'https://example.com/v1'
const metadata = (input: string[], output: string[], endpoints: string[]) => parseAiModelCapabilityMetadata({ input_modalities: input, output_modalities: output, endpoints })!
const invocation = (path = '/v1/chat/completions', headers = {}) => ({ method: 'POST', path, content_type: path.includes('/images/') ? 'multipart/form-data' : 'application/json', headers })
-const model = (overrides = {}) => ({ id: 'catalog-model', type: 'model', modality: 'text', available: true, status: 'available', default_service_scope_id: 'scope-default', selection_required: false, service_offers: [{ is_default: true, service_scope: { id: 'scope-default' }, invocation: invocation() }], ...overrides })
+const model = (overrides = {}) => ({ id: 'catalog-model', type: 'model', modality: 'text', available: true, status: 'available', default_service_scope_id: 'scope-default', selection_required: false, invocation: invocation(), ...overrides })
const publicMetadata = (entry: ReturnType
, section = 'chat') => parseAiModelPublicCapabilityCatalog({ code: 0, data: { schema_version: '2.2', sections: [{ id: section, models: [entry] }] } }).get(entry.id)!
test('a plain conversation model cannot analyze product images or generate images', () => {
@@ -22,20 +22,20 @@ test('image understanding and image output require separate interfaces', () => {
expect(getAiModelCapabilities(generic, 'editor', metadata(['text', 'image'], ['image'], ['/images/edits']))).toMatchObject({ kind: 'image', allowedStages: ['image'] })
})
test('audio transcription is not a copy-writing model just because it returns text over chat', () => {
- expect(getAiModelCapabilities(generic, 'asr', metadata(['audio'], ['text'], ['/chat/completions']))).toMatchObject({ kind: 'other', allowedStages: [] })
+ expect(getAiModelCapabilities(generic, 'asr', metadata(['audio'], ['text'], ['/chat/completions']))).toMatchObject({ kind: 'asr', allowedStages: [] })
})
test('model names and arbitrary providers cannot inherit verified abilities', () => {
expect(getAiModelCapabilities(generic, 'nano-banana-2')).toMatchObject({ source: 'unknown', allowedStages: [] })
expect(getAiModelCapabilities(base, 'nano-banana-unknown')).toMatchObject({ source: 'unknown', allowedStages: [] })
- expect(getAiModelCapabilities(base, 'nano-banana-2').allowedStages).toEqual(['image'])
+ expect(getAiModelCapabilities(base, 'nano-banana-2').allowedStages).toEqual([])
})
test('public catalog classifies models dynamically without guessing names', () => {
- expect(getAiModelCapabilities(base, 'catalog-model', publicMetadata(model()))).toMatchObject({ kind: 'text', allowedStages: ['copy'], source: 'upstream' })
- expect(getAiModelCapabilities(base, 'catalog-model', publicMetadata(model({ modality: 'multimodal' })))).toMatchObject({ kind: 'vision', allowedStages: ['copy'] })
+ expect(getAiModelCapabilities(base, 'catalog-model', publicMetadata(model()))).toMatchObject({ kind: 'text', allowedStages: ['analysis', 'copy'], source: 'upstream' })
+ expect(getAiModelCapabilities(base, 'catalog-model', publicMetadata(model({ modality: 'multimodal' })))).toMatchObject({ kind: 'vision', allowedStages: ['analysis', 'copy'] })
})
test('public image processing tools do not appear as generative image models', () => {
const entry = model({ type: 'image', modality: 'image', capabilities: ['media.tasks'], input_modes: ['file'], output_modes: ['image'] })
- expect(getAiModelCapabilities(base, entry.id, publicMetadata(entry, 'task'))).toMatchObject({ kind: 'other', allowedStages: [] })
+ expect(getAiModelCapabilities(base, entry.id, publicMetadata(entry, 'task'))).toMatchObject({ kind: 'image-tool', allowedStages: [] })
})
test('a declared specialist tool is not opened merely because it shares a chat or image protocol', () => {
const evidence = { ...metadata(['text', 'image'], ['text', 'image'], ['/chat/completions', '/images/edits']), kindHint: 'other' as const }
@@ -54,9 +54,9 @@ test('non-default image editing offers do not override the selected image genera
] })
expect(getAiModelCapabilities(base, entry.id, publicMetadata(entry, 'tool'))).toMatchObject({ kind: 'image', allowedStages: [] })
})
-test('missing public modality details preserve exact verified image-input support', () => {
+test('missing public modality details permit a real image probe without claiming historical verification', () => {
const entry = model({ id: 'gemini-2.5-flash' })
- expect(getAiModelCapabilities(base, entry.id, publicMetadata(entry))).toMatchObject({ kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'verified' })
+ expect(getAiModelCapabilities(base, entry.id, publicMetadata(entry))).toMatchObject({ kind: 'text', allowedStages: ['analysis', 'copy'], source: 'upstream' })
})
test.each(['input', 'output', 'endpoint', 'unavailable'])('explicit withdrawal overrides exact verified evidence: %s', reason => {
const current = metadata(reason === 'input' ? ['text'] : ['text', 'image'], reason === 'output' ? ['text'] : ['image'], reason === 'endpoint' ? ['/images/generations'] : ['/images/edits'])
diff --git a/lib/__tests__/ai-model-protocol-http.test.ts b/lib/__tests__/ai-model-protocol-http.test.ts
new file mode 100644
index 0000000..9c14d0f
--- /dev/null
+++ b/lib/__tests__/ai-model-protocol-http.test.ts
@@ -0,0 +1,71 @@
+/** @jest-environment node */
+import { EventEmitter } from 'node:events'
+import { lookup } from 'node:dns/promises'
+import { request as httpsRequest } from 'node:https'
+import { requestAiModelOperation, requestAiModelOutput } from '@/lib/ai-model-http'
+
+jest.mock('node:dns/promises', () => ({ lookup: jest.fn() }))
+jest.mock('node:https', () => ({ request: jest.fn() }))
+jest.mock('@/lib/ai-model-access', () => ({ AiModelError: class extends Error { constructor(message: string, public status = 400, public code = 'bad_request') { super(message) } } }))
+let sent: { url: string; options: any }[]
+beforeEach(() => {
+ jest.clearAllMocks(); sent = []
+ ;(lookup as jest.Mock).mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
+ ;(httpsRequest as jest.Mock).mockImplementation((url, options, callback) => {
+ sent.push({ url: String(url), options })
+ const req = new EventEmitter() as any
+ req.write = () => true; req.destroy = jest.fn()
+ req.end = () => queueMicrotask(() => { const response = new EventEmitter() as any; response.statusCode = 200; response.headers = {}; response.destroy = jest.fn(); callback(response); response.emit('data', Buffer.from('{}')); response.emit('end') })
+ return req
+ })
+})
+
+test('retains configured /v1 prefix for standard operations and roots the separate extension path', async () => {
+ await requestAiModelOperation('https://models.example.com/v1', 'synthetic-secret', '/v1/messages', '{}', 'request-1', { 'Anthropic-Version': '2023-06-01', 'X-Relay-Route-Option': 'route-1' })
+ await requestAiModelOperation('https://models.example.com/v1', 'synthetic-secret', '/extensions/v1/tasks/task-1/result', undefined, undefined, {})
+ expect(sent.map(item => item.url)).toEqual(['https://models.example.com/v1/messages', 'https://models.example.com/extensions/v1/tasks/task-1/result'])
+ expect(sent[0].options.headers).toMatchObject({ Authorization: 'Bearer synthetic-secret', 'Idempotency-Key': 'request-1', 'Anthropic-Version': '2023-06-01', 'X-Relay-Route-Option': 'route-1' })
+ expect(sent[1].options.method).toBe('GET')
+ expect(sent[1].options.headers['Idempotency-Key']).toBeUndefined()
+})
+
+test.each(['/v1/unknown', '//169.254.169.254/metadata', '/extensions/v1/tasks/../../secret/result', '/extensions/v1/tasks/task-1/result?url=private'])('rejects unsupported or injected operation %s before DNS', async path => {
+ await expect(requestAiModelOperation('https://models.example.com/v1', 'secret', path, '{}', 'request-1', {})).rejects.toMatchObject({ code: 'protocol_unsupported' })
+ expect(lookup).not.toHaveBeenCalled(); expect(httpsRequest).not.toHaveBeenCalled()
+})
+
+test.each([{ Authorization: 'Bearer attacker' }, { Host: 'localhost' }, { 'X-Relay-Route-Option': 'route\r\ninjected' }])('rejects arbitrary headers before transport', async headers => {
+ await expect(requestAiModelOperation('https://models.example.com/v1', 'secret', '/v1/messages', '{}', 'request-1', headers as any)).rejects.toMatchObject({ code: 'protocol_invalid' })
+ expect(httpsRequest).not.toHaveBeenCalled()
+})
+
+test.each(['https://attacker.example/api/external/assets/file', 'http://models.example.com/api/external/assets/file', 'https://models.example.com/admin', 'https://models.example.com/api/external/assets/../admin', 'https://user:pass@models.example.com/api/external/assets/file'])('rejects generated asset target %s without disclosing credentials', async url => {
+ await expect(requestAiModelOutput('https://models.example.com/v1', 'secret', url)).rejects.toMatchObject({ code: 'output_invalid' })
+ expect(httpsRequest).not.toHaveBeenCalled()
+})
+
+test('same-origin assets keep fixed DNS and fresh authorization checks', async () => {
+ const guard = jest.fn()
+ await requestAiModelOutput('https://models.example.com/v1', 'secret', `https://models.example.com/api/external/assets/${'a'.repeat(216)}`, guard)
+ expect(guard).toHaveBeenCalledTimes(1)
+ expect(sent[0].options.headers.Authorization).toBe('Bearer secret')
+ const callback = jest.fn(); sent[0].options.lookup('models.example.com', {}, callback)
+ expect(callback).toHaveBeenCalledWith(null, '93.184.216.34', 4)
+})
+
+test('revoked user creates zero generation or asset sockets', async () => {
+ const guard = async () => { throw new Error('revoked') }
+ await expect(requestAiModelOperation('https://models.example.com/v1', 'secret', '/extensions/v1/tasks', '{}', 'one', {}, guard)).rejects.toThrow('revoked')
+ await expect(requestAiModelOutput('https://models.example.com/v1', 'secret', `https://models.example.com/api/external/assets/${'a'.repeat(32)}`, guard)).rejects.toThrow('revoked')
+ expect(httpsRequest).not.toHaveBeenCalled()
+})
+
+test.each([32, 216, 8192])('accepts a same-origin encrypted relay asset token of length %i', async length => {
+ await requestAiModelOutput('https://models.example.com/v1', 'secret', `https://models.example.com/api/external/assets/${'a'.repeat(length)}`)
+ expect(httpsRequest).toHaveBeenCalledTimes(1)
+})
+
+test.each([31, 8193])('rejects relay asset token length %i outside its published contract', async length => {
+ await expect(requestAiModelOutput('https://models.example.com/v1', 'secret', `https://models.example.com/api/external/assets/${'a'.repeat(length)}`)).rejects.toMatchObject({ code: 'output_invalid' })
+ expect(lookup).not.toHaveBeenCalled(); expect(httpsRequest).not.toHaveBeenCalled()
+})
diff --git a/lib/__tests__/ai-model-protocol.test.ts b/lib/__tests__/ai-model-protocol.test.ts
new file mode 100644
index 0000000..e4a2215
--- /dev/null
+++ b/lib/__tests__/ai-model-protocol.test.ts
@@ -0,0 +1,157 @@
+/** @jest-environment node */
+import sharp from 'sharp'
+import { callAiModelProtocol, parseAiModelProtocols, validateStoredAiModelProtocols, type AiModelProtocol } from '@/lib/ai-model-protocol'
+import { requestAiModelOperation, requestAiModelOutput } from '@/lib/ai-model-http'
+
+jest.mock('@/lib/ai-model-http', () => ({ requestAiModelOperation: jest.fn(), requestAiModelOutput: jest.fn() }))
+const send = requestAiModelOperation as jest.Mock, output = requestAiModelOutput as jest.Mock
+const chat: AiModelProtocol = { id: 'openai-chat', path: '/v1/chat/completions', headers: {}, supportsImageInput: true }
+const asyncImage: AiModelProtocol = { id: 'async-image-task', path: '/extensions/v1/tasks', headers: { 'X-Relay-Route-Option': 'route-image' }, routeId: 'route-image', supportsImageInput: true, inputField: 'file_urls' }
+const base = { baseUrl: 'https://models.example.com/v1', apiKey: 'synthetic-test-secret', modelId: 'declared-model', prompt: '测试', idempotencyKey: 'test-one' }
+const invocation = (path: string, route: string, schema: any = {}) => ({ method: 'POST', path, content_type: 'application/json', headers: { 'X-Relay-Route-Option': route }, input_schema: schema })
+let png: Buffer
+beforeAll(async () => { png = await sharp({ create: { width: 32, height: 32, channels: 3, background: '#ff0000' } }).png().toBuffer() })
+beforeEach(() => { jest.clearAllMocks() })
+
+test('selects inline-image route within the default scope and keeps its exact operation and route', () => {
+ const dataSchema = { properties: { file_urls: { type: 'array', maxItems: 4, items: { type: 'string', pattern: '^(https://|data:image/)' } }, prompt: { maxLength: 1200 } } }
+ const model = { id: 'image-model', modality: 'image', type: 'image', capabilities: ['text_to_image', 'media.tasks'], default_service_scope_id: 'scope-one', default_route_option_id: 'https-only', service_offers: [
+ { service_scope: { id: 'scope-other' }, public_operation_id: 'media.tasks.create', route_options: [{ id: 'wrong-scope', invocation: invocation('/extensions/v1/tasks', 'wrong-scope', dataSchema) }] },
+ { service_scope: { id: 'scope-one' }, public_operation_id: 'media.tasks.create', route_options: [
+ { id: 'https-only', invocation: invocation('/extensions/v1/tasks', 'https-only', { properties: { file_urls: { type: 'array', items: { type: 'string', pattern: '^https://' } } } }) },
+ { id: 'inline-image', invocation: invocation('/extensions/v1/tasks', 'inline-image', dataSchema) },
+ ] },
+ ] }
+ expect(parseAiModelProtocols(model)).toEqual([{ id: 'async-image-task', path: '/extensions/v1/tasks', headers: { 'X-Relay-Route-Option': 'inline-image' }, supportsImageInput: true, inputField: 'file_urls', scopeId: 'scope-one', routeId: 'inline-image', operationId: 'media.tasks.create', maxImages: 4, maxPromptLength: 1200 }])
+})
+
+test('does not infer private reference uploads from generic image capability or URLs', () => {
+ const protocols = parseAiModelProtocols({ type: 'image', modality: 'image', capabilities: ['image_to_image'], invocation: { method: 'POST', path: '/v1/images/generations', content_type: 'application/json', headers: {}, input_schema: { properties: { image: { type: 'array', items: { type: 'string' } } } } } })
+ expect(protocols).toMatchObject([{ id: 'openai-image-generate', supportsImageInput: false }])
+})
+
+test.each(['audio', 'video'])('does not turn %s models into chat or detail generation adapters', modality => {
+ expect(parseAiModelProtocols({ type: modality, modality, invocation: { method: 'POST', path: '/v1/chat/completions', content_type: 'application/json', headers: {} } })).toEqual([])
+})
+
+test('untrusted headers, paths, stored field mutations and route mismatches are rejected', () => {
+ expect(validateStoredAiModelProtocols([{ ...chat, headers: { Authorization: 'injected' } }])).toEqual([])
+ expect(validateStoredAiModelProtocols([{ ...chat, path: '//internal/metadata' }])).toEqual([])
+ expect(validateStoredAiModelProtocols([{ ...asyncImage, routeId: 'different-route' }])).toEqual([])
+ expect(validateStoredAiModelProtocols([{ ...asyncImage, inputField: 'callback_url' }])).toEqual([])
+})
+
+test('protocol headers serialize identically after JSONB reorders object keys', () => {
+ const first = validateStoredAiModelProtocols([{ ...chat, headers: { 'X-Relay-Route-Option': 'route-one', 'Anthropic-Version': '2023-06-01' } }])
+ const restored = validateStoredAiModelProtocols([{ ...chat, headers: { 'Anthropic-Version': '2023-06-01', 'X-Relay-Route-Option': 'route-one' } }])
+ expect(JSON.stringify(first)).toBe(JSON.stringify(restored))
+})
+
+test('Anthropic uses native image content blocks and extracts native text', async () => {
+ send.mockResolvedValue(Response.json({ content: [{ type: 'text', text: '红色方块' }] }))
+ const result = await callAiModelProtocol({ ...base, protocol: { ...chat, id: 'anthropic-messages', path: '/v1/messages', headers: { 'Anthropic-Version': '2023-06-01' } }, images: [{ buffer: png, mimeType: 'image/png' }], maxOutputTokens: 128 })
+ const body = JSON.parse(send.mock.calls[0][3])
+ expect(body).toMatchObject({ max_tokens: 128, stream: false, messages: [{ role: 'user', content: [{ type: 'text' }, { type: 'image', source: { type: 'base64', media_type: 'image/jpeg' } }] }] })
+ expect(JSON.stringify(body)).not.toContain('image_url')
+ expect(result.text).toBe('红色方块')
+})
+
+test('analysis flattens transparent reference images to white JPEG', async () => {
+ const transparent = await sharp({ create: { width: 32, height: 32, channels: 4, background: '#00000000' } }).png().toBuffer()
+ send.mockResolvedValue(Response.json({ choices: [{ message: { content: 'white' } }] }))
+ await callAiModelProtocol({ ...base, protocol: chat, images: [{ buffer: transparent, mimeType: 'image/png' }] })
+ const uri = JSON.parse(send.mock.calls[0][3]).messages[0].content[1].image_url.url
+ expect(uri).toMatch(/^data:image\/jpeg;base64,/)
+ const bytes = Buffer.from(uri.split(',')[1], 'base64')
+ const pixel = await sharp(bytes).raw().toBuffer()
+ expect(Array.from(pixel.slice(0, 3))).toEqual([255, 255, 255])
+})
+
+test('rejected requests that echo credentials stay definite failures without exposing response text', async () => {
+ send.mockResolvedValue(Response.json({ error: { message: base.apiKey, code: 'invalid_request' } }, { status: 400 }))
+ try { await callAiModelProtocol({ ...base, protocol: chat }); throw new Error('expected rejection') } catch (error: any) { expect(error.outcomeUnknown).toBe(false); expect(error.message).not.toContain(base.apiKey) }
+})
+
+test('persists async id before any polling, sends private inline image, decodes typed output', async () => {
+ const events: string[] = []
+ send.mockImplementation(async (_base, _key, path) => {
+ events.push(path.endsWith('/result') ? 'poll' : 'create')
+ return path.endsWith('/result') ? Response.json({ id: 'task-1', status: 'succeeded', ready: true, result: { outputs: [{ type: 'image', b64_json: png.toString('base64') }] } }) : Response.json({ id: 'task-1', object: 'extension.task', status: 'accepted' }, { status: 202 })
+ })
+ const result = await callAiModelProtocol({ ...base, protocol: asyncImage, images: [{ buffer: png, mimeType: 'image/png' }], onTaskSubmitted: async id => { expect(id).toBe('task-1'); events.push('saved') } })
+ expect(events).toEqual(['create', 'saved', 'poll'])
+ expect(JSON.parse(send.mock.calls[0][3]).file_urls[0]).toMatch(/^data:image\/png;base64,/)
+ expect(send.mock.calls[0][4]).toBe('test-one')
+ expect(send.mock.calls[1][3]).toBeUndefined()
+ expect(result.summary.taskId).toBe('task-1')
+ expect((await sharp(result.image).metadata()).width).toBe(32)
+})
+
+test('resumes only GET; no POST and no repeated checkpoint', async () => {
+ send.mockResolvedValue(Response.json({ ready: true, status: 'succeeded', result: { outputs: [{ type: 'image', b64_json: png.toString('base64') }] } }))
+ const saved = jest.fn()
+ await callAiModelProtocol({ ...base, protocol: asyncImage, resumeTaskId: 'task-known', onTaskSubmitted: saved })
+ expect(send).toHaveBeenCalledTimes(1)
+ expect(send.mock.calls[0][2]).toBe('/extensions/v1/tasks/task-known/result')
+ expect(send.mock.calls[0][3]).toBeUndefined()
+ expect(saved).not.toHaveBeenCalled()
+})
+
+test('known task network failure is pending, unknown POST outcome is paused, neither retries', async () => {
+ send.mockRejectedValue(new Error('network'))
+ await expect(callAiModelProtocol({ ...base, protocol: asyncImage, onTaskSubmitted: jest.fn(), resumeTaskId: 'task-known' })).rejects.toMatchObject({ outcomeUnknown: true, taskPending: true, taskId: 'task-known' })
+ await expect(callAiModelProtocol({ ...base, protocol: asyncImage, onTaskSubmitted: jest.fn() })).rejects.toMatchObject({ outcomeUnknown: true, taskPending: false, taskId: null })
+ expect(send).toHaveBeenCalledTimes(2)
+})
+
+test('failed and review tasks are not automatically requeued as pending', async () => {
+ send.mockResolvedValueOnce(Response.json({ error: { code: 'task_failed' } }, { status: 409 }))
+ await expect(callAiModelProtocol({ ...base, protocol: asyncImage, resumeTaskId: 'task-failed', onTaskSubmitted: jest.fn() })).rejects.toMatchObject({ outcomeUnknown: false, taskPending: false })
+ send.mockResolvedValueOnce(Response.json({ error: { code: 'billing_reconciliation_required' } }, { status: 409 }))
+ await expect(callAiModelProtocol({ ...base, protocol: asyncImage, resumeTaskId: 'task-review', onTaskSubmitted: jest.fn() })).rejects.toMatchObject({ outcomeUnknown: true, taskPending: false })
+})
+
+test.each([401, 403, 404])('a known task read rejected with %i remains unresolved without allowing another submission', async status => {
+ send.mockResolvedValue(Response.json({ error: { code: status === 404 ? 'task_not_found' : 'access_denied' } }, { status }))
+ await expect(callAiModelProtocol({ ...base, protocol: asyncImage, resumeTaskId: 'accepted-task', onTaskSubmitted: jest.fn() })).rejects.toMatchObject({ outcomeUnknown: true, taskPending: false, taskId: 'accepted-task' })
+ expect(send).toHaveBeenCalledTimes(1)
+ expect(send.mock.calls[0][2]).toBe('/extensions/v1/tasks/accepted-task/result')
+ expect(send.mock.calls[0][3]).toBeUndefined()
+})
+
+test.each([429, 503])('a temporary known task read failure %i permits GET-only polling', async status => {
+ send.mockResolvedValue(Response.json({ error: { code: 'service_unavailable' } }, { status }))
+ await expect(callAiModelProtocol({ ...base, protocol: asyncImage, resumeTaskId: 'accepted-task', onTaskSubmitted: jest.fn() })).rejects.toMatchObject({ outcomeUnknown: true, taskPending: true, taskId: 'accepted-task' })
+})
+
+test('checkpoint failure preserves the known task and performs no poll', async () => {
+ send.mockResolvedValue(Response.json({ id: 'task-known' }, { status: 202 }))
+ await expect(callAiModelProtocol({ ...base, protocol: asyncImage, onTaskSubmitted: async () => { throw new Error('DB unavailable') } })).rejects.toMatchObject({ outcomeUnknown: true, taskId: 'task-known' })
+ expect(send).toHaveBeenCalledTimes(1)
+})
+
+test('typed checkpoint failure also preserves the accepted upstream task', async () => {
+ send.mockResolvedValue(Response.json({ id: 'task-known' }, { status: 202 }))
+ await expect(callAiModelProtocol({ ...base, protocol: asyncImage, onTaskSubmitted: async () => { throw Object.assign(new Error('checkpoint'), { status: 409 }) } })).rejects.toMatchObject({ outcomeUnknown: true, taskPending: true, taskId: 'task-known' })
+ expect(send).toHaveBeenCalledTimes(1)
+})
+
+test('unreadable local image is a definite preflight failure with no paid request', async () => {
+ await expect(callAiModelProtocol({ ...base, protocol: chat, images: [{ buffer: Buffer.from('invalid'), mimeType: 'image/png' }] })).rejects.toMatchObject({ outcomeUnknown: false })
+ expect(send).not.toHaveBeenCalled()
+})
+
+test('rejects async submission without checkpoint and preflights image and prompt limits', async () => {
+ await expect(callAiModelProtocol({ ...base, protocol: asyncImage })).rejects.toMatchObject({ outcomeUnknown: false })
+ await expect(callAiModelProtocol({ ...base, protocol: { ...chat, supportsImageInput: false }, images: [{ buffer: png, mimeType: 'image/png' }] })).rejects.toMatchObject({ outcomeUnknown: false })
+ await expect(callAiModelProtocol({ ...base, protocol: { ...chat, maxPromptLength: 1 } })).rejects.toMatchObject({ outcomeUnknown: false })
+ expect(send).not.toHaveBeenCalled()
+})
+
+test('does not treat audio or malformed image data as a successful image result', async () => {
+ send.mockResolvedValueOnce(Response.json({ ready: true, status: 'succeeded', result: { outputs: [{ type: 'audio', url: 'https://models.example.com/api/external/assets/audio' }] } }))
+ await expect(callAiModelProtocol({ ...base, protocol: asyncImage, resumeTaskId: 'task-audio', onTaskSubmitted: jest.fn() })).rejects.toMatchObject({ outcomeUnknown: false })
+ expect(output).not.toHaveBeenCalled()
+ send.mockResolvedValueOnce(Response.json({ data: [{ b64_json: Buffer.from('not an image').toString('base64') }] }))
+ await expect(callAiModelProtocol({ ...base, protocol: { id: 'openai-image-edit', path: '/v1/images/edits', headers: {}, supportsImageInput: true, inputField: 'image[]' }, images: [{ buffer: png, mimeType: 'image/png' }] })).rejects.toMatchObject({ outcomeUnknown: false })
+})
diff --git a/lib/__tests__/ai-model-service.test.ts b/lib/__tests__/ai-model-service.test.ts
index 975c966..7f361db 100644
--- a/lib/__tests__/ai-model-service.test.ts
+++ b/lib/__tests__/ai-model-service.test.ts
@@ -5,7 +5,8 @@ import { isSuperAdmin } from '@/lib/department-permission'
import { configureAiModels, editAiModelProvider, getAiModelAdminState, getAiModelRuntimeState, parseAiModelCatalog, resolveAiModel, syncAiModelProvider } from '@/lib/ai-model-service'
import { encryptAiModelKey } from '@/lib/ai-model-crypto'
import { requestAiModel, requestAiModelCapabilityCatalog } from '@/lib/ai-model-http'
-import { parseAiModelCapabilityMetadata } from '@/lib/ai-model-capabilities'
+import { getAiModelStageProtocol, parseAiModelCapabilityMetadata } from '@/lib/ai-model-capabilities'
+import { aiModelVerificationContext, listAiModelVerifications } from '@/lib/ai-model-verification-state'
import { aiModelAudit, getAiModelConfiguration, getAiModelProviderRow, listAiModelProviderRows, listAiModelRows, saveAiModelDefaults, type AiModelProviderRow, type AiModelRow } from '@/lib/ai-model-repository'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
@@ -13,6 +14,7 @@ jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() }))
jest.mock('@/lib/prisma', () => ({ prisma: { user: { findUnique: jest.fn() } } }))
jest.mock('@/lib/ai-model-http', () => ({ ...jest.requireActual('@/lib/ai-model-http'), requestAiModel: jest.fn(), requestAiModelCapabilityCatalog: jest.fn() }))
+jest.mock('@/lib/ai-model-verification-state', () => ({ ...jest.requireActual('@/lib/ai-model-verification-state'), listAiModelVerifications: jest.fn() }))
const mockDb = { $executeRawUnsafe: jest.fn() }
jest.mock('@/lib/ai-model-repository', () => ({
...jest.requireActual('@/lib/ai-model-repository'), aiModelTransaction: jest.fn(async callback => callback(mockDb)),
@@ -35,8 +37,8 @@ beforeEach(() => {
{ id: secondId, name: '第二上游', baseUrl: 'https://new.example.com/v1', enabled: true, credentialSource: 'encrypted', ...encryptAiModelKey('synthetic-second-secret'), legacyBinding: false, revision: 1, syncedAt: null },
]
models = [
- { key: legacyModelKey, providerId: legacyId, id: 'vendor/shared-model', enabled: true, present: true, compatibility: 'confirmed', stages: ['analysis', 'copy', 'image'], capabilityMetadata: parseAiModelCapabilityMetadata({ input_modalities: ['text', 'image'], output_modalities: ['text', 'image'], endpoints: ['/chat/completions', '/images/edits'] }) },
- { key: secondModelKey, providerId: secondId, id: 'vendor/shared-model', enabled: true, present: true, compatibility: 'confirmed', stages: ['analysis', 'copy', 'image'], capabilityMetadata: parseAiModelCapabilityMetadata({ input_modalities: ['text', 'image'], output_modalities: ['text', 'image'], endpoints: ['/chat/completions', '/images/edits'] }) },
+ { key: legacyModelKey, providerId: legacyId, id: 'vendor/shared-model', enabled: true, present: true, stages: ['analysis', 'copy', 'image'], capabilityMetadata: parseAiModelCapabilityMetadata({ input_modalities: ['text', 'image'], output_modalities: ['text', 'image'], endpoints: ['/chat/completions', '/images/edits'] }) },
+ { key: secondModelKey, providerId: secondId, id: 'vendor/shared-model', enabled: true, present: true, stages: ['analysis', 'copy', 'image'], capabilityMetadata: parseAiModelCapabilityMetadata({ input_modalities: ['text', 'image'], output_modalities: ['text', 'image'], endpoints: ['/chat/completions', '/images/edits'] }) },
]
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: true, status: 'active', isActive: true, archivedAt: null })
@@ -44,6 +46,7 @@ beforeEach(() => {
;(listAiModelRows as jest.Mock).mockImplementation(async () => models)
;(getAiModelProviderRow as jest.Mock).mockImplementation(async id => ({ ...providers.find(provider => provider.id === id) }))
;(getAiModelConfiguration as jest.Mock).mockResolvedValue({ revision: 1, defaults: { analysis: legacyModelKey, copy: legacyModelKey, image: legacyModelKey } })
+ ;(listAiModelVerifications as jest.Mock).mockResolvedValue(models.flatMap(model => model.stages.map(stage => ({ id: `${model.key}-${stage}`, modelKey: model.key, stage, status: 'passed', requestId: null, taskId: null, message: null, startedAt: new Date(), deadlineAt: new Date(Date.now() + 600000), finishedAt: new Date(), contextHash: aiModelVerificationContext(providers.find(provider => provider.id === model.providerId)!.baseUrl, model.id, model.capabilityMetadata, model.providerId === legacyId ? 'synthetic-legacy-secret' : 'synthetic-second-secret', getAiModelStageProtocol(model.capabilityMetadata, stage)!) }))))
;(requestAiModel as jest.Mock).mockImplementation(async (_base, _key, _endpoint, _body, _idempotency, beforeRequest) => { await beforeRequest(); return Response.json({ data: [{ id: 'vendor/shared-model' }, { id: 'brand/new-model' }] }) })
})
afterAll(() => { process.env = originalEnv })
@@ -59,7 +62,7 @@ test.each(['disabled-provider', 'disabled-model', 'missing-model', 'unconfirmed'
if (reason === 'disabled-provider') providers[0].enabled = false
if (reason === 'disabled-model') models[0].enabled = false
if (reason === 'missing-model') models[0].present = false
- if (reason === 'unconfirmed') models[0].compatibility = 'unconfirmed'
+ if (reason === 'unconfirmed') (listAiModelVerifications as jest.Mock).mockResolvedValue([])
if (reason === 'wrong-stage') models[0].stages = ['copy']
await expect(resolveAiModel('analysis', legacyModelKey)).rejects.toMatchObject({ code: 'model_unavailable' })
await expect(configureAiModels('admin', { revision: 1, providers: [], defaults: { analysis: legacyModelKey, copy: null, image: null } })).rejects.toMatchObject({ code: 'default_unavailable' })
@@ -76,6 +79,24 @@ test('environment URL drift cannot send a replacement environment key to the ori
await expect(resolveAiModel('image', legacyModelKey)).rejects.toMatchObject({ code: 'model_unavailable' })
expect((await getAiModelRuntimeState()).legacyModels.image).toEqual([])
})
+
+test('environment key rotation followed by new validation cannot reuse an old project binding', async () => {
+ const binding = (await resolveAiModel('image', legacyModelKey)).binding
+ const prior = await listAiModelVerifications()
+ process.env.DETAIL_GENERATOR_API_KEY = 'synthetic-rotated-secret'
+ await expect(resolveAiModel('image', legacyModelKey, binding)).rejects.toMatchObject({ code: 'model_unavailable' })
+ ;(listAiModelVerifications as jest.Mock).mockResolvedValue(prior.map(value => value.modelKey === legacyModelKey && value.stage === 'image' ? {
+ ...value, id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
+ contextHash: aiModelVerificationContext(providers[0].baseUrl, models[0].id, models[0].capabilityMetadata, 'synthetic-rotated-secret', getAiModelStageProtocol(models[0].capabilityMetadata, 'image')!),
+ } : value))
+ const fresh = (await resolveAiModel('image', legacyModelKey)).binding
+ expect(fresh.revision).toBe(binding.revision)
+ expect(fresh.protocolFingerprint).toBe(binding.protocolFingerprint)
+ expect(fresh.verificationId).not.toBe(binding.verificationId)
+ await expect(resolveAiModel('image', legacyModelKey, binding)).rejects.toMatchObject({ code: 'model_binding_changed' })
+ await expect(resolveAiModel('image', legacyModelKey, fresh)).resolves.toMatchObject({ binding: fresh })
+ expect(requestAiModel).not.toHaveBeenCalled()
+})
test('legacy imported endpoint remains immutable even when a new API key is supplied', async () => {
await expect(editAiModelProvider('admin', legacyId, { revision: 1, baseUrl: 'https://other.example.com/v1', apiKey: 'synthetic-new-secret' })).rejects.toThrow('保留原服务地址')
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
@@ -87,6 +108,13 @@ test('admin DTO strips all encrypted material and does not return plaintext or r
const serialized = JSON.stringify(state)
for (const value of ['synthetic-legacy-secret', 'synthetic-second-secret', 'secretCiphertext', 'secretNonce', 'secretAuthTag', providers[1].secretCiphertext!]) expect(serialized).not.toContain(value)
})
+
+test('a removed catalog model keeps its record but exposes no testable stages and a clear reason', async () => {
+ models[0].present = false
+ const state = await getAiModelAdminState()
+ expect(state.providers[0].models[0]).toMatchObject({ present: false, enabled: true, capabilities: { allowedStages: [], protocols: {}, reason: '已从当前连接的模型目录下架,暂不可用' } })
+ expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
+})
test('uninitialized defaults produce no hardcoded menu or automatic default choice', async () => {
;(getAiModelConfiguration as jest.Mock).mockResolvedValue({ revision: 0, defaults: { analysis: null, copy: null, image: null } })
const state = await getAiModelRuntimeState()
@@ -95,7 +123,7 @@ test('uninitialized defaults produce no hardcoded menu or automatic default choi
expect(state.models.analysis).toEqual([legacyModelKey, secondModelKey])
expect(state.modelOptions.analysis.map(value => value.label)).toEqual(['vendor/shared-model', 'vendor/shared-model'])
})
-test('directory synchronization preserves manual compatibility while adding new models disabled', async () => {
+test('directory synchronization never creates verification evidence or enables new models', async () => {
await syncAiModelProvider('admin', legacyId, { revision: 1 })
const statements = mockDb.$executeRawUnsafe.mock.calls.map(call => String(call[0]))
const upserts = statements.filter(sql => sql.startsWith('INSERT INTO ai_model_catalog'))
@@ -120,7 +148,7 @@ test.each([{ data: [{ id: '\nunsafe' }] }, { data: [{ id: 1 }] }, { data: 'incor
})
test('manual confirmation cannot enable image generation for a conversation model', async () => {
models[0].capabilityMetadata = parseAiModelCapabilityMetadata({ input_modalities: ['text'], output_modalities: ['text'], endpoints: ['/chat/completions'] })
- const input = { revision: 1, defaults: { analysis: null, copy: null, image: null }, providers: [{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: true, compatibility: 'confirmed', stages: ['image'] }] }] }
+ const input = { revision: 1, defaults: { analysis: null, copy: null, image: null }, providers: [{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: true, stages: ['image'] }] }] }
await expect(configureAiModels('admin', input)).rejects.toMatchObject({ code: 'model_capability_unsupported' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
@@ -141,7 +169,7 @@ test('stale enabled rows are filtered from menus, defaults and old project execu
test('admin read removes invalid old stages without writes or raw capability metadata exposure', async () => {
models[0].capabilityMetadata = null
const state = await getAiModelAdminState()
- expect(state.providers[0].models[0]).toMatchObject({ enabled: false, stages: [], capabilities: { kind: 'unknown', allowedStages: [] } })
+ expect(state.providers[0].models[0]).toMatchObject({ enabled: true, stages: ['analysis', 'copy', 'image'], capabilities: { kind: 'unknown', allowedStages: [] }, verification: { analysis: { status: 'stale' } } })
expect(JSON.stringify(state)).not.toContain('capabilityMetadata')
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
@@ -153,7 +181,7 @@ test('typed public catalog is restricted to models authorized by the private dir
const inserts = mockDb.$executeRawUnsafe.mock.calls.filter(call => String(call[0]).startsWith('INSERT INTO ai_model_catalog'))
expect(inserts).toHaveLength(1)
expect(inserts[0][3]).toBe('allowed-model')
- expect(JSON.parse(inserts[0][4])).toMatchObject({ inputModalities: ['text'], outputModalities: ['text'], endpoints: ['/chat/completions'] })
+ expect(JSON.parse(inserts[0][4])).toMatchObject({ inputModalities: ['text'], outputModalities: ['text'], endpoints: ['/v1/chat/completions'] })
expect(requestAiModelCapabilityCatalog).toHaveBeenCalledWith('https://api.vip98k.com/v1', expect.any(Function))
})
test('failure of public capability discovery aborts both catalog and capability writes', async () => {
diff --git a/lib/__tests__/ai-model-verification.test.ts b/lib/__tests__/ai-model-verification.test.ts
new file mode 100644
index 0000000..92f2bd2
--- /dev/null
+++ b/lib/__tests__/ai-model-verification.test.ts
@@ -0,0 +1,133 @@
+/** @jest-environment node */
+import { randomUUID } from 'node:crypto'
+import { AiModelError, assertAiModelAdmin } from '@/lib/ai-model-access'
+import { verifyAiModel, refreshAiModelVerification, validateAiModelVerificationOutput } from '@/lib/ai-model-verification'
+import { callAiModelProtocol, AiModelProtocolError } from '@/lib/ai-model-protocol'
+import { resolveAiModelVerification } from '@/lib/ai-model-service'
+import { prisma } from '@/lib/prisma'
+import { detailTransactionLock } from '@/lib/detail-generator/repository'
+
+jest.mock('@/lib/ai-model-access', () => { const actual = jest.requireActual('@/lib/ai-model-access'); return { ...actual, assertAiModelAdmin: jest.fn() } })
+jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
+jest.mock('@/lib/auth', () => ({ authOptions: {} }))
+jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() }))
+jest.mock('@/lib/prisma', () => ({ prisma: { $executeRawUnsafe: jest.fn() } }))
+jest.mock('@/lib/ai-model-service', () => ({ getAiModelAdminState: jest.fn(async () => ({ providers: [] })), resolveAiModelVerification: jest.fn() }))
+jest.mock('@/lib/ai-model-protocol', () => ({ ...jest.requireActual('@/lib/ai-model-protocol'), callAiModelProtocol: jest.fn() }))
+jest.mock('@/lib/detail-generator/repository', () => ({ detailTransactionLock: jest.fn() }))
+const mockDb = { $queryRawUnsafe: jest.fn(), $executeRawUnsafe: jest.fn() }
+jest.mock('@/lib/ai-model-repository', () => ({ aiModelTransaction: jest.fn(async callback => callback(mockDb)), aiModelAudit: jest.fn() }))
+const providerId = '11111111-1111-4111-8111-111111111111', modelKey = '22222222-2222-4222-8222-222222222222'
+const input = () => ({ stage: 'analysis' as const, revision: 1, idempotencyKey: randomUUID() })
+let row: any, duplicate = false, unresolved = 0, active = 0
+beforeEach(() => {
+ jest.clearAllMocks(); duplicate = false; unresolved = 0; active = 0
+ row = { id: randomUUID(), modelKey, stage: 'analysis', contextHash: 'a'.repeat(64), idempotencyKey: randomUUID(), status: 'running', taskId: null, deadlineAt: new Date(Date.now() - 1) }
+ ;(assertAiModelAdmin as jest.Mock).mockResolvedValue('admin')
+ ;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ provider: { baseUrl: 'https://models.example.com/v1' }, model: { id: 'model' }, protocol: { id: 'openai-chat', path: '/v1/chat/completions', headers: {}, supportsImageInput: true }, apiKey: 'synthetic-test-secret', contextHash: row.contextHash })
+ mockDb.$queryRawUnsafe.mockImplementation(async (sql: string, ...args: any[]) => {
+ if (sql.startsWith('INSERT')) { row.idempotencyKey = args[4]; return [row] }
+ if (sql.includes('idempotency_key=$3')) return duplicate ? [row] : []
+ if (sql.includes('FOR UPDATE')) return [row]
+ if (sql.includes('active_executions')) return [{ count: active, own: 0 }]
+ if (sql.includes('COUNT(*)')) return [{ count: unresolved }]
+ return []
+ })
+ mockDb.$executeRawUnsafe.mockResolvedValue(1)
+ ;(prisma.$executeRawUnsafe as jest.Mock).mockResolvedValue(1)
+ ;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { await request.beforeRequest(); return { text: '{"topLeft":"red","bottomRight":"blue"}', requestId: 'request-1', summary: {} } })
+})
+test('a real image-understanding response persists passed evidence without enabling the model', async () => {
+ await verifyAiModel('admin', providerId, modelKey, input())
+ expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
+ expect((callAiModelProtocol as jest.Mock).mock.calls[0][0].images[0].buffer).toBeInstanceOf(Buffer)
+ expect(mockDb.$executeRawUnsafe.mock.calls.some(([sql]) => sql.includes("status='passed'"))).toBe(true)
+ expect(mockDb.$executeRawUnsafe.mock.calls.some(([sql]) => /UPDATE ai_model_catalog|ai_model_configuration/.test(sql))).toBe(false)
+ expect(detailTransactionLock).toHaveBeenCalledWith(mockDb, 'detail-generator-execution')
+})
+test('repeated idempotency keys and unresolved requests cannot submit another model call', async () => {
+ duplicate = true
+ await verifyAiModel('admin', providerId, modelKey, input())
+ expect(callAiModelProtocol).not.toHaveBeenCalled()
+ duplicate = false; unresolved = 1
+ await expect(verifyAiModel('admin', providerId, modelKey, input())).rejects.toMatchObject({ code: 'verification_unresolved' })
+ expect(callAiModelProtocol).not.toHaveBeenCalled()
+})
+test('verification respects active generation capacity', async () => {
+ active = 2
+ await expect(verifyAiModel('admin', providerId, modelKey, input())).rejects.toMatchObject({ code: 'verification_busy' })
+ expect(callAiModelProtocol).not.toHaveBeenCalled()
+ const capacity = mockDb.$queryRawUnsafe.mock.calls.find(([sql]) => sql.includes('active_executions'))!
+ expect(capacity[0]).toContain("status='queued' AND checkpoint->>'dispatchPending'='true'")
+ expect(capacity[0]).toContain("status='unknown' AND task_id IS NOT NULL")
+})
+test('revocation before admission performs no call or insert', async () => {
+ ;(assertAiModelAdmin as jest.Mock).mockRejectedValue(new AiModelError('forbidden', 403))
+ await expect(verifyAiModel('admin', providerId, modelKey, input())).rejects.toMatchObject({ status: 403 })
+ expect(callAiModelProtocol).not.toHaveBeenCalled()
+ expect(mockDb.$queryRawUnsafe).not.toHaveBeenCalled()
+})
+test.each([false, true])('protocol outcomeUnknown=%s is persisted accurately, never retried', async unknown => {
+ ;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { await request.beforeRequest(); throw new AiModelProtocolError('synthetic-test-secret must not escape', unknown, 'req-2', unknown ? 'task-2' : null) })
+ await verifyAiModel('admin', providerId, modelKey, input())
+ expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
+ const terminal = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))!
+ expect(terminal[2]).toBe(unknown ? 'unknown' : 'failed')
+ expect(JSON.stringify(terminal)).not.toContain('synthetic-test-secret')
+})
+test('a persisted task can only be queried through resumeTaskId and its original idempotency key', async () => {
+ row.status = 'unknown'; row.taskId = 'task-original'
+ await refreshAiModelVerification('admin', providerId, row.id)
+ expect(callAiModelProtocol).toHaveBeenCalledWith(expect.objectContaining({ resumeTaskId: 'task-original', idempotencyKey: row.idempotencyKey }))
+ expect(mockDb.$queryRawUnsafe.mock.calls.some(([sql]) => sql.startsWith('INSERT'))).toBe(false)
+ expect(mockDb.$queryRawUnsafe.mock.calls.find(([sql]) => sql.includes('active_executions'))?.[2]).toBe(row.id)
+})
+test('unknown without a checkpoint cannot be resumed or bypassed with manual confirmation', async () => {
+ row.status = 'unknown'; row.taskId = null
+ await expect(refreshAiModelVerification('admin', providerId, row.id)).rejects.toMatchObject({ code: 'verification_not_resumable' })
+ await expect(verifyAiModel('admin', providerId, modelKey, { ...input(), compatibility: 'confirmed' })).rejects.toThrow('参数无效')
+ expect(callAiModelProtocol).not.toHaveBeenCalled()
+})
+test('changed context before dispatch records known failure without pretending it passed', async () => {
+ ;(resolveAiModelVerification as jest.Mock).mockResolvedValueOnce({ ...(await (resolveAiModelVerification as jest.Mock)()), contextHash: row.contextHash })
+ ;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { (resolveAiModelVerification as jest.Mock).mockResolvedValue({ contextHash: 'b'.repeat(64) }); await request.beforeRequest(); throw new Error('must not send') })
+ await verifyAiModel('admin', providerId, modelKey, input())
+ const terminal = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))!
+ expect(terminal[2]).toBe('failed')
+})
+
+test('configuration changes immediately after admission persist known failure and make no model call', async () => {
+ const original = await (resolveAiModelVerification as jest.Mock)()
+ ;(resolveAiModelVerification as jest.Mock).mockResolvedValueOnce(original).mockRejectedValue(new AiModelError('连接已改变', 409, 'verification_context_changed'))
+ await verifyAiModel('admin', providerId, modelKey, input())
+ expect(callAiModelProtocol).not.toHaveBeenCalled()
+ expect(mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))?.[2]).toBe('failed')
+})
+
+test('revocation after an upstream task was accepted preserves unknown and the query checkpoint', async () => {
+ ;(callAiModelProtocol as jest.Mock).mockImplementation(async request => {
+ await request.beforeRequest()
+ await request.onTaskSubmitted('task-accepted')
+ throw new AiModelError('权限已撤销', 403)
+ })
+ await verifyAiModel('admin', providerId, modelKey, input())
+ const terminal = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))!
+ expect(terminal[2]).toBe('unknown')
+ expect(terminal[4]).toBe('task-accepted')
+ expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
+})
+
+test('a resumed task remains unknown when result lookup fails before the request guard', async () => {
+ row.status = 'unknown'; row.taskId = 'task-original'
+ ;(callAiModelProtocol as jest.Mock).mockRejectedValue(new AiModelProtocolError('DNS temporarily unavailable', true, null, 'task-original'))
+ await refreshAiModelVerification('admin', providerId, row.id)
+ const terminal = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))!
+ expect(terminal[2]).toBe('unknown')
+ expect(terminal[4]).toBe('task-original')
+ expect(callAiModelProtocol).toHaveBeenCalledWith(expect.objectContaining({ resumeTaskId: 'task-original' }))
+})
+test('output acceptance checks image interpretation and actual outline structure', async () => {
+ await expect(validateAiModelVerificationOutput('analysis', { text: '{"topLeft":"blue","bottomRight":"red"}' })).rejects.toMatchObject({ code: 'verification_output_invalid' })
+ await expect(validateAiModelVerificationOutput('copy', { text: '{"text":"ok"}' })).rejects.toMatchObject({ code: 'verification_output_invalid' })
+ await expect(validateAiModelVerificationOutput('copy', { text: '{"sellingPoints":["蓝色"],"warnings":[],"screens":[{"title":"蓝色产品","body":"型号TEST","imagePrompt":"蓝色产品,无文字"}]}' })).resolves.toMatchObject({ outlineSchemaVerified: true })
+})
diff --git a/lib/__tests__/detail-generator-backend.test.ts b/lib/__tests__/detail-generator-backend.test.ts
index 9bd81cf..1cf9406 100644
--- a/lib/__tests__/detail-generator-backend.test.ts
+++ b/lib/__tests__/detail-generator-backend.test.ts
@@ -20,12 +20,12 @@ jest.mock('@/lib/ai-model-service', () => ({
captureAiModelBindings: jest.fn(), getAiModelRuntimeState: jest.fn(),
resolveAiModel: jest.fn(async (stage, selection) => {
if (selection.startsWith('unvalidated')) throw new (require('@/lib/detail-generator/access').DetailGeneratorError)('所选模型未确认兼容')
- return { apiKey: 'unit-test-key', binding: { providerId: '11111111-1111-4111-8111-111111111111', modelKey: '22222222-2222-4222-8222-222222222222', modelId: selection, providerName: '测试上游', baseUrl: 'https://model.example.test/v1', revision: 1 } }
+ return { apiKey: 'unit-test-key', protocol: { id: 'openai-chat', path: '/v1/chat/completions', headers: {}, supportsImageInput: true }, binding: { providerId: '11111111-1111-4111-8111-111111111111', modelKey: '22222222-2222-4222-8222-222222222222', modelId: selection, providerName: '测试上游', baseUrl: 'https://model.example.test/v1', revision: 1 } }
}),
}))
-jest.mock('@/lib/ai-model-http', () => ({ requestAiModel: jest.fn(async (baseUrl, apiKey, endpoint, body, idempotencyKey, beforeRequest) => {
+jest.mock('@/lib/ai-model-http', () => ({ requestAiModelOperation: jest.fn(async (baseUrl, apiKey, endpoint, body, idempotencyKey, _headers, beforeRequest) => {
await beforeRequest()
- return global.fetch(baseUrl + endpoint, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Idempotency-Key': idempotencyKey }, body })
+ return global.fetch(baseUrl + endpoint.slice(3), { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Idempotency-Key': idempotencyKey }, body })
}) }))
const originalFetch = global.fetch
diff --git a/lib/__tests__/detail-generator-jobs.test.ts b/lib/__tests__/detail-generator-jobs.test.ts
index cee3878..40065af 100644
--- a/lib/__tests__/detail-generator-jobs.test.ts
+++ b/lib/__tests__/detail-generator-jobs.test.ts
@@ -2,7 +2,7 @@
import { isSuperAdmin } from '@/lib/department-permission'
import { processDetailJob } from '@/lib/detail-generator/jobs'
import { analyzeDetailFacts, composeDetailOutline, DetailProviderError, generateDetailImage } from '@/lib/detail-generator/provider'
-import { checkpointDetailJob, findDetailAsset, findDetailProject, finishDetailJob, saveDetailJobAsset, saveDetailJobDocument, type StoredDetailJob } from '@/lib/detail-generator/repository'
+import { checkpointDetailJob, deferDetailJob, findDetailAsset, findDetailProject, finishDetailJob, saveDetailJobAsset, saveDetailJobDocument, type StoredDetailJob } from '@/lib/detail-generator/repository'
import type { DetailDocument, DetailScreen } from '@/lib/detail-generator/types'
import { prisma } from '@/lib/prisma'
@@ -10,7 +10,7 @@ jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() }))
jest.mock('@/lib/prisma', () => ({ prisma: { user: { findUnique: jest.fn() } } }))
-jest.mock('@/lib/detail-generator/repository', () => ({ assertDetailDocumentAssets: jest.fn(), checkpointDetailJob: jest.fn(), claimDetailJob: jest.fn(), findDetailAsset: jest.fn(), findDetailProject: jest.fn(), finishDetailJob: jest.fn(), renewDetailJobLease: jest.fn(), saveDetailJobAsset: jest.fn(), saveDetailJobDocument: jest.fn() }))
+jest.mock('@/lib/detail-generator/repository', () => ({ assertDetailDocumentAssets: jest.fn(), checkpointDetailJob: jest.fn(), claimDetailJob: jest.fn(), deferDetailJob: jest.fn(), findDetailAsset: jest.fn(), findDetailProject: jest.fn(), finishDetailJob: jest.fn(), renewDetailJobLease: jest.fn(), saveDetailJobAsset: jest.fn(), saveDetailJobDocument: jest.fn() }))
jest.mock('@/lib/detail-generator/storage', () => ({ deleteDetailFile: jest.fn(), readDetailFile: jest.fn().mockResolvedValue(Buffer.from('image')), saveDetailFile: jest.fn().mockResolvedValue('private/image.png') }))
jest.mock('@/lib/detail-generator/render', () => ({ renderDetailExport: jest.fn() }))
jest.mock('@/lib/detail-generator/provider', () => ({ ...jest.requireActual('@/lib/detail-generator/provider'), analyzeDetailFacts: jest.fn(), composeDetailOutline: jest.fn(), generateDetailImage: jest.fn() }))
@@ -83,7 +83,61 @@ test('remaining-screen generation skips completed imagery and stops immediately
;(generateDetailImage as jest.Mock).mockRejectedValue(new DetailProviderError('生成结果未知', true))
await processDetailJob(makeJob('generate'))
expect(generateDetailImage).toHaveBeenCalledTimes(1)
- expect(generateDetailImage).toHaveBeenCalledWith(expect.anything(), '场景', expect.anything(), 'job-1-screen-second', 'owner-1', bindings.image)
+ expect(generateDetailImage).toHaveBeenCalledWith(expect.anything(), '场景', expect.anything(), 'job-1-screen-second', 'owner-1', bindings.image, expect.objectContaining({ onTaskSubmitted: expect.any(Function) }))
expect(saveDetailJobAsset).not.toHaveBeenCalled()
expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'unknown', expect.objectContaining({ stage: 'screen-second' }), '生成结果未知', null)
})
+
+function pendingImageJob() {
+ document.approvedAt = new Date().toISOString()
+ document.analysis = { sellingPoints: [], warnings: [], screens: [] }
+ document.screens = [{ id: 'first', title: '一', body: '', imagePrompt: '场景', width: 750, height: 1200, backgroundColor: '#FFFFFF', texts: [] }]
+ return makeJob('generate')
+}
+
+test('known asynchronous image identity is persisted before deferred polling', async () => {
+ const job = pendingImageJob()
+ ;(generateDetailImage as jest.Mock).mockImplementation(async (...args) => {
+ await args[6].onTaskSubmitted('task-1')
+ expect(checkpointDetailJob).toHaveBeenLastCalledWith('job-1', 'lease-1', expect.objectContaining({ upstreamTask: expect.objectContaining({ id: 'task-1' }), dispatchPending: true }), 0)
+ throw new DetailProviderError('仍在生成', true, null, 'task-1', true)
+ })
+ await processDetailJob(job)
+ expect(deferDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', expect.objectContaining({ upstreamTask: expect.objectContaining({ id: 'task-1', polls: 1 }), pollNotBefore: expect.any(Number) }))
+ expect(finishDetailJob).not.toHaveBeenCalled()
+})
+
+test('worker recovery passes persisted task identity and does not start another image request', async () => {
+ const job = pendingImageJob()
+ job.checkpoint = { stage: 'screen-first', dispatchPending: true, upstreamTask: { id: 'task-1', stage: 'screen-first', submittedAt: Date.now() - 60_000, polls: 1 } }
+ ;(generateDetailImage as jest.Mock).mockImplementation(async (...args) => {
+ expect(args[6].resumeTaskId).toBe('task-1')
+ throw new DetailProviderError('仍在生成', true, null, 'task-1', true)
+ })
+ await processDetailJob(job)
+ expect(generateDetailImage).toHaveBeenCalledTimes(1)
+ expect(deferDetailJob).toHaveBeenCalledTimes(1)
+})
+
+test('revocation immediately before resumed polling preserves the accepted task as unknown', async () => {
+ const job = pendingImageJob()
+ const task = { id: 'task-accepted', stage: 'screen-first', submittedAt: Date.now() - 60_000, polls: 1 }
+ job.checkpoint = { stage: 'screen-first', dispatchPending: true, upstreamTask: task }
+ ;(isSuperAdmin as jest.Mock).mockResolvedValueOnce(true).mockResolvedValueOnce(true).mockResolvedValueOnce(false)
+ await processDetailJob(job)
+ expect(generateDetailImage).not.toHaveBeenCalled()
+ expect(deferDetailJob).not.toHaveBeenCalled()
+ expect(checkpointDetailJob).toHaveBeenCalledTimes(1)
+ expect(checkpointDetailJob).toHaveBeenLastCalledWith('job-1', 'lease-1', expect.objectContaining({ dispatchPending: true, upstreamTask: task }), 0)
+ expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'unknown', expect.objectContaining({ dispatchPending: true, upstreamTask: task }), expect.stringContaining('超级管理员'), null)
+})
+
+test.each(['missing', 'expired', 'wrong-screen'])('unsafe asynchronous checkpoint stays paused: %s', async reason => {
+ const job = pendingImageJob()
+ job.checkpoint = { stage: 'screen-first', dispatchPending: true }
+ if (reason !== 'missing') job.checkpoint.upstreamTask = { id: 'task-1', stage: reason === 'wrong-screen' ? 'screen-other' : 'screen-first', submittedAt: Date.now() - 7 * 60 * 60_000, polls: 1 }
+ await processDetailJob(job)
+ expect(generateDetailImage).not.toHaveBeenCalled()
+ expect(deferDetailJob).not.toHaveBeenCalled()
+ expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'unknown', expect.objectContaining({ dispatchPending: true }), expect.any(String), null)
+})
diff --git a/lib/ai-model-capabilities.ts b/lib/ai-model-capabilities.ts
index 5a7b816..855834d 100644
--- a/lib/ai-model-capabilities.ts
+++ b/lib/ai-model-capabilities.ts
@@ -1,5 +1,6 @@
-import type { AiModelCapabilities, AiModelStage } from './ai-model-types'
+import { AI_MODEL_KINDS, type AiModelCapabilities, type AiModelStage, type AiModelKind } from './ai-model-types'
import { AiModelError } from './ai-model-access'
+import { parseAiModelProtocols, validateStoredAiModelProtocols, type AiModelProtocol } from './ai-model-protocol'
/** Server-owned evidence. Never accepted from configuration writes or inferred from model names. */
export interface AiModelCapabilityMetadata {
@@ -13,6 +14,7 @@ export interface AiModelCapabilityMetadata {
outputDeclared?: boolean
endpointsDeclared?: boolean
unavailable?: boolean
+ protocols?: AiModelProtocol[]
}
function object(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null }
@@ -32,7 +34,26 @@ export function parseAiModelCapabilityMetadata(value: unknown, sourceUrl: string
if (raw.available === false) return { version: 1, inputModalities: [], outputModalities: [], endpoints: [], sourceUrl, unavailable: true }
return null
}
- return { version: 1, inputModalities, outputModalities, endpoints, sourceUrl, inputDeclared: true, outputDeclared: true, endpointsDeclared: true, unavailable: raw.available === false }
+ const protocols: AiModelProtocol[] = []
+ for (const endpoint of endpoints) {
+ const path = endpoint.startsWith('/v1/') ? endpoint : `/v1${endpoint}`
+ if (path === '/v1/chat/completions') protocols.push({ id: 'openai-chat', path, headers: {}, supportsImageInput: inputModalities.includes('image') })
+ if (path === '/v1/images/edits') protocols.push({ id: 'openai-image-edit', path, headers: {}, inputField: 'image[]', supportsImageInput: inputModalities.includes('image') })
+ }
+ return { version: 1, inputModalities, outputModalities, endpoints, protocols, sourceUrl, inputDeclared: true, outputDeclared: true, endpointsDeclared: true, unavailable: raw.available === false }
+}
+
+function publicKind(model: Record, section: Record, input: string[], output: string[]): AiModelKind {
+ const capabilities = strings(model.capabilities) || [], tasks = strings(model.task_types) || []
+ if (model.category === 'three_d_space' || tasks.includes('interactive_3d') || output.includes('interactive_3d')) return 'three-d'
+ if (model.category === 'music_generation' || tasks.includes('text_to_music')) return 'music'
+ if (model.type === 'video' || model.modality === 'video' || output.includes('video')) return 'video'
+ if (tasks.includes('text_to_speech') || input.includes('text') && output.includes('audio')) return 'tts'
+ if (capabilities.some(value => ['audio_transcription', 'speech_to_text'].includes(value)) || input.some(value => value.startsWith('audio')) && output.includes('text')) return 'asr'
+ if (model.type === 'embedding' || model.modality === 'embedding' || capabilities.includes('openai.embeddings') || output.includes('embedding')) return 'embedding'
+ if (model.type === 'image' || model.modality === 'image') return capabilities.some(value => ['text_to_image', 'image_to_image', 'openai.images.generations', 'openai.images.edits'].includes(value)) ? 'image' : 'image-tool'
+ if (model.type === 'model' && section.id === 'chat') return model.modality === 'multimodal' ? 'vision' : model.modality === 'text' ? 'text' : 'other'
+ return 'other'
}
export const AI_MODEL_PUBLIC_CATALOG_URL = 'https://api.vip98k.com/api/v2/catalog/models'
@@ -51,7 +72,6 @@ export function parseAiModelPublicCapabilityCatalog(value: unknown): Map => Boolean(offer)) : []
// Do not combine non-default service offers or route-specific protocols into the default request path.
const selected = offers.find(offer => offer.is_default === true && object(offer.service_scope)?.id === model.default_service_scope_id)
- const invocation = object(offers.length ? selected?.invocation : model.invocation)
const rawInputModes = selected?.input_modes ?? model.input_modes, rawOutputModes = selected?.output_modes ?? model.output_modes
const inputModes = strings(rawInputModes), outputModes = strings(rawOutputModes)
if (rawInputModes !== undefined && inputModes === null || rawOutputModes !== undefined && outputModes === null) throw new AiModelError('上游能力目录模态字段无效', 502, 'capability_catalog_invalid')
@@ -61,16 +81,13 @@ export function parseAiModelPublicCapabilityCatalog(value: unknown): Map Array.from(new Set(modes.map(mode => ['image_url', 'image_base64'].includes(mode) ? 'image' : mode === 'prompt' ? 'text' : mode))).sort()
const inputModalities = normalizeModes(inputModes || (textModel ? ['text'] : []))
const outputModalities = normalizeModes(outputModes || (textModel ? ['text'] : imageModel ? ['image'] : []))
- const headers = object(invocation?.headers)
- const endpoints: string[] = []
- if (model.selection_required !== true && invocation?.method === 'POST' && headers && Object.keys(headers).length === 0) {
- if (invocation.path === '/v1/chat/completions' && invocation.content_type === 'application/json') endpoints.push('/chat/completions')
- if (invocation.path === '/v1/images/edits' && invocation.content_type === 'multipart/form-data') endpoints.push('/images/edits')
- }
- result.set(model.id, { version: 1, inputModalities, outputModalities, endpoints, sourceUrl: AI_MODEL_PUBLIC_CATALOG_URL,
+ const protocols = parseAiModelProtocols(model)
+ const endpoints = protocols.map(protocol => protocol.path)
+ const kindHint = publicKind(model, section, inputModes || [], outputModes || [])
+ result.set(model.id, { version: 1, inputModalities, outputModalities, endpoints, protocols, sourceUrl: AI_MODEL_PUBLIC_CATALOG_URL,
inputDeclared: inputModes !== null, outputDeclared: outputModes !== null, endpointsDeclared: false,
unavailable: model.available === false || typeof model.status === 'string' && model.status !== 'available' || selected?.available === false || typeof selected?.status === 'string' && selected.status !== 'available',
- kindHint: imageModel ? 'image' : textModel ? model.modality === 'multimodal' ? 'vision' : 'text' : 'other' })
+ kindHint })
}
}
if (result.size > 5000) throw new AiModelError('上游能力目录过大', 502, 'capability_catalog_invalid')
@@ -84,7 +101,8 @@ function storedMetadata(value: unknown): AiModelCapabilityMetadata | null {
if (!inputModalities || !outputModalities || !endpoints || raw.sourceUrl !== null && typeof raw.sourceUrl !== 'string') return null
return { version: 1, inputModalities, outputModalities, endpoints, sourceUrl: raw.sourceUrl as string | null,
inputDeclared: raw.inputDeclared === true, outputDeclared: raw.outputDeclared === true, endpointsDeclared: raw.endpointsDeclared === true, unavailable: raw.unavailable === true,
- ...(['text', 'vision', 'image', 'other', 'unknown'].includes(String(raw.kindHint)) ? { kindHint: raw.kindHint as AiModelCapabilities['kind'] } : {}) }
+ protocols: validateStoredAiModelProtocols(raw.protocols),
+ ...(AI_MODEL_KINDS.includes(raw.kindHint as AiModelKind) ? { kindHint: raw.kindHint as AiModelKind } : {}) }
}
/** Stable across PostgreSQL JSONB property ordering, so unchanged sync does not invalidate active jobs. */
@@ -93,7 +111,7 @@ export function aiModelCapabilityFingerprint(value: unknown): string { return JS
export function mergeAiModelCapabilityMetadata(primary: AiModelCapabilityMetadata | null, additional: AiModelCapabilityMetadata | null): AiModelCapabilityMetadata | null {
if (!primary || !additional) return primary || additional
if (primary.unavailable && !primary.inputDeclared && !primary.outputDeclared && !primary.endpointsDeclared) return { ...additional, unavailable: true }
- return { ...primary,
+ return { ...primary, kindHint: additional.kindHint || primary.kindHint, protocols: additional.protocols || [], endpoints: additional.endpoints,
inputModalities: additional.inputDeclared ? primary.inputModalities.filter(value => additional.inputModalities.includes(value)) : primary.inputModalities,
outputModalities: additional.outputDeclared ? primary.outputModalities.filter(value => additional.outputModalities.includes(value)) : primary.outputModalities,
unavailable: primary.unavailable === true || additional.unavailable === true }
@@ -101,17 +119,27 @@ export function mergeAiModelCapabilityMetadata(primary: AiModelCapabilityMetadat
export function getAiModelCapabilities(baseUrl: string, modelId: string, metadata?: unknown): AiModelCapabilities {
const evidence = storedMetadata(metadata)
- // Missing public fields do not erase real acceptance evidence. Explicit capability withdrawal does.
- const verified: AiModelStage[] = baseUrl === 'https://api.vip98k.com/v1' ? modelId === 'gemini-2.5-flash' ? ['analysis', 'copy'] : modelId === 'nano-banana-2' ? ['image'] : [] : []
- const verifiedAllowed = verified.filter(stage => !evidence?.unavailable && evidence?.kindHint !== 'other' && (!evidence?.inputDeclared || evidence.inputModalities.includes('text') && (stage === 'copy' || evidence.inputModalities.includes('image'))) && (!evidence?.outputDeclared || evidence.outputModalities.includes(stage === 'image' ? 'image' : 'text')) && (!evidence?.endpointsDeclared || evidence.endpoints.some(endpoint => endpoint === (stage === 'image' ? '/images/edits' : '/chat/completions') || endpoint === (stage === 'image' ? '/v1/images/edits' : '/v1/chat/completions'))))
- if (verifiedAllowed.length) return { kind: verifiedAllowed.includes('image') ? 'image' : verifiedAllowed.includes('analysis') ? 'vision' : 'text', allowedStages: verifiedAllowed, source: 'verified', reason: '依据本平台已完成的实际接口验证' }
+ void baseUrl; void modelId
if (!evidence) return { kind: 'unknown', allowedStages: [], source: 'unknown', reason: '上游未提供可核验能力,暂不能开放' }
const textOutput = evidence.outputModalities.includes('text'), imageOutput = evidence.outputModalities.includes('image'), imageInput = evidence.inputModalities.includes('image'), textInput = evidence.inputModalities.includes('text')
- const chat = evidence.endpoints.includes('/chat/completions') || evidence.endpoints.includes('/v1/chat/completions')
- const imageEdit = evidence.endpoints.includes('/images/edits') || evidence.endpoints.includes('/v1/images/edits')
+ const protocols = evidence.protocols || []
+ const chat = protocols.some(protocol => ['openai-chat', 'anthropic-messages'].includes(protocol.id))
+ const imageEdit = protocols.some(protocol => ['openai-image-edit', 'openai-image-generate', 'async-image-task'].includes(protocol.id) && protocol.supportsImageInput)
const allowedStages: AiModelStage[] = []
- if (!evidence.unavailable && evidence.kindHint !== 'other' && textInput && textOutput && chat) { if (imageInput) allowedStages.push('analysis'); allowedStages.push('copy') }
- if (!evidence.unavailable && evidence.kindHint !== 'other' && textInput && imageOutput && imageInput && imageEdit) allowedStages.push('image')
- const kind = evidence.kindHint || (imageOutput ? 'image' : textOutput && textInput ? imageInput ? 'vision' : 'text' : evidence.outputModalities.length ? 'other' : 'unknown')
- return { kind, allowedStages, source: 'upstream', reason: evidence.unavailable ? '上游已停用该模型' : allowedStages.length ? '依据上游公布的输入、输出与接口能力' : kind === 'image' ? '图片模型尚不支持当前产品参考图接口' : kind === 'other' ? '当前详情页不支持该模型类型' : '上游能力尚不匹配当前详情页接口' }
+ const kind = evidence.kindHint || (evidence.outputModalities.includes('audio') && textInput ? 'tts' : evidence.inputModalities.some(value => value.startsWith('audio')) && textOutput ? 'asr' : evidence.outputModalities.includes('video') ? 'video' : evidence.outputModalities.includes('embedding') ? 'embedding' : imageOutput ? 'image' : textOutput && textInput ? imageInput ? 'vision' : 'text' : evidence.outputModalities.length ? 'other' : 'unknown')
+ if (!evidence.unavailable && ['text', 'vision', 'image'].includes(kind) && chat && (!evidence.inputDeclared || textInput) && (!evidence.outputDeclared || textOutput)) { if (!evidence.inputDeclared || imageInput) allowedStages.push('analysis'); allowedStages.push('copy') }
+ if (!evidence.unavailable && kind === 'image' && imageEdit && (!evidence.outputDeclared || imageOutput)) allowedStages.push('image')
+ return { kind, allowedStages, source: 'upstream', protocols: Object.fromEntries(allowedStages.map(stage => {
+ const protocol = getAiModelStageProtocol(metadata, stage)!
+ return [stage, { id: protocol.id, path: protocol.path, scopeId: protocol.scopeId, routeId: protocol.routeId, operationId: protocol.operationId }]
+ })), reason: evidence.unavailable ? '上游已停用该模型' : allowedStages.length ? '接口已适配;通过对应用途的实际测试后可开放' : kind === 'image' ? '当前路由尚不支持私有产品参考图' : ['text', 'vision'].includes(kind) ? '该接口或明确输入能力尚不匹配详情页' : '该类型不适用于详情页的三个用途' }
+}
+
+export function getAiModelStageProtocol(metadata: unknown, stage: AiModelStage): AiModelProtocol | null {
+ const evidence = storedMetadata(metadata)
+ if (!evidence || evidence.unavailable) return null
+ const protocol = (evidence.protocols || []).find(protocol => stage === 'image'
+ ? ['openai-image-edit', 'openai-image-generate', 'async-image-task'].includes(protocol.id) && protocol.supportsImageInput
+ : ['openai-chat', 'anthropic-messages'].includes(protocol.id)) || null
+ return protocol && stage === 'analysis' && !evidence.inputDeclared ? { ...protocol, supportsImageInput: true } : protocol
}
diff --git a/lib/ai-model-http.ts b/lib/ai-model-http.ts
index 53a0428..4854d27 100644
--- a/lib/ai-model-http.ts
+++ b/lib/ai-model-http.ts
@@ -30,11 +30,38 @@ export async function resolveAiModelPublicAddresses(baseUrl: string, resolver: A
}
/** Fixed DNS lookup, original hostname TLS verification, no redirects and bounded streamed response. */
-export async function requestAiModel(baseUrl: string, apiKey: string, endpoint: '/models' | '/chat/completions' | '/images/edits', body?: string | FormData, idempotencyKey?: string, beforeRequest?: () => Promise): Promise {
+export async function requestAiModel(baseUrl: string, apiKey: string, endpoint: '/models' | '/chat/completions' | '/messages' | '/images/edits' | '/images/generations', body?: string | FormData, idempotencyKey?: string, beforeRequest?: () => Promise): Promise {
const normalized = normalizeAiModelBaseUrl(baseUrl)
return requestAiModelUrl(normalized, new URL(normalized + endpoint), { Authorization: `Bearer ${apiKey}`, ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}) }, endpoint === '/models', body, beforeRequest)
}
+/** Only fixed, implemented operations are executable; directory URLs never become arbitrary request targets. */
+export async function requestAiModelOperation(baseUrl: string, apiKey: string, path: string, body: string | FormData | undefined, idempotencyKey: string | undefined, protocolHeaders: Record, beforeRequest?: () => Promise): Promise {
+ const normalized = normalizeAiModelBaseUrl(baseUrl)
+ if (!['/v1/chat/completions', '/v1/messages', '/v1/images/edits', '/v1/images/generations', '/extensions/v1/tasks'].includes(path) && !/^\/extensions\/v1\/tasks\/[A-Za-z0-9_-]{1,160}\/result$/.test(path)) throw new AiModelError('模型接口尚未适配', 400, 'protocol_unsupported')
+ const headers: Record = { Authorization: `Bearer ${apiKey}` }
+ if (idempotencyKey) {
+ if (!/^[\x21-\x7e]{1,128}$/.test(idempotencyKey)) throw new AiModelError('模型请求编号无效')
+ headers['Idempotency-Key'] = idempotencyKey
+ }
+ for (const [key, value] of Object.entries(protocolHeaders)) {
+ if (!['X-Relay-Route-Option', 'Anthropic-Version'].includes(key) || !/^[A-Za-z0-9._-]{1,160}$/.test(value)) throw new AiModelError('模型协议请求头无效', 400, 'protocol_invalid')
+ headers[key] = value
+ }
+ const target = path.startsWith('/v1/') ? new URL(normalized + path.slice(3)) : new URL(path, normalized)
+ return requestAiModelUrl(normalized, target, headers, false, body, beforeRequest, body === undefined && path.endsWith('/result') ? 10_000 : undefined)
+}
+
+/** Generated private assets stay on the configured provider. No credential-bearing request follows a redirect. */
+export async function requestAiModelOutput(baseUrl: string, apiKey: string, outputUrl: string, beforeRequest?: () => Promise): Promise {
+ const normalized = normalizeAiModelBaseUrl(baseUrl)
+ let target: URL
+ try { target = new URL(outputUrl) } catch { throw new AiModelError('模型产物地址无效', 502, 'output_invalid') }
+ // Relay assets use an encrypted Base64url reference, not a short database ID.
+ if (target.origin !== new URL(normalized).origin || target.username || target.password || target.hash || !/^\/api\/external\/assets\/[A-Za-z0-9_-]{32,8192}$/.test(target.pathname)) throw new AiModelError('模型产物不在已适配的私有文件入口', 502, 'output_invalid')
+ return requestAiModelUrl(normalized, target, { Authorization: `Bearer ${apiKey}` }, false, undefined, beforeRequest)
+}
+
/** This provider publishes a separate typed directory. Its public request never carries the private API key. */
export async function requestAiModelCapabilityCatalog(baseUrl: string, beforeRequest?: () => Promise): Promise {
const normalized = normalizeAiModelBaseUrl(baseUrl)
@@ -42,7 +69,7 @@ export async function requestAiModelCapabilityCatalog(baseUrl: string, beforeReq
return requestAiModelUrl(normalized, new URL('https://api.vip98k.com/api/v2/catalog/models'), {}, true, undefined, beforeRequest)
}
-async function requestAiModelUrl(normalized: string, target: URL, headers: Record, catalog: boolean, body?: string | FormData, beforeRequest?: () => Promise): Promise {
+async function requestAiModelUrl(normalized: string, target: URL, headers: Record, catalog: boolean, body?: string | FormData, beforeRequest?: () => Promise, timeoutMs?: number): Promise {
const addresses = await resolveAiModelPublicAddresses(normalized)
let requestBody: Readable | null = null
if (typeof body === 'string') { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = String(Buffer.byteLength(body)); requestBody = Readable.from([body]) }
@@ -76,7 +103,7 @@ async function requestAiModelUrl(normalized: string, target: URL, headers: Recor
try { finish(null, new Response([204, 205, 304].includes(res.statusCode || 0) ? null : new Uint8Array(Buffer.concat(chunks)), { status: res.statusCode || 502, headers: responseHeaders })) } catch { finish(new Error('Provider response invalid')) }
})
})
- const timer = setTimeout(() => { req.destroy(); finish(new Error('Provider request timed out')) }, catalog ? 20_000 : 180_000)
+ const timer = setTimeout(() => { req.destroy(); finish(new Error('Provider request timed out')) }, timeoutMs ?? (catalog ? 20_000 : 180_000))
req.on('error', () => finish(new Error('Provider request failed')))
if (requestBody) { requestBody.on('error', () => { req.destroy(); finish(new Error('Provider request body failed')) }); requestBody.pipe(req) } else req.end()
})
diff --git a/lib/ai-model-protocol.ts b/lib/ai-model-protocol.ts
new file mode 100644
index 0000000..0bb7852
--- /dev/null
+++ b/lib/ai-model-protocol.ts
@@ -0,0 +1,230 @@
+import sharp from 'sharp'
+import { requestAiModelOperation, requestAiModelOutput } from './ai-model-http'
+
+export const AI_MODEL_ADAPTER_VERSION = 2
+export interface AiModelProtocol {
+ id: 'openai-chat' | 'anthropic-messages' | 'openai-image-edit' | 'openai-image-generate' | 'async-image-task'
+ path: string
+ headers: Record
+ inputField?: string
+ supportsImageInput: boolean
+ scopeId?: string
+ routeId?: string
+ operationId?: string
+ maxImages?: number
+ maxPromptLength?: number
+}
+const paths: Record = { 'openai-chat': '/v1/chat/completions', 'anthropic-messages': '/v1/messages', 'openai-image-edit': '/v1/images/edits', 'openai-image-generate': '/v1/images/generations', 'async-image-task': '/extensions/v1/tasks' }
+function record(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null }
+function list(value: unknown): string[] { return Array.isArray(value) && value.every(item => typeof item === 'string') ? value : [] }
+function safeHeaders(value: unknown): Record | null {
+ const raw = record(value)
+ if (!raw) return null
+ const result: Record = {}
+ for (const [name, item] of Object.entries(raw).sort(([left], [right]) => left.localeCompare(right))) {
+ if (!['X-Relay-Route-Option', 'Anthropic-Version'].includes(name) || typeof item !== 'string' || !/^[A-Za-z0-9._-]{1,160}$/.test(item)) return null
+ result[name] = item
+ }
+ return result
+}
+function identifier(value: unknown): value is string { return typeof value === 'string' && /^[A-Za-z0-9._-]{1,160}$/.test(value) }
+
+/** Parse only a known operation, keeping route evidence separate from a model's combined capabilities. */
+function parseInvocation(model: Record, offer: Record, route: Record): AiModelProtocol | null {
+ const invocation = record(route.invocation ?? offer.invocation ?? model.invocation)
+ if (!invocation || invocation.method !== 'POST') return null
+ const id = (Object.keys(paths) as AiModelProtocol['id'][]).find(key => paths[key] === invocation.path)
+ const headers = safeHeaders(invocation.headers)
+ if (!id || !headers) return null
+ if (invocation.content_type !== (id === 'openai-image-edit' ? 'multipart/form-data' : 'application/json')) return null
+ const scopeId = record(offer.service_scope)?.id, routeId = route.id
+ if (scopeId !== undefined && !identifier(scopeId) || routeId !== undefined && !identifier(routeId)) return null
+ if (routeId && headers['X-Relay-Route-Option'] !== routeId) return null
+ const schema = record(invocation.input_schema ?? route.input_schema)
+ const properties = record(schema?.properties) ?? {}
+ const modes = list(route.input_modes ?? offer.input_modes ?? model.input_modes)
+ const capabilities = list(offer.capabilities ?? model.capabilities)
+ const isTextModel = ['text', 'multimodal'].includes(model.modality) && model.type === 'model'
+ const isImageModel = model.modality === 'image' && [...capabilities, ...list(model.capabilities)].some(value => ['text_to_image', 'image_to_image', 'openai.images.generations', 'openai.images.edits'].includes(value))
+ let inputField: string | undefined, supportsImageInput = false
+ if (id === 'openai-chat' || id === 'anthropic-messages') {
+ if (!isTextModel) return null
+ supportsImageInput = modes.some(mode => ['image', 'image_url', 'image_base64'].includes(mode)) || (!modes.length && model.modality === 'multimodal')
+ } else {
+ if (!isImageModel) return null
+ if (id === 'openai-image-edit') {
+ inputField = properties['image[]'] !== false && properties['image[]'] ? 'image[]' : properties.image !== false && properties.image ? 'image' : undefined
+ supportsImageInput = Boolean(inputField)
+ } else {
+ // JSON image requests are safe for private files only when the route explicitly permits inline data.
+ inputField = ['file_urls', 'images', 'image'].find(field => {
+ const property = record(properties[field]), items = record(property?.items)
+ const pattern = items?.pattern
+ if (typeof pattern !== 'string' || pattern.length > 512 || !pattern.includes('data:image/') || items?.type !== 'string' || !(property?.type === 'array' || Array.isArray(property?.type) && property.type.includes('array'))) return false
+ try { return new RegExp(pattern).test('data:image/png;base64,AA==') } catch { return false }
+ })
+ supportsImageInput = Boolean(inputField)
+ }
+ if (id === 'async-image-task' && !supportsImageInput) return null
+ }
+ const maxImages = inputField && record(properties[inputField])?.maxItems
+ const maxPromptLength = record(properties.prompt)?.maxLength
+ return { id, path: paths[id], headers, supportsImageInput, ...(inputField ? { inputField } : {}),
+ ...(scopeId ? { scopeId } : {}), ...(routeId ? { routeId } : {}),
+ ...(identifier(offer.public_operation_id) ? { operationId: offer.public_operation_id } : {}),
+ ...(Number.isInteger(maxImages) && maxImages > 0 ? { maxImages: Math.min(8, maxImages) } : {}),
+ ...(Number.isInteger(maxPromptLength) && maxPromptLength > 0 ? { maxPromptLength } : {}) }
+}
+
+/** At setup time pick a concrete compatible route in the default scope; no runtime routing fallback. */
+export function parseAiModelProtocols(value: unknown): AiModelProtocol[] {
+ const model = record(value)
+ if (!model || model.available === false || model.status && model.status !== 'available') return []
+ const offers = Array.isArray(model.service_offers) ? model.service_offers.map(record).filter(Boolean) as Record[] : []
+ if (!offers.length) { const protocol = parseInvocation(model, {}, {}); return protocol ? [protocol] : [] }
+ if (!identifier(model.default_service_scope_id)) return []
+ const found: AiModelProtocol[] = []
+ for (const offer of offers) {
+ if (record(offer.service_scope)?.id !== model.default_service_scope_id || offer.available === false) continue
+ const routes = Array.isArray(offer.route_options) ? offer.route_options.map(record).filter(Boolean) as Record[] : []
+ const eligible = routes.filter(route => route.available !== false).sort((a, b) => Number(b.id === model.default_route_option_id) - Number(a.id === model.default_route_option_id))
+ const protocols = eligible.map(route => parseInvocation(model, offer, route)).filter(Boolean) as AiModelProtocol[]
+ const chosen = protocols.find(protocol => protocol.supportsImageInput) ?? protocols[0]
+ if (chosen && !found.some(protocol => JSON.stringify(protocol) === JSON.stringify(chosen))) found.push(chosen)
+ }
+ return found.sort((a, b) => Number(b.supportsImageInput) - Number(a.supportsImageInput))
+}
+
+export function validateStoredAiModelProtocols(value: unknown): AiModelProtocol[] {
+ if (!Array.isArray(value) || value.length > 8) return []
+ const result: AiModelProtocol[] = []
+ for (const entry of value) {
+ const raw = record(entry), headers = safeHeaders(raw?.headers)
+ if (!raw || !Object.prototype.hasOwnProperty.call(paths, raw.id) || raw.path !== paths[raw.id as AiModelProtocol['id']] || !headers || typeof raw.supportsImageInput !== 'boolean') return []
+ if (['scopeId', 'routeId', 'operationId'].some(field => raw[field] !== undefined && !identifier(raw[field]))) return []
+ if (raw.routeId && headers['X-Relay-Route-Option'] !== raw.routeId) return []
+ if (raw.inputField !== undefined && !['image', 'image[]', 'images', 'file_urls'].includes(raw.inputField)) return []
+ if (['maxImages', 'maxPromptLength'].some(field => raw[field] !== undefined && (!Number.isInteger(raw[field]) || raw[field] < 1 || raw[field] > (field === 'maxImages' ? 8 : 1000000)))) return []
+ if (raw.id === 'async-image-task' && (!raw.supportsImageInput || raw.inputField !== 'file_urls') || raw.id === 'openai-image-generate' && raw.supportsImageInput && !raw.inputField || raw.id === 'openai-image-edit' && (!raw.supportsImageInput || !['image', 'image[]'].includes(raw.inputField))) return []
+ result.push({ id: raw.id, path: raw.path, headers, supportsImageInput: raw.supportsImageInput,
+ ...Object.fromEntries(['inputField', 'scopeId', 'routeId', 'operationId', 'maxImages', 'maxPromptLength'].filter(key => raw[key] !== undefined).map(key => [key, raw[key]])) })
+ }
+ return result
+}
+
+export class AiModelProtocolError extends Error {
+ readonly taskPending: boolean
+ constructor(message: string, readonly outcomeUnknown: boolean, readonly requestId: string | null = null, readonly taskId: string | null = null, taskPending = outcomeUnknown && Boolean(taskId)) { super(message); this.name = 'AiModelProtocolError'; this.taskPending = taskPending }
+}
+export interface AiModelProtocolInput {
+ baseUrl: string; apiKey: string; modelId: string; protocol: AiModelProtocol; prompt: string
+ images?: Array<{ buffer: Buffer; mimeType: string }>
+ idempotencyKey: string; beforeRequest?: () => Promise
+ onTaskSubmitted?: (taskId: string) => Promise; resumeTaskId?: string
+ maxOutputTokens?: number
+}
+export interface AiModelProtocolResult { text?: string; image?: Buffer; requestId: string | null; summary: Record }
+
+function safeRequestId(response: Response, data: any, apiKey: string): string | null {
+ const id = response.headers.get('x-request-id') || data?.request_id || data?.error?.request_id
+ return typeof id === 'string' && /^[\w-]{1,120}$/.test(id) && !id.includes(apiKey) ? id : null
+}
+async function readJson(response: Response, apiKey: string, taskId: string | null = null): Promise<{ data: any; requestId: string | null }> {
+ const text = await response.text()
+ let data: any
+ try { data = JSON.parse(text) } catch { throw new AiModelProtocolError('模型响应无法确认,已暂停;请勿重复提交', true, null, taskId) }
+ const requestId = safeRequestId(response, data, apiKey)
+ const code = data?.error?.code ?? data?.code
+ if (!response.ok || data?.status === 'pending_review') {
+ const terminalTaskFailure = response.status === 409 && ['task_failed', 'task_cancelled', 'task_expired', 'task_timeout', 'content_policy_violation'].includes(code)
+ const review = ['billing_reconciliation_required', 'submission_pending_reconciliation', 'image_size_mismatch', 'task_state_unknown'].includes(code) || data?.status === 'pending_review'
+ // Losing access to an accepted task does not prove that generation failed.
+ const unknown = Boolean(taskId) ? !terminalTaskFailure : response.status >= 500 || review || code === 'request_outcome_unknown'
+ const pending = Boolean(taskId) && unknown && !review && (response.status >= 500 || response.status === 429)
+ throw new AiModelProtocolError(unknown ? '模型请求结果待核查,已暂停;请勿重复提交' : `模型服务拒绝请求(${response.status})`, unknown, requestId, taskId, pending)
+ }
+ if (text.includes(apiKey)) throw new AiModelProtocolError('模型响应含敏感内容,已暂停', true, requestId, taskId)
+ return { data, requestId }
+}
+async function normalizeImages(images: AiModelProtocolInput['images'], forAnalysis: boolean): Promise {
+ return Promise.all((images ?? []).map(image => {
+ const pipeline = sharp(image.buffer, { limitInputPixels: 40_000_000 }).rotate().resize(forAnalysis ? 1024 : 1536, forAnalysis ? 1024 : 1536, { fit: 'inside', withoutEnlargement: true })
+ return forAnalysis ? pipeline.flatten({ background: '#ffffff' }).jpeg({ quality: 85 }).toBuffer() : pipeline.png().toBuffer()
+ }))
+}
+async function decodeImage(data: any, input: AiModelProtocolInput, taskId: string | null): Promise {
+ let bytes: Buffer
+ if (typeof data?.b64_json === 'string' && /^[A-Za-z0-9+/]+={0,2}$/.test(data.b64_json) && data.b64_json.length <= 28 * 1024 * 1024) bytes = Buffer.from(data.b64_json, 'base64')
+ else if (typeof data?.url === 'string') {
+ const response = await requestAiModelOutput(input.baseUrl, input.apiKey, data.url, input.beforeRequest)
+ if (!response.ok) throw new AiModelProtocolError('模型图片暂时无法读取,已保留任务', true, null, taskId)
+ bytes = Buffer.from(await response.arrayBuffer())
+ } else throw new AiModelProtocolError('模型没有返回可保存的图片', false, null, taskId)
+ try {
+ const output = await sharp(bytes, { limitInputPixels: 40_000_000 }).rotate().resize(4096, 4096, { fit: 'inside', withoutEnlargement: true }).png().toBuffer()
+ if (output.length > 20 * 1024 * 1024) throw new Error('size')
+ return output
+ } catch { throw new AiModelProtocolError('模型图片无法解码或超出限制', false, null, taskId) }
+}
+
+export async function callAiModelProtocol(input: AiModelProtocolInput): Promise {
+ const protocol = validateStoredAiModelProtocols([input.protocol])[0]
+ if (!protocol) throw new AiModelProtocolError('模型协议无效,请重新同步', false)
+ const imageCount = input.images?.length ?? 0
+ if (imageCount > (protocol.maxImages ?? 8) || imageCount && !protocol.supportsImageInput) throw new AiModelProtocolError('该模型接口不支持当前参考图片', false)
+ if (protocol.maxPromptLength && input.prompt.length > protocol.maxPromptLength) throw new AiModelProtocolError(`该模型提示词最多 ${protocol.maxPromptLength} 字,请精简内容`, false)
+ if (protocol.id === 'async-image-task' && !input.onTaskSubmitted) throw new AiModelProtocolError('异步任务缺少持久化检查点', false)
+ if (input.resumeTaskId && (!/^[A-Za-z0-9_-]{1,160}$/.test(input.resumeTaskId) || protocol.id !== 'async-image-task')) throw new AiModelProtocolError('模型任务编号无效', false)
+ let taskId = input.resumeTaskId ?? null, requestId: string | null = null, dispatched = Boolean(input.resumeTaskId)
+ const summary = { adapterVersion: AI_MODEL_ADAPTER_VERSION, protocol: protocol.id, routeId: protocol.routeId ?? null }
+ try {
+ if (!taskId) {
+ const forAnalysis = protocol.id === 'openai-chat' || protocol.id === 'anthropic-messages'
+ const images = await normalizeImages(input.images, forAnalysis)
+ const imageMimeType = forAnalysis ? 'image/jpeg' : 'image/png'
+ if (images.some(image => image.length > 10 * 1024 * 1024) || images.reduce((total, image) => total + image.length, 0) > 24 * 1024 * 1024) throw new AiModelProtocolError('参考图片总量超出模型限制,请减少图片', false)
+ const dataUris = images.map(image => `data:${imageMimeType};base64,${image.toString('base64')}`)
+ let body: string | FormData
+ const maxTokens = input.maxOutputTokens && Number.isInteger(input.maxOutputTokens) ? Math.max(32, Math.min(8000, input.maxOutputTokens)) : 8000
+ if (protocol.id === 'openai-chat') body = JSON.stringify({ model: input.modelId, messages: [{ role: 'user', content: images.length ? [{ type: 'text', text: input.prompt }, ...dataUris.map(url => ({ type: 'image_url', image_url: { url } }))] : input.prompt }], max_tokens: maxTokens, stream: false })
+ else if (protocol.id === 'anthropic-messages') body = JSON.stringify({ model: input.modelId, messages: [{ role: 'user', content: [{ type: 'text', text: input.prompt }, ...images.map(image => ({ type: 'image', source: { type: 'base64', media_type: imageMimeType, data: image.toString('base64') } }))] }], max_tokens: maxTokens, stream: false })
+ else if (protocol.id === 'openai-image-edit') {
+ body = new FormData(); body.set('model', input.modelId); body.set('prompt', input.prompt); body.set('n', '1'); body.set('response_format', 'b64_json')
+ images.forEach((image, index) => (body as FormData).append(protocol.inputField!, new Blob([new Uint8Array(image)], { type: 'image/png' }), `reference-${index + 1}.png`))
+ } else body = JSON.stringify({ model: input.modelId, prompt: input.prompt, ...(images.length ? { [protocol.inputField!]: dataUris } : {}), ...(protocol.id === 'openai-image-generate' ? { n: 1, response_format: 'b64_json' } : {}) })
+ dispatched = true
+ const response = await requestAiModelOperation(input.baseUrl, input.apiKey, protocol.path, body, input.idempotencyKey, protocol.headers, input.beforeRequest)
+ const parsed = await readJson(response, input.apiKey); requestId = parsed.requestId
+ if (protocol.id === 'async-image-task') {
+ if (typeof parsed.data?.id !== 'string' || !/^[A-Za-z0-9_-]{1,160}$/.test(parsed.data.id)) throw new AiModelProtocolError('模型未返回可核查的任务编号,请勿重发', true, requestId)
+ taskId = parsed.data.id
+ try { await input.onTaskSubmitted!(taskId!) } catch { throw new AiModelProtocolError('模型任务已受理,但检查点未保存;已暂停后续请求', true, requestId, taskId) }
+ } else if (protocol.id === 'openai-chat' || protocol.id === 'anthropic-messages') {
+ const content = protocol.id === 'openai-chat' ? parsed.data?.choices?.[0]?.message?.content : parsed.data?.content
+ const text = typeof content === 'string' ? content : Array.isArray(content) ? content.filter(item => item?.type === 'text' && typeof item.text === 'string').map(item => item.text).join('\n') : ''
+ if (!text.trim()) throw new AiModelProtocolError('模型未返回可用文字', false, requestId)
+ return { text, requestId, summary }
+ } else return { image: await decodeImage(parsed.data?.data?.[0], input, null), requestId, summary }
+ }
+ const deadline = Date.now() + 40_000
+ for (let attempt = 0; attempt < 20 && Date.now() < deadline; attempt++) {
+ const response = await requestAiModelOperation(input.baseUrl, input.apiKey, `/extensions/v1/tasks/${taskId}/result`, undefined, undefined, {}, input.beforeRequest)
+ const parsed = await readJson(response, input.apiKey, taskId); requestId = parsed.requestId ?? requestId
+ if (parsed.data?.ready === true) {
+ if (!['succeeded', 'partially_succeeded'].includes(parsed.data.status)) throw new AiModelProtocolError('模型任务未成功完成', false, requestId, taskId)
+ const outputs = parsed.data?.result?.outputs
+ const image = Array.isArray(outputs) ? outputs.find(item => item?.type === 'image' && (typeof item.url === 'string' || typeof item.b64_json === 'string')) : null
+ if (!image) throw new AiModelProtocolError('模型任务没有图片产物', false, requestId, taskId)
+ return { image: await decodeImage(image, input, taskId), requestId, summary: { ...summary, taskId } }
+ }
+ if (response.status !== 202 || parsed.data?.ready !== false) throw new AiModelProtocolError('模型任务状态无法确认,已保留任务', true, requestId, taskId)
+ await new Promise(resolve => setTimeout(resolve, 2000))
+ }
+ throw new AiModelProtocolError('模型仍在处理中,已保留任务,可继续查询', true, requestId, taskId)
+ } catch (error) {
+ if (error instanceof AiModelProtocolError) throw error
+ // Authentication/revision guards fail before the transport opens a socket.
+ if (error && typeof error === 'object' && 'status' in error && [400, 401, 403, 409].includes(Number(error.status))) throw error
+ throw new AiModelProtocolError(dispatched ? '模型请求结果无法确认,已暂停;请勿重复提交' : '参考图片无法读取,请更换图片', dispatched, requestId, taskId)
+ }
+}
diff --git a/lib/ai-model-repository.ts b/lib/ai-model-repository.ts
index c2cf8e6..89ac2db 100644
--- a/lib/ai-model-repository.ts
+++ b/lib/ai-model-repository.ts
@@ -10,7 +10,7 @@ export interface AiModelProviderRow {
secretCiphertext: string | null; secretNonce: string | null; secretAuthTag: string | null;
legacyBinding: boolean; revision: number; syncedAt: Date | null
}
-export interface AiModelRow extends Omit { providerId: string; capabilityMetadata?: unknown }
+export interface AiModelRow extends Omit { providerId: string; capabilityMetadata?: unknown }
export interface AiModelConfigurationRow { revision: number; defaults: AiModelDefaults }
const providerColumns = `id, name, base_url AS "baseUrl", enabled, credential_source AS "credentialSource", secret_ciphertext AS "secretCiphertext", secret_nonce AS "secretNonce", secret_auth_tag AS "secretAuthTag", legacy_binding AS "legacyBinding", revision, synced_at AS "syncedAt"`
@@ -23,7 +23,7 @@ export async function getAiModelProviderRow(id: string, db: AiModelDatabase = pr
return rows[0]
}
export async function listAiModelRows(db: AiModelDatabase = prisma): Promise {
- return db.$queryRawUnsafe(`SELECT id AS key, provider_id AS "providerId", model_id AS id, enabled, stages, present, compatibility, capability_metadata AS "capabilityMetadata" FROM ai_model_catalog ORDER BY model_id, id`)
+ return db.$queryRawUnsafe(`SELECT id AS key, provider_id AS "providerId", model_id AS id, enabled, stages, present, capability_metadata AS "capabilityMetadata" FROM ai_model_catalog ORDER BY model_id, id`)
}
export async function getAiModelConfiguration(db: AiModelDatabase = prisma): Promise {
const rows = await db.$queryRawUnsafe>(`SELECT revision, default_analysis AS analysis, default_copy AS copy, default_image AS image FROM ai_model_configuration WHERE id = 'detail-generator'`)
diff --git a/lib/ai-model-service.ts b/lib/ai-model-service.ts
index d170652..f87fdc1 100644
--- a/lib/ai-model-service.ts
+++ b/lib/ai-model-service.ts
@@ -1,12 +1,13 @@
-import { randomUUID } from 'node:crypto'
+import { createHash, randomUUID } from 'node:crypto'
import { z } from 'zod'
import { AiModelError, assertAiModelAdmin } from './ai-model-access'
import { decryptAiModelKey, encryptAiModelKey, isAiModelEncryptionConfigured } from './ai-model-crypto'
import { normalizeAiModelBaseUrl, requestAiModel, requestAiModelCapabilityCatalog } from './ai-model-http'
-import { aiModelCapabilityFingerprint, getAiModelCapabilities, mergeAiModelCapabilityMetadata, parseAiModelCapabilityMetadata, parseAiModelPublicCapabilityCatalog, type AiModelCapabilityMetadata } from './ai-model-capabilities'
+import { aiModelCapabilityFingerprint, getAiModelCapabilities, getAiModelStageProtocol, mergeAiModelCapabilityMetadata, parseAiModelCapabilityMetadata, parseAiModelPublicCapabilityCatalog, type AiModelCapabilityMetadata } from './ai-model-capabilities'
import { aiModelAudit, aiModelTransaction, assertAiModelRevision, encryptedAiModelFields, getAiModelConfiguration, getAiModelProviderRow, insertAiModelProvider, listAiModelProviderRows, listAiModelRows, saveAiModelDefaults, updateAiModelProvider, type AiModelDatabase, type AiModelProviderRow, type AiModelRow } from './ai-model-repository'
import { AI_MODEL_STAGES, type AiModelAdminState, type AiModelBinding, type AiModelBindings, type AiModelConfigurationInput, type AiModelDefaults, type AiModelRuntimeState, type AiModelStage } from './ai-model-types'
import { DETAIL_MODEL_DEFAULTS } from './detail-generator/types'
+import { aiModelVerificationContext, aiModelVerificationSummary, listAiModelVerifications, type AiModelVerificationRow } from './ai-model-verification-state'
const id = z.string().uuid()
const revision = z.number().int().min(0)
@@ -14,7 +15,7 @@ const key = z.string().trim().min(1).max(4096).regex(/^[\x21-\x7e]+$/)
const createSchema = z.object({ name: z.string().trim().min(1).max(100), baseUrl: z.string().trim().min(1).max(500), apiKey: key, enabled: z.boolean().optional() }).strict()
const updateSchema = z.object({ revision: revision.min(1), name: z.string().trim().min(1).max(100).optional(), baseUrl: z.string().trim().min(1).max(500).optional(), apiKey: key.optional(), enabled: z.boolean().optional() }).strict()
const defaultsSchema = z.object({ analysis: id.nullable(), copy: id.nullable(), image: id.nullable() }).strict()
-const configurationSchema = z.object({ revision, defaults: defaultsSchema, providers: z.array(z.object({ id, revision: revision.min(1), models: z.array(z.object({ key: id, enabled: z.boolean(), stages: z.array(z.enum(AI_MODEL_STAGES)).max(3), compatibility: z.enum(['confirmed', 'unconfirmed']) }).strict()).max(5000) }).strict()).max(100) }).strict()
+const configurationSchema = z.object({ revision, defaults: defaultsSchema, providers: z.array(z.object({ id, revision: revision.min(1), models: z.array(z.object({ key: id, enabled: z.boolean(), stages: z.array(z.enum(AI_MODEL_STAGES)).max(3) }).strict()).max(5000) }).strict()).max(100) }).strict()
function parse(schema: z.ZodType, value: unknown): T { const parsed = schema.safeParse(value); if (!parsed.success) throw new AiModelError('提交的模型配置格式无效'); return parsed.data }
export function assertAiModelId(value: string): string { if (!id.safeParse(value).success) throw new AiModelError('模型服务编号无效', 404); return value }
function hasCredential(provider: AiModelProviderRow): boolean {
@@ -28,14 +29,22 @@ function providerKey(provider: AiModelProviderRow): string {
else if (provider.secretCiphertext && provider.secretNonce && provider.secretAuthTag) return decryptAiModelKey({ secretCiphertext: provider.secretCiphertext, secretNonce: provider.secretNonce, secretAuthTag: provider.secretAuthTag })
throw new AiModelError('模型服务尚未配置可用密钥', 503, 'credential_unavailable')
}
-function modelAvailable(model: AiModelRow, provider: AiModelProviderRow, stage: AiModelStage): boolean { return provider.enabled && hasCredential(provider) && model.present && model.enabled && model.compatibility === 'confirmed' && model.stages.includes(stage) && getAiModelCapabilities(provider.baseUrl, model.id, model.capabilityMetadata).allowedStages.includes(stage) }
+function modelContext(model: AiModelRow, provider: AiModelProviderRow, stage: AiModelStage): string | null {
+ const protocol = getAiModelStageProtocol(model.capabilityMetadata, stage)
+ return protocol && hasCredential(provider) ? aiModelVerificationContext(provider.baseUrl, model.id, model.capabilityMetadata, providerKey(provider), protocol) : null
+}
+function modelVerified(model: AiModelRow, provider: AiModelProviderRow, stage: AiModelStage, verifications: AiModelVerificationRow[]): boolean {
+ return aiModelVerificationSummary(verifications, model.key, stage, modelContext(model, provider, stage)).status === 'passed'
+}
+function modelAvailable(model: AiModelRow, provider: AiModelProviderRow, stage: AiModelStage, verifications: AiModelVerificationRow[]): boolean { return provider.enabled && hasCredential(provider) && model.present && model.enabled && model.stages.includes(stage) && getAiModelCapabilities(provider.baseUrl, model.id, model.capabilityMetadata).allowedStages.includes(stage) && modelVerified(model, provider, stage, verifications) }
export async function getAiModelAdminState(): Promise {
- const [providers, models, configuration] = await Promise.all([listAiModelProviderRows(), listAiModelRows(), getAiModelConfiguration()])
+ const [providers, models, configuration, verifications] = await Promise.all([listAiModelProviderRows(), listAiModelRows(), getAiModelConfiguration(), listAiModelVerifications()])
return { providers: providers.map(provider => ({ id: provider.id, name: provider.name, baseUrl: provider.baseUrl, enabled: provider.enabled, credentialSource: provider.credentialSource, hasApiKey: hasCredential(provider), revision: provider.revision, syncedAt: provider.syncedAt?.toISOString() || null, models: models.filter(model => model.providerId === provider.id).map(({ providerId: _providerId, capabilityMetadata, ...model }) => {
- const capabilities = getAiModelCapabilities(provider.baseUrl, model.id, capabilityMetadata)
- const stages = model.stages.filter(stage => capabilities.allowedStages.includes(stage))
- return { ...model, stages, enabled: model.enabled && stages.length > 0, capabilities }
+ const storedCapabilities = getAiModelCapabilities(provider.baseUrl, model.id, capabilityMetadata)
+ const capabilities = model.present ? storedCapabilities : { ...storedCapabilities, allowedStages: [], protocols: {}, reason: '已从当前连接的模型目录下架,暂不可用' }
+ const verification = Object.fromEntries(AI_MODEL_STAGES.map(stage => [stage, aiModelVerificationSummary(verifications, model.key, stage, modelContext({ ...model, providerId: provider.id, capabilityMetadata }, provider, stage))])) as import('./ai-model-types').AiModelCatalogEntry['verification']
+ return { ...model, capabilities, verification }
}) })), ...configuration, encryptionConfigured: isAiModelEncryptionConfigured(), legacyAvailable: Boolean(process.env.DETAIL_GENERATOR_API_KEY?.trim()) && !providers.some(provider => provider.legacyBinding) }
}
@@ -62,7 +71,7 @@ export async function editAiModelProvider(userId: string, providerId: string, bo
if (baseUrl !== current.baseUrl && !encrypted) throw new AiModelError('更改服务地址须同时填写该服务的密钥,避免把旧密钥发送到新地址')
const next = { ...current, ...(input.name !== undefined ? { name: input.name } : {}), ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), baseUrl, ...(encrypted ? { ...encrypted, credentialSource: 'encrypted' as const } : {}) }
await updateAiModelProvider(db, next)
- if (baseUrl !== current.baseUrl) await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET present=FALSE,enabled=FALSE,stages=ARRAY[]::text[],compatibility='unconfirmed',capability_metadata=NULL WHERE provider_id=$1`, providerId)
+ if (baseUrl !== current.baseUrl) await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET present=FALSE,enabled=FALSE,stages=ARRAY[]::text[],capability_metadata=NULL WHERE provider_id=$1`, providerId)
await aiModelAudit(db, userId, 'provider.update', providerId, { name: next.name, baseUrl, enabled: next.enabled, credentialChanged: Boolean(encrypted), addressChanged: baseUrl !== current.baseUrl })
})
return getAiModelAdminState()
@@ -95,7 +104,7 @@ export async function importAiModelEnvironment(userId: string): Promise stages[stage].includes(modelId) && allowed.includes(stage))
- await db.$executeRawUnsafe(`INSERT INTO ai_model_catalog (id,provider_id,model_id,enabled,stages,present,compatibility) VALUES ($1,$2,$3,$4,$5::text[],TRUE,$6)`, modelKey, provider.id, modelId, selected.length > 0, selected, selected.length ? 'confirmed' : 'unconfirmed')
+ await db.$executeRawUnsafe(`INSERT INTO ai_model_catalog (id,provider_id,model_id,enabled,stages,present) VALUES ($1,$2,$3,FALSE,$4::text[],TRUE)`, modelKey, provider.id, modelId, selected)
}
const current = await getAiModelConfiguration(db)
const defaultFor = (stage: AiModelStage) => current.defaults[stage] || (getAiModelCapabilities(provider.baseUrl, DETAIL_MODEL_DEFAULTS[stage]).allowedStages.includes(stage) ? imported.get(DETAIL_MODEL_DEFAULTS[stage]) || null : null)
@@ -155,11 +164,11 @@ export async function syncAiModelProvider(userId: string, providerId: string, bo
}
async function validateDefaults(db: AiModelDatabase, defaults: AiModelDefaults): Promise {
- const [providers, models] = await Promise.all([listAiModelProviderRows(db), listAiModelRows(db)])
+ const [providers, models, verifications] = await Promise.all([listAiModelProviderRows(db), listAiModelRows(db), listAiModelVerifications(db)])
for (const stage of AI_MODEL_STAGES) {
if (!defaults[stage]) continue
const model = models.find(value => value.key === defaults[stage]); const provider = model && providers.find(value => value.id === model.providerId)
- if (!model || !provider || !modelAvailable(model, provider, stage)) throw new AiModelError('默认模型须来自已启用服务,且已确认对应阶段接口兼容', 400, 'default_unavailable')
+ if (!model || !provider || !modelAvailable(model, provider, stage, verifications)) throw new AiModelError('默认模型须已启用,并通过当前连接对应用途的实际测试', 400, 'default_unavailable')
}
}
@@ -170,7 +179,8 @@ export async function configureAiModels(userId: string, body: unknown): Promise<
await assertAiModelAdmin(userId)
assertAiModelRevision((await getAiModelConfiguration(db)).revision, input.revision)
const prior = await listAiModelRows(db)
- const changes: Array<{ key: string; providerId: string; enabled: boolean; stages: AiModelStage[]; compatibility: string }> = []
+ const verifications = await listAiModelVerifications(db)
+ const changes: Array<{ key: string; providerId: string; enabled: boolean; stages: AiModelStage[] }> = []
for (const provider of input.providers) {
const current = await getAiModelProviderRow(provider.id, db); assertAiModelRevision(current.revision, provider.revision)
if (new Set(provider.models.map(model => model.key)).size !== provider.models.length) throw new AiModelError('模型编号重复')
@@ -179,10 +189,10 @@ export async function configureAiModels(userId: string, body: unknown): Promise<
const existing = prior.find(value => value.key === model.key && value.providerId === provider.id)
if (!existing) throw new AiModelError('模型不属于当前服务')
const capabilities = getAiModelCapabilities(current.baseUrl, existing.id, existing.capabilityMetadata)
- if (model.stages.some(stage => !capabilities.allowedStages.includes(stage))) throw new AiModelError('所选用途超出模型已确认的能力范围,管理员确认不能扩展模型能力', 400, 'model_capability_unsupported')
- if (new Set(model.stages).size !== model.stages.length || model.enabled && (!model.stages.length || model.compatibility !== 'confirmed' || !existing.present)) throw new AiModelError('启用模型前须选择用途、确认接口兼容,并确认仍在上游目录中')
- if (existing.enabled !== model.enabled || existing.compatibility !== model.compatibility || JSON.stringify([...existing.stages].sort()) !== JSON.stringify([...model.stages].sort())) { changed = true; changes.push({ ...model, providerId: provider.id }) }
- await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET enabled=$2,stages=$3::text[],compatibility=$4,updated_at=NOW() WHERE id=$1`, model.key, model.enabled, model.stages, model.compatibility)
+ if (model.stages.some(stage => !capabilities.allowedStages.includes(stage))) throw new AiModelError('所选用途不适用于该模型的当前接口与能力', 400, 'model_capability_unsupported')
+ if (new Set(model.stages).size !== model.stages.length || model.enabled && (!model.stages.length || !existing.present || model.stages.some(stage => !modelVerified(existing, current, stage, verifications)))) throw new AiModelError('启用前须通过所选用途的实际测试,并确认模型仍在上游目录中', 400, 'model_verification_required')
+ if (existing.enabled !== model.enabled || JSON.stringify([...existing.stages].sort()) !== JSON.stringify([...model.stages].sort())) { changed = true; changes.push({ ...model, providerId: provider.id }) }
+ await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET enabled=$2,stages=$3::text[],updated_at=NOW() WHERE id=$1`, model.key, model.enabled, model.stages)
}
if (changed) await db.$executeRawUnsafe(`UPDATE ai_model_providers SET revision=revision+1,updated_at=NOW() WHERE id=$1`, provider.id)
}
@@ -194,12 +204,12 @@ export async function configureAiModels(userId: string, body: unknown): Promise<
}
export async function getAiModelRuntimeState(): Promise {
- const [providers, models, config] = await Promise.all([listAiModelProviderRows(), listAiModelRows(), getAiModelConfiguration()])
+ const [providers, models, config, verifications] = await Promise.all([listAiModelProviderRows(), listAiModelRows(), getAiModelConfiguration(), listAiModelVerifications()])
const result: AiModelRuntimeState = { configured: true, defaults: { analysis: '', copy: '', image: '' }, models: { analysis: [], copy: [], image: [] }, modelOptions: { analysis: [], copy: [], image: [] }, legacyModels: { analysis: [], copy: [], image: [] } }
for (const stage of AI_MODEL_STAGES) {
for (const model of models) {
const provider = providers.find(value => value.id === model.providerId)
- if (!provider || !modelAvailable(model, provider, stage)) continue
+ if (!provider || !modelAvailable(model, provider, stage, verifications)) continue
result.models[stage].push(model.key)
result.modelOptions[stage].push({ value: model.key, label: model.id, providerName: provider.name })
if (provider.legacyBinding) result.legacyModels[stage].push(model.id)
@@ -210,14 +220,25 @@ export async function getAiModelRuntimeState(): Promise {
return result
}
-export async function resolveAiModel(stage: AiModelStage, selection: string, expected?: AiModelBinding): Promise<{ binding: AiModelBinding; apiKey: string }> {
- const [providers, models] = await Promise.all([listAiModelProviderRows(), listAiModelRows()])
+export async function resolveAiModel(stage: AiModelStage, selection: string, expected?: AiModelBinding) {
+ const [providers, models, verifications] = await Promise.all([listAiModelProviderRows(), listAiModelRows(), listAiModelVerifications()])
const model = models.find(value => value.key === selection) || models.find(value => value.id === selection && providers.some(provider => provider.id === value.providerId && provider.legacyBinding))
const provider = model && providers.find(value => value.id === model.providerId)
- if (!model || !provider || !modelAvailable(model, provider, stage)) throw new AiModelError('所选模型当前不可用,请检查超级管理员的模型配置', 400, 'model_unavailable')
- const binding: AiModelBinding = { providerId: provider.id, modelKey: model.key, modelId: model.id, providerName: provider.name, baseUrl: provider.baseUrl, revision: provider.revision }
- if (expected && (expected.providerId !== binding.providerId || expected.modelKey !== binding.modelKey || expected.modelId !== binding.modelId || expected.baseUrl !== binding.baseUrl || expected.revision !== binding.revision)) throw new AiModelError('模型连接配置已改变,请刷新模型选择后重新分析;当前任务已停止', 409, 'model_binding_changed')
- return { binding, apiKey: providerKey(provider) }
+ if (!model || !provider || !modelAvailable(model, provider, stage, verifications)) throw new AiModelError('所选模型当前不可用,请检查超级管理员的模型配置及实际测试结果', 400, 'model_unavailable')
+ const protocolFingerprint = createHash('sha256').update(aiModelCapabilityFingerprint(model.capabilityMetadata)).update(JSON.stringify(getAiModelStageProtocol(model.capabilityMetadata, stage))).digest('hex')
+ // Pin the server-owned validation generation too: environment key rotation does not change the provider revision.
+ const verificationId = aiModelVerificationSummary(verifications, model.key, stage, modelContext(model, provider, stage)).id!
+ const binding: AiModelBinding = { providerId: provider.id, modelKey: model.key, modelId: model.id, providerName: provider.name, baseUrl: provider.baseUrl, revision: provider.revision, protocolFingerprint, verificationId }
+ if (expected && (expected.providerId !== binding.providerId || expected.modelKey !== binding.modelKey || expected.modelId !== binding.modelId || expected.baseUrl !== binding.baseUrl || expected.revision !== binding.revision || expected.protocolFingerprint !== binding.protocolFingerprint || expected.verificationId !== binding.verificationId)) throw new AiModelError('模型连接、路由或测试记录已改变,请刷新模型选择后重新分析;当前任务已停止', 409, 'model_binding_changed')
+ return { binding, apiKey: providerKey(provider), protocol: getAiModelStageProtocol(model.capabilityMetadata, stage)! }
+}
+export async function resolveAiModelVerification(providerId: string, modelKey: string, stage: AiModelStage, expectedRevision?: number) {
+ const provider = await getAiModelProviderRow(assertAiModelId(providerId))
+ if (expectedRevision !== undefined) assertAiModelRevision(provider.revision, expectedRevision)
+ const model = (await listAiModelRows()).find(row => row.key === assertAiModelId(modelKey) && row.providerId === providerId)
+ const protocol = model && getAiModelStageProtocol(model.capabilityMetadata, stage)
+ if (!model || !protocol || !provider.enabled || !model.present || !hasCredential(provider) || !getAiModelCapabilities(provider.baseUrl, model.id, model.capabilityMetadata).allowedStages.includes(stage)) throw new AiModelError('该模型的当前连接或用途暂不支持测试', 400, 'model_capability_unsupported')
+ return { provider, model, protocol, apiKey: providerKey(provider), contextHash: modelContext(model, provider, stage)! }
}
export async function captureAiModelBindings(models: Record): Promise {
const values = await Promise.all(AI_MODEL_STAGES.map(async stage => [stage, (await resolveAiModel(stage, models[stage])).binding] as const))
diff --git a/lib/ai-model-types.ts b/lib/ai-model-types.ts
index 4e099e0..5554d8d 100644
--- a/lib/ai-model-types.ts
+++ b/lib/ai-model-types.ts
@@ -1,11 +1,22 @@
export const AI_MODEL_STAGES = ['analysis', 'copy', 'image'] as const
export type AiModelStage = typeof AI_MODEL_STAGES[number]
export type AiModelDefaults = Record
+export const AI_MODEL_KINDS = ['text', 'vision', 'image', 'tts', 'asr', 'music', 'video', 'embedding', 'image-tool', 'three-d', 'other', 'unknown'] as const
+export type AiModelKind = typeof AI_MODEL_KINDS[number]
+export interface AiModelVerificationSummary {
+ status: 'untested' | 'running' | 'passed' | 'failed' | 'unknown' | 'stale'
+ id: string | null
+ checkedAt: string | null
+ requestId: string | null
+ message: string | null
+ canResume?: boolean
+}
export interface AiModelCapabilities {
- kind: 'text' | 'vision' | 'image' | 'other' | 'unknown'
+ kind: AiModelKind
allowedStages: AiModelStage[]
source: 'upstream' | 'verified' | 'unknown'
reason: string
+ protocols?: Partial>
}
export interface AiModelCatalogEntry {
key: string
@@ -13,7 +24,7 @@ export interface AiModelCatalogEntry {
enabled: boolean
stages: AiModelStage[]
present: boolean
- compatibility: 'confirmed' | 'unconfirmed'
+ verification: Record
capabilities: AiModelCapabilities
}
export interface AiModelProvider {
@@ -37,7 +48,7 @@ export interface AiModelAdminState {
export interface AiModelConfigurationInput {
revision: number
defaults: AiModelDefaults
- providers: Array<{ id: string; revision: number; models: Array<{ key: string; enabled: boolean; stages: AiModelStage[]; compatibility: 'confirmed' | 'unconfirmed' }> }>
+ providers: Array<{ id: string; revision: number; models: Array<{ key: string; enabled: boolean; stages: AiModelStage[] }> }>
}
export interface AiModelChoice { value: string; label: string; providerName: string }
export interface AiModelRuntimeState {
@@ -47,5 +58,5 @@ export interface AiModelRuntimeState {
modelOptions: Record
legacyModels: Record
}
-export interface AiModelBinding { providerId: string; modelKey: string; modelId: string; providerName: string; baseUrl: string; revision: number }
+export interface AiModelBinding { providerId: string; modelKey: string; modelId: string; providerName: string; baseUrl: string; revision: number; protocolFingerprint?: string; verificationId?: string }
export type AiModelBindings = Record
diff --git a/lib/ai-model-verification-state.ts b/lib/ai-model-verification-state.ts
new file mode 100644
index 0000000..62555e6
--- /dev/null
+++ b/lib/ai-model-verification-state.ts
@@ -0,0 +1,37 @@
+import { createHash } from 'node:crypto'
+import { prisma } from './prisma'
+import type { AiModelDatabase } from './ai-model-repository'
+import type { AiModelStage, AiModelVerificationSummary } from './ai-model-types'
+import { aiModelCapabilityFingerprint } from './ai-model-capabilities'
+import { AI_MODEL_ADAPTER_VERSION, type AiModelProtocol } from './ai-model-protocol'
+
+export interface AiModelVerificationRow {
+ id: string; modelKey: string; stage: AiModelStage; contextHash: string; idempotencyKey: string; actorId: string;
+ status: 'running' | 'passed' | 'failed' | 'unknown'; requestId: string | null; taskId: string | null;
+ message: string | null; evidence: Record; startedAt: Date; deadlineAt: Date; finishedAt: Date | null;
+}
+export const AI_MODEL_VERIFICATION_COLUMNS = `id,model_key AS "modelKey",stage,context_hash AS "contextHash",idempotency_key AS "idempotencyKey",actor_id AS "actorId",status,request_id AS "requestId",task_id AS "taskId",message,evidence,started_at AS "startedAt",deadline_at AS "deadlineAt",finished_at AS "finishedAt"`
+
+export async function listAiModelVerifications(db: AiModelDatabase = prisma): Promise {
+ return db.$queryRawUnsafe(`SELECT DISTINCT ON (model_key,stage) ${AI_MODEL_VERIFICATION_COLUMNS} FROM ai_model_verifications ORDER BY model_key,stage,started_at DESC,id DESC`)
+}
+
+/** Private, never returned through DTOs. A key rotation invalidates evidence without depending on UI revisions. */
+export function aiModelVerificationContext(baseUrl: string, modelId: string, metadata: unknown, apiKey: string, protocol: AiModelProtocol): string {
+ return createHash('sha256').update(JSON.stringify({ baseUrl, modelId, capability: aiModelCapabilityFingerprint(metadata),
+ credential: createHash('sha256').update(apiKey).digest('hex'), adapter: AI_MODEL_ADAPTER_VERSION, protocol })).digest('hex')
+}
+
+export function verificationEffectiveStatus(row: AiModelVerificationRow): AiModelVerificationRow['status'] {
+ return row.status === 'running' && new Date(row.deadlineAt).getTime() <= Date.now() ? 'unknown' : row.status
+}
+
+export function aiModelVerificationSummary(rows: AiModelVerificationRow[], modelKey: string, stage: AiModelStage, contextHash: string | null): AiModelVerificationSummary {
+ const row = rows.find(value => value.modelKey === modelKey && value.stage === stage)
+ if (!row) return { status: 'untested', id: null, checkedAt: null, requestId: null, message: null }
+ const effective = verificationEffectiveStatus(row)
+ const status = effective === 'unknown' ? 'unknown' : row.contextHash !== contextHash ? 'stale' : effective
+ return { status, id: row.id, checkedAt: row.finishedAt?.toISOString() || row.startedAt.toISOString(), requestId: row.requestId,
+ canResume: Boolean(row.taskId && row.contextHash === contextHash && ['unknown', 'running'].includes(effective)),
+ message: status === 'stale' ? '连接、路由或模型能力已改变,请重新测试' : status === 'unknown' ? '请求结果尚未确认,已暂停重复测试;请核查上游请求记录' : row.message }
+}
diff --git a/lib/ai-model-verification.ts b/lib/ai-model-verification.ts
new file mode 100644
index 0000000..6e8e3d3
--- /dev/null
+++ b/lib/ai-model-verification.ts
@@ -0,0 +1,136 @@
+import { randomUUID } from 'node:crypto'
+import sharp from 'sharp'
+import { z } from 'zod'
+import { AiModelError, assertAiModelAdmin } from './ai-model-access'
+import { aiModelAudit, aiModelTransaction } from './ai-model-repository'
+import { getAiModelAdminState, resolveAiModelVerification } from './ai-model-service'
+import { AI_MODEL_ADAPTER_VERSION, callAiModelProtocol } from './ai-model-protocol'
+import { AI_MODEL_STAGES, type AiModelStage } from './ai-model-types'
+import { AI_MODEL_VERIFICATION_COLUMNS, type AiModelVerificationRow } from './ai-model-verification-state'
+import { prisma } from './prisma'
+import { detailAnalysisSchema } from './detail-generator/validation'
+import { detailTransactionLock } from './detail-generator/repository'
+import type { AiModelDatabase } from './ai-model-repository'
+
+const requestSchema = z.object({ stage: z.enum(AI_MODEL_STAGES), revision: z.number().int().positive(), idempotencyKey: z.string().uuid() }).strict()
+function safeId(value: unknown, secret: string): string | null { return typeof value === 'string' && /^[\w.-]{1,160}$/.test(value) && !value.includes(secret) ? value : null }
+async function assertExecutionSlot(db: AiModelDatabase, actorId: string, resumeId: string | null = null): Promise {
+ await detailTransactionLock(db, 'detail-generator-execution')
+ const rows = await db.$queryRawUnsafe>(`SELECT COUNT(*)::int AS count,COUNT(*) FILTER (WHERE actor_id=$1)::int AS own FROM (
+ SELECT actor_id FROM ai_model_verifications WHERE ($2::text IS NULL OR id<>$2) AND (status='running' OR status='unknown' AND task_id IS NOT NULL)
+ UNION ALL SELECT owner_id AS actor_id FROM detail_generator_jobs WHERE status='running' AND lease_expires_at>NOW()
+ OR status='queued' AND checkpoint->>'dispatchPending'='true' AND COALESCE(checkpoint #>> '{upstreamTask,id}', '') ~ '^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,199}$'
+ ) AS active_executions`, actorId, resumeId)
+ if (rows[0]?.count >= 2 || rows[0]?.own >= 1) throw new AiModelError('已有生成或模型测试正在执行,请等待完成', 409, 'verification_busy')
+}
+async function referenceImage(): Promise {
+ return sharp({ create: { width: 512, height: 512, channels: 3, background: '#ffffff' } }).composite([
+ { input: await sharp({ create: { width: 180, height: 180, channels: 3, background: '#ff0000' } }).png().toBuffer(), left: 32, top: 32 },
+ { input: await sharp({ create: { width: 180, height: 180, channels: 3, background: '#0000ff' } }).png().toBuffer(), left: 300, top: 300 },
+ ]).png().toBuffer()
+}
+function parseJson(text: string | undefined): any {
+ if (!text) throw new AiModelError('模型未返回可验证的内容', 422, 'verification_output_invalid')
+ try { return JSON.parse(text.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '')) } catch { throw new AiModelError('模型未返回要求的 JSON 内容', 422, 'verification_output_invalid') }
+}
+export async function validateAiModelVerificationOutput(stage: AiModelStage, result: { text?: string; image?: Buffer }): Promise> {
+ if (stage === 'analysis') {
+ const value = parseJson(result.text)
+ if (value.topLeft !== 'red' || value.bottomRight !== 'blue') throw new AiModelError('图片内容识别未通过,不能开放资料分析', 422, 'verification_output_invalid')
+ return { imageInputVerified: true, colorPositionsMatched: true, textCharacters: result.text!.length }
+ }
+ if (stage === 'copy') {
+ const value = parseJson(result.text)
+ if (Array.isArray(value?.screens)) value.screens = value.screens.map((screen: any) => ({ ...screen, id: randomUUID() }))
+ if (!detailAnalysisSchema.safeParse(value).success || value.screens.length !== 1) throw new AiModelError('文案大纲结构未通过,不能开放卖点文案', 422, 'verification_output_invalid')
+ return { outlineSchemaVerified: true, screenCount: 1, textCharacters: result.text!.length }
+ }
+ if (!result.image) throw new AiModelError('模型未返回图片', 422, 'verification_output_invalid')
+ const image = sharp(result.image, { limitInputPixels: 40_000_000 }), metadata = await image.metadata(), stats = await image.stats()
+ if (!metadata.width || !metadata.height || metadata.width < 64 || metadata.height < 64 || !stats.channels.some(channel => channel.stdev > 1)) throw new AiModelError('模型返回的图片为空白或无效', 422, 'verification_output_invalid')
+ return { referenceImageAccepted: true, imageDecoded: true, width: metadata.width, height: metadata.height, bytes: result.image.length }
+}
+
+async function executeVerification(row: AiModelVerificationRow, userId: string, providerId: string, resume: boolean): Promise {
+ let sent = false, apiKey = '', knownTaskId = row.taskId
+ const beforeRequest = async () => {
+ await assertAiModelAdmin(userId)
+ const current = await resolveAiModelVerification(providerId, row.modelKey, row.stage)
+ if (current.contextHash !== row.contextHash) throw new AiModelError('连接或路由已改变,测试已停止', 409, 'verification_context_changed')
+ sent = true
+ }
+ try {
+ const context = await resolveAiModelVerification(providerId, row.modelKey, row.stage)
+ apiKey = context.apiKey
+ if (context.contextHash !== row.contextHash) throw new AiModelError('验证对应的连接或路由已改变,不能继续此任务', 409, 'verification_context_changed')
+ const prompt = row.stage === 'analysis' ? '观察图片:左上方方块与右下方方块分别是什么颜色?只输出 JSON:{"topLeft":"英文小写颜色","bottomRight":"英文小写颜色"}。不要根据文字猜测,必须读取图片。'
+ : row.stage === 'copy' ? '真实产品资料:型号TEST,颜色蓝色。不要编造其他规格。写一屏中文详情页,严格只输出 JSON:{"sellingPoints":["卖点"],"warnings":[],"screens":[{"title":"标题","body":"正文","imagePrompt":"无文字的场景描述"}]}。'
+ : '依据参考图片生成一张简洁的产品展示图,保留两个方块的颜色与相对位置,白色背景,不要加入文字或标志。'
+ const result = await callAiModelProtocol({ baseUrl: context.provider.baseUrl, apiKey: context.apiKey, modelId: context.model.id, protocol: context.protocol,
+ prompt, ...(row.stage !== 'copy' ? { images: [{ buffer: await referenceImage(), mimeType: 'image/png' }] } : {}), maxOutputTokens: row.stage === 'copy' ? 1200 : 1024,
+ idempotencyKey: row.idempotencyKey, beforeRequest, ...(resume ? { resumeTaskId: row.taskId! } : {}),
+ onTaskSubmitted: async taskId => {
+ if (!safeId(taskId, context.apiKey)) throw new AiModelError('上游任务编号无效', 502, 'verification_task_invalid')
+ knownTaskId = taskId
+ const updated = await prisma.$executeRawUnsafe(`UPDATE ai_model_verifications SET task_id=$2 WHERE id=$1 AND status='running' AND (task_id IS NULL OR task_id=$2)`, row.id, taskId)
+ if (updated !== 1) throw new AiModelError('任务检查点未保存,已暂停后续请求', 409, 'verification_checkpoint_failed')
+ } })
+ const evidence = await validateAiModelVerificationOutput(row.stage, result)
+ await assertAiModelAdmin(userId)
+ await aiModelTransaction(async db => {
+ const current = await resolveAiModelVerification(providerId, row.modelKey, row.stage)
+ if (current.contextHash !== row.contextHash) throw new AiModelError('验证期间连接或路由已改变', 409, 'verification_context_changed')
+ await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status='passed',request_id=$2,message='实际接口测试通过',evidence=$3::jsonb,finished_at=NOW() WHERE id=$1 AND status='running'`, row.id, safeId(result.requestId, context.apiKey), JSON.stringify({ ...evidence, adapterVersion: AI_MODEL_ADAPTER_VERSION, protocol: context.protocol.id, scopeId: context.protocol.scopeId, routeId: context.protocol.routeId, operationId: context.protocol.operationId }))
+ await aiModelAudit(db, userId, 'model.verification-passed', providerId, { verificationId: row.id, modelKey: row.modelKey, stage: row.stage, evidence })
+ })
+ } catch (error) {
+ const outcome = error as { outcomeUnknown?: boolean; taskId?: string; requestId?: string }
+ const unknown = typeof outcome.outcomeUnknown === 'boolean' ? (sent || Boolean(knownTaskId)) && outcome.outcomeUnknown : Boolean(knownTaskId) || sent && !(error instanceof AiModelError)
+ const message = unknown ? '请求结果尚未确认,请核查上游记录;禁止重复提交' : error instanceof AiModelError ? error.message : '模型测试未通过'
+ await aiModelTransaction(async db => {
+ await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status=$2,request_id=$3,task_id=COALESCE(task_id,$4),message=$5,finished_at=NOW() WHERE id=$1 AND status='running'`, row.id, unknown ? 'unknown' : 'failed', safeId(outcome.requestId, apiKey), safeId(outcome.taskId || knownTaskId, apiKey), message)
+ await aiModelAudit(db, userId, unknown ? 'model.verification-unknown' : 'model.verification-failed', providerId, { verificationId: row.id, modelKey: row.modelKey, stage: row.stage })
+ })
+ }
+}
+
+export async function verifyAiModel(userId: string, providerId: string, modelKey: string, body: unknown) {
+ const parsed = requestSchema.safeParse(body)
+ if (!parsed.success) throw new AiModelError('测试参数无效')
+ await assertAiModelAdmin(userId)
+ const input = parsed.data, context = await resolveAiModelVerification(providerId, modelKey, input.stage, input.revision)
+ const created = await aiModelTransaction(async db => {
+ await assertAiModelAdmin(userId)
+ const prior = await db.$queryRawUnsafe(`SELECT ${AI_MODEL_VERIFICATION_COLUMNS} FROM ai_model_verifications WHERE model_key=$1 AND stage=$2 AND idempotency_key=$3`, modelKey, input.stage, input.idempotencyKey)
+ if (prior[0]) return null // A repeated HTTP request never repeats a model call.
+ await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status='unknown',message='进程或请求中断,结果需核查',finished_at=NOW() WHERE status='running' AND deadline_at<=NOW()`)
+ const active = await db.$queryRawUnsafe>(`SELECT COUNT(*)::int AS count FROM ai_model_verifications WHERE model_key=$1 AND stage=$2 AND status IN ('running','unknown')`, modelKey, input.stage)
+ if (active[0]?.count) throw new AiModelError('该用途存在进行中或结果未知的请求,不能重复测试;请先核查原请求', 409, 'verification_unresolved')
+ await assertExecutionSlot(db, userId)
+ const rows = await db.$queryRawUnsafe(`INSERT INTO ai_model_verifications (id,model_key,stage,context_hash,idempotency_key,actor_id,status) VALUES ($1,$2,$3,$4,$5,$6,'running') RETURNING ${AI_MODEL_VERIFICATION_COLUMNS}`, randomUUID(), modelKey, input.stage, context.contextHash, input.idempotencyKey, userId)
+ await aiModelAudit(db, userId, 'model.verification-start', providerId, { verificationId: rows[0].id, modelKey, stage: input.stage })
+ return rows[0]
+ })
+ if (created) await executeVerification(created, userId, providerId, false)
+ return getAiModelAdminState()
+}
+
+/** Only fetch an already-created upstream task. This endpoint can never submit a new generation. */
+export async function refreshAiModelVerification(userId: string, providerId: string, verificationId: string) {
+ if (!z.string().uuid().safeParse(verificationId).success) throw new AiModelError('测试编号无效')
+ await assertAiModelAdmin(userId)
+ const row = await aiModelTransaction(async db => {
+ const rows = await db.$queryRawUnsafe(`SELECT ${AI_MODEL_VERIFICATION_COLUMNS} FROM ai_model_verifications WHERE id=$1 FOR UPDATE`, verificationId)
+ const current = rows[0]
+ if (!current?.taskId || !['unknown','running'].includes(current.status)) throw new AiModelError('没有可以查询的上游任务', 409, 'verification_not_resumable')
+ if (current.status === 'running' && current.deadlineAt.getTime() > Date.now()) throw new AiModelError('测试仍在执行,请等待', 409, 'verification_busy')
+ const context = await resolveAiModelVerification(providerId, current.modelKey, current.stage)
+ if (context.contextHash !== current.contextHash) throw new AiModelError('原连接或路由已改变,请在上游核查原任务', 409, 'verification_context_changed')
+ await assertExecutionSlot(db, userId, current.id)
+ await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status='running',actor_id=$2,deadline_at=NOW()+INTERVAL '10 minutes',finished_at=NULL WHERE id=$1`, current.id, userId)
+ await aiModelAudit(db, userId, 'model.verification-query', providerId, { verificationId: current.id, modelKey: current.modelKey, stage: current.stage })
+ return current
+ })
+ await executeVerification(row, userId, providerId, true)
+ return getAiModelAdminState()
+}
diff --git a/lib/detail-generator/jobs.ts b/lib/detail-generator/jobs.ts
index 6ef425e..fda2d92 100644
--- a/lib/detail-generator/jobs.ts
+++ b/lib/detail-generator/jobs.ts
@@ -2,11 +2,12 @@ import { randomUUID } from 'crypto'
import sharp from 'sharp'
import { assertDetailGeneratorUser, DetailGeneratorError } from './access'
import { analyzeDetailFacts, composeDetailOutline, DetailProviderError, generateDetailImage, type DetailProviderImage } from './provider'
-import { assertDetailDocumentAssets, checkpointDetailJob, claimDetailJob, findDetailAsset, findDetailProject, finishDetailJob, renewDetailJobLease, saveDetailJobAsset, saveDetailJobDocument, type StoredDetailJob } from './repository'
+import { assertDetailDocumentAssets, checkpointDetailJob, claimDetailJob, deferDetailJob, findDetailAsset, findDetailProject, finishDetailJob, renewDetailJobLease, saveDetailJobAsset, saveDetailJobDocument, type StoredDetailJob } from './repository'
import { deleteDetailFile, readDetailFile, saveDetailFile } from './storage'
import { renderDetailExport } from './render'
import { type DetailDocument, type DetailExportFormat } from './types'
import { detailModelBindingsSchema } from './validation'
+import { canPollDetailUpstreamTask, readDetailUpstreamTask } from './upstream-task'
async function inputImages(document: DetailDocument, ownerId: string, projectId: string): Promise {
const ids = [...document.product.assetIds, ...document.product.referenceAssetIds].slice(0, 8)
@@ -55,12 +56,15 @@ export async function processDetailJob(current: StoredDetailJob): Promise
checkpoint = { ...checkpoint, stage, dispatchPending: true, dispatchKey: key }
await persistCheckpoint(progress)
try { await assertDetailGeneratorUser(current.ownerId) } catch (error) {
- checkpoint = { ...checkpoint, dispatchPending: false }
- await persistCheckpoint(progress)
+ // Revocation stops polling but cannot turn an already accepted generation into a safe retry.
+ if (!readDetailUpstreamTask(checkpoint)) {
+ checkpoint = { ...checkpoint, dispatchPending: false }
+ await persistCheckpoint(progress)
+ }
throw error
}
try { return await call(key) } catch (error) {
- if (error instanceof DetailProviderError && !error.outcomeUnknown || error instanceof DetailGeneratorError) {
+ if (error instanceof DetailProviderError && !error.outcomeUnknown || error instanceof DetailGeneratorError && !readDetailUpstreamTask(checkpoint)) {
checkpoint = { ...checkpoint, dispatchPending: false }
await persistCheckpoint(progress)
}
@@ -79,7 +83,8 @@ export async function processDetailJob(current: StoredDetailJob): Promise
document.modelBindings = parsedBindings.data
}
await assertDetailDocumentAssets(document, current.projectId, current.ownerId)
- if (checkpoint.dispatchPending === true) throw new DetailProviderError('模型请求结果未知,任务已暂停;请核查服务商记录', true)
+ const upstreamTask = readDetailUpstreamTask(checkpoint)
+ if (checkpoint.dispatchPending === true && (!upstreamTask || !['generate', 'regenerate'].includes(current.kind) || !canPollDetailUpstreamTask(upstreamTask))) throw new DetailProviderError('模型请求结果未知,任务已暂停;请核查服务商记录', true)
if (checkpoint.stage === 'complete') {
await finishDetailJob(current.id, token, 'succeeded', checkpoint)
return
@@ -105,11 +110,22 @@ export async function processDetailJob(current: StoredDetailJob): Promise
const target = current.kind === 'regenerate' ? document.screens.filter(screen => screen.id === current.payload.screenId) : document.screens
if (!target.length) throw new DetailGeneratorError('没有待生成屏幕')
const completed = new Set(Array.isArray(checkpoint.completedScreenIds) ? checkpoint.completedScreenIds as string[] : [])
+ if (checkpoint.dispatchPending === true && !target.some(screen => `screen-${screen.id}` === upstreamTask?.stage && !completed.has(screen.id) && !(current.kind === 'generate' && screen.image))) throw new DetailProviderError('待查询的图片任务与当前屏幕不一致,已暂停', true)
for (let index = 0; index < target.length; index++) {
const screen = target[index]
if (completed.has(screen.id) || (current.kind === 'generate' && screen.image)) continue
const progress = Math.round(index / target.length * 90)
- const buffer = await dispatch(`screen-${screen.id}`, progress, key => generateDetailImage(document, screen.imagePrompt, images, key, current.ownerId, document.modelBindings!.image))
+ const stage = `screen-${screen.id}`
+ const resume = readDetailUpstreamTask(checkpoint)
+ const buffer = await dispatch(stage, progress, key => generateDetailImage(document, screen.imagePrompt, images, key, current.ownerId, document.modelBindings!.image, {
+ resumeTaskId: resume?.stage === stage ? resume.id : undefined,
+ onTaskSubmitted: async id => {
+ const saved = { id, stage, submittedAt: Date.now(), polls: 0 }
+ if (!readDetailUpstreamTask({ stage, upstreamTask: saved })) throw new DetailProviderError('上游任务编号无效,已暂停核查', true)
+ checkpoint = { ...checkpoint, upstreamTask: saved }
+ await persistCheckpoint(progress)
+ },
+ }))
const metadata = await sharp(buffer).metadata()
const assetId = randomUUID()
const storageKey = await saveDetailFile(current.projectId, buffer, 'png')
@@ -117,7 +133,7 @@ export async function processDetailJob(current: StoredDetailJob): Promise
const replaced = { ...screen, image: { assetId, ...area } }
document = { ...document, screens: document.screens.map(value => value.id === screen.id ? replaced : value) }
completed.add(screen.id)
- const receivedCheckpoint = { ...checkpoint, completedScreenIds: Array.from(completed), stage: 'rendering', dispatchPending: false }
+ const receivedCheckpoint = { ...checkpoint, completedScreenIds: Array.from(completed), stage: 'rendering', dispatchPending: false, upstreamTask: undefined, pollNotBefore: undefined }
try {
await saveDetailJobAsset(current.id, token, { id: assetId, projectId: current.projectId, ownerId: current.ownerId, kind: 'generated', name: `${index + 1}-${screen.title}.png`, mimeType: 'image/png', storageKey, byteSize: buffer.length, width: metadata.width, height: metadata.height }, document, receivedCheckpoint, Math.round((index + 1) / target.length * 95))
checkpoint = receivedCheckpoint
@@ -146,6 +162,12 @@ export async function processDetailJob(current: StoredDetailJob): Promise
checkpoint = { ...checkpoint, stage: 'complete', dispatchPending: false }
await finishDetailJob(current.id, token, 'succeeded', checkpoint)
} catch (error) {
+ const upstreamTask = readDetailUpstreamTask(checkpoint)
+ if (error instanceof DetailProviderError && error.taskPending && upstreamTask && canPollDetailUpstreamTask(upstreamTask) && !leaseLost) {
+ checkpoint = { ...checkpoint, upstreamTask: { ...upstreamTask, polls: upstreamTask.polls + 1 }, pollNotBefore: Date.now() + 30_000 }
+ await deferDetailJob(current.id, token, checkpoint)
+ return
+ }
const unknown = error instanceof DetailProviderError ? error.outcomeUnknown : checkpoint.dispatchPending === true
const safeMessage = error instanceof DetailProviderError || error instanceof DetailGeneratorError ? error.message : unknown ? '模型结果尚未确认,请核查请求记录,勿重复提交' : '任务执行失败,请检查输入资料或服务端配置'
const requestId = error instanceof DetailProviderError ? error.requestId : null
diff --git a/lib/detail-generator/provider.ts b/lib/detail-generator/provider.ts
index a342cb6..22ca83c 100644
--- a/lib/detail-generator/provider.ts
+++ b/lib/detail-generator/provider.ts
@@ -3,14 +3,14 @@ import sharp from 'sharp'
import { assertDetailGeneratorUser, DetailGeneratorError } from './access'
import { DETAIL_MAX_SCREENS, type DetailAnalysis, type DetailDocument } from './types'
import { captureAiModelBindings, getAiModelRuntimeState, resolveAiModel } from '@/lib/ai-model-service'
-import { requestAiModel } from '@/lib/ai-model-http'
+import { callAiModelProtocol } from '@/lib/ai-model-protocol'
import type { AiModelBinding, AiModelStage } from '@/lib/ai-model-types'
import { assertAiModelAdmin } from '@/lib/ai-model-access'
import { detailAnalysisSchema } from './validation'
export interface DetailProviderImage { buffer: Buffer; mimeType: string }
export class DetailProviderError extends Error {
- constructor(message: string, readonly outcomeUnknown: boolean, readonly requestId: string | null = null) { super(message); this.name = 'DetailProviderError' }
+ constructor(message: string, readonly outcomeUnknown: boolean, readonly requestId: string | null = null, readonly taskId: string | null = null, readonly taskPending = false) { super(message); this.name = 'DetailProviderError' }
}
export async function getDetailModels(): Promise<{ analysis: string[]; copy: string[]; image: string[] }> {
@@ -28,30 +28,21 @@ function requestIdFrom(response: Response, data: any, apiKey?: string): string |
return typeof value === 'string' && /^[\w-]{1,120}$/.test(value) && (!apiKey || !value.includes(apiKey)) ? value : null
}
-async function callProvider(stage: AiModelStage, selection: string, endpoint: '/chat/completions' | '/images/edits', body: string | FormData, idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise {
+async function callProvider(stage: AiModelStage, selection: string, prompt: string, images: DetailProviderImage[], idempotencyKey: string, ownerId: string, expected?: AiModelBinding, execution?: { onTaskSubmitted?: (taskId: string) => Promise; resumeTaskId?: string }) {
await assertDetailGeneratorUser(ownerId)
- const { apiKey, binding } = await resolveAiModel(stage, selection, expected)
- if (typeof body === 'string') body = JSON.stringify({ ...JSON.parse(body), model: binding.modelId })
- else body.set('model', binding.modelId)
+ const { apiKey, binding, protocol } = await resolveAiModel(stage, selection, expected)
try {
- const response = await requestAiModel(binding.baseUrl, apiKey, endpoint, body, idempotencyKey, async () => {
- await resolveAiModel(stage, selection, binding)
- await assertDetailGeneratorUser(ownerId)
- await assertAiModelAdmin(ownerId)
- })
- const bytes = await readDetailResponseBytes(response, 32 * 1024 * 1024)
- let data: any
- try { data = JSON.parse(bytes.toString('utf8')) } catch { throw new DetailProviderError('模型响应格式无法确认,请核查请求结果', true, requestIdFrom(response, null, apiKey)) }
- const requestId = requestIdFrom(response, data, apiKey)
- if (!response.ok) {
- const unknown = response.status >= 500 || data?.error?.code === 'request_outcome_unknown' || data?.code === 'request_outcome_unknown'
- throw new DetailProviderError(unknown ? '模型请求结果未知,已暂停;请核查服务商请求记录,勿重复提交' : `模型服务拒绝了请求(${response.status})`, unknown, requestId)
- }
- if (bytes.includes(Buffer.from(apiKey))) throw new DetailProviderError('模型响应含敏感内容,已暂停;请核查服务商请求记录', true, requestId)
- return data
+ return await callAiModelProtocol({ baseUrl: binding.baseUrl, apiKey, modelId: binding.modelId, protocol, prompt, images,
+ idempotencyKey, maxOutputTokens: stage === 'copy' ? 8000 : 3000, ...execution,
+ beforeRequest: async () => {
+ await resolveAiModel(stage, selection, binding)
+ await assertDetailGeneratorUser(ownerId)
+ await assertAiModelAdmin(ownerId)
+ } })
} catch (error) {
if (error instanceof DetailProviderError || error instanceof DetailGeneratorError) throw error
- throw new DetailProviderError('模型请求结果未知,已暂停;请核查服务商请求记录,勿重复提交', true)
+ const upstream = error as { outcomeUnknown?: boolean; requestId?: string | null; taskId?: string | null; taskPending?: boolean }
+ throw new DetailProviderError(upstream.outcomeUnknown === false ? '模型服务拒绝或未完成当前请求' : '模型请求结果未知,已暂停;请核查服务商请求记录,勿重复提交', upstream.outcomeUnknown !== false, upstream.requestId || null, upstream.taskId || null, upstream.taskPending === true)
}
}
@@ -81,16 +72,16 @@ async function imageContent(images: DetailProviderImage[]) {
export async function analyzeDetailFacts(document: DetailDocument, images: DetailProviderImage[], extractedText: string, idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise {
const prompt = `你是产品资料分析员。只提取用户明确提供的产品事实,图片只能判断可见外观,不得推测材质、尺寸、认证、价格或性能。识别冲突和缺失信息,不要编造。返回 JSON,字段 facts(事实字符串数组)、warnings(待确认字符串数组)。\n产品:${JSON.stringify(document.product)}\n用户资料:${extractedText.slice(0, 60000)}`
- const data = await callProvider('analysis', document.models.analysis, '/chat/completions', JSON.stringify({ model: document.models.analysis, messages: [{ role: 'user', content: [{ type: 'text', text: prompt }, ...await imageContent(images)] }], temperature: 0.2, max_tokens: 3000 }), idempotencyKey, ownerId, expected)
- const content = data?.choices?.[0]?.message?.content
+ const data = await callProvider('analysis', document.models.analysis, prompt, images, idempotencyKey, ownerId, expected)
+ const content = data.text
if (typeof content !== 'string' || !content.trim()) throw new DetailProviderError('模型未返回可用的产品分析', false)
return content.slice(0, 30000)
}
export async function composeDetailOutline(document: DetailDocument, facts: string, idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise {
const prompt = `根据给出的真实产品资料提炼卖点和电商详情页大纲,不添加未经提供的参数、认证、性能或促销承诺。缺少的信息写入 warnings,不用于卖点。每屏标题和正文使用中文;imagePrompt 描述不含文字、标志的场景,保持产品结构和外观。品牌是草案,不能声称已获品牌认可。返回严格 JSON:{"sellingPoints":["..."],"warnings":["..."],"screens":[{"title":"...","body":"...","imagePrompt":"..."}]},最多${DETAIL_MAX_SCREENS}屏,建议4至6屏。\n产品资料:${JSON.stringify(document.product)}\n分析:${facts}\n品牌风格草案:${JSON.stringify(document.brand)}`
- const data = await callProvider('copy', document.models.copy, '/chat/completions', JSON.stringify({ model: document.models.copy, messages: [{ role: 'user', content: prompt }], temperature: 0.3, max_tokens: 8000 }), idempotencyKey, ownerId, expected)
- const content = data?.choices?.[0]?.message?.content
+ const data = await callProvider('copy', document.models.copy, prompt, [], idempotencyKey, ownerId, expected)
+ const content = data.text
let value: any
try {
if (typeof content !== 'string') throw new Error('empty')
@@ -102,20 +93,12 @@ export async function composeDetailOutline(document: DetailDocument, facts: stri
return parsed.data
}
-export async function generateDetailImage(document: DetailDocument, prompt: string, images: DetailProviderImage[], idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise {
+export async function generateDetailImage(document: DetailDocument, prompt: string, images: DetailProviderImage[], idempotencyKey: string, ownerId: string, expected?: AiModelBinding, execution?: { onTaskSubmitted?: (taskId: string) => Promise; resumeTaskId?: string }): Promise {
if (!images.length) throw new DetailGeneratorError('请先上传或关联产品图片')
- const body = new FormData()
- body.set('model', document.models.image)
- body.set('prompt', `生成用于同一产品详情页的一张高质量场景图片。严格保持产品参考中的具体款式、颜色、结构和比例;不得替换为相似产品。全套品牌调性一致:${document.brand.tone || '简洁、真实、清晰的产品摄影'};配色:${document.brand.primaryColor}、${document.brand.backgroundColor}。图片中不要生成文字、价格、标志或水印。第一张是实际产品;其余为该产品或风格参考,不得将参考中的其他产品混入。\n${prompt}`)
- body.set('n', '1'); body.set('size', '1024x1024'); body.set('response_format', 'b64_json')
- for (let index = 0; index < Math.min(images.length, 8); index++) {
- const image = await sharp(images[index].buffer, { limitInputPixels: 40_000_000 }).rotate().resize(1536, 1536, { fit: 'inside', withoutEnlargement: true }).png().toBuffer()
- body.append(images.length === 1 ? 'image' : 'image[]', new Blob([new Uint8Array(image)], { type: 'image/png' }), `reference-${index + 1}.png`)
- }
- const data = await callProvider('image', document.models.image, '/images/edits', body, idempotencyKey, ownerId, expected)
- const encoded = data?.data?.[0]?.b64_json
- if (typeof encoded !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length > 24 * 1024 * 1024) throw new DetailProviderError('模型未返回可保存的图片', false)
- const buffer = Buffer.from(encoded, 'base64')
+ const fullPrompt = `生成用于同一产品详情页的一张高质量场景图片。严格保持产品参考中的具体款式、颜色、结构和比例;不得替换为相似产品。全套品牌调性一致:${document.brand.tone || '简洁、真实、清晰的产品摄影'};配色:${document.brand.primaryColor}、${document.brand.backgroundColor}。图片中不要生成文字、价格、标志或水印。第一张是实际产品;其余为该产品或风格参考,不得将参考中的其他产品混入。\n${prompt}`
+ const data = await callProvider('image', document.models.image, fullPrompt, images, idempotencyKey, ownerId, expected, execution)
+ const buffer = data.image
+ if (!buffer) throw new DetailProviderError('模型未返回可保存的图片', false)
try {
const output = await sharp(buffer, { limitInputPixels: 40_000_000 }).rotate().resize(4096, 4096, { fit: 'inside', withoutEnlargement: true }).png().toBuffer()
if (output.length > 20 * 1024 * 1024) throw new Error('size')
diff --git a/lib/detail-generator/repository.ts b/lib/detail-generator/repository.ts
index 402eca3..f3a8e85 100644
--- a/lib/detail-generator/repository.ts
+++ b/lib/detail-generator/repository.ts
@@ -154,10 +154,17 @@ export async function enqueueDetailJob(input: { projectId: string; ownerId: stri
export async function claimDetailJob(): Promise {
return prisma.$transaction(async tx => {
await detailTransactionLock(tx, 'detail-generator-execution')
- await tx.$executeRawUnsafe(`UPDATE detail_generator_jobs SET status = CASE WHEN checkpoint->>'dispatchPending' = 'true' THEN 'unknown' ELSE 'queued' END, error = CASE WHEN checkpoint->>'dispatchPending' = 'true' THEN '执行中断,模型请求结果未知;请核查服务商记录,勿重复提交' ELSE NULL END, lease_token = NULL, lease_expires_at = NULL, updated_at = NOW() WHERE status = 'running' AND lease_expires_at < NOW()`)
- const count = await tx.$queryRawUnsafe('SELECT COUNT(*)::int AS count FROM detail_generator_jobs WHERE status = \'running\' AND lease_expires_at >= NOW()')
- if (Number(count[0]?.count) >= 2) return null
- const candidates = await tx.$queryRawUnsafe(`SELECT ${jobColumns} FROM detail_generator_jobs j WHERE status = 'queued' AND EXISTS (SELECT 1 FROM detail_generator_projects p WHERE p.id = j.project_id AND p.deleted_at IS NULL) AND NOT EXISTS (SELECT 1 FROM detail_generator_jobs other WHERE other.owner_id = j.owner_id AND other.status = 'running' AND other.lease_expires_at >= NOW()) ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED`)
+ const unresolved = `(checkpoint->>'dispatchPending' = 'true' AND NOT (
+ kind IN ('generate','regenerate')
+ AND COALESCE(checkpoint #>> '{upstreamTask,id}', '') ~ '^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,199}$'
+ AND COALESCE(checkpoint #>> '{upstreamTask,stage}', '') = checkpoint->>'stage'
+ AND checkpoint->>'stage' LIKE 'screen-%'))`
+ await tx.$executeRawUnsafe(`UPDATE detail_generator_jobs SET status = CASE WHEN ${unresolved} THEN 'unknown' ELSE 'queued' END, error = CASE WHEN ${unresolved} THEN '执行中断,模型请求结果未知;请核查服务商记录,勿重复提交' ELSE NULL END, lease_token = NULL, lease_expires_at = NULL, updated_at = NOW() WHERE status = 'running' AND lease_expires_at < NOW()`)
+ const pendingTask = `checkpoint->>'dispatchPending' = 'true' AND COALESCE(checkpoint #>> '{upstreamTask,id}', '') ~ '^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,199}$'`
+ const activeVerification = `((status='running' AND deadline_at>NOW()) OR (status IN ('running','unknown') AND task_id IS NOT NULL))`
+ const count = await tx.$queryRawUnsafe(`SELECT ((SELECT COUNT(*) FROM detail_generator_jobs WHERE (status='running' AND lease_expires_at>=NOW()) OR (status='queued' AND ${pendingTask})) + (SELECT COUNT(*) FROM ai_model_verifications WHERE ${activeVerification}))::int AS count`)
+ // A queued known upstream task already owns its slot. Let it poll even when both slots are occupied.
+ const candidates = await tx.$queryRawUnsafe(`SELECT ${jobColumns} FROM detail_generator_jobs j WHERE status = 'queued' AND ($1::boolean OR (${pendingTask})) AND (CASE WHEN jsonb_typeof(checkpoint->'pollNotBefore') = 'number' THEN (checkpoint->>'pollNotBefore')::numeric ELSE 0 END) <= EXTRACT(EPOCH FROM NOW()) * 1000 AND EXISTS (SELECT 1 FROM detail_generator_projects p WHERE p.id = j.project_id AND p.deleted_at IS NULL) AND NOT EXISTS (SELECT 1 FROM detail_generator_jobs other WHERE other.owner_id = j.owner_id AND other.status = 'running' AND other.lease_expires_at >= NOW()) AND NOT EXISTS (SELECT 1 FROM ai_model_verifications v WHERE v.actor_id=j.owner_id AND ${activeVerification}) ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED`, Number(count[0]?.count) < 2)
if (!candidates[0]) return null
const rows = await tx.$queryRawUnsafe(`UPDATE detail_generator_jobs SET status = 'running', lease_token = $2, lease_expires_at = NOW() + INTERVAL '10 minutes', started_at = COALESCE(started_at, NOW()), updated_at = NOW() WHERE id = $1 RETURNING ${jobColumns}`, candidates[0].id, randomUUID())
return job(rows[0])
@@ -174,6 +181,12 @@ export async function checkpointDetailJob(id: string, token: string, checkpoint:
if (count !== 1) throw new DetailGeneratorError('任务执行租约已失效', 409, 'lease_lost')
}
+/** Re-admit the same task for read-only polling; its persisted identity prevents another POST. */
+export async function deferDetailJob(id: string, token: string, checkpoint: Record): Promise {
+ const count = await prisma.$executeRawUnsafe(`UPDATE detail_generator_jobs SET status = 'queued', checkpoint = $3::jsonb, lease_token = NULL, lease_expires_at = NULL, updated_at = NOW() WHERE id = $1 AND lease_token = $2 AND status = 'running' AND lease_expires_at >= NOW()`, id, token, JSON.stringify(checkpoint))
+ if (count !== 1) throw new DetailGeneratorError('任务执行租约已失效', 409, 'lease_lost')
+}
+
export async function saveDetailJobDocument(jobId: string, token: string, document: DetailDocument, checkpoint: Record, progress: number): Promise {
return prisma.$transaction(async tx => {
const rows = await tx.$queryRawUnsafe(`SELECT ${jobColumns} FROM detail_generator_jobs WHERE id = $1 AND lease_token = $2 AND status = 'running' AND lease_expires_at >= NOW() FOR UPDATE`, jobId, token)
diff --git a/lib/detail-generator/upstream-task.ts b/lib/detail-generator/upstream-task.ts
new file mode 100644
index 0000000..e51e284
--- /dev/null
+++ b/lib/detail-generator/upstream-task.ts
@@ -0,0 +1,15 @@
+/** A persisted upstream identity permits result reads, never another create request. */
+export interface DetailUpstreamTask { id: string; stage: string; submittedAt: number; polls: number }
+
+export function readDetailUpstreamTask(checkpoint: Record): DetailUpstreamTask | null {
+ const value = checkpoint.upstreamTask as Partial | undefined
+ if (!value || typeof value.id !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,199}$/.test(value.id)
+ || typeof value.stage !== 'string' || !value.stage.startsWith('screen-') || value.stage !== checkpoint.stage
+ || typeof value.submittedAt !== 'number' || !Number.isFinite(value.submittedAt) || value.submittedAt <= 0
+ || !Number.isInteger(value.polls) || Number(value.polls) < 0) return null
+ return value as DetailUpstreamTask
+}
+
+export function canPollDetailUpstreamTask(task: DetailUpstreamTask, now = Date.now()): boolean {
+ return task.submittedAt <= now && now - task.submittedAt < 6 * 60 * 60 * 1000 && task.polls < 240
+}
diff --git a/lib/detail-generator/validation.ts b/lib/detail-generator/validation.ts
index 640a1ae..92c4e66 100644
--- a/lib/detail-generator/validation.ts
+++ b/lib/detail-generator/validation.ts
@@ -7,7 +7,7 @@ const shortText = z.string().trim().max(200)
const color = z.string().regex(/^#[0-9a-fA-F]{6}$/, '颜色须为六位十六进制')
const imageLayer = z.object({ assetId: id, x: z.number().min(0).max(750), y: z.number().min(0).max(2400), width: z.number().min(1).max(750), height: z.number().min(1).max(2400) }).strict()
const imageArea = z.object({ x: z.number().min(0).max(750), y: z.number().min(0).max(2400), width: z.number().min(1).max(750), height: z.number().min(1).max(2400) }).strict()
-const modelBinding = z.object({ providerId: id, modelKey: id, modelId: z.string().min(1).max(200), providerName: z.string().min(1).max(100), baseUrl: z.string().max(500), revision: z.number().int().min(1) }).strict()
+const modelBinding = z.object({ providerId: id, modelKey: id, modelId: z.string().min(1).max(200), providerName: z.string().min(1).max(100), baseUrl: z.string().max(500), revision: z.number().int().min(1), protocolFingerprint: z.string().regex(/^[0-9a-f]{64}$/).optional(), verificationId: id.optional() }).strict()
export const detailModelBindingsSchema = z.object({ analysis: modelBinding, copy: modelBinding, image: modelBinding }).strict()
const textLayer = z.object({
id: z.string().min(1).max(100), text: z.string().max(3000), x: z.number().min(0).max(750), y: z.number().min(0).max(2400),
diff --git a/prisma/migrations/20261013_ai_model_verifications/migration.sql b/prisma/migrations/20261013_ai_model_verifications/migration.sql
new file mode 100644
index 0000000..41587e3
--- /dev/null
+++ b/prisma/migrations/20261013_ai_model_verifications/migration.sql
@@ -0,0 +1,24 @@
+-- Server-observed verification replaces the administrator compatibility checkbox.
+-- Historical compatibility values remain inert for a reversible application rollout.
+ALTER TABLE ai_model_catalog DROP CONSTRAINT ai_model_enabled_compatible;
+ALTER TABLE ai_model_catalog ADD CONSTRAINT ai_model_enabled_stages CHECK (NOT enabled OR cardinality(stages) > 0);
+
+CREATE TABLE ai_model_verifications (
+ id TEXT PRIMARY KEY,
+ model_key TEXT NOT NULL REFERENCES ai_model_catalog(id) ON DELETE RESTRICT,
+ stage TEXT NOT NULL CHECK (stage IN ('analysis','copy','image')),
+ context_hash TEXT NOT NULL CHECK (context_hash ~ '^[0-9a-f]{64}$'),
+ idempotency_key TEXT NOT NULL,
+ actor_id TEXT NOT NULL,
+ status TEXT NOT NULL CHECK (status IN ('running','passed','failed','unknown')),
+ request_id TEXT,
+ task_id TEXT,
+ message TEXT,
+ evidence JSONB NOT NULL DEFAULT '{}'::JSONB,
+ started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ deadline_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes',
+ finished_at TIMESTAMPTZ,
+ CONSTRAINT ai_model_verifications_idempotency_key UNIQUE(model_key,stage,idempotency_key)
+);
+CREATE INDEX ai_model_verifications_model_stage_idx ON ai_model_verifications(model_key,stage,started_at DESC);
+CREATE UNIQUE INDEX ai_model_verifications_unresolved_key ON ai_model_verifications(model_key,stage) WHERE status IN ('running','unknown');
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 9b36fb4..587814f 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -106,10 +106,32 @@ model AiModelCatalog {
analysisDefaults AiModelConfiguration[] @relation("AiModelDefaultAnalysis")
copyDefaults AiModelConfiguration[] @relation("AiModelDefaultCopy")
imageDefaults AiModelConfiguration[] @relation("AiModelDefaultImage")
+ verifications AiModelVerification[]
@@unique([providerId, modelId], map: "ai_model_catalog_provider_model_key")
@@map("ai_model_catalog")
}
+model AiModelVerification {
+ id String @id
+ modelKey String @map("model_key")
+ stage String
+ contextHash String @map("context_hash")
+ idempotencyKey String @map("idempotency_key")
+ actorId String @map("actor_id")
+ status String
+ requestId String? @map("request_id")
+ taskId String? @map("task_id")
+ message String?
+ evidence Json @default("{}")
+ startedAt DateTime @default(now()) @db.Timestamptz(6) @map("started_at")
+ deadlineAt DateTime @default(dbgenerated("(now() + '00:10:00'::interval)")) @db.Timestamptz(6) @map("deadline_at")
+ finishedAt DateTime? @db.Timestamptz(6) @map("finished_at")
+ model AiModelCatalog @relation(fields: [modelKey], references: [id], onDelete: Restrict)
+ @@unique([modelKey, stage, idempotencyKey], map: "ai_model_verifications_idempotency_key")
+ @@index([modelKey, stage, startedAt(sort: Desc)], map: "ai_model_verifications_model_stage_idx")
+ @@map("ai_model_verifications")
+}
+
model AiModelConfiguration {
id String @id
defaultAnalysis String? @map("default_analysis")
diff --git a/scripts/ai-model-migration.test.mjs b/scripts/ai-model-migration.test.mjs
index a23eaae..03a687f 100644
--- a/scripts/ai-model-migration.test.mjs
+++ b/scripts/ai-model-migration.test.mjs
@@ -27,9 +27,18 @@ test('AI registry gate preserves catalog and original connection uniqueness', ()
test('AI registry gate refuses missing, weakened or cascading credential and model constraints', () => {
const evidence = aiModelRegistryEvidence()
- for (const name of ['ai_model_credential_complete', 'ai_model_enabled_compatible', 'ai_model_catalog_stages_check']) {
+ for (const name of ['ai_model_credential_complete', 'ai_model_enabled_stages', 'ai_model_catalog_stages_check', 'ai_model_verifications_status_check', 'ai_model_verifications_stage_check', 'ai_model_verifications_context_hash_check']) {
assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, constraints: evidence.constraints.filter(row => row.constraint_name !== name) }), /constraint mismatch/)
assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, constraints: evidence.constraints.map(row => row.constraint_name === name ? { ...row, expression: 'true' } : row) }), /expression mismatch/)
}
assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, constraints: evidence.constraints.map(row => row.contype === 'f' ? { ...row, delete_action: 'c' } : row) }), /deletion mismatch/)
})
+
+test('verification gate refuses lost idempotency and weakened unresolved-request protection', () => {
+ const evidence = aiModelRegistryEvidence()
+ for (const name of ['ai_model_verifications_idempotency_key', 'ai_model_verifications_unresolved_key', 'ai_model_verifications_model_stage_idx']) {
+ assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, indexes: evidence.indexes.filter(row => row.indexname !== name) }), /index/)
+ }
+ assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, indexes: evidence.indexes.map(row => row.indexname === 'ai_model_verifications_unresolved_key' ? { ...row, predicate: "status = 'running'::text" } : row) }), /predicate mismatch/)
+ assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, columns: evidence.columns.filter(row => row.table_name !== 'ai_model_verifications' || row.column_name !== 'task_id') }), /ai_model_verifications.task_id/)
+})
diff --git a/scripts/fixtures/ai-model-registry-evidence.mjs b/scripts/fixtures/ai-model-registry-evidence.mjs
index 3b28e1d..c54528d 100644
--- a/scripts/fixtures/ai-model-registry-evidence.mjs
+++ b/scripts/fixtures/ai-model-registry-evidence.mjs
@@ -1,7 +1,7 @@
// Schema-only fixture captured from the two additive migrations in disposable PostgreSQL.
export function aiModelRegistryEvidence() {
const column = (table_name, column_name, data_type, is_nullable = 'NO', column_default = null, udt_name = null) => ({ table_name, column_name, data_type, is_nullable, column_default, udt_name })
- const provider = 'ai_model_providers', catalog = 'ai_model_catalog', config = 'ai_model_configuration', audit = 'ai_model_audit'
+ const provider = 'ai_model_providers', catalog = 'ai_model_catalog', config = 'ai_model_configuration', audit = 'ai_model_audit', verification = 'ai_model_verifications'
const unique = (tablename, indexname, columns, predicate = '') => ({ tablename, indexname, columns, predicate, indisunique: true, indisprimary: false, indisvalid: true, indisready: true, access_method: 'btree' })
const foreign = (tablename, name, referenced_table) => ({ tablename, constraint_name: `${tablename}_${name}_fkey`, contype: 'f', convalidated: true, delete_action: 'r', columns: [name], referenced_table, referenced_columns: ['id'] })
const check = (tablename, constraint_name, columns, expression) => ({ tablename, constraint_name, columns, expression, contype: 'c', convalidated: true })
@@ -20,20 +20,32 @@ export function aiModelRegistryEvidence() {
column(config, 'revision', 'integer', 'NO', '1'), column(config, 'updated_at', 'timestamp with time zone', 'NO', 'now()'),
...['id', 'actor_id', 'action'].map(name => column(audit, name, 'text')), column(audit, 'provider_id', 'text', 'YES'),
column(audit, 'details', 'jsonb', 'NO', "'{}'::jsonb"), column(audit, 'created_at', 'timestamp with time zone', 'NO', 'now()'),
+ ...['id', 'model_key', 'stage', 'context_hash', 'idempotency_key', 'actor_id', 'status'].map(name => column(verification, name, 'text')),
+ ...['request_id', 'task_id', 'message'].map(name => column(verification, name, 'text', 'YES')),
+ column(verification, 'evidence', 'jsonb', 'NO', "'{}'::jsonb"),
+ ...['started_at', 'deadline_at'].map(name => column(verification, name, 'timestamp with time zone')),
+ column(verification, 'finished_at', 'timestamp with time zone', 'YES'),
],
indexes: [
unique(provider, 'ai_model_providers_legacy_binding_key', ['legacy_binding'], '(legacy_binding = true)'),
unique(catalog, 'ai_model_catalog_provider_model_key', ['provider_id', 'model_id']),
{ tablename: audit, indexname: 'ai_model_audit_created_idx', indexdef: 'CREATE INDEX ai_model_audit_created_idx ON public.ai_model_audit USING btree (created_at)' },
+ unique(verification, 'ai_model_verifications_idempotency_key', ['model_key', 'stage', 'idempotency_key']),
+ unique(verification, 'ai_model_verifications_unresolved_key', ['model_key', 'stage'], "status = ANY (ARRAY['running'::text, 'unknown'::text])"),
+ { tablename: verification, indexname: 'ai_model_verifications_model_stage_idx', indexdef: 'CREATE INDEX ai_model_verifications_model_stage_idx ON public.ai_model_verifications USING btree (model_key, stage, started_at DESC)', indisvalid: true, indisready: true, indisunique: false, predicate: '' },
],
constraints: [
foreign(catalog, 'provider_id', provider), ...['default_analysis', 'default_copy', 'default_image'].map(name => foreign(config, name, catalog)),
+ foreign(verification, 'model_key', catalog),
check(provider, 'ai_model_providers_credential_source_check', ['credential_source'], "credential_source = ANY (ARRAY['environment'::text, 'encrypted'::text])"),
check(provider, 'ai_model_providers_revision_check', ['revision'], 'revision > 0'),
check(provider, 'ai_model_credential_complete', ['credential_source', 'secret_ciphertext', 'secret_nonce', 'secret_auth_tag'], "credential_source = 'environment'::text AND secret_ciphertext IS NULL AND secret_nonce IS NULL AND secret_auth_tag IS NULL OR credential_source = 'encrypted'::text AND secret_ciphertext IS NOT NULL AND secret_nonce IS NOT NULL AND secret_auth_tag IS NOT NULL"),
check(catalog, 'ai_model_catalog_stages_check', ['stages'], "stages <@ ARRAY['analysis'::text, 'copy'::text, 'image'::text]"),
check(catalog, 'ai_model_catalog_compatibility_check', ['compatibility'], "compatibility = ANY (ARRAY['confirmed'::text, 'unconfirmed'::text])"),
- check(catalog, 'ai_model_enabled_compatible', ['enabled', 'stages', 'compatibility'], "NOT enabled OR cardinality(stages) > 0 AND compatibility = 'confirmed'::text"),
+ check(catalog, 'ai_model_enabled_stages', ['enabled', 'stages'], 'NOT enabled OR cardinality(stages) > 0'),
+ check(verification, 'ai_model_verifications_stage_check', ['stage'], "stage = ANY (ARRAY['analysis'::text, 'copy'::text, 'image'::text])"),
+ check(verification, 'ai_model_verifications_context_hash_check', ['context_hash'], "context_hash ~ '^[0-9a-f]{64}$'::text"),
+ check(verification, 'ai_model_verifications_status_check', ['status'], "status = ANY (ARRAY['running'::text, 'passed'::text, 'failed'::text, 'unknown'::text])"),
check(config, 'ai_model_configuration_id_check', ['id'], "id = 'detail-generator'::text"),
check(config, 'ai_model_configuration_revision_check', ['revision'], 'revision > 0'),
],
diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1
index 2e3bd80..6c21d4f 100644
--- a/scripts/git-incremental-deploy.ps1
+++ b/scripts/git-incremental-deploy.ps1
@@ -1254,13 +1254,14 @@ verify_detail_generator_schema() {
local name="$1"
docker exec -i "$name" node <<'NODE'
const fs = require('fs'), crypto = require('crypto'), { PrismaClient } = require('@prisma/client');
-const migrations = ['20261012_detail_generator', '20261012_ai_model_registry', '20261013_ai_model_capabilities'];
+const migrations = ['20261012_detail_generator', '20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications'];
const required = { detail_generator_projects: ['id','owner_id','document','revision','deleted_at'], detail_generator_assets: ['id','project_id','owner_id','storage_key','byte_size'], detail_generator_jobs: ['id','project_id','owner_id','status','idempotency_key','fingerprint','project_revision','payload','checkpoint','lease_token','lease_expires_at'], detail_generator_brand_profiles: ['id','name','profile','revision'] };
const modelFields = {
ai_model_providers: { id:'text', name:'text', base_url:'text', enabled:'boolean', credential_source:'text', secret_ciphertext:'text?', secret_nonce:'text?', secret_auth_tag:'text?', legacy_binding:'boolean', revision:'integer', synced_at:'timestamp with time zone?', created_at:'timestamp with time zone', updated_at:'timestamp with time zone' },
ai_model_catalog: { id:'text', provider_id:'text', model_id:'text', enabled:'boolean', stages:'ARRAY', present:'boolean', compatibility:'text', capability_metadata:'jsonb?', created_at:'timestamp with time zone', updated_at:'timestamp with time zone' },
ai_model_configuration: { id:'text', default_analysis:'text?', default_copy:'text?', default_image:'text?', revision:'integer', updated_at:'timestamp with time zone' },
ai_model_audit: { id:'text', actor_id:'text', action:'text', provider_id:'text?', details:'jsonb', created_at:'timestamp with time zone' },
+ ai_model_verifications: { id:'text', model_key:'text', stage:'text', context_hash:'text', idempotency_key:'text', actor_id:'text', status:'text', request_id:'text?', task_id:'text?', message:'text?', evidence:'jsonb', started_at:'timestamp with time zone', deadline_at:'timestamp with time zone', finished_at:'timestamp with time zone?' },
};
const p = new PrismaClient({ log: [] });
(async () => {
@@ -1644,6 +1645,7 @@ const expectedMigrationNames = new Set([
'20261012_detail_generator',
'20261012_ai_model_registry',
'20261013_ai_model_capabilities',
+ '20261013_ai_model_verifications',
]);
if (migrations.length !== expectedMigrationNames.size
|| migrations.some((item) => !expectedMigrationNames.has(item && item.name)
diff --git a/scripts/git-incremental-deploy.test.mjs b/scripts/git-incremental-deploy.test.mjs
index 75639f4..2cb8967 100644
--- a/scripts/git-incremental-deploy.test.mjs
+++ b/scripts/git-incremental-deploy.test.mjs
@@ -82,6 +82,7 @@ test('migration deployment gate recognizes the latest approved schema migration'
assert.match(gate, /20261012_detail_generator/)
assert.match(gate, /20261012_ai_model_registry/)
assert.match(gate, /20261013_ai_model_capabilities/)
+ assert.match(gate, /20261013_ai_model_verifications/)
})
test('direct replacement runs database and dependency gates before switching traffic', () => {
@@ -179,7 +180,7 @@ test('enabled containers bind private persistence and verify actual Node22 PSD/P
assert.doesNotMatch(runtime, /fetch\(|generateDetailImage|runDetailGeneratorWorker/)
})
-test('AI model cutover gate reads both migration checksums and rejects missing or mistyped capability state', async () => {
+test('AI model cutover gate checks every migration and rejects missing, mistyped or wrongly nullable verification state', async () => {
const code = shellFunction('verify_detail_generator_schema').match(/<<'NODE'\n([\s\S]*?)\nNODE/)[1]
const columns = [
...aiModelRegistryEvidence().columns,
@@ -205,15 +206,22 @@ test('AI model cutover gate reads both migration checksums and rejects missing o
}
const accepted = await run()
assert.equal(accepted.exitCode, 0)
- assert.deepEqual(accepted.checked, ['20261012_detail_generator', '20261012_ai_model_registry', '20261013_ai_model_capabilities'])
- for (const migration of ['20261012_ai_model_registry', '20261013_ai_model_capabilities']) assert.equal((await run(columns, migration)).exitCode, 1)
+ assert.deepEqual(accepted.checked, ['20261012_detail_generator', '20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications'])
+ for (const migration of ['20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications']) assert.equal((await run(columns, migration)).exitCode, 1)
assert.equal((await run(columns.filter(row => row.column_name !== 'capability_metadata'))).exitCode, 1)
assert.equal((await run(columns.map(row => row.column_name === 'capability_metadata' ? { ...row, data_type: 'text' } : row))).exitCode, 1)
+ assert.equal((await run(columns.filter(row => row.table_name !== 'ai_model_verifications'))).exitCode, 1, 'missing verification table must block cutover')
+ for (const field of ['stage', 'context_hash', 'task_id', 'evidence']) {
+ const target = row => row.table_name === 'ai_model_verifications' && row.column_name === field
+ assert.equal((await run(columns.filter(row => !target(row)))).exitCode, 1, `missing ${field} must block cutover`)
+ assert.equal((await run(columns.map(row => target(row) ? { ...row, data_type: 'boolean' } : row))).exitCode, 1, `mistyped ${field} must block cutover`)
+ assert.equal((await run(columns.map(row => target(row) ? { ...row, is_nullable: row.is_nullable === 'YES' ? 'NO' : 'YES' } : row))).exitCode, 1, `wrong nullability for ${field} must block cutover`)
+ }
})
test('AI registry migration files are present in builder and immutable runner verification scope', () => {
const dockerfile = readFileSync(new URL('../Dockerfile', import.meta.url), 'utf8')
- for (const migration of ['20261012_ai_model_registry', '20261013_ai_model_capabilities']) {
+ for (const migration of ['20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications']) {
assert.ok(dockerfile.includes(`test -f "/app/prisma/migrations/${migration}/migration.sql"`))
assert.ok(dockerfile.includes(`COPY --from=builder --chown=root:root /app/prisma/migrations/${migration} ./prisma/migrations/${migration}`))
}
diff --git a/scripts/safe-production-migrate.mjs b/scripts/safe-production-migrate.mjs
index 37c9b18..8329889 100644
--- a/scripts/safe-production-migrate.mjs
+++ b/scripts/safe-production-migrate.mjs
@@ -44,6 +44,7 @@ const TARGET_MIGRATIONS = Object.freeze([
'20261012_detail_generator',
'20261012_ai_model_registry',
'20261013_ai_model_capabilities',
+ '20261013_ai_model_verifications',
])
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
@@ -358,7 +359,7 @@ async function queryEvidence(prisma, localMigrations) {
OR (table_name = 'material_permission_pack_items' AND column_name IN ('can_access_private'))
OR table_name IN ('material_permission_pack_departments', 'team_function_configs', 'team_function_config_audits', 'material_purge_jobs', 'material_purge_audits')
OR table_name IN ('detail_generator_projects', 'detail_generator_assets', 'detail_generator_jobs', 'detail_generator_brand_profiles')
- OR table_name IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration', 'ai_model_audit')
+ OR table_name IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration', 'ai_model_audit', 'ai_model_verifications')
OR (table_name = 'api_keys' AND column_name IN ('secret_ciphertext', 'secret_nonce', 'secret_auth_tag'))
OR (table_name = 'api_key_reveal_audits' AND column_name IN ('id', 'api_key_id', 'viewer_user_id', 'created_at'))
OR (table_name = 'system_settings' AND column_name IN ('headerLogoUrl', 'loginLogoUrl', 'logoUrl'))
@@ -430,7 +431,8 @@ async function queryEvidence(prisma, localMigrations) {
'materials_purge_references_idx', 'material_assets_purge_references_idx', 'material_skus_purge_references_idx',
'brand_knowledge_purge_references_idx', 'system_settings_purge_references_idx', 'performance_work_items_purge_references_idx',
'work_review_versions_purge_references_idx', 'package_upload_jobs_purge_references_idx', 'task_detail_changes_purge_references_idx',
- 'ai_model_providers_legacy_binding_key', 'ai_model_catalog_provider_model_key', 'ai_model_audit_created_idx'
+ 'ai_model_providers_legacy_binding_key', 'ai_model_catalog_provider_model_key', 'ai_model_audit_created_idx',
+ 'ai_model_verifications_idempotency_key', 'ai_model_verifications_unresolved_key', 'ai_model_verifications_model_stage_idx'
)
ORDER BY idx.relname`,
)
@@ -473,7 +475,7 @@ async function queryEvidence(prisma, localMigrations) {
'team_function_configs_organization_unit_id_fkey', 'team_function_configs_lead_employee_profile_id_fkey',
'team_function_config_audits_config_id_fkey', 'team_function_configs_revision_check',
'material_purge_audits_job_id_fkey'
- ) OR cls.relname IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration')
+ ) OR cls.relname IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration', 'ai_model_verifications')
OR (cls.relname IN ('material_logs', 'material_download_logs') AND ref.relname = 'materials'))
ORDER BY con.conname`,
)
@@ -920,6 +922,7 @@ export function assertAiModelRegistryEvidence(evidence) {
ai_model_catalog: { id: 'text', provider_id: 'text', model_id: 'text', enabled: 'boolean', stages: 'ARRAY', present: 'boolean', compatibility: 'text', capability_metadata: 'jsonb?', created_at: 'timestamp with time zone', updated_at: 'timestamp with time zone' },
ai_model_configuration: { id: 'text', default_analysis: 'text?', default_copy: 'text?', default_image: 'text?', revision: 'integer', updated_at: 'timestamp with time zone' },
ai_model_audit: { id: 'text', actor_id: 'text', action: 'text', provider_id: 'text?', details: 'jsonb', created_at: 'timestamp with time zone' },
+ ai_model_verifications: { id: 'text', model_key: 'text', stage: 'text', context_hash: 'text', idempotency_key: 'text', actor_id: 'text', status: 'text', request_id: 'text?', task_id: 'text?', message: 'text?', evidence: 'jsonb', started_at: 'timestamp with time zone', deadline_at: 'timestamp with time zone', finished_at: 'timestamp with time zone?' },
}
for (const [table, fields] of Object.entries(required)) for (const [column, definition] of Object.entries(fields)) {
const nullable = definition.endsWith('?'), type = nullable ? definition.slice(0, -1) : definition
@@ -938,8 +941,16 @@ export function assertAiModelRegistryEvidence(evidence) {
if (normalizeConstraintExpression(legacy?.predicate) !== 'legacy_binding=true') fail('Database index predicate mismatch: ai_model_providers_legacy_binding_key')
assertExactUniqueBtreeIndex(legacy && { ...legacy, predicate: '' }, 'ai_model_providers', 'ai_model_providers_legacy_binding_key', ['legacy_binding'])
assertExactBtreeIndex(indexes.find(row => row.indexname === 'ai_model_audit_created_idx')?.indexdef, 'ai_model_audit', ['created_at'])
+ assertExactUniqueBtreeIndex(indexes.find(row => row.indexname === 'ai_model_verifications_idempotency_key'), 'ai_model_verifications', 'ai_model_verifications_idempotency_key', ['model_key', 'stage', 'idempotency_key'])
+ const unresolved = indexes.find(row => row.indexname === 'ai_model_verifications_unresolved_key')
+ if (normalizeConstraintExpression(unresolved?.predicate) !== normalizeConstraintExpression("status = ANY (ARRAY['running', 'unknown'])")) fail('Database index predicate mismatch: ai_model_verifications_unresolved_key')
+ assertExactUniqueBtreeIndex(unresolved && { ...unresolved, predicate: '' }, 'ai_model_verifications', 'ai_model_verifications_unresolved_key', ['model_key', 'stage'])
+ const verificationOrder = indexes.find(row => row.indexname === 'ai_model_verifications_model_stage_idx')
+ if (!verificationOrder || verificationOrder.indisvalid !== true || verificationOrder.indisready !== true || verificationOrder.indisunique !== false || String(verificationOrder.predicate || '') !== ''
+ || !/^CREATE INDEX \S+ ON (?:public\.)?ai_model_verifications USING btree \(model_key, stage, started_at DESC\)$/.test(String(verificationOrder.indexdef || ''))) fail('Database index mismatch: ai_model_verifications_model_stage_idx')
for (const [table, column, referenced] of [
['ai_model_catalog', 'provider_id', 'ai_model_providers'],
+ ['ai_model_verifications', 'model_key', 'ai_model_catalog'],
...['default_analysis', 'default_copy', 'default_image'].map(column => ['ai_model_configuration', column, 'ai_model_catalog']),
]) {
const name = `${table}_${column}_fkey`, constraint = constraints.find(row => row.constraint_name === name)
@@ -952,7 +963,10 @@ export function assertAiModelRegistryEvidence(evidence) {
['ai_model_providers', 'ai_model_credential_complete', ['credential_source', 'secret_ciphertext', 'secret_nonce', 'secret_auth_tag'], "credential_source = 'environment' AND secret_ciphertext IS NULL AND secret_nonce IS NULL AND secret_auth_tag IS NULL OR credential_source = 'encrypted' AND secret_ciphertext IS NOT NULL AND secret_nonce IS NOT NULL AND secret_auth_tag IS NOT NULL"],
['ai_model_catalog', 'ai_model_catalog_stages_check', ['stages'], "stages <@ ARRAY['analysis', 'copy', 'image']"],
['ai_model_catalog', 'ai_model_catalog_compatibility_check', ['compatibility'], "compatibility = ANY (ARRAY['confirmed', 'unconfirmed'])"],
- ['ai_model_catalog', 'ai_model_enabled_compatible', ['enabled', 'stages', 'compatibility'], "NOT enabled OR cardinality(stages) > 0 AND compatibility = 'confirmed'"],
+ ['ai_model_catalog', 'ai_model_enabled_stages', ['enabled', 'stages'], 'NOT enabled OR cardinality(stages) > 0'],
+ ['ai_model_verifications', 'ai_model_verifications_stage_check', ['stage'], "stage = ANY (ARRAY['analysis', 'copy', 'image'])"],
+ ['ai_model_verifications', 'ai_model_verifications_context_hash_check', ['context_hash'], "context_hash ~ '^[0-9a-f]{64}$'"],
+ ['ai_model_verifications', 'ai_model_verifications_status_check', ['status'], "status = ANY (ARRAY['running', 'passed', 'failed', 'unknown'])"],
['ai_model_configuration', 'ai_model_configuration_id_check', ['id'], "id = 'detail-generator'"],
['ai_model_configuration', 'ai_model_configuration_revision_check', ['revision'], 'revision > 0'],
]) assertExactCheckConstraint(constraints.find(row => row.constraint_name === name), table, name, fields, expression)
diff --git a/scripts/safe-production-migrate.test.mjs b/scripts/safe-production-migrate.test.mjs
index dc754d3..ad41be4 100644
--- a/scripts/safe-production-migrate.test.mjs
+++ b/scripts/safe-production-migrate.test.mjs
@@ -427,6 +427,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
['20261012_detail_generator', { checksum: 'ag' }],
['20261012_ai_model_registry', { checksum: 'ah' }],
['20261013_ai_model_capabilities', { checksum: 'ai' }],
+ ['20261013_ai_model_verifications', { checksum: 'aj' }],
])
const evidence = {
migrationRows: [
@@ -701,6 +702,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
evidence.migrationRows.push({ migration_name: '20261009_material_purge_reference_indexes', checksum: 'af', finished_at: 'now', rolled_back_at: null })
evidence.migrationRows.push({ migration_name: '20261012_detail_generator', checksum: 'ag', finished_at: 'now', rolled_back_at: null })
evidence.migrationRows.push({ migration_name: '20261012_ai_model_registry', checksum: 'ah', finished_at: 'now', rolled_back_at: null }, { migration_name: '20261013_ai_model_capabilities', checksum: 'ai', finished_at: 'now', rolled_back_at: null })
+ evidence.migrationRows.push({ migration_name: '20261013_ai_model_verifications', checksum: 'aj', finished_at: 'now', rolled_back_at: null })
const aiModels = aiModelRegistryEvidence()
for (const field of ['columns', 'indexes', 'constraints']) evidence[field].push(...aiModels[field])
for (const [table, fields] of Object.entries({
@@ -1072,12 +1074,12 @@ test('container supply chain and migrator image are immutable while SOP keeps co
assert.match(sop, /-v "\$HOST_RESULT_DIR:\/audit"/)
assert.match(sop, /test -f "\$PREFLIGHT_AUDIT_FILE" && test ! -L "\$PREFLIGHT_AUDIT_FILE"/)
assert.doesNotMatch(sop, /-v \/root\/gongxiangp2-migration-audit:\/audit/)
- assert.match(sop, /三十五份目标 migration/)
- assert.match(sop, /三十五条目标 migration/)
+ assert.match(sop, /三十六份目标 migration/)
+ assert.match(sop, /三十六条目标 migration/)
const targetBlock = migratorSource.match(/const TARGET_MIGRATIONS = Object\.freeze\(\[([\s\S]*?)\]\)/)?.[1]
assert.ok(targetBlock, 'Migrator target list must be inspectable')
const targets = [...targetBlock.matchAll(/'([^']+)'/g)].map(match => match[1])
- assert.equal(targets.length, 35)
+ assert.equal(targets.length, 36)
for (const migration of targets) assert.ok(sop.includes(`\`${migration}\``), `SOP must name ${migration}`)
const applyMigration = migratorSource.indexOf("['migrate', 'deploy'")
diff --git a/scripts/verify-ai-model-integration.ts b/scripts/verify-ai-model-integration.ts
new file mode 100644
index 0000000..76201fd
--- /dev/null
+++ b/scripts/verify-ai-model-integration.ts
@@ -0,0 +1,84 @@
+/** Isolated HTTP acceptance. Paid tests are explicit, one model + purpose per invocation. */
+import assert from 'node:assert/strict'
+import { randomUUID } from 'node:crypto'
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { PrismaClient } from '@prisma/client'
+import type { AiModelAdminState, AiModelStage } from '../lib/ai-model-types'
+
+const db = new URL(process.env.DATABASE_URL || '')
+assert.equal(db.hostname, '127.0.0.1'); assert.equal(db.port, '55441'); assert.equal(db.pathname, '/ai_model_test')
+const base = 'http://localhost:3121', output = 'output/ai-model-integration-v2'
+const prisma = new PrismaClient({ log: [] })
+const phase = process.argv[2] || 'inspect'
+async function save(name: string, value: unknown) { await mkdir(output, { recursive: true }); await writeFile(`${output}/${name}.json`, JSON.stringify(value, null, 2)) }
+async function session(index: number) {
+ const jar = new Map()
+ const store = (r: Response) => { for (const c of r.headers.getSetCookie()) { const first = c.split(';')[0], split = first.indexOf('='); jar.set(first.slice(0, split), first.slice(split + 1)) } }
+ const cookie = () => [...jar].map(([k,v]) => `${k}=${v}`).join('; ')
+ const csrf = await fetch(base + '/api/auth/csrf'); store(csrf)
+ const { csrfToken } = await csrf.json()
+ const response = await fetch(base + '/api/auth/callback/credentials', { method:'POST', redirect:'manual', headers:{'Content-Type':'application/x-www-form-urlencoded', Origin:base, Cookie:cookie()}, body:new URLSearchParams({csrfToken,email:`ai-admin-${index}@example.invalid`,password:'AiAdminLocalTest-20261012',callbackUrl:base+'/admin',json:'true'}) })
+ store(response); assert.equal(response.status,200); return cookie()
+}
+async function request(cookie: string, path = '', method = 'GET', body?: unknown) {
+ const response = await fetch(base+'/api/admin/ai-models'+path,{method,redirect:'manual',headers:{Cookie:cookie,'Content-Type':'application/json'},...(body===undefined?{}:{body:JSON.stringify(body)})})
+ const raw = await response.text()
+ if(process.env.DETAIL_GENERATOR_API_KEY)assert.ok(!raw.includes(process.env.DETAIL_GENERATOR_API_KEY),'Secret leaked in response')
+ return {status:response.status,data:raw?JSON.parse(raw):null}
+}
+function ok(result: Awaited>): AiModelAdminState { assert.equal(result.status,200,JSON.stringify(result.data));return result.data }
+async function main() {
+ const admin = await session(0)
+ let state = ok(await request(admin))
+ if(phase==='bootstrap') {
+ if(state.legacyAvailable)state=ok(await request(admin,'/import-environment','POST'))
+ let provider=state.providers.find(p=>p.credentialSource==='environment'); assert.ok(provider)
+ if(!provider.enabled)state=ok(await request(admin,`/providers/${provider.id}`,'PATCH',{revision:provider.revision,enabled:true}))
+ provider=state.providers.find(p=>p.id===provider!.id)!
+ state=ok(await request(admin,`/providers/${provider.id}/sync`,'POST',{revision:provider.revision}))
+ await save('catalog-state',state)
+ } else if(phase==='test') {
+ const stage=process.argv[3] as AiModelStage; assert.ok(['analysis','copy','image'].includes(stage))
+ const modelId=stage==='image'?'nano-banana-2':'gemini-3-flash'
+ const provider=state.providers.find(p=>p.credentialSource==='environment'); assert.ok(provider)
+ const model=provider.models.find(m=>m.id===modelId); assert.ok(model)
+ assert.ok(model.capabilities.allowedStages.includes(stage),`${modelId} has no ${stage} adapter`)
+ let receipt:{idempotencyKey:string;revision:number;providerId:string;modelKey:string;stage:AiModelStage}
+ try {receipt=JSON.parse(await readFile(`${output}/live-${stage}-request.json`,'utf8'))}
+ catch {receipt={idempotencyKey:randomUUID(),revision:provider.revision,providerId:provider.id,modelKey:model.key,stage};await save(`live-${stage}-request`,receipt)}
+ assert.equal(receipt.providerId,provider.id);assert.equal(receipt.modelKey,model.key)
+ state=ok(await request(admin,`/providers/${provider.id}/models/${model.key}/verify`,'POST',{stage,revision:receipt.revision,idempotencyKey:receipt.idempotencyKey}))
+ const result=state.providers.find(p=>p.id===provider.id)!.models.find(m=>m.key===model.key)!.verification[stage]
+ await save(`live-${stage}-result`,result);console.log(JSON.stringify({model:modelId,stage,...result}));return
+ } else if(phase==='query') {
+ const stage=process.argv[3] as AiModelStage;assert.ok(['analysis','copy','image'].includes(stage))
+ const provider=state.providers.find(p=>p.credentialSource==='environment')!, model=provider.models.find(m=>m.id===(stage==='image'?'nano-banana-2':'gemini-3-flash'))!
+ const result=model.verification[stage];assert.ok(result.id&&result.canResume,'No known task to query')
+ state=ok(await request(admin,`/providers/${provider.id}/verifications/${result.id}/refresh`,'POST'))
+ const checked=state.providers.find(p=>p.id===provider.id)!.models.find(m=>m.key===model.key)!.verification[stage]
+ await save(`live-${stage}-result`,checked);console.log(JSON.stringify({stage,...checked}));return
+ } else if(phase==='configure') {
+ const provider=state.providers.find(p=>p.credentialSource==='environment')!, gemini=provider.models.find(m=>m.id==='gemini-3-flash')!, image=provider.models.find(m=>m.id==='nano-banana-2')!
+ for(const [model,stage] of [[gemini,'analysis'],[gemini,'copy'],[image,'image']] as const)assert.equal(model.verification[stage].status,'passed')
+ state=ok(await request(admin,'/configuration','PUT',{revision:state.revision,defaults:{analysis:gemini.key,copy:gemini.key,image:image.key},providers:state.providers.map(p=>({id:p.id,revision:p.revision,models:p.models.map(m=>({key:m.key,enabled:p.id===provider.id&&[gemini.key,image.key].includes(m.key),stages:p.id===provider.id&&m.key===gemini.key?['analysis','copy']:p.id===provider.id&&m.key===image.key?['image']:[]}))}))}))
+ await save('configured-state',state)
+ } else if(phase==='access') {
+ const counts=await prisma.$queryRawUnsafe>('SELECT COUNT(*)::int AS count FROM ai_model_verifications')
+ assert.equal((await request('')).status,401)
+ const provider=state.providers.find(p=>p.credentialSource==='environment')!, model=provider.models.find(m=>m.id==='gemini-3-flash')!
+ for(const index of [2,3]) {
+ const cookie=await session(index)
+ for(const [url,method,body] of [['','GET',undefined],['/configuration','PUT',{}],[`/providers/${provider.id}/sync`,'POST',{revision:provider.revision}],[`/providers/${provider.id}/models/${model.key}/verify`,'POST',{stage:'copy',revision:provider.revision,idempotencyKey:randomUUID()}]] as const)assert.equal((await request(cookie,url,method,body)).status,403)
+ }
+ const revoked=await session(1), user=await prisma.user.findUniqueOrThrow({where:{email:'ai-admin-1@example.invalid'}})
+ try{await prisma.user.update({where:{id:user.id},data:{isSuperAdmin:false}});assert.equal((await request(revoked)).status,403);assert.equal((await request(revoked,`/providers/${provider.id}/models/${model.key}/verify`,'POST',{stage:'copy',revision:provider.revision,idempotencyKey:randomUUID()})).status,403)}finally{await prisma.user.update({where:{id:user.id},data:{isSuperAdmin:true}})}
+ assert.equal((await request(admin,'/configuration','PUT',{revision:state.revision,defaults:state.defaults,providers:[{id:provider.id,revision:provider.revision,models:[{key:model.key,enabled:true,stages:['copy'],compatibility:'confirmed'}]}]})).status,400)
+ assert.equal((await request(admin,`/providers/${provider.id}/models/${model.key}/verify`,'POST',{stage:'image',revision:provider.revision,idempotencyKey:randomUUID()})).status,400)
+ const after=await prisma.$queryRawUnsafe>('SELECT COUNT(*)::int AS count FROM ai_model_verifications')
+ assert.equal(after[0].count,counts[0].count)
+ const evidence={anonymous:401,ordinaryAndRegularAdmin:403,oldSessionRevocation:403,manualCompatibilitySpoof:400,incompatibleImageTest:400,newVerificationRecords:0,productionTouched:false}
+ await save('http-access-evidence',evidence);console.log(JSON.stringify(evidence));return
+ } else assert.equal(phase,'inspect')
+ console.log(JSON.stringify({phase,providers:state.providers.map(p=>({name:p.name,enabled:p.enabled,models:p.models.length,defaults:p.models.filter(m=>['gemini-3-flash','nano-banana-2'].includes(m.id)).map(m=>({id:m.id,capabilities:m.capabilities,verification:m.verification}))}))}))
+}
+main().catch(error=>{console.error(error instanceof Error?error.message:'Acceptance failed');process.exitCode=1}).finally(()=>prisma.$disconnect())