fix(ai): validate every reference image and enforce tested limits

This commit is contained in:
WuYongJie 2026-10-15 15:37:39 +08:00
parent d477231b66
commit 46e8e02779
20 changed files with 412 additions and 41 deletions

View File

@ -1,7 +1,8 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { refreshAiModelVerification } 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; verificationId: string } }) {
return aiModelAdminRoute(async userId => refreshAiModelVerification(userId, context.params.id, context.params.verificationId))
export async function POST(request: Request, context: { params: { id: string; verificationId: string } }) {
return aiModelAdminRoute(async userId => refreshAiModelVerification(userId, context.params.id, context.params.verificationId, request.body ? await parseDetailJson(request) : {}))
}

View File

@ -19,6 +19,34 @@ const configured = (): AiModelAdminState => ({
afterEach(() => jest.restoreAllMocks())
beforeEach(() => sessionStorage.clear())
test.each(['untested', 'passed'] as const)('参考图数量以实测证据为准,%s 不把平台测试上限当成已支持', async status => {
const next = configured(), model = next.providers[0].models[1]
model.capabilities.protocols = { image: { id: 'openai-image-generate', path: '/v1/images/generations', maxImages: 8, referenceLimitSource: 'verification' } }
model.verification.image = { ...model.verification.image, status, ...(status === 'passed' ? { verifiedReferenceImageCount: 4 } : {}) }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const row = await screen.findByLabelText('nano-banana-2 · 现有上游', { selector: 'div' })
fireEvent.click(within(row).getByRole('button', { name: 'nano-banana-2 · 现有上游 图片生成测试详情' }))
expect(within(row).getByText(status === 'passed' ? '参考图:已逐张验证 4 张,本平台最多 4 张' : '参考图:数量待测试(本平台最多测试 8 张)')).toBeVisible()
})
test('数量选择、费用确认和实际测试请求使用同一个参考图数量', async () => {
const next = configured(), model = next.providers[0].models[1]
model.capabilities.protocols = { image: { id: 'openai-image-generate', path: '/v1/images/generations', maxImages: 8, referenceLimitSource: 'verification' } }
model.verification.image.verifiedReferenceImageCount = 4
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const row = await screen.findByLabelText('nano-banana-2 · 现有上游', { selector: 'div' })
fireEvent.click(within(row).getByRole('button', { name: 'nano-banana-2 · 现有上游 图片生成测试详情' }))
fireEvent.change(within(row).getByRole('combobox', { name: 'nano-banana-2 · 现有上游 本次测试参考图数量' }), { target: { value: '2' } })
fireEvent.click(within(row).getByRole('button', { name: '测试 nano-banana-2 · 现有上游 的图片生成' }))
const dialog = await screen.findByRole('dialog')
expect(dialog).toHaveTextContent('逐张校验 2 张参考图,输出一张拼图。')
fireEvent.click(within(dialog).getByRole('button', { name: '开始测试' }))
await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1))
expect(JSON.parse((global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/verify'))[1].body)).toMatchObject({ stage: 'image', referenceImageCount: 2 })
})
function jointConfiguration() {
const next = configured()
next.providers[0].models[2].capabilities.combinedTextTest = true

View File

@ -32,7 +32,7 @@ function needsReferenceAdaptation(model: AiModelCatalogEntry) {
}
interface ProviderForm { id: string | null; revision: number; name: string; baseUrl: string; apiKey: string }
interface RecoveryForm { providerId: string; verificationId: string; modelId: string; outcome: 'not_executed' | 'failed' | 'completed'; reference: string; note: string; confirmed: boolean }
interface RecoveryForm { providerId: string; verificationId: string; modelId: string; outcome: 'not_executed' | 'failed' | 'completed'; reference: string; note: string; confirmed: boolean; canFetchImage: boolean; requestId: string }
const pendingStorageKey = 'ai-model-pending-submissions-v1'
class ModelRequestError extends Error { constructor(message: string, readonly status: number) { super(message) } }
@ -63,6 +63,7 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
const [statusFilter, setStatusFilter] = useState('all')
const [page, setPage] = useState(1)
const [testStages, setTestStages] = useState<Record<string, AiModelTestPurpose>>({})
const [testReferenceCounts, setTestReferenceCounts] = useState<Record<string, number>>({})
const [expandedDetails, setExpandedDetails] = useState<Record<string, boolean>>({})
const busyRef = useRef(false)
const uncertainAttempts = useRef(new Map<string, { stage: AiModelTestPurpose; previousId: string | null; requestId: string }>())
@ -145,7 +146,7 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
accept(next, options.preserveDraft)
setNotice(success)
if (identity.startsWith('submission:')) setNotice(next.submissionCheck?.received ? '已找到原测试记录' : '原提交未被受理,已关闭,可以重新测试')
if (identity === 'reconcile') setRecovery(null)
if (identity === 'reconcile' || identity === 'recover-result') setRecovery(null)
if (options.closeForm) setForm(null)
} catch (cause) {
const message = cause instanceof Error ? cause.message : '操作未完成,请重试'
@ -188,14 +189,15 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
setBusy(`confirm:${model.key}`)
setTestStages((current) => ({ ...current, [model.key]: stage }))
const label = purposeLabel(stage), attemptedStages = aiModelTestStages(stage)
const confirmation = await confirmAction({ title: `测试${label}`, description: <><span className="break-all">{model.id}</span><br />{stage === 'analysis-copy' && <>一次调用,分别验证读图和文案能力。<br /></>}将实际调用模型,可能产生上游费用。</>, confirmLabel: '开始测试', tone: 'primary' })
const referenceImageCount = stage === 'image' && model.capabilities.protocols?.image?.referenceLimitSource === 'verification' ? testReferenceCounts[model.key] ?? model.verification.image.verifiedReferenceImageCount ?? model.capabilities.protocols.image.maxImages : undefined
const confirmation = await confirmAction({ title: `测试${label}`, description: <><span className="break-all">{model.id}</span><br />{stage === 'analysis-copy' && <>一次调用,分别验证读图和文案能力。<br /></>}{referenceImageCount && <>逐张校验 {referenceImageCount} 张参考图,输出一张拼图。<br /></>}将实际调用模型,可能产生上游费用。</>, confirmLabel: '开始测试', tone: 'primary' })
if (!confirmation.confirmed) { busyRef.current = false; setBusy(''); return }
setBusy(`verify:${model.key}:${stage}`); setError(''); setNotice('')
let idempotencyKey = ''
try {
idempotencyKey = createBrowserUuid()
uncertainAttempts.current.set(model.key, { stage, previousId: savedModel.verification[attemptedStages[0]].id, requestId: idempotencyKey }); rememberAttempts()
const next = await request(`/providers/${encodeURIComponent(provider.id)}/models/${encodeURIComponent(model.key)}/verify`, { method: 'POST', body: JSON.stringify({ stage, revision: savedProvider.revision, idempotencyKey }) })
const next = await request(`/providers/${encodeURIComponent(provider.id)}/models/${encodeURIComponent(model.key)}/verify`, { method: 'POST', body: JSON.stringify({ stage, revision: savedProvider.revision, idempotencyKey, ...(referenceImageCount ? { referenceImageCount } : {}) }) })
uncertainAttempts.current.delete(model.key); rememberAttempts()
accept(next, true)
} catch (cause) {
@ -256,6 +258,10 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
void run('reconcile', () => request(`/providers/${encodeURIComponent(recovery.providerId)}/verifications/${encodeURIComponent(recovery.verificationId)}/reconcile`, { method: 'POST', body: JSON.stringify({ outcome: recovery.outcome, reference: recovery.reference, note: recovery.note, confirmed: recovery.confirmed }) }), '核查已记录,可以重新测试', { preserveDraft: true })
}}>
<h4 className="break-all text-sm font-semibold">核查原请求 · {recovery.modelId}</h4>
{recovery.canFetchImage && <div className="space-y-2">
<p className="text-xs text-[#66758b]">有客户请求编号时,可取回原图并校验,不会重新生成。</p>
<div className="flex min-w-0 flex-wrap items-end gap-2"><label className="min-w-0 flex-1 basis-64 space-y-1.5 text-sm">客户请求编号<input className={fieldClass} value={recovery.requestId} disabled={!!busy} maxLength={36} placeholder="填写已核实的原请求编号" onChange={event => setRecovery({ ...recovery, requestId: event.target.value.trim() })} /></label><button type="button" className={buttonClass} disabled={!!busy || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(recovery.requestId)} onClick={() => void run('recover-result', () => request(`/providers/${encodeURIComponent(recovery.providerId)}/verifications/${encodeURIComponent(recovery.verificationId)}/refresh`, { method: 'POST', body: JSON.stringify({ requestId: recovery.requestId }) }), '原请求查询已完成,请查看测试状态', { preserveDraft: true })}>{busy === 'recover-result' ? '取回中…' : '取回原结果'}</button></div>
</div>}
<p className="text-xs text-[#66758b]">先在上游核查并确认请求已结束。此操作不会通过接口测试。</p>
<div className="grid gap-3 sm:grid-cols-2"><label className="space-y-1.5 text-sm">上游核查结论<select className={fieldClass} value={recovery.outcome} disabled={!!busy} onChange={event => setRecovery({ ...recovery, outcome: event.target.value as RecoveryForm['outcome'] })}><option value="not_executed">未执行</option><option value="failed">已失败</option><option value="completed">已完成,结果未取回</option></select></label><label className="space-y-1.5 text-sm">凭证编号<input autoFocus required minLength={3} maxLength={160} placeholder="工单或上游日志编号" className={fieldClass} value={recovery.reference} disabled={!!busy} onChange={event => setRecovery({ ...recovery, reference: event.target.value })} /></label></div>
<label className="block space-y-1.5 text-sm">核查说明<textarea required minLength={10} maxLength={500} rows={2} className={fieldClass} value={recovery.note} disabled={!!busy} placeholder="填写核查来源和结果,不要填写密钥" onChange={event => setRecovery({ ...recovery, note: event.target.value })} /></label>
@ -343,7 +349,7 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
{attempt ? <button type="button" className={buttonClass} disabled={statusBlocked} aria-label={`核查 ${model.id} · ${provider.name} 的测试提交`} onClick={() => void run(`submission:${model.key}`, () => request(`/providers/${encodeURIComponent(provider.id)}/models/${encodeURIComponent(model.key)}/submission`, { method: 'POST', body: JSON.stringify({ stage: attempt.stage, idempotencyKey: attempt.requestId }) }), '', { preserveDraft: true })}>核查提交</button> : result.canResume && result.id ? <button type="button" className={buttonClass} disabled={statusBlocked || !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' }), '', { preserveDraft: true })
}}>{busy === `result:${model.key}` ? '查询中…' : '查询结果'}</button> : result.canReconcile && result.id ? <button type="button" className={buttonClass} disabled={statusBlocked} aria-label={`核查 ${model.id} · ${provider.name} 的${selectedStage.label}结果`} onClick={() => { setError(''); setNotice(''); setRecovery({ providerId: provider.id, verificationId: result.id!, modelId: model.id, outcome: 'not_executed', reference: '', note: '', confirmed: false }) }}>核查结果</button> : <button type="button" className={buttonClass} disabled={!!blockedReason} aria-describedby={showBlockedReason ? blockedReasonId : testStatusId} aria-label={`测试 ${model.id} · ${provider.name} 的${selectedStage.label}`} onClick={() => void testModel(provider, model, selectedStage.id)}>{testing || result.status === 'running' ? '测试中…' : retest ? '重测' : '测试'}</button>}
}}>{busy === `result:${model.key}` ? '查询中…' : '查询结果'}</button> : result.canReconcile && result.id ? <button type="button" className={buttonClass} disabled={statusBlocked} aria-label={`核查 ${model.id} · ${provider.name} 的${selectedStage.label}结果`} onClick={() => { setError(''); setNotice(''); setRecovery({ providerId: provider.id, verificationId: result.id!, modelId: model.id, outcome: 'not_executed', reference: '', note: '', confirmed: false, canFetchImage: selectedStage.id === 'image' && provider.enabled && provider.hasApiKey && model.present && ['openai-image-generate', 'openai-image-edit'].includes(model.capabilities.protocols?.image?.id || ''), requestId: result.requestId || '' }) }}>核查结果</button> : <button type="button" className={buttonClass} disabled={!!blockedReason} aria-describedby={showBlockedReason ? blockedReasonId : testStatusId} aria-label={`测试 ${model.id} · ${provider.name} 的${selectedStage.label}`} onClick={() => void testModel(provider, model, selectedStage.id)}>{testing || result.status === 'running' ? '测试中…' : retest ? '重测' : '测试'}</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 id={testStatusId} role="status" className={`flex flex-wrap gap-x-3 gap-y-1 text-xs ${verificationColor(result)}`}>{testing ? '正在调用模型,请稍候' : attempt ? '提交结果未确认,请核查' : selectedStage.id === 'analysis-copy' ? testedStages.map(stage => {
@ -352,7 +358,8 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
}) : result.status === 'running' ? '测试进行中,自动更新状态' : verificationLabel(result)}</p>
{showBlockedReason && <p id={blockedReasonId} className="text-xs text-[#66758b]">{blockedReason}</p>}
{canInspect && <div id={detailsId} hidden={!expandedDetails[detailsId]} className="space-y-1 break-words pb-2 text-xs text-[#66758b]">
{protocol && <>{protocol.maxImages && <p>参考图:最多 {protocol.maxImages} 张</p>}<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>}</>}
{protocol && <>{protocol.maxImages && <p>{protocol.referenceLimitSource === 'verification' ? result.status === 'passed' && result.verifiedReferenceImageCount ? `参考图:已逐张验证 ${result.verifiedReferenceImageCount} 张,本平台最多 ${Math.min(protocol.maxImages, result.verifiedReferenceImageCount)} 张` : `参考图:数量待测试(本平台最多测试 ${protocol.maxImages} 张)` : `参考图:最多 ${protocol.maxImages} 张`}</p>}<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>}</>}
{protocol?.referenceLimitSource === 'verification' && <label className="flex flex-wrap items-center gap-2"><span>本次测试数量</span><select className={`${fieldClass} !w-auto`} aria-label={`${model.id} · ${provider.name} 本次测试参考图数量`} disabled={!!busy || result.status === 'running' || result.status === 'unknown' || !!attempt} value={testReferenceCounts[model.key] ?? result.verifiedReferenceImageCount ?? protocol.maxImages} onChange={event => setTestReferenceCounts(current => ({ ...current, [model.key]: Number(event.target.value) }))}>{[1, 2, 4, 8].filter(count => count <= (protocol.maxImages ?? 8)).map(count => <option key={count} value={count}>{count} 张</option>)}</select></label>}
{testedStages.map(stage => { const value = model.verification[stage]; return <div key={stage} className="space-y-1">{selectedStage.id === 'analysis-copy' && <p className="font-medium text-[#344054]">{stages.find(entry => entry.id === stage)!.label}</p>}{value.reconciled && <p>历史请求经人工核查结束,尚需重新测试。</p>}{value.message && <p>{value.message}</p>}{value.checkedAt && <p>{new Date(value.checkedAt).toLocaleString('zh-CN')}</p>}{value.requestId && <p className="break-all">请求编号:{value.requestId}</p>}</div> })}
{result.taskId && <p className="break-all">上游任务:{result.taskId}</p>}
</div>}

