feat(ai): verify model capabilities through real protocol tests

This commit is contained in:
WuYongJie 2026-10-13 12:28:41 +08:00
parent 9270bbb457
commit 94ea82e7cb
37 changed files with 1660 additions and 202 deletions

View File

@ -120,7 +120,8 @@ RUN test -f "/app/prisma/migrations/20261008_permission_group_department_identit
test -f "/app/prisma/migrations/20261009_material_purge_outbox/migration.sql" && \
test -f "/app/prisma/migrations/20261009_material_purge_reference_indexes/migration.sql" && \
test -f "/app/prisma/migrations/20261012_ai_model_registry/migration.sql" && \
test -f "/app/prisma/migrations/20261013_ai_model_capabilities/migration.sql"
test -f "/app/prisma/migrations/20261013_ai_model_capabilities/migration.sql" && \
test -f "/app/prisma/migrations/20261013_ai_model_verifications/migration.sql"
# 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失,
# 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。
@ -284,6 +285,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260924_add_certif
COPY --from=builder --chown=root:root /app/prisma/migrations/20261012_detail_generator ./prisma/migrations/20261012_detail_generator
COPY --from=builder --chown=root:root /app/prisma/migrations/20261012_ai_model_registry ./prisma/migrations/20261012_ai_model_registry
COPY --from=builder --chown=root:root /app/prisma/migrations/20261013_ai_model_capabilities ./prisma/migrations/20261013_ai_model_capabilities
COPY --from=builder --chown=root:root /app/prisma/migrations/20261013_ai_model_verifications ./prisma/migrations/20261013_ai_model_verifications
COPY --from=builder --chown=root:root /app/lib ./lib
COPY --from=builder --chown=root:root /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py
COPY --from=builder --chown=root:root /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs

View File

