feat(ai): verify model capabilities through real protocol tests
This commit is contained in:
parent
9270bbb457
commit
94ea82e7cb
@ -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
|
||||
|
||||
@ -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)))
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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`。目录同步为只读查询,脚本不提交生成任务。
|
||||
是否可向用户开放,以当前服务端用途验证记录为准。实际执行过哪些模型及产品验收项目,须另附对应记录,不从目录数量、适配协议数量或部署成功推断。
|
||||
|
||||
24
docs/ai-model-verification-acceptance-2026-10-13.md
Normal file
24
docs/ai-model-verification-acceptance-2026-10-13.md
Normal 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 解析或部署成功推断。
|
||||
本次真实产品只验证一屏及其重生成,不据此宣称完整多屏的品牌风格一致性已验收。
|
||||
@ -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 被记录在当次发布记录中。
|
||||
|
||||
|
||||
@ -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'])
|
||||
|
||||
71
lib/__tests__/ai-model-protocol-http.test.ts
Normal file
71
lib/__tests__/ai-model-protocol-http.test.ts
Normal 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()
|
||||
})
|
||||
157
lib/__tests__/ai-model-protocol.test.ts
Normal file
157
lib/__tests__/ai-model-protocol.test.ts
Normal 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 })
|
||||
})
|
||||
@ -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 () => {
|
||||
|
||||
133
lib/__tests__/ai-model-verification.test.ts
Normal file
133
lib/__tests__/ai-model-verification.test.ts
Normal 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 })
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
230
lib/ai-model-protocol.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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'`)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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>
|
||||
|
||||
37
lib/ai-model-verification-state.ts
Normal file
37
lib/ai-model-verification-state.ts
Normal 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 }
|
||||
}
|
||||
136
lib/ai-model-verification.ts
Normal file
136
lib/ai-model-verification.ts
Normal 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()
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
15
lib/detail-generator/upstream-task.ts
Normal file
15
lib/detail-generator/upstream-task.ts
Normal 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
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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');
|
||||
@ -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")
|
||||
|
||||
@ -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/)
|
||||
})
|
||||
|
||||
@ -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'),
|
||||
],
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}`))
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'")
|
||||
|
||||
84
scripts/verify-ai-model-integration.ts
Normal file
84
scripts/verify-ai-model-integration.ts
Normal 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())
|
||||
Loading…
x
Reference in New Issue
Block a user