View File

@ -49,6 +49,24 @@ test('evidence-based recovery never enables or approves a model, keeps pending c
expect((global.fetch as jest.Mock).mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1)
expect((global.fetch as jest.Mock).mock.calls.some(([url]) => url.endsWith('/verify'))).toBe(false)
})
test('a known customer image request is retrieved without generation or automatic enabling', async () => {
const requestId = '1c8c2fa5-5ad0-436b-977f-a047a64fa845', unknown = snapshot(), model = unknown.providers[0].models[0]
model.id = 'image-model'; model.capabilities = { kind: 'image', allowedStages: ['image'], source: 'upstream', reason: '', protocols: { image: { id: 'openai-image-generate', path: '/v1/images/generations' } } }
model.verification.image = { status: 'unknown', id: 'verification', checkedAt: null, requestId: null, message: '连接中断', canReconcile: true }
const passed = JSON.parse(JSON.stringify(unknown)); passed.providers[0].models[0].verification.image = { ...model.verification.image, status: 'passed', requestId, canReconcile: false, verifiedReferenceImageCount: 4 }
global.fetch = jest.fn(async (url: string) => response(url.endsWith('/refresh') ? passed : unknown)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '核查 image-model · 平台模型服务 的图片生成结果' }))
expect(screen.getByRole('button', { name: '取回原结果' })).toBeDisabled()
fireEvent.change(screen.getByLabelText('客户请求编号'), { target: { value: requestId } })
fireEvent.click(screen.getByRole('button', { name: '取回原结果' }))
await screen.findByText('原请求查询已完成,请查看测试状态')
const posts = (global.fetch as jest.Mock).mock.calls.filter(([, init]) => init?.method === 'POST')
expect(posts).toHaveLength(1); expect(posts[0][0]).toMatch(/\/refresh$/)
expect(JSON.parse(posts[0][1].body)).toEqual({ requestId })
expect(screen.getByRole('checkbox', { name: '开放 image-model · 平台模型服务 给用户' })).not.toBeChecked()
})
test('a running request polls read-only state without resubmitting; unmount cancels polling', async () => {
jest.useFakeTimers()
const running = snapshot(); running.providers[0].models[0].verification.analysis.status = 'running'

View File

@ -75,10 +75,11 @@ test('a standard reference probe is testable without claiming a declared or alre
const evidence = { ...metadata(['text'], ['image'], ['/images/generations']), protocols: [{
id: 'openai-image-generate', path: '/v1/images/generations', headers: { 'X-Relay-Route-Option': 'candidate-route' }, supportsImageInput: true,
inputField: 'image', referenceTransport: 'json-inline', referenceContract: 'probe-required',
requestParameters: { n: 1, response_format: 'b64_json' }, maxImages: 1, scopeId: 'candidate-scope', routeId: 'candidate-route',
requestParameters: { n: 1, response_format: 'b64_json' }, maxImages: 8, scopeId: 'candidate-scope', routeId: 'candidate-route',
}] }
expect(getAiModelCapabilities(base, 'standard-reference-candidate', evidence)).toMatchObject({
kind: 'image', allowedStages: ['image'], reason: '参考图采用标准图片参数;通过实际测试后可开放',
protocols: { image: { maxImages: 8, referenceLimitSource: 'verification' } },
})
expect(getAiModelCapabilities(base, 'unavailable-candidate', { ...evidence, unavailable: true })).toMatchObject({
allowedStages: [], reason: '上游当前不可用,暂不能测试或开放',

View File

@ -0,0 +1,49 @@
/** @jest-environment node */
import sharp from 'sharp'
import { createMultiReferenceChallenge, multiReferenceGrid, multiReferenceImages, multiReferencePrompt, readMultiReferenceChallenge, validateMultiReferenceOutput } from '@/lib/ai-model-multireference'
import { verifiedReferenceImageCount } from '@/lib/ai-model-verification-state'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
async function composite(challenge: ReturnType<typeof createMultiReferenceChallenge>, replacement?: 'repeat-first' | 'reverse' | 'omit-last') {
const { columns, rows } = multiReferenceGrid(challenge.references.length)
let images = await multiReferenceImages(challenge)
if (replacement === 'repeat-first') images = images.map(() => images[0])
if (replacement === 'reverse') images.reverse()
if (replacement === 'omit-last') images.pop()
return sharp({ create: { width: columns * 512, height: rows * 512, channels: 3, background: '#fff' } }).composite(images.map((image, i) => ({ input: image.buffer, left: i % columns * 512, top: Math.floor(i / columns) * 512 }))).png().toBuffer()
}
test.each([2, 4, 8])('all %i independently supplied references must match their ordered tiles', async count => {
const challenge = createMultiReferenceChallenge(count)
expect(readMultiReferenceChallenge(JSON.parse(JSON.stringify(challenge)))).toEqual(challenge)
expect(new Set(challenge.references.map(value => `${value.topLeft}/${value.bottomRight}`)).size).toBe(count)
const prompt = multiReferencePrompt(challenge)
expect(prompt).not.toMatch(/red|blue|green|yellow|红色|蓝色|绿色|黄色/i)
const correct = await composite(challenge)
await expect(validateMultiReferenceOutput(correct, challenge)).resolves.toMatchObject({ referenceImageCount: count, allReferenceImagesMatched: true, referenceMatches: Array(count).fill(true) })
const padded = await sharp(correct).extend({ top: 100, bottom: 700, left: 60, right: 60, background: '#fff' }).png().toBuffer()
await expect(validateMultiReferenceOutput(padded, challenge)).resolves.toMatchObject({ referenceImageCount: count, allReferenceImagesMatched: true })
for (const mutation of ['repeat-first', 'reverse', 'omit-last'] as const) await expect(validateMultiReferenceOutput(await composite(challenge, mutation), challenge)).rejects.toMatchObject({ code: 'verification_output_invalid' })
})
test('invalid counts and duplicated checkpoints are rejected before a model request', () => {
for (const count of [0, 1, 9, 2.5]) expect(() => createMultiReferenceChallenge(count)).toThrow()
for (const value of [null, { version: 1, references: [] }, { version: 1, references: [{ topLeft: 'red', bottomRight: 'blue' }, { topLeft: 'red', bottomRight: 'blue' }] }]) expect(() => readMultiReferenceChallenge(value)).toThrow()
})
test('square-panel layout may include unused cells but cannot omit or replace an input', async () => {
const challenge = createMultiReferenceChallenge(2), images = await multiReferenceImages(challenge)
const background = Buffer.from('<svg width="1024" height="1024"><rect width="1024" height="1024" fill="white"/><path d="M512 0V1024 M0 512H1024" stroke="black" stroke-width="2"/></svg>')
const image = await sharp(background).composite(images.map((value, i) => ({ input: value.buffer, left: i * 512, top: 0 }))).png().toBuffer()
await expect(validateMultiReferenceOutput(image, challenge)).resolves.toMatchObject({ referenceImageCount: 2, referenceMatches: [true, true], referenceGrid: { columns: 2, rows: 2 } })
const missing = await sharp(background).composite([{ input: images[0].buffer, left: 0, top: 0 }]).png().toBuffer()
await expect(validateMultiReferenceOutput(missing, challenge)).rejects.toThrow()
})
test('a count assertion without per-reference pixel evidence cannot expand the runtime allowance', () => {
const base: any = { stage: 'image', status: 'passed', evidence: { referenceImageCount: 8 }, deadlineAt: new Date() }
expect(verifiedReferenceImageCount(base)).toBeNull()
expect(verifiedReferenceImageCount({ ...base, evidence: { referenceImageCount: 8, multiReferenceRuleVersion: 1, allReferenceImagesMatched: true, referenceMatches: [true] } })).toBeNull()
expect(verifiedReferenceImageCount({ ...base, evidence: { referenceImageCount: 4, multiReferenceRuleVersion: 1, allReferenceImagesMatched: true, referenceMatches: [true, true, false, true] } })).toBeNull()
expect(verifiedReferenceImageCount({ ...base, evidence: { referenceImageCount: 4, multiReferenceRuleVersion: 1, allReferenceImagesMatched: true, referenceMatches: Array(4).fill(true) } })).toBe(4)
})

View File

@ -29,6 +29,18 @@ test('retains configured /v1 prefix for standard operations and roots the separa
expect(sent[1].options.headers['Idempotency-Key']).toBeUndefined()
})
test('retained response endpoint only permits an authenticated GET on the configured public origin', async () => {
const path = '/api/external/requests/1c8c2fa5-5ad0-436b-977f-a047a64fa845/result'
await requestAiModelOperation('https://models.example.com/v1', 'secret', path, undefined, undefined, {})
expect(sent).toHaveLength(1)
expect(sent[0].url).toBe('https://models.example.com' + path)
expect(sent[0].options.method).toBe('GET')
expect(sent[0].options.headers).toEqual({ Authorization: 'Bearer secret' })
await expect(requestAiModelOperation('https://models.example.com/v1', 'secret', path, '{}', undefined, {})).rejects.toMatchObject({ code: 'protocol_invalid' })
await expect(requestAiModelOperation('https://models.example.com/v1', 'secret', path, undefined, 'new-generation', {})).rejects.toMatchObject({ code: 'protocol_invalid' })
expect(sent).toHaveLength(1)
})
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()

View File

@ -13,6 +13,31 @@ let png: Buffer
beforeAll(async () => { png = await sharp({ create: { width: 32, height: 32, channels: 3, background: '#ff0000' } }).png().toBuffer() })
beforeEach(() => { jest.clearAllMocks() })
test('retained image response is fetched read-only and decoded through the existing private asset guard', async () => {
const requestId = '1c8c2fa5-5ad0-436b-977f-a047a64fa845', guard = jest.fn()
send.mockResolvedValue(Response.json({ data: [{ url: 'https://models.example.com/api/external/assets/' + 'a'.repeat(64) }] }))
output.mockResolvedValue(new Response(new Uint8Array(png)))
const result = await callAiModelProtocol({ ...base, protocol: { id: 'openai-image-generate', path: '/v1/images/generations', headers: {}, supportsImageInput: false }, resumeRequestId: requestId, beforeRequest: guard })
expect(send).toHaveBeenCalledTimes(1)
expect(send).toHaveBeenCalledWith(base.baseUrl, base.apiKey, `/api/external/requests/${requestId}/result`, undefined, undefined, {}, guard)
expect(output).toHaveBeenCalledWith(base.baseUrl, base.apiKey, expect.stringContaining('/api/external/assets/'), guard)
expect(result.requestId).toBe(requestId)
expect(result.summary).toMatchObject({ retainedResponse: true, storedWidth: 32 })
expect((await sharp(result.image).metadata()).width).toBe(32)
})
test.each([404, 403, 524])('unavailable retained response HTTP %i stays unknown without POST or retry', async status => {
const requestId = '1c8c2fa5-5ad0-436b-977f-a047a64fa845'
send.mockResolvedValue(Response.json({ error: { code: 'not_available' } }, { status }))
await expect(callAiModelProtocol({ ...base, protocol: { id: 'openai-image-generate', path: '/v1/images/generations', headers: {}, supportsImageInput: false }, resumeRequestId: requestId })).rejects.toMatchObject({ outcomeUnknown: true, requestId })
expect(send).toHaveBeenCalledTimes(1); expect(output).not.toHaveBeenCalled()
})
test('invalid retained request id and incompatible text protocol make no request', async () => {
await expect(callAiModelProtocol({ ...base, protocol: chat, resumeRequestId: '1c8c2fa5-5ad0-436b-977f-a047a64fa845' })).rejects.toThrow('原图片请求编号无效')
expect(send).not.toHaveBeenCalled()
})
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: [
@ -235,9 +260,9 @@ const probeModel = (schema: any = { additionalProperties: true, properties: {} }
}] }],
})
test('an explicitly open standard generations schema permits one bounded reference test with a persistent candidate marker', async () => {
test('an explicitly open standard generations schema permits bounded reference tests with a persistent candidate marker', async () => {
const protocol = parseAiModelProtocols(probeModel())[0]
expect(protocol).toMatchObject({ id: 'openai-image-generate', supportsImageInput: true, inputField: 'image', referenceTransport: 'json-inline', referenceContract: 'probe-required', maxImages: 1, routeId: 'route-probe', scopeId: 'scope-probe', requestParameters: { n: 1, response_format: 'b64_json' } })
expect(protocol).toMatchObject({ id: 'openai-image-generate', supportsImageInput: true, inputField: 'image', referenceTransport: 'json-inline', referenceContract: 'probe-required', maxImages: 8, routeId: 'route-probe', scopeId: 'scope-probe', requestParameters: { n: 1, response_format: 'b64_json' } })
expect(validateStoredAiModelProtocols(JSON.parse(JSON.stringify([protocol])))).toEqual([protocol])
send.mockResolvedValue(Response.json({ data: [{ b64_json: png.toString('base64') }] }))
const result = await callAiModelProtocol({ ...base, protocol, images: [{ buffer: png, mimeType: 'image/png' }] })
@ -246,6 +271,20 @@ test('an explicitly open standard generations schema permits one bounded referen
expect(result.summary).toMatchObject({ referenceContract: 'probe-required', referenceTransport: 'json-inline', routeId: 'route-probe' })
})
test('JSON image arrays retain all eight distinct image payloads in input order', async () => {
const protocol = parseAiModelProtocols(probeModel())[0]
const images = await Promise.all(Array.from({ length: 8 }, async (_, i) => ({ buffer: await sharp({ create: { width: 32, height: 32, channels: 3, background: { r: 20 + i * 20, g: 30, b: 240 - i * 20 } } }).png().toBuffer(), mimeType: 'image/png' })))
send.mockResolvedValue(Response.json({ data: [{ b64_json: png.toString('base64') }] }))
await callAiModelProtocol({ ...base, protocol, images })
const payloads: string[] = JSON.parse(send.mock.calls[0][3]).image
expect(payloads).toHaveLength(8); expect(new Set(payloads).size).toBe(8)
for (let i = 0; i < images.length; i++) {
const source = await sharp(images[i].buffer).raw().toBuffer(), sent = await sharp(Buffer.from(payloads[i].split(',')[1], 'base64')).raw().toBuffer()
expect(sent).toEqual(source)
}
expect(send).toHaveBeenCalledTimes(1)
})
test.each([
{},
{ additionalProperties: false },
@ -286,12 +325,12 @@ test('a declared reference route takes precedence over an open-schema default ca
expect(parseAiModelProtocols(model)[0].referenceContract).toBeUndefined()
})
test('candidate storage cannot switch transport, endpoint, count or identity, and multi-reference probes never reach the API', async () => {
test('candidate storage cannot switch transport, endpoint or identity, and requests above the tested binding never reach the API', async () => {
const protocol = parseAiModelProtocols(probeModel())[0]
for (const mutation of [{ referenceContract: 'verified' }, { referenceTransport: 'multipart' }, { maxImages: 8 }, { scopeId: undefined }, { routeId: undefined }, { inputField: 'file_urls' }, { id: 'openai-chat', path: '/v1/chat/completions' }]) {
for (const mutation of [{ referenceContract: 'verified' }, { referenceTransport: 'multipart' }, { maxImages: 9 }, { scopeId: undefined }, { routeId: undefined }, { inputField: 'file_urls' }, { id: 'openai-chat', path: '/v1/chat/completions' }]) {
expect(validateStoredAiModelProtocols([{ ...protocol, ...mutation }])).toEqual([])
}
await expect(callAiModelProtocol({ ...base, protocol, images: [{ buffer: png, mimeType: 'image/png' }, { buffer: png, mimeType: 'image/png' }] })).rejects.toMatchObject({ outcomeUnknown: false, message: '当前模型最多可接收 1 张参考图,请减少图片' })
await expect(callAiModelProtocol({ ...base, protocol: { ...protocol, maxImages: 1 }, images: [{ buffer: png, mimeType: 'image/png' }, { buffer: png, mimeType: 'image/png' }] })).rejects.toMatchObject({ outcomeUnknown: false, message: '当前模型最多可接收 1 张参考图,请减少图片' })
expect(send).not.toHaveBeenCalled()
})
@ -345,6 +384,14 @@ test('explicit final upstream rejection is failed, while an opaque 502 retains i
expect(send).toHaveBeenCalledTimes(2)
})
test('a public load rejection is shown as busy without changing uncertain failures into retryable rejections', async () => {
send.mockResolvedValueOnce(Response.json({ error: { code: 'invalid_request', message: 'The model load is too high, please try again later' } }, { status: 400, headers: { 'x-request-id': 'busy-request' } }))
await expect(callAiModelProtocol({ ...base, protocol: chat })).rejects.toMatchObject({ outcomeUnknown: false, requestId: 'busy-request', message: '上游模型负载过高,本次请求已被拒绝,请稍后重新测试' })
send.mockResolvedValueOnce(Response.json({ error: { code: 'invalid_request', message: 'The model load is too high' } }, { status: 503 }))
await expect(callAiModelProtocol({ ...base, protocol: chat })).rejects.toMatchObject({ outcomeUnknown: true, message: '模型请求结果待核查,已暂停;请勿重复提交' })
expect(send).toHaveBeenCalledTimes(2)
})
test('review status and an accepted task override an otherwise definite rejection code', async () => {
send.mockResolvedValueOnce(Response.json({ status: 'pending_review', error: { code: 'service_rejected' } }, { status: 502 }))
await expect(callAiModelProtocol({ ...base, protocol: chat })).rejects.toMatchObject({ outcomeUnknown: true })

View File

@ -96,6 +96,14 @@ test('live running cannot be reconciled and queryable original task cannot be ma
await expect(reconcileAiModelVerification('admin', providerId, row.id, proof)).rejects.toMatchObject({ code: 'verification_query_required' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('a retained image lookup may still be closed with evidence when its response cannot be retrieved; closure never approves it', async () => {
row.stage = 'image'; row.requestId = randomUUID()
expect(aiModelVerificationSummary([row], modelKey, 'image', row.contextHash)).toMatchObject({ canResume: false, canReconcile: true })
await reconcileAiModelVerification('admin', providerId, row.id, proof)
expect(mockDb.$executeRawUnsafe).toHaveBeenCalledWith(expect.stringContaining("status='failed'"), row.id, expect.stringContaining('upstream-log-123'))
expect(resolveAiModelVerification).not.toHaveBeenCalled()
})
test('changed connection allows recording upstream evidence but never regenerates', async () => {
row.taskId = 'original-task'
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ contextHash: 'changed' })

View File

@ -282,6 +282,23 @@ test('only current passed reference-candidate evidence permits explicit enableme
expect(requestAiModel).not.toHaveBeenCalled()
})
test.each([2, 4, 8])('runtime binds only the %i references independently validated, even when the platform ceiling is 8', async count => {
const previous = await listAiModelVerifications(), metadata = referenceCandidateMetadata()
metadata.protocols[0].maxImages = 8
models[0].capabilityMetadata = metadata; models[0].stages = ['image']
const passed = { ...previous.find(value => value.modelKey === legacyModelKey && value.stage === 'image')!,
contextHash: aiModelVerificationContext(providers[0].baseUrl, models[0].id, metadata, 'synthetic-legacy-secret', getAiModelStageProtocol(metadata, 'image')!),
evidence: { ruleVersion: 2, referenceImageCount: count, multiReferenceRuleVersion: 1, allReferenceImagesMatched: true, referenceMatches: Array(count).fill(true) } }
;(listAiModelVerifications as jest.Mock).mockResolvedValue([...previous.filter(value => value.modelKey !== legacyModelKey), passed])
await expect(resolveAiModel('image', legacyModelKey)).resolves.toMatchObject({ protocol: { maxImages: count } })
const state = (await getAiModelAdminState()).providers[0].models[0]
expect(state.verification.image.verifiedReferenceImageCount).toBe(count)
expect(state.capabilities.protocols?.image).toMatchObject({ maxImages: 8, referenceLimitSource: 'verification' })
passed.evidence.referenceMatches[0] = false
await expect(resolveAiModel('image', legacyModelKey)).rejects.toMatchObject({ code: 'model_unavailable' })
expect(requestAiModel).not.toHaveBeenCalled()
})
test('admin read removes invalid old stages without writes or raw capability metadata exposure', async () => {
models[0].capabilityMetadata = null
const state = await getAiModelAdminState()

View File

@ -9,6 +9,7 @@ import { prisma } from '@/lib/prisma'
import { detailTransactionLock } from '@/lib/detail-generator/repository'
import { aiModelAudit } from '@/lib/ai-model-repository'
import sharp from 'sharp'
import { multiReferenceGrid, multiReferenceImages } from '@/lib/ai-model-multireference'
jest.mock('@/lib/ai-model-access', () => { const actual = jest.requireActual('@/lib/ai-model-access'); return { ...actual, assertAiModelAdmin: jest.fn(), assertAiModelAdminForWrite: jest.fn() } })
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
@ -43,6 +44,31 @@ beforeEach(() => {
;(prisma.$queryRawUnsafe as jest.Mock).mockResolvedValue([{ id: row.id }])
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { await request.beforeRequest(); const analysis = row.evidence?.challenge || { topLeft: 'red', bottomRight: 'blue' }; return { text: JSON.stringify(row.evidence?.purpose === 'analysis-copy' ? { analysis, copy: validCopy } : analysis), requestId: 'request-1', summary: {} } })
})
test.each([2, 4, 8])('native image acceptance verifies every one of %i persisted references', async count => {
const original = await (resolveAiModelVerification as jest.Mock)()
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ ...original, protocol: { id: 'openai-image-generate', path: '/v1/images/generations', supportsImageInput: true, headers: {}, referenceContract: 'probe-required', maxImages: 8 } })
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => {
await request.beforeRequest()
const challenge = row.evidence.multiReferenceChallenge, expected = await multiReferenceImages(challenge)
expect(request.images.map((value: any) => value.buffer)).toEqual(expected.map(value => value.buffer))
expect(request.prompt).not.toMatch(/red|blue|green|yellow|红色|蓝色|绿色|黄色/i)
const { columns, rows } = multiReferenceGrid(count)
const image = await sharp({ create: { width: columns * 512, height: rows * 512, channels: 3, background: '#fff' } }).composite(expected.map((value, i) => ({ input: value.buffer, left: i % columns * 512, top: Math.floor(i / columns) * 512 }))).png().toBuffer()
return { image, requestId: 'request-multi', summary: {} }
})
await verifyAiModel('admin', providerId, modelKey, { ...input(), stage: 'image', ...(count === 8 ? {} : { referenceImageCount: count }) })
expect(row.evidence.multiReferenceChallenge.references).toHaveLength(count)
const passed = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes("status='passed'"))!
expect(JSON.parse(passed[3])).toMatchObject({ referenceImageCount: count, allReferenceImagesMatched: true, referenceMatches: Array(count).fill(true) })
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
})
test('changing a persisted test quantity cannot reuse its UUID or dispatch another paid call', async () => {
duplicate = true; row.evidence = { purpose: 'image', referenceImageCount: 2 }
await expect(verifyAiModel('admin', providerId, modelKey, { ...input(), stage: 'image', referenceImageCount: 4 })).rejects.toMatchObject({ code: 'verification_idempotency_conflict' })
expect(callAiModelProtocol).not.toHaveBeenCalled()
})
test('a real image-understanding response persists passed evidence without enabling the model', async () => {
await verifyAiModel('admin', providerId, modelKey, input())
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
@ -181,6 +207,36 @@ test('unknown without a checkpoint cannot be resumed or bypassed with manual con
await expect(verifyAiModel('admin', providerId, modelKey, { ...input(), compatibility: 'confirmed' })).rejects.toThrow('参数无效')
expect(callAiModelProtocol).not.toHaveBeenCalled()
})
test('a recovered customer request reuses the persisted multi-image challenge and never creates another verification', async () => {
const requestId = randomUUID(), original = await (resolveAiModelVerification as jest.Mock)()
row.status = 'unknown'; row.stage = 'image'; row.evidence = { challenge: { topLeft: 'red', bottomRight: 'blue' }, referenceImageCount: 2, multiReferenceChallenge: { version: 1, references: [{ topLeft: 'red', bottomRight: 'blue' }, { topLeft: 'yellow', bottomRight: 'green' }] } }
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ ...original, protocol: { id: 'openai-image-generate', path: '/v1/images/generations', headers: {}, supportsImageInput: true, maxImages: 8 } })
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => {
await request.beforeRequest()
const refs = await multiReferenceImages(row.evidence.multiReferenceChallenge)
return { image: await sharp({ create: { width: 1024, height: 512, channels: 3, background: '#fff' } }).composite(refs.map((value, i) => ({ input: value.buffer, left: i * 512, top: 0 }))).png().toBuffer(), requestId, summary: {} }
})
await refreshAiModelVerification('admin', providerId, row.id, { requestId })
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
expect(callAiModelProtocol).toHaveBeenCalledWith(expect.objectContaining({ resumeRequestId: requestId, idempotencyKey: row.idempotencyKey }))
expect((callAiModelProtocol as jest.Mock).mock.calls[0][0]).not.toHaveProperty('resumeTaskId')
expect(mockDb.$queryRawUnsafe.mock.calls.some(([sql]) => sql.startsWith('INSERT'))).toBe(false)
const passed = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes("status='passed'"))!
expect(JSON.parse(passed[3])).toMatchObject({ retainedResponse: true, referenceImageCount: 2, referenceMatches: [true, true] })
expect(aiModelAudit).toHaveBeenCalledWith(mockDb, 'admin', 'model.verification-query', providerId, expect.objectContaining({ requestId, retainedResponse: true }))
})
test('unavailable retained result keeps the original request id unknown and cannot be replaced', async () => {
const requestId = randomUUID(), original = await (resolveAiModelVerification as jest.Mock)()
row.status = 'unknown'; row.stage = 'image'; row.requestId = requestId; row.evidence = { challenge: { topLeft: 'red', bottomRight: 'blue' } }
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ ...original, protocol: { id: 'openai-image-generate', path: '/v1/images/generations', headers: {}, supportsImageInput: true } })
;(callAiModelProtocol as jest.Mock).mockRejectedValue(new AiModelProtocolError('暂未取回', true, requestId))
await refreshAiModelVerification('admin', providerId, row.id)
expect(mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))?.slice(2, 4)).toEqual(['unknown', requestId])
await expect(refreshAiModelVerification('admin', providerId, row.id, { requestId: randomUUID() })).rejects.toMatchObject({ code: 'verification_request_conflict' })
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
})
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') })