@ -0,0 +1,8 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { verifyAiModel } from '@/lib/ai-model-verification'
import { parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export const maxDuration = 300
export async function POST(request: Request, context: { params: { id: string; modelKey: string } }) {
return aiModelAdminRoute(async userId => verifyAiModel(userId, context.params.id, context.params.modelKey, await parseDetailJson(request)))
}

View File

@ -0,0 +1,7 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { refreshAiModelVerification } from '@/lib/ai-model-verification'
export const runtime = 'nodejs'
export const maxDuration = 300
export async function POST(_request: Request, context: { params: { id: string; verificationId: string } }) {
return aiModelAdminRoute(async userId => refreshAiModelVerification(userId, context.params.id, context.params.verificationId))
}

View File

@ -1,16 +1,17 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import AiModelManagementPanel from './AiModelManagementPanel'
import type { AiModelAdminState } from '@/lib/ai-model-types'
import type { AiModelAdminState, AiModelVerificationSummary } from '@/lib/ai-model-types'
function response(body: unknown, status = 200) { return { ok: status < 400, status, json: async () => body } as Response }
const initial = (): AiModelAdminState => ({ providers: [], revision: 0, defaults: { analysis: null, copy: null, image: null }, encryptionConfigured: true, legacyAvailable: true })
const verification = (status: AiModelVerificationSummary['status'] = 'untested') => ({ analysis: { status, id: null, checkedAt: null, requestId: null, message: null }, copy: { status, id: null, checkedAt: null, requestId: null, message: null }, image: { status, id: null, checkedAt: null, requestId: null, message: null } })
const configured = (): AiModelAdminState => ({
...initial(), revision: 2, legacyAvailable: false,
providers: [{ id: 'provider-1', name: '现有上游', baseUrl: 'https://api.example.com', enabled: true, credentialSource: 'environment', hasApiKey: true, revision: 3, syncedAt: null, models: [
{ key: 'model-text', id: 'gemini-2.5-flash', enabled: true, stages: ['analysis', 'copy'], present: true, compatibility: 'confirmed', capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'verified', reason: '支持图片理解与文字输出' } },
{ key: 'model-image', id: 'nano-banana-2', enabled: true, stages: ['image'], present: true, compatibility: 'confirmed', capabilities: { kind: 'image', allowedStages: ['image'], source: 'verified', reason: '支持图片生成' } },
{ key: 'model-new', id: 'new-vision-model', enabled: false, stages: [], present: true, compatibility: 'unconfirmed', capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'upstream', reason: '上游声明图片输入、文字输出' } },
{ key: 'model-missing', id: 'removed-model', enabled: false, stages: [], present: false, compatibility: 'unconfirmed', capabilities: { kind: 'unknown', allowedStages: [], source: 'unknown', reason: '目录未提供能力信息,待核验' } },
{ key: 'model-text', id: 'gemini-2.5-flash', enabled: true, stages: ['analysis', 'copy'], present: true, verification: verification('passed'), capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'verified', reason: '支持图片理解与文字输出' } },
{ key: 'model-image', id: 'nano-banana-2', enabled: true, stages: ['image'], present: true, verification: verification('passed'), capabilities: { kind: 'image', allowedStages: ['image'], source: 'verified', reason: '支持图片生成' } },
{ key: 'model-new', id: 'new-vision-model', enabled: false, stages: [], present: true, verification: verification(), capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'upstream', reason: '上游声明图片输入、文字输出' } },
{ key: 'model-missing', id: 'removed-model', enabled: false, stages: [], present: false, verification: verification(), capabilities: { kind: 'unknown', allowedStages: [], source: 'unknown', reason: '目录未提供能力信息,待核验' } },
] }],
defaults: { analysis: 'model-text', copy: 'model-text', image: 'model-image' },
})
@ -33,11 +34,12 @@ test('显式迁入当前连接并同步上游目录,使用服务器版本号',
const call = (global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/sync'))
expect(call[1].method).toBe('POST')
expect(JSON.parse(call[1].body)).toEqual({ revision: 3 })
expect((global.fetch as jest.Mock).mock.calls.every(([url]) => !url.includes('generate') && !url.includes('chat'))).toBe(true)
expect((global.fetch as jest.Mock).mock.calls.every(([url]) => !url.includes('generate') && !url.includes('chat') && !url.endsWith('/verify'))).toBe(true)
})
test('搜索与状态筛选仅影响显示,配置保存包含全部模型和各版本号', async () => {
const next = configured()
next.providers[0].models[2].verification.analysis.status = 'passed'
global.fetch = jest.fn(async (_url: string, init: RequestInit = {}) => response(init.method === 'PUT' ? { ...next, revision: 3 } : next)) as typeof fetch
render(<AiModelManagementPanel />)
await screen.findByText('new-vision-model')
@ -45,7 +47,7 @@ test('搜索与状态筛选仅影响显示,配置保存包含全部模型和
expect(screen.queryByText('removed-model')).not.toBeInTheDocument()
const model = screen.getByLabelText('new-vision-model · 现有上游', { selector: 'div' })
expect(within(model).getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).toBeDisabled()
fireEvent.click(within(model).getByRole('checkbox', { name: '确认 new-vision-model · 现有上游 接口兼容' }))
expect(within(model).queryByRole('checkbox', { name: /接口兼容/ })).not.toBeInTheDocument()
fireEvent.click(within(model).getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' }))
fireEvent.click(within(model).getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' }))
fireEvent.change(screen.getByLabelText('资料分析默认模型', { exact: false }), { target: { value: 'model-new' } })
@ -58,7 +60,8 @@ test('搜索与状态筛选仅影响显示,配置保存包含全部模型和
expect(body.defaults.analysis).toBe('model-new')
expect(body.providers[0].revision).toBe(3)
expect(body.providers[0].models).toHaveLength(4)
expect(body.providers[0].models.find((entry: any) => entry.key === 'model-new')).toEqual({ key: 'model-new', enabled: true, stages: ['analysis'], compatibility: 'confirmed' })
expect(body.providers[0].models.find((entry: any) => entry.key === 'model-new')).toEqual({ key: 'model-new', enabled: true, stages: ['analysis'] })
expect(JSON.stringify(body)).not.toMatch(/compatibility|verification/)
expect(JSON.stringify(body)).not.toContain('apiKey')
fireEvent.change(screen.getByLabelText('搜索模型或上游'), { target: { value: '' } })
fireEvent.change(screen.getByLabelText('按状态筛选模型'), { target: { value: 'missing' } })
@ -107,7 +110,7 @@ test('目录同步进行时禁止重复请求和配置修改', async () => {
const sync = await screen.findByRole('button', { name: '同步 现有上游 的模型目录' })
fireEvent.click(sync); fireEvent.click(sync)
expect(sync).toBeDisabled()
expect(screen.getByRole('checkbox', { name: '确认 new-vision-model · 现有上游 接口兼容' })).toBeDisabled()
expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).toBeDisabled()
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/sync'))).toHaveLength(1)
finish(response(configured()))
await waitFor(() => expect(sync).toBeEnabled())
@ -123,7 +126,7 @@ test('缺少加密配置时禁止新增密钥,仍可显式迁入服务端连
test('目录按 20 条分页,搜索回第一页,保存仍包含不可见模型', async () => {
const next = configured()
next.providers[0].models = Array.from({ length: 45 }, (_, index) => ({ key: `key-${index}`, id: `catalog-model-${String(index).padStart(2, '0')}`, enabled: false, stages: [], present: true, compatibility: 'unconfirmed', capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'upstream', reason: '支持图片理解与文字输出' } }))
next.providers[0].models = Array.from({ length: 45 }, (_, index) => ({ key: `key-${index}`, id: `catalog-model-${String(index).padStart(2, '0')}`, enabled: false, stages: [], present: true, verification: verification('passed'), capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'upstream', reason: '支持图片理解与文字输出' } }))
next.defaults = { analysis: null, copy: null, image: null }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
@ -134,7 +137,7 @@ test('目录按 20 条分页,搜索回第一页,保存仍包含不可见模
expect(screen.getByLabelText('catalog-model-20 · 现有上游', { selector: 'div' })).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('搜索模型或上游'), { target: { value: 'catalog-model-44' } })
expect(screen.getByText('1 个模型 · 第 1 / 1 页')).toBeInTheDocument()
fireEvent.click(screen.getByRole('checkbox', { name: '确认 catalog-model-44 · 现有上游 接口兼容' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'catalog-model-44 · 现有上游 用于资料分析' }))
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
await screen.findByText('模型配置已保存')
const call = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init.method === 'PUT')
@ -143,7 +146,7 @@ test('目录按 20 条分页,搜索回第一页,保存仍包含不可见模
test('按能力筛选未开放模型,纯对话不能分配生图或图片分析', async () => {
const next = configured()
next.providers[0].models[2] = { ...next.providers[0].models[2], id: 'text-chat-only', capabilities: { kind: 'text', allowedStages: ['copy'], source: 'upstream', reason: '仅支持文字输入与文字输出' } }
next.providers[0].models[2] = { ...next.providers[0].models[2], id: 'text-chat-only', verification: verification('passed'), capabilities: { kind: 'text', allowedStages: ['copy'], source: 'upstream', reason: '仅支持文字输入与文字输出' } }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.change(await screen.findByLabelText('按能力筛选模型'), { target: { value: 'text' } })
@ -152,11 +155,10 @@ test('按能力筛选未开放模型,纯对话不能分配生图或图片分
expect(screen.queryByText('new-vision-model')).not.toBeInTheDocument()
const image = screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于图片生成' })
expect(image).toBeDisabled()
expect(image).toHaveAccessibleDescription('仅支持文字输入与文字输出')
expect(image.closest('label')).toHaveAttribute('title', '不支持图片输出')
expect(image).toHaveAccessibleDescription('此模型不支持图片生成')
expect(image.closest('label')).toHaveAttribute('title', '此模型不支持图片生成')
expect(screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于资料分析' })).toBeDisabled()
expect(screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于卖点文案' })).toBeEnabled()
fireEvent.click(screen.getByRole('checkbox', { name: '确认 text-chat-only · 现有上游 接口兼容' }))
expect(image).toBeDisabled()
expect(screen.getByRole('checkbox', { name: '开放 text-chat-only · 现有上游 给用户' })).toBeDisabled()
fireEvent.click(screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于卖点文案' }))
@ -167,12 +169,14 @@ test('按能力筛选未开放模型,纯对话不能分配生图或图片分
expect(within(copy).getByRole('option', { name: 'text-chat-only · 现有上游' })).toBeInTheDocument()
})
test('能力待核验模型不能通过人工兼容确认解锁任何用途', async () => {
test('能力待识别模型没有人工兼容开关,不能解锁用途或实际测试', async () => {
const next = configured()
next.providers[0].models[2].capabilities = { kind: 'unknown', allowedStages: [], source: 'unknown', reason: '目录未提供能力信息,待核验' }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('checkbox', { name: '确认 new-vision-model · 现有上游 接口兼容' }))
await screen.findByText('new-vision-model')
expect(screen.queryByRole('checkbox', { name: /接口兼容/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /测试 new-vision-model/ })).not.toBeInTheDocument()
for (const label of ['资料分析', '卖点文案', '图片生成']) expect(screen.getByRole('checkbox', { name: `new-vision-model · 现有上游 用于${label}` })).toBeDisabled()
expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).toBeDisabled()
fireEvent.change(screen.getByLabelText('按能力筛选模型'), { target: { value: 'unknown' } })
@ -187,7 +191,7 @@ test('已识别生图能力但协议未适配时显示待适配,不误称无
render(<AiModelManagementPanel />)
const image = await screen.findByRole('checkbox', { name: 'image-other-protocol · 现有上游 用于图片生成' })
expect(image).toBeDisabled()
expect(image.closest('label')).toHaveAttribute('title', '当前详情页未适配此用途')
expect(image.closest('label')).toHaveAttribute('title', '具备图片输出能力,当前参考图协议待适配')
expect(image).toHaveAccessibleDescription('具备图片输出能力,当前参考图协议待适配')
expect(screen.getByText('生图 · 待适配')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('按能力筛选模型'), { target: { value: 'image' } })
@ -241,3 +245,169 @@ test('旧配置中能力不符的用途允许取消,取消后不能重新选
expect(image).toBeDisabled()
expect(screen.getByRole('checkbox', { name: '开放 gemini-2.5-flash · 现有上游 给用户' })).toBeChecked()
})
test('实际测试需明确确认费用,取消不调用模型;通过后仍需管理员开放', async () => {
const next = configured()
const passed = configured()
passed.providers[0].models[2].verification.analysis = { status: 'passed', id: 'verification-1', checkedAt: '2026-10-13T01:00:00Z', requestId: 'request-1', message: '结构化分析与图片输入已验证' }
global.fetch = jest.fn(async (url: string) => response(url.endsWith('/verify') ? passed : next)) as typeof fetch
render(<AiModelManagementPanel />)
const testButton = await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })
expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' })).toBeDisabled()
fireEvent.click(testButton)
let dialog = await screen.findByRole('dialog', { name: '测试资料分析' })
expect(within(dialog).getByText('将实际调用模型,可能产生上游费用。')).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }))
await waitFor(() => expect(testButton).toBeEnabled())
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(0)
fireEvent.click(testButton)
dialog = await screen.findByRole('dialog', { name: '测试资料分析' })
fireEvent.click(within(dialog).getByRole('button', { name: '开始测试' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' })).toBeEnabled())
const calls = (global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))
expect(calls).toHaveLength(1)
expect(calls[0][0]).toBe('/api/admin/ai-models/providers/provider-1/models/model-new/verify')
expect(JSON.parse(calls[0][1].body)).toEqual({ stage: 'analysis', revision: 3, idempotencyKey: expect.stringMatching(/^[0-9a-f-]{36}$/i) })
expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).not.toBeChecked()
expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于卖点文案' })).toBeDisabled()
expect(screen.getByRole('button', { name: '保存配置' })).toBeDisabled()
expect(screen.getByLabelText('new-vision-model · 现有上游 测试用途')).toHaveValue('analysis')
})
test('未保存配置禁止测试,撤销后允许;每次仅提交所选用途', async () => {
const next = configured()
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' }))
expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '撤销' }))
fireEvent.change(screen.getByLabelText('new-vision-model · 现有上游 测试用途'), { target: { value: 'copy' } })
fireEvent.click(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的卖点文案' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1))
const call = (global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/verify'))
expect(JSON.parse(call[1].body).stage).toBe('copy')
await waitFor(() => expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的卖点文案' })).toBeEnabled())
})
test('测试处理中禁止重复请求;未知结果暂停整个模型并可只读刷新', async () => {
const next = configured()
const unknown = configured()
unknown.providers[0].models[2].verification.analysis = { status: 'unknown', id: 'verification-unknown', checkedAt: '2026-10-13T01:00:00Z', requestId: 'upstream-request-unknown', message: '上游响应中断,需先核查执行结果' }
let finish!: (value: Response) => void
global.fetch = jest.fn((url: string) => url.endsWith('/verify') ? new Promise<Response>((resolve) => { finish = resolve }) : Promise.resolve(response(next))) as typeof fetch
render(<AiModelManagementPanel />)
const testButton = await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })
fireEvent.click(testButton)
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
await screen.findByText('正在调用模型,请稍候')
fireEvent.click(testButton)
expect(testButton).toBeDisabled()
expect(screen.getByRole('button', { name: '同步 现有上游 的模型目录' })).toBeDisabled()
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1)
finish(response(unknown))
await screen.findByText('结果未知,暂停核查')
expect(testButton).toBeDisabled()
fireEvent.change(screen.getByLabelText('new-vision-model · 现有上游 测试用途'), { target: { value: 'copy' } })
expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).toBeDisabled()
expect(screen.getByText('请求编号:upstream-request-unknown')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '刷新测试状态' }))
await screen.findByText('测试状态已刷新')
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1)
})
test('付费请求响应丢失后仅查询状态,查询也失败则本地暂停而不重试', async () => {
const next = configured()
let reads = 0
global.fetch = jest.fn(async (url: string) => {
if (url.endsWith('/verify')) throw new Error('网络中断')
if (++reads > 1) throw new Error('状态不可达')
return response(next)
}) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
await screen.findByText('结果未知,暂停核查')
expect(screen.getByRole('alert')).toHaveTextContent('网络中断;已停止提交,请核查测试状态。')
expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).toBeDisabled()
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1)
expect(reads).toBe(2)
})
test('响应丢失且 GET 仍为旧记录时保持暂停,手动刷新也不重复提交', async () => {
global.fetch = jest.fn(async (url: string) => {
if (url.endsWith('/verify')) throw new Error('连接中断')
return response(configured())
}) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
await screen.findByText('结果未知,暂停核查')
const call = (global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/verify'))
expect(screen.getByText(`请求编号:${JSON.parse(call[1].body).idempotencyKey}`)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '刷新测试状态' }))
await screen.findByText('测试状态已刷新')
expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).toBeDisabled()
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1)
})
test('已有上游任务仅查询结果,无费用弹窗,不发送新验证请求', async () => {
const next = configured()
next.providers[0].models[2].verification.analysis = { status: 'unknown', id: 'existing-verification', checkedAt: null, requestId: 'existing-task', message: '上游任务仍在执行', canResume: true }
const completed = configured()
completed.providers[0].models[2].verification.analysis = { status: 'passed', id: 'existing-verification', checkedAt: '2026-10-13T01:00:00Z', requestId: 'existing-task', message: '测试通过' }
global.fetch = jest.fn(async (url: string) => response(url.endsWith('/refresh') ? completed : next)) as typeof fetch
render(<AiModelManagementPanel />)
expect(await screen.findByText('等待上游结果,可查询')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '查询 new-vision-model · 现有上游 的资料分析结果' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' })).toBeEnabled())
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
const calls = (global.fetch as jest.Mock).mock.calls.filter(([, init]) => init.method === 'POST')
expect(calls).toHaveLength(1)
expect(calls[0][0]).toBe('/api/admin/ai-models/providers/provider-1/verifications/existing-verification/refresh')
expect(calls[0][1].body).toBeUndefined()
expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).not.toBeChecked()
})
test('每个用途显示服务端绑定协议和路由,详情默认折叠且可键盘聚焦', async () => {
const next = configured()
next.providers[0].models[2].capabilities.protocols = {
analysis: { id: 'openai-chat', path: '/v1/chat/completions', scopeId: 'scope-a', routeId: 'vision-route', operationId: 'analyze' },
copy: { id: 'openai-responses', path: '/v1/responses', routeId: 'copy-route' },
}
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const details = await screen.findByRole('button', { name: 'new-vision-model · 现有上游 资料分析测试详情' })
expect(details).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByText('协议:openai-chat')).not.toBeVisible()
details.focus()
expect(details).toHaveFocus()
fireEvent.click(details)
expect(details).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('接口:/v1/chat/completions')).toBeVisible()
expect(screen.getByText('路由:scope-a / vision-route / analyze')).toBeVisible()
fireEvent.change(screen.getByLabelText('new-vision-model · 现有上游 测试用途'), { target: { value: 'copy' } })
expect(screen.queryByText('协议:openai-chat')).not.toBeInTheDocument()
const copyDetails = screen.getByRole('button', { name: 'new-vision-model · 现有上游 卖点文案测试详情' })
expect(copyDetails).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(copyDetails)
expect(screen.getByText('协议:openai-responses')).toBeVisible()
expect(screen.getByText('路由:copy-route')).toBeVisible()
expect((global.fetch as jest.Mock).mock.calls).toHaveLength(1)
})
test.each([['tts', '语音合成'], ['asr', '语音识别'], ['music', '音乐'], ['video', '视频'], ['embedding', '向量'], ['image-tool', '图像工具'], ['three-d', '3D']] as const)('目录可筛选 %s,但未接入详情页的类型不能测试或开放', async (kind, label) => {
const next = configured()
next.providers[0].models[2].capabilities = { kind, allowedStages: [], source: 'upstream', reason: '当前详情页未接入该类型' }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const filter = await screen.findByLabelText('按能力筛选模型')
expect(within(filter).getByRole('option', { name: label })).toBeInTheDocument()
fireEvent.change(filter, { target: { value: kind } })
expect(screen.getByText('new-vision-model')).toBeInTheDocument()
expect(screen.queryByText('gemini-2.5-flash')).not.toBeInTheDocument()
expect(screen.getByText('暂未接入详情页用途')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /测试 new-vision-model/ })).not.toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).toBeDisabled()
})

View File

@ -1,7 +1,8 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Plus, RefreshCw, Save, Search } from 'lucide-react'
import { Info, Plus, RefreshCw, Save, Search } from 'lucide-react'
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
import type { AiModelAdminState, AiModelCatalogEntry, AiModelProvider, AiModelStage } from '@/lib/ai-model-types'
const stages: Array<{ id: AiModelStage; label: string; shortLabel: string }> = [
@ -11,19 +12,22 @@ const fieldClass = 'min-h-11 w-full min-w-0 rounded-lg border border-[#cfd8e3] b
const buttonClass = 'inline-flex min-h-11 shrink-0 items-center justify-center gap-2 rounded-lg border border-[#cfd8e3] bg-white px-3 text-sm font-medium text-[#344054] transition-colors hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
const primaryClass = `${buttonClass} !border-[#0050cb] !bg-[#0050cb] !text-white hover:!bg-[#003da3]`
const checkboxClass = 'h-4 w-4 shrink-0 accent-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed'
const capabilityLabels = { text: '对话', vision: '视觉理解', image: '生图', other: '其他', unknown: '待核验' } as const
const capabilityLabels = { text: '对话', vision: '视觉理解', image: '生图', tts: '语音合成', asr: '语音识别', music: '音乐', video: '视频', embedding: '向量', 'image-tool': '图像工具', 'three-d': '3D', other: '其他', unknown: '待识别' } as const
const verificationLabels = { untested: '待测试', running: '测试中', passed: '已通过', failed: '未通过', unknown: '暂停核查', stale: '需重新测试' } as const
interface ProviderForm { id: string | null; revision: number; name: string; baseUrl: string; apiKey: string }
class ModelRequestError extends Error { constructor(message: string, readonly status: number) { super(message) } }
async function request(path = '', init: RequestInit = {}): Promise<AiModelAdminState> {
const response = await fetch(`/api/admin/ai-models${path}`, { ...init, cache: 'no-store', headers: { 'Content-Type': 'application/json', ...init.headers } })
const body = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(typeof body.error === 'string' ? body.error : body.error?.message || '操作未完成,请重试')
if (!response.ok) throw new ModelRequestError(typeof body.error === 'string' ? body.error : body.error?.message || '操作未完成,请重试', response.status)
if (!Array.isArray(body.providers)) throw new Error('未能读取服务端状态')
return body as AiModelAdminState
}
function usable(provider: AiModelProvider, model: AiModelCatalogEntry, stage: AiModelStage) {
return provider.enabled && provider.hasApiKey && model.present && model.enabled && model.compatibility === 'confirmed' && model.stages.includes(stage) && model.capabilities.allowedStages.includes(stage)
return provider.enabled && provider.hasApiKey && model.present && model.enabled && model.verification[stage].status === 'passed' && model.stages.includes(stage) && model.capabilities.allowedStages.includes(stage)
}
export default function AiModelManagementPanel({ onStateChange }: { onStateChange?: (state: { dirty: boolean; busy: boolean }) => void }) {
@ -39,10 +43,23 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
const [capabilityFilter, setCapabilityFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all')
const [page, setPage] = useState(1)
const [testStages, setTestStages] = useState<Record<string, AiModelStage>>({})
const [expandedDetails, setExpandedDetails] = useState<Record<string, boolean>>({})
const busyRef = useRef(false)
const uncertainAttempts = useRef(new Map<string, { stage: AiModelStage; previousId: string | null; requestId: string }>())
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
const dirty = !!state && !!draft && JSON.stringify(state) !== JSON.stringify(draft)
function accept(next: AiModelAdminState) { setState(next); setDraft(next) }
function accept(next: AiModelAdminState) {
const resolved = { ...next, providers: next.providers.map((provider) => ({ ...provider, models: provider.models.map((model) => {
const attempt = uncertainAttempts.current.get(model.key)
if (!attempt) return model
const result = model.verification[attempt.stage]
if (result.id && result.id !== attempt.previousId) { uncertainAttempts.current.delete(model.key); return model }
return { ...model, verification: { ...model.verification, [attempt.stage]: { ...result, status: 'unknown' as const, requestId: attempt.requestId, canResume: false, message: '尚未获取本次测试记录,请核查请求编号后再操作' } } }
}) })) }
setState(resolved); setDraft(resolved)
}
useEffect(() => {
let cancelled = false
@ -57,11 +74,11 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
useEffect(() => () => { onStateChange?.({ dirty: false, busy: false }) }, [onStateChange])
useEffect(() => {
if (!dirty && !form) return
if (!dirty && !form && !busy.startsWith('verify:')) return
const prevent = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
window.addEventListener('beforeunload', prevent)
return () => window.removeEventListener('beforeunload', prevent)
}, [dirty, form])
}, [dirty, form, busy])
async function run(identity: string, action: () => Promise<AiModelAdminState>, success: string, closeForm = false) {
if (busyRef.current) return
@ -103,6 +120,32 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
void run('provider-save', () => request(form.id ? `/providers/${encodeURIComponent(form.id)}` : '/providers', { method: form.id ? 'PATCH' : 'POST', body: JSON.stringify(body) }), form.id ? '上游已保存' : '上游已添加,请同步模型目录', true)
}
async function testModel(provider: AiModelProvider, model: AiModelCatalogEntry, stage: AiModelStage) {
if (busyRef.current || dirty || form || !provider.enabled || !provider.hasApiKey || !model.present || !model.capabilities.allowedStages.includes(stage) || Object.values(model.verification).some((result) => result.status === 'running' || result.status === 'unknown')) return
busyRef.current = true
setBusy(`confirm:${model.key}`)
setTestStages((current) => ({ ...current, [model.key]: stage }))
const label = stages.find((item) => item.id === stage)!.label
const confirmation = await confirmAction({ title: `测试${label}`, description: <><span className="break-all">{model.id}</span><br />将实际调用模型,可能产生上游费用。</>, confirmLabel: '开始测试', tone: 'primary' })
if (!confirmation.confirmed) { busyRef.current = false; setBusy(''); return }
setBusy(`verify:${model.key}:${stage}`); setError(''); setNotice('')
let idempotencyKey = ''
try {
idempotencyKey = crypto.randomUUID()
const next = await request(`/providers/${encodeURIComponent(provider.id)}/models/${encodeURIComponent(model.key)}/verify`, { method: 'POST', body: JSON.stringify({ stage, revision: provider.revision, idempotencyKey }) })
accept(next)
} catch (cause) {
// Refresh state after a lost response; never repeat a potentially billable request.
const message = cause instanceof Error ? cause.message : '测试请求未完成'
if (idempotencyKey && (!(cause instanceof ModelRequestError) || cause.status >= 500)) uncertainAttempts.current.set(model.key, { stage, previousId: model.verification[stage].id, requestId: idempotencyKey })
try { accept(await request()) } catch {
const markUnknown = (current: AiModelAdminState | null) => current && ({ ...current, providers: current.providers.map((entry) => entry.id !== provider.id ? entry : { ...entry, models: entry.models.map((item) => item.key !== model.key ? item : { ...item, verification: { ...item.verification, [stage]: { ...item.verification[stage], status: 'unknown' as const, message: '未能获取测试结果,请刷新状态核查' } } }) }) })
setState(markUnknown); setDraft(markUnknown)
}
setError(`${message};已停止提交,请核查测试状态。`)
} finally { busyRef.current = false; setBusy('') }
}
const rows = useMemo(() => (draft?.providers || []).flatMap((provider) => provider.models.map((model) => ({ provider, model }))), [draft])
const visibleRows = rows.filter(({ provider, model }) => {
const query = search.trim().toLowerCase()
@ -111,13 +154,15 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
if (statusFilter === 'enabled' && !model.enabled) return false
if (statusFilter === 'disabled' && model.enabled) return false
if (statusFilter === 'missing' && model.present) return false
if (statusFilter === 'unconfirmed' && model.compatibility !== 'unconfirmed') return false
if (statusFilter === 'untested' && !model.capabilities.allowedStages.some((stage) => ['untested', 'stale'].includes(model.verification[stage].status))) return false
if (statusFilter === 'attention' && !Object.values(model.verification).some((result) => ['failed', 'unknown', 'running'].includes(result.status))) return false
return true
})
const providerBlocked = !!busy || dirty || !!form
const pageCount = Math.max(1, Math.ceil(visibleRows.length / 20))
const currentPage = Math.min(page, pageCount)
const pageRows = visibleRows.slice((currentPage - 1) * 20, currentPage * 20)
const needsStatusRefresh = rows.some(({ model }) => Object.values(model.verification).some((result) => result.status === 'running' || result.status === 'unknown'))
if (loading) return <section aria-label="正在加载 AI 模型接入" className="space-y-4 p-4 sm:p-6"><div className="h-6 w-36 motion-safe:animate-pulse rounded bg-[#e8edf3]" /><div className="h-24 motion-safe:animate-pulse rounded-lg bg-[#eef2f6]" /><div className="h-44 motion-safe:animate-pulse rounded-lg bg-[#eef2f6]" /></section>
if (!draft) return <section className="p-4 sm:p-6"><p role="alert" className="mb-3 text-sm text-[#b42318]">{error || 'AI 模型配置未能加载'}</p><button type="button" className={buttonClass} disabled={!!busy} onClick={() => void run('reload', () => request(), '已重新加载')}>重新加载</button></section>
@ -126,6 +171,7 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-base font-semibold">AI 模型接入</h3>
<div className="flex flex-wrap gap-2">
{needsStatusRefresh && <button type="button" className={buttonClass} disabled={providerBlocked} onClick={() => void run('refresh-status', () => request(), '测试状态已刷新')}><RefreshCw className="h-4 w-4" aria-hidden="true" />刷新测试状态</button>}
{draft.legacyAvailable && <button type="button" className={buttonClass} disabled={providerBlocked} onClick={() => void run('import', () => request('/import-environment', { method: 'POST' }), '当前服务端连接已迁入')} >{busy === 'import' ? '正在迁入…' : '迁入当前连接'}</button>}
<button type="button" className={buttonClass} disabled={providerBlocked || !draft.encryptionConfigured} onClick={() => { setForm({ id: null, revision: 0, name: '', baseUrl: '', apiKey: '' }); setFormError(''); setNotice('') }}><Plus className="h-4 w-4" aria-hidden="true" />新增上游</button>
</div>
@ -149,7 +195,7 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
</form>}
</section>
<section aria-label="用户可选模型" className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3"><h4 className="text-sm font-semibold text-[#344054]">用户可选模型</h4><div className="flex items-center gap-2">{dirty && <span role="status" className="text-xs text-[#66758b]">有未保存修改</span>}<button type="button" className={buttonClass} disabled={!dirty || !!busy || !!form} onClick={() => { setDraft(state); setError(''); setNotice('') }}>撤销</button><button type="button" className={primaryClass} disabled={!dirty || !!busy || !!form} onClick={() => void run('configuration', () => request('/configuration', { method: 'PUT', body: JSON.stringify({ revision: draft.revision, defaults: draft.defaults, providers: draft.providers.map((provider) => ({ id: provider.id, revision: provider.revision, models: provider.models.map(({ key, enabled, stages: roles, compatibility }) => ({ key, enabled, stages: roles, compatibility })) })) }) }), '模型配置已保存')}><Save className="h-4 w-4" aria-hidden="true" />{busy === 'configuration' ? '保存中…' : '保存配置'}</button></div></div>
<div className="flex flex-wrap items-center justify-between gap-3"><h4 className="text-sm font-semibold text-[#344054]">用户可选模型</h4><div className="flex items-center gap-2">{dirty && <span role="status" className="text-xs text-[#66758b]">有未保存修改</span>}<button type="button" className={buttonClass} disabled={!dirty || !!busy || !!form} onClick={() => { setDraft(state); setError(''); setNotice('') }}>撤销</button><button type="button" className={primaryClass} disabled={!dirty || !!busy || !!form} onClick={() => void run('configuration', () => request('/configuration', { method: 'PUT', body: JSON.stringify({ revision: draft.revision, defaults: draft.defaults, providers: draft.providers.map((provider) => ({ id: provider.id, revision: provider.revision, models: provider.models.map(({ key, enabled, stages: roles }) => ({ key, enabled, stages: roles })) })) }) }), '模型配置已保存')}><Save className="h-4 w-4" aria-hidden="true" />{busy === 'configuration' ? '保存中…' : '保存配置'}</button></div></div>
<fieldset disabled={!!busy || !!form} className="grid min-w-0 gap-3 sm:grid-cols-3">
<legend className="sr-only">阶段默认模型</legend>
{stages.map((stage) => {
@ -161,25 +207,55 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
})}
</fieldset>
{!!rows.length && <>
<div className="grid min-w-0 grid-cols-2 gap-2 sm:flex sm:flex-wrap"><label className="relative col-span-2 min-w-0 sm:flex-1 sm:basis-48"><span className="sr-only">搜索模型或上游</span><Search className="pointer-events-none absolute left-3 top-3.5 h-4 w-4 text-[#66758b]" aria-hidden="true" /><input className={`${fieldClass} !pl-9`} value={search} onChange={(event) => { setSearch(event.target.value); setPage(1) }} placeholder="搜索模型或上游" /></label><select aria-label="按能力筛选模型" className={`${fieldClass} sm:!w-auto sm:flex-none`} value={capabilityFilter} onChange={(event) => { setCapabilityFilter(event.target.value); setPage(1) }}><option value="all">全部能力</option>{Object.entries(capabilityLabels).map(([kind, label]) => <option key={kind} value={kind}>{label}</option>)}</select><select aria-label="按状态筛选模型" className={`${fieldClass} sm:!w-auto sm:flex-none`} value={statusFilter} onChange={(event) => { setStatusFilter(event.target.value); setPage(1) }}><option value="all">全部状态</option><option value="enabled">已开放</option><option value="disabled">未开放</option><option value="unconfirmed">兼容待确认</option><option value="missing">已离开目录</option></select></div>
<p className="text-xs text-[#66758b]">用户可自行选择已开放的模型;不支持的用途不可勾选。</p>
<div className="hidden grid-cols-[minmax(0,1fr)_100px_220px_156px] gap-3 border-b border-[#e2e8f0] bg-[#f8fafc] px-3 py-2 text-xs font-semibold text-[#4f6077] xl:grid" aria-hidden="true"><span>模型 / 上游</span><span>开放给用户</span><span>用户可选用途</span><span>接口兼容</span></div>
<div className="grid min-w-0 grid-cols-2 gap-2 sm:flex sm:flex-wrap"><label className="relative col-span-2 min-w-0 sm:flex-1 sm:basis-48"><span className="sr-only">搜索模型或上游</span><Search className="pointer-events-none absolute left-3 top-3.5 h-4 w-4 text-[#66758b]" aria-hidden="true" /><input className={`${fieldClass} !pl-9`} value={search} onChange={(event) => { setSearch(event.target.value); setPage(1) }} placeholder="搜索模型或上游" /></label><select aria-label="按能力筛选模型" className={`${fieldClass} sm:!w-auto sm:flex-none`} value={capabilityFilter} onChange={(event) => { setCapabilityFilter(event.target.value); setPage(1) }}><option value="all">全部能力</option>{Object.entries(capabilityLabels).map(([kind, label]) => <option key={kind} value={kind}>{label}</option>)}</select><select aria-label="按状态筛选模型" className={`${fieldClass} sm:!w-auto sm:flex-none`} value={statusFilter} onChange={(event) => { setStatusFilter(event.target.value); setPage(1) }}><option value="all">全部状态</option><option value="enabled">已开放</option><option value="disabled">未开放</option><option value="untested">待测试</option><option value="attention">待处理</option><option value="missing">已离开目录</option></select></div>
<p className="text-xs text-[#66758b]">先测试用途,再勾选开放并保存。{dirty ? ' 测试前请先保存或撤销修改。' : ''}</p>
<div className="hidden grid-cols-[minmax(0,1fr)_88px_200px_248px] gap-3 border-b border-[#e2e8f0] bg-[#f8fafc] px-3 py-2 text-xs font-semibold text-[#4f6077] xl:grid" aria-hidden="true"><span>模型 / 上游</span><span>开放给用户</span><span>用户可选用途</span><span>实际测试</span></div>
<div className="divide-y divide-[#e2e8f0] border-b border-[#e2e8f0]" aria-label="模型目录">
{!visibleRows.length && <p role="status" className="px-3 py-5 text-sm text-[#66758b]">没有匹配模型</p>}
{pageRows.map(({ provider, model }) => <div key={model.key} aria-label={`${model.id} · ${provider.name}`} className="grid min-w-0 gap-x-3 gap-y-1 px-3 py-3 sm:grid-cols-[minmax(0,1fr)_auto] xl:grid-cols-[minmax(0,1fr)_100px_220px_156px] xl:items-center">
{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 <div key={model.key} aria-label={`${model.id} · ${provider.name}`} className="grid min-w-0 gap-x-3 gap-y-1 px-3 py-3 sm:grid-cols-[minmax(0,1fr)_auto] xl:grid-cols-[minmax(0,1fr)_88px_200px_248px] xl:items-center">
<div className="min-w-0"><p className="break-all text-sm font-medium">{model.id}</p><p className="mt-1 break-words text-xs text-[#66758b]"><span title={model.capabilities.reason}>{capabilityLabels[model.capabilities.kind]}{model.capabilities.kind !== 'unknown' && !model.capabilities.allowedStages.length ? ' · 待适配' : ''}</span> · {provider.name}{!provider.enabled ? ' · 上游已停用' : ''}{!model.present ? ' · 已离开上游目录' : ''}</p><span id={`capability-${model.key}`} className="sr-only">{model.capabilities.reason}</span></div>
<label className="inline-flex min-h-11 items-center gap-2 text-sm"><input type="checkbox" className={checkboxClass} checked={model.enabled} disabled={!!busy || !!form || !model.enabled && (!model.present || !provider.enabled || !provider.hasApiKey || model.compatibility !== 'confirmed' || !model.stages.some((stage) => model.capabilities.allowedStages.includes(stage)))} aria-label={`开放 ${model.id} · ${provider.name} 给用户`} onChange={(event) => updateModel(provider.id, model.key, { enabled: event.target.checked })} />开放</label>
<label className="inline-flex min-h-11 items-center gap-2 text-sm"><input type="checkbox" className={checkboxClass} checked={model.enabled} disabled={!!busy || !!form || !model.enabled && (!model.present || !provider.enabled || !provider.hasApiKey || !model.stages.length || !model.stages.every((stage) => model.capabilities.allowedStages.includes(stage) && model.verification[stage].status === 'passed'))} aria-label={`开放 ${model.id} · ${provider.name} 给用户`} onChange={(event) => updateModel(provider.id, model.key, { enabled: event.target.checked })} />开放</label>
<fieldset disabled={!!busy || !!form} className="flex min-w-0 flex-wrap gap-x-3"><legend className="sr-only">{model.id} 的用户可选用途</legend>{stages.map((stage) => {
const supported = model.capabilities.allowedStages.includes(stage.id)
const adapted = model.capabilities.allowedStages.includes(stage.id)
const supported = adapted && model.verification[stage.id].status === 'passed'
const checked = model.stages.includes(stage.id)
const reason = model.capabilities.kind === 'unknown' ? '能力待核验,暂不能开放此用途' : stage.id === 'image' && (model.capabilities.kind === 'text' || model.capabilities.kind === 'vision') ? '不支持图片输出' : '当前详情页未适配此用途'
return <label key={stage.id} title={supported ? undefined : reason} className={`inline-flex min-h-11 items-center gap-1.5 text-xs ${!supported ? 'text-[#66758b]' : ''}`}><input type="checkbox" className={checkboxClass} aria-label={`${model.id} · ${provider.name} 用于${stage.label}`} aria-describedby={`capability-${model.key}`} checked={checked} disabled={!supported && !checked} onChange={(event) => { if (!event.target.checked || supported) updateModel(provider.id, model.key, { stages: event.target.checked ? [...model.stages, stage.id] : model.stages.filter((value) => value !== stage.id) }) }} />{stage.shortLabel}</label>
const reason = supported ? `${stage.label}测试已通过` : adapted ? `${stage.label}:${verificationLabels[model.verification[stage.id].status]},通过后可开放` : model.capabilities.allowedStages.length ? `此模型不支持${stage.label}` : model.capabilities.reason
return <label key={stage.id} title={reason} className={`inline-flex min-h-11 items-center gap-1.5 text-xs ${!supported ? 'text-[#66758b]' : ''}`}><input type="checkbox" className={checkboxClass} aria-label={`${model.id} · ${provider.name} 用于${stage.label}`} aria-describedby={`purpose-${model.key}-${stage.id}`} checked={checked} disabled={!supported && !checked} onChange={(event) => { if (!event.target.checked || supported) updateModel(provider.id, model.key, { stages: event.target.checked ? [...model.stages, stage.id] : model.stages.filter((value) => value !== stage.id) }) }} />{stage.shortLabel}<span id={`purpose-${model.key}-${stage.id}`} className="sr-only">{reason}</span></label>
})}</fieldset>
<label className="inline-flex min-h-11 items-center gap-2 text-xs"><input type="checkbox" className={checkboxClass} disabled={!!busy || !!form} checked={model.compatibility === 'confirmed'} aria-label={`确认 ${model.id} · ${provider.name} 接口兼容`} onChange={(event) => updateModel(provider.id, model.key, { compatibility: event.target.checked ? 'confirmed' : 'unconfirmed', ...(!event.target.checked ? { enabled: false } : {}) })} />管理员已确认</label>
</div>)}
<div className="min-w-0 space-y-1.5">
{selectedStage && result ? <>
<div className="flex min-w-0 items-center gap-2">
<select aria-label={`${model.id} · ${provider.name} 测试用途`} className={`${fieldClass} !px-2`} disabled={!!busy || unsettled} value={selectedStage.id} onChange={(event) => setTestStages((current) => ({ ...current, [model.key]: event.target.value as AiModelStage }))}>
{testableStages.map((stage) => <option key={stage.id} value={stage.id}>{stage.shortLabel} · {verificationLabels[model.verification[stage.id].status]}</option>)}
</select>
{result.canResume && result.id ? <button type="button" className={buttonClass} disabled={providerBlocked || !provider.enabled || !provider.hasApiKey} aria-label={`查询 ${model.id} · ${provider.name} 的${selectedStage.label}结果`} onClick={() => {
setTestStages((current) => ({ ...current, [model.key]: selectedStage.id }))
void run(`result:${model.key}`, () => request(`/providers/${encodeURIComponent(provider.id)}/verifications/${encodeURIComponent(result.id!)}/refresh`, { method: 'POST' }), '')
}}>{busy === `result:${model.key}` ? '查询中…' : '查询结果'}</button> : <button type="button" className={buttonClass} disabled={blockedTest} aria-label={`测试 ${model.id} · ${provider.name} 的${selectedStage.label}`} onClick={() => void testModel(provider, model, selectedStage.id)}>{testing || result.status === 'running' ? '测试中…' : result.status === 'passed' || result.status === 'stale' ? '重测' : '测试'}</button>}
{canInspect && <button type="button" className={`${buttonClass} !w-11 !px-0`} aria-label={`${model.id} · ${provider.name} ${selectedStage.label}测试详情`} aria-expanded={!!expandedDetails[detailsId]} aria-controls={detailsId} title="测试详情" onClick={() => setExpandedDetails((current) => ({ ...current, [detailsId]: !current[detailsId] }))}><Info className="h-4 w-4" aria-hidden="true" /></button>}
</div>
<p role="status" className={`text-xs ${result.status === 'passed' ? 'text-[#087052]' : result.status === 'failed' || result.status === 'unknown' ? 'text-[#b42318]' : 'text-[#66758b]'}`}>{testing ? '正在调用模型,请稍候' : result.status === 'unknown' ? result.canResume ? '等待上游结果,可查询' : '结果未知,暂停核查' : result.status === 'running' ? '测试进行中,可刷新状态' : `${selectedStage.shortLabel} · ${verificationLabels[result.status]}`}</p>
{canInspect && <div id={detailsId} hidden={!expandedDetails[detailsId]} className="space-y-1 break-words pb-2 text-xs text-[#66758b]">
{protocol && <><p className="break-all">协议:{protocol.id}</p><p className="break-all">接口:{protocol.path}</p>{(protocol.scopeId || protocol.routeId || protocol.operationId) && <p className="break-all">路由:{[protocol.scopeId, protocol.routeId, protocol.operationId].filter(Boolean).join(' / ')}</p>}</>}
{result.message && <p>{result.message}</p>}{result.checkedAt && <p>{new Date(result.checkedAt).toLocaleString('zh-CN')}</p>}{result.requestId && <p className="break-all">请求编号:{result.requestId}</p>}
</div>}
</> : <p className="text-xs text-[#66758b]">{model.capabilities.kind === 'unknown' ? '能力待识别,暂不可测试' : '暂未接入详情页用途'}</p>}
</div>
</div>})}
</div>
<nav aria-label="模型目录分页" className="flex flex-wrap items-center justify-between gap-3 text-sm"><span className="text-xs text-[#66758b]">{visibleRows.length} 个模型 · 第 {currentPage} / {pageCount} 页</span><div className="flex gap-2"><button type="button" className={buttonClass} disabled={currentPage <= 1} onClick={() => setPage(currentPage - 1)}>上一页</button><button type="button" className={buttonClass} disabled={currentPage >= pageCount} onClick={() => setPage(currentPage + 1)}>下一页</button></div></nav>
</>}
</section>
{confirmActionDialog}
</section>
}