View File

@ -156,7 +156,7 @@ export function getAiModelCapabilities(baseUrl: string, modelId: string, metadat
: '该类型不适用于详情页的分析、文案或生图'
return { kind, allowedStages, combinedTextTest, 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, ...(protocol.maxImages ? { maxImages: protocol.maxImages } : {}) }]
return [stage, { id: protocol.id, path: protocol.path, scopeId: protocol.scopeId, routeId: protocol.routeId, operationId: protocol.operationId, ...(protocol.maxImages ? { maxImages: protocol.maxImages } : {}), ...(protocol.referenceContract === 'probe-required' ? { referenceLimitSource: 'verification' } : {}) }]
})), reason }
}

View File

@ -38,7 +38,9 @@ export async function requestAiModel(baseUrl: string, apiKey: string, endpoint:
/** 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 retainedResult = /^\/api\/external\/requests\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\/result$/i.test(path)
if (retainedResult && (body !== undefined || idempotencyKey !== undefined || Object.keys(protocolHeaders).length)) throw new AiModelError('原请求结果仅允许只读查询', 400, 'protocol_invalid')
if (!retainedResult && !['/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('模型请求编号无效')

View File

@ -0,0 +1,71 @@
import { randomInt } from 'node:crypto'
import sharp from 'sharp'
import { AiModelError } from './ai-model-access'
import { createVerificationChallenge, readVerificationChallenge, referenceLayoutMatches, verificationReferenceImage, type VerificationChallenge } from './ai-model-verification-output'
export interface MultiReferenceChallenge { version: 1; references: VerificationChallenge[] }
/** Distinct, ordered pairs prevent a model from passing by copying reference #1. */
export function createMultiReferenceChallenge(count: number): MultiReferenceChallenge {
if (!Number.isInteger(count) || count < 2 || count > 8) throw new AiModelError('多图测试数量须为 2 至 8 张', 400)
const references: VerificationChallenge[] = []
while (references.length < count) {
const next = createVerificationChallenge()
if (!references.some(value => value.topLeft === next.topLeft && value.bottomRight === next.bottomRight)) references.push(next)
}
// The generation is already random; shuffle the final order independently too.
for (let i = references.length - 1; i > 0; i--) { const j = randomInt(i + 1); [references[i], references[j]] = [references[j], references[i]] }
return { version: 1, references }
}
export function readMultiReferenceChallenge(value: unknown): MultiReferenceChallenge {
const raw = value as MultiReferenceChallenge | null
if (raw?.version !== 1 || !Array.isArray(raw.references) || raw.references.length < 2 || raw.references.length > 8 || raw.references.some(value => !value || typeof value !== 'object')) throw new AiModelError('多图测试检查点无效,不能重建或猜测参考图', 409)
const references = raw.references.map(readVerificationChallenge)
if (new Set(references.map(value => `${value.topLeft}/${value.bottomRight}`)).size !== references.length) throw new AiModelError('多图测试参考图重复,检查点无效', 409)
return { version: 1, references }
}
export function multiReferenceGrid(count: number) {
const columns = count <= 4 ? 2 : 4
return { columns, rows: Math.ceil(count / columns) }
}
export function multiReferencePrompt(challenge: MultiReferenceChallenge): string {
const count = challenge.references.length, { columns, rows } = multiReferenceGrid(count)
return `你收到 ${count} 张独立参考图。必须逐张读取,将全部参考图按原始输入顺序排成 ${columns} 列 ${rows} 行的等大方格拼图:从左到右、从上到下,每格仅对应一张参考图。每格内严格保留该参考图两个方块各自的原始颜色、形状和左上/右下相对位置。每个色块直接采样对应参考图的原色,禁止改色或调色。所有格子等宽等高;纯白背景,无外边框、无格间空隙,未用的格子留白。平面重绘,不要文字、序号、标志、阴影、纹理、渐变或光照。不能重复第一张图代替其余参考图。`
}
export async function multiReferenceImages(challenge: MultiReferenceChallenge) {
return Promise.all(challenge.references.map(async reference => ({ buffer: await verificationReferenceImage(reference), mimeType: 'image/png' as const })))
}
/** Verify each tile against its own hidden challenge, never the adapter's claims. */
export async function validateMultiReferenceOutput(image: Buffer | undefined, challenge: MultiReferenceChallenge): Promise<Record<string, unknown>> {
if (!image) throw new AiModelError('多图测试未返回图片', 422, 'verification_output_invalid')
// Output padding is a layout choice, not evidence that an input was ignored.
// Normalize only the exterior white margin; preserve tile order and all pixels
// inside the content so missing, repeated or swapped references still fail.
const content = await sharp(image, { limitInputPixels: 40_000_000 }).flatten({ background: '#fff' }).trim({ background: '#fff', threshold: 25 }).png().toBuffer()
const metadata = await sharp(content, { limitInputPixels: 40_000_000 }).metadata()
const storedMetadata = await sharp(image, { limitInputPixels: 40_000_000 }).metadata()
const count = challenge.references.length, intended = multiReferenceGrid(count), square = Math.ceil(Math.sqrt(count))
// Image models may preserve square reference panels instead of stretching them.
// Test bounded row-major layouts; every input must match its own distinct tile
// in one complete layout. We never match a color from a different input's tile.
const layouts = [intended, { columns: square, rows: square }, { columns: intended.columns, rows: intended.columns }]
.filter((grid, index, list) => list.findIndex(value => value.columns === grid.columns && value.rows === grid.rows) === index)
if (!metadata.width || !metadata.height || metadata.width < intended.columns * 64 || metadata.height < intended.rows * 64) throw new AiModelError('多图测试返回的图片无效或尺寸过小', 422, 'verification_output_invalid')
let matches: boolean[] = [], matchedGrid: { columns: number; rows: number } | null = null
for (const { columns, rows } of layouts) {
const attempt: boolean[] = []
for (let index = 0; index < count; index++) {
const col = index % columns, row = Math.floor(index / columns)
const left = Math.floor(col * metadata.width / columns), top = Math.floor(row * metadata.height / rows)
const right = Math.floor((col + 1) * metadata.width / columns), bottom = Math.floor((row + 1) * metadata.height / rows)
const tile = await sharp(content, { limitInputPixels: 40_000_000 }).extract({ left, top, width: right - left, height: bottom - top }).png().toBuffer()
attempt.push(await referenceLayoutMatches(tile, challenge.references[index]))
}
if (!matches.length || attempt.filter(Boolean).length > matches.filter(Boolean).length) matches = attempt
if (attempt.every(Boolean)) { matchedGrid = { columns, rows }; matches = attempt; break }
}
if (!matchedGrid) throw new AiModelError(`多图测试未通过:第 ${matches.flatMap((value, index) => value ? [] : [index + 1]).join('、')} 张参考图的颜色或位置未保留`, 422, 'verification_output_invalid')
return { referenceImageCount: challenge.references.length, allReferenceImagesMatched: true, referenceMatches: matches,
referenceImageAccepted: true, referenceLayoutMatched: true, imageDecoded: true, referenceGrid: matchedGrid,
width: storedMetadata.width, height: storedMetadata.height, bytes: image.length, multiReferenceRuleVersion: 1 }
}

View File

@ -214,8 +214,9 @@ function parseInvocation(model: Record<string, any>, offer: Record<string, any>,
const imageConstraints = inputField && (referenceTransport === 'multipart' ? ['image', 'images', 'file_urls'].flatMap(field => referenceSchemas(field).flatMap(value => schemaParts(value))) : referenceSchemas(inputField).flatMap(value => schemaParts(value)))
if (imageConstraints && imageConstraints.some(part => part.maxItems !== undefined && (!Number.isInteger(part.maxItems) || part.maxItems < 1))) return null
const declaredLimits = imageConstraints ? imageConstraints.flatMap(part => [part.maxItems, ...(Array.isArray(part.anyOf) ? part.anyOf.map((entry: any) => record(entry)?.maxItems) : [])]).filter(value => Number.isInteger(value) && value > 0) : []
// Without a published image-array contract, only the single-image challenge is verified.
const maxImages = referenceContract === 'probe-required' ? 1 : declaredLimits.length ? Math.min(...declaredLimits) : undefined
// This is the platform's test ceiling, not a claimed upstream limit. Runtime
// bindings are reduced to the number of independent references actually verified.
const maxImages = referenceContract === 'probe-required' ? 8 : declaredLimits.length ? Math.min(...declaredLimits) : undefined
const maxPromptLength = record(properties.prompt)?.maxLength
// The editor has no quality control: choose an explicit supported working
// quality instead of relying on an undocumented upstream default.
@ -265,7 +266,7 @@ export function validateStoredAiModelProtocols(value: unknown): AiModelProtocol[
if (['maxImages', 'maxPromptLength'].some(field => raw[field] !== undefined && (!Number.isInteger(raw[field]) || raw[field] < 1 || raw[field] > (field === 'maxImages' ? 8 : 1000000)))) return []
if (raw.imageQuality !== undefined && (raw.id !== 'openai-image-generate' || raw.imageQuality !== 'medium')) return []
if (raw.referenceTransport !== undefined && (!['json-inline', 'multipart'].includes(raw.referenceTransport) || raw.id !== 'openai-image-generate' || !raw.supportsImageInput || !['image', 'images', 'file_urls'].includes(raw.inputField) || raw.referenceTransport === 'multipart' && raw.inputField !== 'image')) return []
if (raw.referenceContract !== undefined && (raw.referenceContract !== 'probe-required' || raw.id !== 'openai-image-generate' || raw.referenceTransport !== 'json-inline' || raw.inputField !== 'image' || raw.maxImages !== 1 || !raw.routeId || !raw.scopeId)) return []
if (raw.referenceContract !== undefined && (raw.referenceContract !== 'probe-required' || raw.id !== 'openai-image-generate' || raw.referenceTransport !== 'json-inline' || raw.inputField !== 'image' || !Number.isInteger(raw.maxImages) || raw.maxImages < 1 || raw.maxImages > 8 || !raw.routeId || !raw.scopeId)) return []
const requestParameters = raw.requestParameters !== undefined ? safeImageParameters(raw.requestParameters) : undefined
if (raw.referenceTransport !== undefined && (!requestParameters || requestParameters.n !== 1 || !requestParameters.response_format) || raw.requestParameters !== undefined && (raw.referenceTransport === undefined || !requestParameters)) 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 []
@ -284,7 +285,7 @@ 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
onTaskSubmitted?: (taskId: string) => Promise<void>; resumeTaskId?: string; resumeRequestId?: string
maxOutputTokens?: number
}
export interface AiModelProtocolResult { text?: string; image?: Buffer; requestId: string | null; summary: Record<string, unknown> }
@ -304,9 +305,10 @@ async function readJson(response: Response, apiKey: string, taskId: string | nul
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 definiteRejection = ['service_rejected', 'model_temporarily_unavailable'].includes(code)
const overloaded = typeof data?.error?.message === 'string' && /model load is too high|model is overloaded|capacity exceeded/i.test(data.error.message.slice(0, 1000))
const unknown = Boolean(taskId) ? !terminalTaskFailure : review || code === 'request_outcome_unknown' || !definiteRejection && response.status >= 500
const pending = Boolean(taskId) && unknown && !review && (response.status >= 500 || response.status === 429)
throw new AiModelProtocolError(unknown ? '模型请求结果待核查,已暂停;请勿重复提交' : code === 'model_temporarily_unavailable' ? '上游模型暂不可用,请稍后重新测试' : `上游拒绝模型请求(HTTP ${response.status}),测试未通过`, unknown, requestId, taskId, pending)
throw new AiModelProtocolError(unknown ? '模型请求结果待核查,已暂停;请勿重复提交' : overloaded ? '上游模型负载过高,本次请求已被拒绝,请稍后重新测试' : code === 'model_temporarily_unavailable' ? '上游模型暂不可用,请稍后重新测试' : `上游拒绝模型请求(HTTP ${response.status}),测试未通过`, unknown, requestId, taskId, pending)
}
if (text.includes(apiKey)) throw new AiModelProtocolError('模型响应含敏感内容,已暂停', true, requestId, taskId)
return { data, requestId }
@ -344,11 +346,25 @@ export async function callAiModelProtocol(input: AiModelProtocolInput): Promise<
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)
if (input.resumeRequestId && (input.resumeTaskId || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(input.resumeRequestId) || !['openai-image-generate', 'openai-image-edit'].includes(protocol.id))) throw new AiModelProtocolError('原图片请求编号无效', false)
let taskId = input.resumeTaskId ?? null, requestId: string | null = null, dispatched = Boolean(input.resumeTaskId)
const summary: Record<string, unknown> = { adapterVersion: AI_MODEL_ADAPTER_VERSION, protocol: protocol.id, routeId: protocol.routeId ?? null,
...(protocol.referenceTransport ? { referenceTransport: protocol.referenceTransport, requestParameters: protocol.requestParameters } : {}),
...(protocol.referenceContract ? { referenceContract: protocol.referenceContract } : {}) }
try {
if (input.resumeRequestId) {
// Fetch the original response only. An unavailable result never authorizes another generation.
requestId = input.resumeRequestId
try {
const response = await requestAiModelOperation(input.baseUrl, input.apiKey, `/api/external/requests/${requestId}/result`, undefined, undefined, {}, input.beforeRequest)
const parsed = await readJson(response, input.apiKey)
if (response.status !== 200 || !Array.isArray(parsed.data?.data) || parsed.data.data.length !== 1) throw new Error('Original response unavailable')
return { image: await decodeImage(parsed.data.data[0], input, null, summary), requestId, summary: { ...summary, retainedResponse: true } }
} catch (error) {
if (error && typeof error === 'object' && 'status' in error && [400, 401, 403, 409].includes(Number(error.status))) throw error
throw new AiModelProtocolError('原请求图片尚未取回,请核查编号、访问权限或留存状态;勿重发生成', true, requestId)
}
}
if (!taskId) {
const forAnalysis = protocol.id === 'openai-chat' || protocol.id === 'anthropic-messages'
const images = await normalizeImages(input.images, forAnalysis)

View File

@ -7,7 +7,7 @@ import { aiModelCapabilityFingerprint, getAiModelCapabilities, getAiModelStagePr
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'
import { aiModelVerificationContext, aiModelVerificationSummary, listAiModelVerifications, verifiedReferenceImageCount, type AiModelVerificationRow } from './ai-model-verification-state'
const id = z.string().uuid()
const revision = z.number().int().min(0)
@ -40,7 +40,9 @@ function modelContext(model: AiModelRow, provider: AiModelProviderRow, stage: Ai
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'
if (aiModelVerificationSummary(verifications, model.key, stage, modelContext(model, provider, stage)).status !== 'passed') return false
const protocol = getAiModelStageProtocol(model.capabilityMetadata, stage)
return stage !== 'image' || protocol?.referenceContract !== 'probe-required' || verifiedReferenceImageCount(verifications.find(row => row.modelKey === model.key && row.stage === stage)) !== null
}
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) }
@ -261,7 +263,9 @@ export async function resolveAiModel(stage: AiModelStage, selection: string, exp
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)! }
const protocol = getAiModelStageProtocol(model.capabilityMetadata, stage)!
const testedCount = stage === 'image' && protocol.referenceContract === 'probe-required' ? verifiedReferenceImageCount(verifications.find(row => row.modelKey === model.key && row.stage === stage)) : null
return { binding, apiKey: providerKey(provider), protocol: testedCount ? { ...protocol, maxImages: Math.min(protocol.maxImages ?? 8, testedCount) } : protocol }
}
export async function resolveAiModelVerification(providerId: string, modelKey: string, stage: AiModelStage, expectedRevision?: number) {
const provider = await getAiModelProviderRow(assertAiModelId(providerId))

View File

@ -18,13 +18,14 @@ export interface AiModelVerificationSummary {
taskId?: string | null
submissionKey?: string
purpose?: AiModelTestPurpose
verifiedReferenceImageCount?: number
}
export interface AiModelCapabilities {
kind: AiModelKind
allowedStages: AiModelStage[]
source: 'upstream' | 'verified' | 'unknown'
reason: string
protocols?: Partial<Record<AiModelStage, { id: string; path: string; scopeId?: string; routeId?: string; operationId?: string; maxImages?: number }>>
protocols?: Partial<Record<AiModelStage, { id: string; path: string; scopeId?: string; routeId?: string; operationId?: string; maxImages?: number; referenceLimitSource?: 'verification' }>>
combinedTextTest?: boolean
}
export interface AiModelCatalogEntry {

View File

@ -30,7 +30,7 @@ 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') }
}
async function referenceLayoutMatches(bytes: Buffer, expected: VerificationChallenge): Promise<boolean> {
export async function referenceLayoutMatches(bytes: Buffer, expected: VerificationChallenge): Promise<boolean> {
const { data, info } = await sharp(bytes, { limitInputPixels: 40_000_000 }).flatten({ background: '#fff' }).removeAlpha().resize(96, 96, { fit: 'fill' }).raw().toBuffer({ resolveWithObject: true })
const hues = { red: 0, yellow: 60, green: 120, blue: 240 }
const found = { topLeft: { n: 0, x: 0, y: 0 }, bottomRight: { n: 0, x: 0, y: 0 } }

View File

@ -42,6 +42,16 @@ export function verificationEffectiveStatus(row: AiModelVerificationRow): AiMode
return row.status === 'running' && new Date(row.deadlineAt).getTime() <= Date.now() ? 'unknown' : row.status
}
/** A single-image pass cannot authorize an untested multi-image binding. */
export function verifiedReferenceImageCount(row: AiModelVerificationRow | undefined): number | null {
if (!row || row.stage !== 'image' || verificationEffectiveStatus(row) !== 'passed') return null
const count = row.evidence?.referenceImageCount ?? 1
if (!Number.isInteger(count) || (count as number) < 1 || (count as number) > 8) return null
if ((count as number) > 1 && (row.evidence?.multiReferenceRuleVersion !== 1 || row.evidence?.allReferenceImagesMatched !== true ||
!Array.isArray(row.evidence.referenceMatches) || row.evidence.referenceMatches.length !== count || row.evidence.referenceMatches.some(value => value !== true))) return null
return count as number
}
export function aiModelVerificationSummary(rows: AiModelVerificationRow[], modelKey: string, stage: AiModelStage, contextHash: string | null, queryAvailable = true): 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 }
@ -52,6 +62,7 @@ export function aiModelVerificationSummary(rows: AiModelVerificationRow[], model
const message = status === 'failed' && reconciliation?.outcome === 'failed' && typeof reconciliation.note === 'string'
? `已核查上游执行失败:${reconciliation.note}` : row.message
return { status, id: row.id, checkedAt: row.finishedAt?.toISOString() || row.startedAt.toISOString(), requestId: row.requestId,
...(status === 'passed' && stage === 'image' && verifiedReferenceImageCount(row) ? { verifiedReferenceImageCount: verifiedReferenceImageCount(row)! } : {}),
canResume, canReconcile: effective === 'unknown' && !canResume, reconciled: Boolean(row.evidence?.reconciliation), taskId: row.taskId, submissionKey: row.idempotencyKey, purpose: row.evidence?.purpose === 'analysis-copy' ? 'analysis-copy' : stage,
message: status === 'stale' ? '连接、路由或测试规则已改变,请重新测试' : status === 'unknown' && (row.status !== 'unknown' || !row.message) ? '请求结果尚未确认,已暂停重复测试;请核查上游请求记录' : message }
}

View File

@ -11,8 +11,9 @@ import { AI_MODEL_VERIFICATION_RULE_VERSION, createVerificationChallenge, readVe
export { validateAiModelVerificationOutput } from './ai-model-verification-output'
import { detailTransactionLock } from './detail-generator/repository'
import type { AiModelDatabase } from './ai-model-repository'
import { createMultiReferenceChallenge, multiReferenceImages, multiReferencePrompt, readMultiReferenceChallenge, validateMultiReferenceOutput } from './ai-model-multireference'
const requestSchema = z.object({ stage: z.enum(AI_MODEL_TEST_PURPOSES), revision: z.number().int().positive(), idempotencyKey: z.string().uuid() }).strict()
const requestSchema = z.object({ stage: z.enum(AI_MODEL_TEST_PURPOSES), revision: z.number().int().positive(), idempotencyKey: z.string().uuid(), referenceImageCount: z.number().int().min(1).max(8).optional() }).strict()
function safeId(value: unknown, secret: string): string | null { return typeof value === 'string' && /^[\w.-]{1,160}$/.test(value) && !value.includes(secret) ? value : null }
/** Persist only bounded adapter facts; model output cannot supply or override validation evidence. */
function imageTransportEvidence(value: unknown, protocol: AiModelProtocol, secret: string): Record<string, unknown> {
@ -50,7 +51,7 @@ function assertCombinedContexts(contexts: VerificationContext[]): void {
const transport = (context: VerificationContext) => JSON.stringify({ ...context.protocol, supportsImageInput: undefined })
if (contexts.length !== 2 || !contexts[0].protocol.supportsImageInput || transport(contexts[0]) !== transport(contexts[1])) throw new AiModelError('当前模型不能在同一路由联合验证分析和文案', 400, 'verification_combined_unsupported')
}
async function executeVerification(rows: AiModelVerificationRow[], userId: string, providerId: string, resume: boolean): Promise<void> {
async function executeVerification(rows: AiModelVerificationRow[], userId: string, providerId: string, resume: boolean, retainedRequestId?: string): Promise<void> {
const row = rows[0], combined = rows.length === 2
let sent = false, resultReceived = false, apiKey = '', knownTaskId = row.taskId, knownRequestId: string | null = null
const beforeRequest = async () => {
@ -77,13 +78,15 @@ async function executeVerification(rows: AiModelVerificationRow[], userId: strin
knownRequestId = safeId(row.requestId, apiKey)
if (context.contextHash !== row.contextHash) throw new AiModelError('验证对应的连接或路由已改变,不能继续此任务', 409, 'verification_context_changed')
const challenge = readVerificationChallenge(row.evidence?.challenge)
const prompt = combined ? '一次完成两项独立任务。analysis:必须读取参考图,识别左上与右下方块的颜色,以英文小写填写。copy:真实产品资料为型号TEST、颜色蓝色,图片只是读图测试图,不是该产品外观;只依据这两条真实资料写一屏中文详情页,不编造材质、尺寸、性能或认证。严格只输出一个JSON对象:{"analysis":{"topLeft":"英文小写颜色","bottomRight":"英文小写颜色"},"copy":{"sellingPoints":["卖点"],"warnings":[],"screens":[{"title":"标题","body":"正文","imagePrompt":"无文字的场景描述"}]}}。'
const multi = row.evidence?.multiReferenceChallenge ? readMultiReferenceChallenge(row.evidence.multiReferenceChallenge) : null
if (multi && (row.stage !== 'image' || multi.references.length > (context.protocol.maxImages ?? 8))) throw new AiModelError('多图测试数量与当前协议不一致', 409, 'verification_checkpoint_invalid')
const prompt = multi ? multiReferencePrompt(multi) : combined ? '一次完成两项独立任务。analysis:必须读取参考图,识别左上与右下方块的颜色,以英文小写填写。copy:真实产品资料为型号TEST、颜色蓝色,图片只是读图测试图,不是该产品外观;只依据这两条真实资料写一屏中文详情页,不编造材质、尺寸、性能或认证。严格只输出一个JSON对象:{"analysis":{"topLeft":"英文小写颜色","bottomRight":"英文小写颜色"},"copy":{"sellingPoints":["卖点"],"warnings":[],"screens":[{"title":"标题","body":"正文","imagePrompt":"无文字的场景描述"}]}}。'
: 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 verificationReferenceImage(challenge), mimeType: 'image/png' }] } : {}), maxOutputTokens: combined ? 1800 : row.stage === 'copy' ? 1200 : 1024,
idempotencyKey: row.idempotencyKey, beforeRequest, ...(resume ? { resumeTaskId: row.taskId! } : {}),
prompt, ...(row.stage !== 'copy' ? { images: multi ? await multiReferenceImages(multi) : [{ buffer: await verificationReferenceImage(challenge), mimeType: 'image/png' }] } : {}), maxOutputTokens: combined ? 1800 : row.stage === 'copy' ? 1200 : 1024,
idempotencyKey: row.idempotencyKey, beforeRequest, ...(retainedRequestId ? { resumeRequestId: retainedRequestId } : resume ? { resumeTaskId: row.taskId! } : {}),
onTaskSubmitted: async taskId => {
if (!safeId(taskId, context.apiKey)) throw new AiModelError('上游任务编号无效', 502, 'verification_task_invalid')
knownTaskId = taskId
@ -97,9 +100,10 @@ async function executeVerification(rows: AiModelVerificationRow[], userId: strin
resultReceived = true // The native protocol has returned a terminal, decoded result.
knownRequestId = safeId(result.requestId, context.apiKey) || knownRequestId
const checks: Partial<Record<AiModelVerificationRow['stage'], AiModelTextCheck>> = combined ? await validateAiModelCombinedVerificationOutput(result, challenge)
: { [row.stage]: { status: 'passed', message: '实际接口测试通过', evidence: {
: { [row.stage]: { status: 'passed', message: multi ? `${multi.references.length} 张参考图逐张内容校验通过` : '实际接口测试通过', evidence: {
...(row.stage === 'image' ? imageTransportEvidence(result.summary, context.protocol, context.apiKey) : {}),
...await validateAiModelVerificationOutput(row.stage, result, challenge),
...(retainedRequestId ? { retainedResponse: true } : {}),
...(multi ? await validateMultiReferenceOutput(result.image, multi) : await validateAiModelVerificationOutput(row.stage, result, challenge)),
} } }
await assertAiModelAdmin(userId)
await aiModelTransaction(async db => {
@ -123,7 +127,7 @@ async function executeVerification(rows: AiModelVerificationRow[], userId: strin
})
} catch (error) {
const outcome = error as { outcomeUnknown?: boolean; taskId?: string; requestId?: string }
const unknown = !resultReceived && (typeof outcome.outcomeUnknown === 'boolean' ? (sent || Boolean(knownTaskId)) && outcome.outcomeUnknown : Boolean(knownTaskId) || sent && !(error instanceof AiModelError))
const unknown = !resultReceived && (retainedRequestId ? true : typeof outcome.outcomeUnknown === 'boolean' ? (sent || Boolean(knownTaskId)) && outcome.outcomeUnknown : Boolean(knownTaskId) || sent && !(error instanceof AiModelError))
const safeProtocolMessage = error instanceof AiModelProtocolError && (!apiKey || !error.message.includes(apiKey)) ? error.message : null
const message = safeProtocolMessage || (unknown ? '请求结果尚未确认,请核查上游记录;禁止重复提交' : error instanceof AiModelError ? error.message : '模型测试未通过')
await aiModelTransaction(async db => {
@ -142,11 +146,14 @@ export async function verifyAiModel(userId: string, providerId: string, modelKey
const input = parsed.data, stages = aiModelTestStages(input.stage)
const contexts = await Promise.all(stages.map(stage => resolveAiModelVerification(providerId, modelKey, stage, input.revision)))
if (input.stage === 'analysis-copy') assertCombinedContexts(contexts)
const referenceImageCount = input.stage === 'image' ? input.referenceImageCount ?? (contexts[0].protocol.referenceContract === 'probe-required' ? contexts[0].protocol.maxImages ?? 1 : 1) : 1
if (input.referenceImageCount !== undefined && input.stage !== 'image' || referenceImageCount > (contexts[0].protocol.maxImages ?? 8)) throw new AiModelError('参考图测试数量超出当前用途或协议范围', 400)
const created = await aiModelTransaction(async db => {
await assertAiModelAdminForWrite(userId, db)
const prior = await db.$queryRawUnsafe<AiModelVerificationRow[]>(`SELECT ${AI_MODEL_VERIFICATION_COLUMNS} FROM ai_model_verifications WHERE model_key=$1 AND idempotency_key=$2`, modelKey, input.idempotencyKey)
if (prior[0]) {
if ((prior[0].evidence?.purpose || prior[0].stage) !== input.stage) throw new AiModelError('该请求编号已用于其他测试,请创建新请求', 409, 'verification_idempotency_conflict')
if (Number(prior[0].evidence?.referenceImageCount ?? 1) !== referenceImageCount) throw new AiModelError('该请求编号已用于不同的参考图数量,不能重复提交', 409, 'verification_idempotency_conflict')
return null // A repeated HTTP request never repeats a model call.
}
const closed = await db.$queryRawUnsafe<Array<{ id: string }>>(`SELECT id FROM ai_model_audit WHERE action='model.verification-intake-cancelled' AND details->>'modelKey'=$1 AND details->>'idempotencyKey'=$2`, modelKey, input.idempotencyKey)
@ -155,7 +162,9 @@ export async function verifyAiModel(userId: string, providerId: string, modelKey
const active = await db.$queryRawUnsafe<Array<{ count: number }>>(`SELECT COUNT(*)::int AS count FROM ai_model_verifications WHERE model_key=$1 AND status IN ('running','unknown')`, modelKey)
if (active[0]?.count) throw new AiModelError('该模型存在进行中或结果未知的请求,不能重复测试;请先核查原请求', 409, 'verification_unresolved')
await assertExecutionSlot(db, userId)
const evidence = { challenge: createVerificationChallenge(), purpose: input.stage, ...(input.stage === 'analysis-copy' ? { executionGroupId: randomUUID() } : {}) }
const evidence = { challenge: createVerificationChallenge(), purpose: input.stage,
...(input.stage === 'image' ? { referenceImageCount, ...(referenceImageCount > 1 ? { multiReferenceChallenge: createMultiReferenceChallenge(referenceImageCount) } : {}) } : {}),
...(input.stage === 'analysis-copy' ? { executionGroupId: randomUUID() } : {}) }
const rows: AiModelVerificationRow[] = []
for (let index = 0; index < stages.length; index++) {
const stage = stages[index]
@ -169,15 +178,20 @@ export async function verifyAiModel(userId: string, providerId: string, modelKey
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) {
/** Fetch an existing task or retained image response. This endpoint can never submit a new generation. */
export async function refreshAiModelVerification(userId: string, providerId: string, verificationId: string, body: unknown = {}) {
const parsed = z.object({ requestId: z.string().uuid().optional() }).strict().safeParse(body)
if (!parsed.success) throw new AiModelError('请输入有效的客户请求编号')
if (!z.string().uuid().safeParse(verificationId).success) throw new AiModelError('测试编号无效')
await assertAiModelAdmin(userId)
let retainedRequestId: string | undefined
const row = await aiModelTransaction(async db => {
await assertAiModelAdminForWrite(userId, 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')
const requestId = parsed.data.requestId || current?.requestId
if (!current || !['unknown','running'].includes(current.status) || !current.taskId && !(current.stage === 'image' && requestId)) throw new AiModelError('没有可以查询的上游任务或客户请求编号', 409, 'verification_not_resumable')
if (parsed.data.requestId && (current.taskId || current.requestId && current.requestId !== parsed.data.requestId)) throw new AiModelError('原请求编号已固定,不能替换为其他请求', 409, 'verification_request_conflict')
if (current.status === 'running' && current.deadlineAt.getTime() > Date.now()) throw new AiModelError('测试仍在执行,请等待', 409, 'verification_busy')
const execution = await lockAiModelVerificationExecution(db, current)
const contexts = await Promise.all(execution.map(async item => {
@ -187,11 +201,19 @@ export async function refreshAiModelVerification(userId: string, providerId: str
return context
}))
if (execution.length === 2) assertCombinedContexts(contexts)
if (!current.taskId) {
if (!z.string().uuid().safeParse(requestId).success) throw new AiModelError('请输入有效的客户请求编号')
if (!['openai-image-generate', 'openai-image-edit'].includes(contexts[0].protocol.id)) throw new AiModelError('该协议尚未适配原图片响应取回', 400, 'protocol_unsupported')
retainedRequestId = requestId!
}
await assertExecutionSlot(db, userId, verificationExecutionId(current))
for (const item of execution) 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`, item.id, userId)
await aiModelAudit(db, userId, 'model.verification-query', providerId, { verificationId: current.id, modelKey: current.modelKey, stage: current.stage })
for (const item of execution) {
await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status='running',actor_id=$2,deadline_at=NOW()+INTERVAL '10 minutes',finished_at=NULL,request_id=COALESCE(request_id,$3) WHERE id=$1`, item.id, userId, retainedRequestId || null)
if (retainedRequestId) item.requestId = retainedRequestId
}
await aiModelAudit(db, userId, 'model.verification-query', providerId, { verificationId: current.id, modelKey: current.modelKey, stage: current.stage, ...(retainedRequestId ? { requestId: retainedRequestId, retainedResponse: true } : {}) })
return execution
})
await executeVerification(row, userId, providerId, true)
await executeVerification(row, userId, providerId, true, retainedRequestId)
return getAiModelAdminState()
}