View File

@ -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`。目录同步为只读查询,脚本不提交生成任务。
是否可向用户开放,以当前服务端用途验证记录为准。实际执行过哪些模型及产品验收项目,须另附对应记录,不从目录数量、适配协议数量或部署成功推断。

View File

@ -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 解析或部署成功推断。
本次真实产品只验证一屏及其重生成,不据此宣称完整多屏的品牌风格一致性已验收。

View File

@ -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 被记录在当次发布记录中。

View File

@ -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<typeof model>, 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'])

View File

@ -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()
})

View File

@ -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 })
})

View File

@ -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 () => {

View File

@ -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 })
})

View File

@ -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

View File

@ -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)
})

View File

@ -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<string, unknown> | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : 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<string, unknown>, section: Record<string, unknown>, 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<string,
const offers = Array.isArray(model.service_offers) ? model.service_offers.map(object).filter((offer): offer is Record<string, unknown> => 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<string,
const normalizeModes = (modes: string[]) => 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
}

View File

@ -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<void>): Promise<Response> {
export async function requestAiModel(baseUrl: string, apiKey: string, endpoint: '/models' | '/chat/completions' | '/messages' | '/images/edits' | '/images/generations', body?: string | FormData, idempotencyKey?: string, beforeRequest?: () => Promise<void>): Promise<Response> {
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<string, string>, beforeRequest?: () => Promise<void>): Promise<Response> {
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<string, string> = { 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<void>): Promise<Response> {
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<void>): Promise<Response> {
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<string, string>, catalog: boolean, body?: string | FormData, beforeRequest?: () => Promise<void>): Promise<Response> {
async function requestAiModelUrl(normalized: string, target: URL, headers: Record<string, string>, catalog: boolean, body?: string | FormData, beforeRequest?: () => Promise<void>, timeoutMs?: number): Promise<Response> {
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()
})

230
lib/ai-model-protocol.ts Normal file
View File

@ -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<string, string>
inputField?: string
supportsImageInput: boolean
scopeId?: string
routeId?: string
operationId?: string
maxImages?: number
maxPromptLength?: number
}
const paths: Record<AiModelProtocol['id'], string> = { '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<string, any> | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, any> : null }
function list(value: unknown): string[] { return Array.isArray(value) && value.every(item => typeof item === 'string') ? value : [] }
function safeHeaders(value: unknown): Record<string, string> | null {
const raw = record(value)
if (!raw) return null
const result: Record<string, string> = {}
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<string, any>, offer: Record<string, any>, route: Record<string, any>): 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<string, any>[] : []
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<string, any>[] : []
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<void>
onTaskSubmitted?: (taskId: string) => Promise<void>; resumeTaskId?: string
maxOutputTokens?: number
}
export interface AiModelProtocolResult { text?: string; image?: Buffer; requestId: string | null; summary: Record<string, unknown> }
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<Buffer[]> {
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<Buffer> {
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<AiModelProtocolResult> {
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)
}
}

View File

@ -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<AiModelCatalogEntry, 'capabilities'> { providerId: string; capabilityMetadata?: unknown }
export interface AiModelRow extends Omit<AiModelCatalogEntry, 'capabilities' | 'verification'> { 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<AiModelRow[]> {
return db.$queryRawUnsafe<AiModelRow[]>(`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<AiModelRow[]>(`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<AiModelConfigurationRow> {
const rows = await db.$queryRawUnsafe<Array<{ revision: number; analysis: string | null; copy: string | null; image: string | null }>>(`SELECT revision, default_analysis AS analysis, default_copy AS copy, default_image AS image FROM ai_model_configuration WHERE id = 'detail-generator'`)

View File

@ -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<T>(schema: z.ZodType<T>, 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<AiModelAdminState> {
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<AiModelA
const modelKey = randomUUID(); imported.set(modelId, modelKey)
const allowed = getAiModelCapabilities(provider.baseUrl, modelId).allowedStages
const selected = AI_MODEL_STAGES.filter(stage => 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<void> {
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<AiModelRuntimeState> {
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<AiModelRuntimeState> {
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<AiModelStage, string>): Promise<AiModelBindings> {
const values = await Promise.all(AI_MODEL_STAGES.map(async stage => [stage, (await resolveAiModel(stage, models[stage])).binding] as const))

View File

@ -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<AiModelStage, string | null>
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<Record<AiModelStage, { id: string; path: string; scopeId?: string; routeId?: string; operationId?: string }>>
}
export interface AiModelCatalogEntry {
key: string
@ -13,7 +24,7 @@ export interface AiModelCatalogEntry {
enabled: boolean
stages: AiModelStage[]
present: boolean
compatibility: 'confirmed' | 'unconfirmed'
verification: Record<AiModelStage, AiModelVerificationSummary>
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<AiModelStage, AiModelChoice[]>
legacyModels: Record<AiModelStage, string[]>
}
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<AiModelStage, AiModelBinding>

View File

@ -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<string, unknown>; 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<AiModelVerificationRow[]> {
return db.$queryRawUnsafe<AiModelVerificationRow[]>(`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 }
}

View File

@ -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<void> {
await detailTransactionLock(db, 'detail-generator-execution')
const rows = await db.$queryRawUnsafe<Array<{ count: number; own: number }>>(`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<Buffer> {
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<Record<string, unknown>> {
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<void> {
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<AiModelVerificationRow[]>(`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<Array<{ count: number }>>(`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<AiModelVerificationRow[]>(`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<AiModelVerificationRow[]>(`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()
}

View File

@ -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<DetailProviderImage[]> {
const ids = [...document.product.assetIds, ...document.product.referenceAssetIds].slice(0, 8)
@ -55,12 +56,15 @@ export async function processDetailJob(current: StoredDetailJob): Promise<void>
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<void>
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<void>
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<void>
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<void>
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

View File

@ -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<any> {
async function callProvider(stage: AiModelStage, selection: string, prompt: string, images: DetailProviderImage[], idempotencyKey: string, ownerId: string, expected?: AiModelBinding, execution?: { onTaskSubmitted?: (taskId: string) => Promise<void>; 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<string> {
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<DetailAnalysis> {
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<Buffer> {
export async function generateDetailImage(document: DetailDocument, prompt: string, images: DetailProviderImage[], idempotencyKey: string, ownerId: string, expected?: AiModelBinding, execution?: { onTaskSubmitted?: (taskId: string) => Promise<void>; resumeTaskId?: string }): Promise<Buffer> {
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')

View File

@ -154,10 +154,17 @@ export async function enqueueDetailJob(input: { projectId: string; ownerId: stri
export async function claimDetailJob(): Promise<StoredDetailJob | null> {
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<Row[]>('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<Row[]>(`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<Row[]>(`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<Row[]>(`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<Row[]>(`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<string, unknown>): Promise<void> {
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<string, unknown>, progress: number): Promise<DetailProject> {
return prisma.$transaction(async tx => {
const rows = await tx.$queryRawUnsafe<Row[]>(`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)

View File

@ -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<string, unknown>): DetailUpstreamTask | null {
const value = checkpoint.upstreamTask as Partial<DetailUpstreamTask> | 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
}

View File

@ -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),

View File

@ -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');

View File

@ -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")

View File

@ -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/)
})

View File

@ -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'),
],

View File

@ -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)

View File

@ -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}`))
}

View File

@ -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)

View File

@ -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'")

View File

@ -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<string, string>()
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<ReturnType<typeof request>>): 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<Array<{count:number}>>('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<Array<{count:number}>>('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())