fix(ai): support private reference images and explain model failures

This commit is contained in:
WuYongJie 2026-10-15 09:45:53 +08:00
parent 88ce9e3798
commit cfa8f0ff9b
14 changed files with 315 additions and 20 deletions

View File

@ -24,6 +24,19 @@ function jointConfiguration() {
next.providers[0].models[2].capabilities.combinedTextTest = true
return next
}
test.each(['failed', 'unknown'] as const)('生图 %s 的具体原因直接可见,未知结果不能重试', async status => {
const next = configured(), model = next.providers[0].models[1]
model.enabled = false; model.stages = []
model.verification.image = { status, id: 'verification-image', checkedAt: null, requestId: 'request-image', message: '上游返回异常响应(502),请核查请求结果;勿重复提交', canReconcile: status === 'unknown' }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const row = await screen.findByLabelText('nano-banana-2 · 现有上游', { selector: 'div' })
expect(within(row).getAllByText(model.verification.image.message!, { selector: 'p' }).find(element => !element.closest('[hidden]'))).toBeVisible()
expect(within(row).getByRole('checkbox', { name: 'nano-banana-2 · 现有上游 用于图片生成' })).toBeDisabled()
if (status === 'unknown') expect(within(row).getByRole('button', { name: '核查 nano-banana-2 · 现有上游 的图片生成结果' })).toBeEnabled()
expect((global.fetch as jest.Mock).mock.calls.every(([, init]) => !init?.method)).toBe(true)
})
test('浏览器没有 randomUUID 时仍能提交一个有效 UUID 的联合测试', async () => {
const original = Object.getOwnPropertyDescriptor(globalThis.crypto, 'randomUUID')
Object.defineProperty(globalThis.crypto, 'randomUUID', { configurable: true, value: undefined })
@ -294,7 +307,7 @@ test('能力待识别模型没有人工兼容开关,不能解锁用途或实
expect(screen.queryByText('gemini-2.5-flash')).not.toBeInTheDocument()
})
test('已识别生图能力但协议未适配时显示待适配,不误称无法生图', async () => {
test('生图接口未接入时就近显示服务端具体原因', async () => {
const next = configured()
next.providers[0].models[2] = { ...next.providers[0].models[2], id: 'image-other-protocol', capabilities: { kind: 'image', allowedStages: [], source: 'upstream', reason: '具备图片输出能力,当前参考图协议待适配' } }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
@ -303,7 +316,7 @@ test('已识别生图能力但协议未适配时显示待适配,不误称无
expect(image).toBeDisabled()
expect(image.closest('label')).toHaveAttribute('title', '具备图片输出能力,当前参考图协议待适配')
expect(image).toHaveAccessibleDescription('具备图片输出能力,当前参考图协议待适配')
expect(screen.getByText('生图 · 待适配')).toBeInTheDocument()
expect(screen.getAllByRole('status').find(element => element.textContent === '具备图片输出能力,当前参考图协议待适配')).toBeVisible()
fireEvent.change(screen.getByLabelText('按能力筛选模型'), { target: { value: 'image' } })
expect(screen.getByText('image-other-protocol')).toBeInTheDocument()
expect(screen.queryByText('gemini-2.5-flash')).not.toBeInTheDocument()
@ -588,7 +601,7 @@ test.each([['tts', '语音合成'], ['asr', '语音识别'], ['music', '音乐']
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.getByRole('status')).toHaveTextContent('当前详情页未接入该类型')
expect(screen.queryByRole('button', { name: /测试 new-vision-model/ })).not.toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).toBeDisabled()
})

View File

@ -307,10 +307,10 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
: !provider.hasApiKey ? '请先配置上游密钥'
: !model.present ? '模型已离开上游目录,请同步目录'
: unsettled ? '已有测试进行中或结果未知,请先查询状态'
: !selectedStage ? '该用途尚未适配' : ''
: !selectedStage ? model.capabilities.reason : ''
const testStatusId = `test-status-${model.key}`
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>
<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>{capabilityLabels[model.capabilities.kind]}</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.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 adapted = model.capabilities.allowedStages.includes(stage.id)
@ -341,7 +341,8 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
{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.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>}
</> : <p className="text-xs text-[#66758b]">{model.capabilities.kind === 'unknown' ? '能力待识别,暂不可测试' : '暂未接入详情页用途'}</p>}
{!testing && !attempt && !expandedDetails[detailsId] && ['failed', 'stale', 'unknown'].includes(result.status) && result.message && <p className="break-words text-xs text-[#b42318]">{result.message}</p>}
</> : <p role="status" className="break-words text-xs text-[#66758b]">{blockedReason}</p>}
</div>
</div>})}
</div>

View File

@ -0,0 +1,53 @@
# 生图模型参考图适配验收(2026-10-15)
本次平台适配、测试门禁、权限与异常处理验收通过。真实模型结果为两项通过、三项上游失败,失败型号没有获得开放资格;不能将本报告理解为所有型号都已可用。
## 实现范围
- 按上游正式目录的输入能力和 JSON Schema 识别参考图数组。明确声明图片输入、图生图能力且允许 Data URI 的接口,传入私有参考图;不按模型名字猜能力,不公开产品文件,不切换到其他路由重试。
- 支持 `gpt-image-2`、`gpt-image-2-vip`、`gpt-image-2.5`、`gpt-image-2.5-flare`、`gpt-image-2.5-sunburst` 的原生 `images/generations` 参考图请求。复用已有图片归一化、结果解码和参考图验证。
- 仅文字输入的型号显示“仅支持文生图,不能接收产品参考图”。上游未提供参考图参数、上游停用、接口未适配分别给出原因。失败及未知结果直接显示安全诊断;展开详情不重复显示原因。
- 明确的上游终止拒绝记为失败;无法确认的 502、已受理任务及待核查状态继续暂停,保留请求编号,不自动重新调用。核查失败后保留核查说明,核查本身不会使模型通过。
- 目录同步增加可选 `modelKeys` 范围,服务端核对归属后只更新选中型号;现有全量同步仍可使用。目录同步不会自动启用、修改默认模型或插入测试通过记录。
## 真实模型验收
使用独立 QA 数据库、真实上游密钥和平台原生管理员测试接口。每型号只发起一次新请求,随机参考图颜色和位置由服务端校验;没有用模型自称支持或 HTTP 200 代替图片验证。
| 型号 | 真实结果 | 证据与处理 |
| --- | --- | --- |
| gpt-image-2 | 通过 | 请求 `b7ef37d7-34ca-41da-9dd0-ac2f02ee85ca`;1254×1254 图片可解码,两个色块颜色和相对位置匹配 |
| gpt-image-2-vip | 通过 | 请求 `11d3f051-fb61-48fd-b3b3-c1c672ec1774`;1254×1254 图片可解码,参考图验证通过 |
| gpt-image-2.5 | 未通过 | 请求 `e01ecf80-57b8-42a5-adfc-a8a88b6b66c7`;上游最终 `failed_final / 502 / service_rejected`,供应端 403 |
| gpt-image-2.5-flare | 未通过 | 请求 `34ee3b42-f86b-4714-aac6-e82a131d4d71`;同类上游终止拒绝,供应端 403 |
| gpt-image-2.5-sunburst | 未通过 | 请求 `46e4acad-d70a-498e-b29f-c5206ebbe92e`;同类上游终止拒绝,供应端 403 |
三个失败请求按本次幂等编号只读核查上游最终记录,再通过平台已有核查接口关闭未知状态,均保持失败和禁止开放。没有修改上游服务配置。供应端 403 的进一步原因不在本次平台证据中,不能推断为参考图格式错误。
`nano-banana-2-2k-cl`、`nano-banana-2-4k-cl` 仅声明文字输入,测试请求在服务端调用前拒绝,不消耗模型调用。其余型号继续以各自的正式能力和实际测试结果为准;没有宣称已完成全部上游型号的真实出图验收。
目录依据:[上游正式模型目录](https://api.vip98k.com/api/v2/catalog/models)(schema 2.2)及其 `format=skill` 调用说明。
## 验证结果
- 独立发布预览仅包含本次文件,基于 `88ce9e3798fb18accf002acb92f2fbc1fc8b9687`;未包含详情页知识库等其他在途修改,也没有数据库迁移或新依赖。
- 13 个相关测试套件、258 项测试通过;相关文件 ESLint 无错误;TypeScript 检查和本地生产构建通过。
- 原生 API 验证通过:五个原幂等请求重放没有新增执行;两个纯文生图型号调用前拒绝;三个失败型号启用被拒绝;通过型号可保存开放后恢复原配置,其他配置及默认值未变。
- 未登录返回 401;非超级管理员测试请求返回 403;以上负向操作没有新增测试记录或模型执行。
- 已检查 1440×1000 桌面与 390×844 手机实际页面。桌面内容宽/滚动宽均为 1425,手机均为 375,无横向溢出;通过用途能勾选,失败用途禁用;确认弹窗取消未调用模型;失败详情显示一次。
## Impeccable audit(Operate 模式,限定本次模型面板)
| 项目 | 分数 | 结果 |
| --- | --- | --- |
| 可访问性 | 3/4 | 状态可读,按钮和用途有名称、禁用原因;次要文字、错误、通过颜色对白底对比度分别为 4.69、6.57、6.09;未声称完成全站 WCAG 认证 |
| 性能 | 4/4 | 无新依赖、轮询或额外模型调用;沿用分页 |
| 响应式 | 4/4 | 桌面与手机实测,无横向溢出;测试和详情按钮高 44px |
| 主题一致性 | 3/4 | 保留现有管理面板颜色与布局,无无效 dark 变体;现有颜色常量未做全站迁移 |
| 实现完整性 | 4/4 | 具体原因来自服务端能力或真实记录;bundled detector 对目标组件无发现 |
总分 18/20。本范围无 P0/P1;原有管理页其他区块未纳入本次修改。原始验证记录、测试日志和桌面/手机截图在本地 `output/ai-image-adapter-20261015`。
## 发布约束
发布只提交本报告与对应适配、状态、测试文件。生产替换前校验原提交和镜像、执行队列门禁,保留上一镜像。上线后只刷新这五个型号的能力;不复制 QA 测试记录,不自动启用新模型,不改变现有默认模型。生产真实复测若仍失败,必须保持失败或待核查并保留原请求,不能为发布而伪造通过。

View File

@ -21,6 +21,17 @@ test('image understanding and image output require separate interfaces', () => {
expect(getAiModelCapabilities(generic, 'generator', metadata(['text'], ['image'], ['/images/generations']))).toMatchObject({ kind: 'image', allowedStages: [] })
expect(getAiModelCapabilities(generic, 'editor', metadata(['text', 'image'], ['image'], ['/images/edits']))).toMatchObject({ kind: 'image', allowedStages: ['image'] })
})
test('image models explain missing reference input and unavailable upstream separately', () => {
expect(getAiModelCapabilities(generic, 'text-image', metadata(['text'], ['image'], ['/images/generations']))).toMatchObject({ allowedStages: [], reason: '仅支持文生图,不能接收产品参考图' })
expect(getAiModelCapabilities(generic, 'reference-image', metadata(['text', 'image'], ['image'], ['/images/generations']))).toMatchObject({ allowedStages: [], reason: '上游尚未提供已适配的参考图接口' })
const undeclaredReference = metadata(['text'], ['image'], ['/images/generations'])
undeclaredReference.inputDeclared = false
undeclaredReference.protocols = [{ id: 'openai-image-generate', path: '/v1/images/generations', headers: {}, supportsImageInput: false }]
expect(getAiModelCapabilities(generic, 'undeclared-reference', undeclaredReference)).toMatchObject({ allowedStages: [], reason: '上游未提供可用的参考图参数,暂不能接收产品参考图' })
const unavailable = { ...metadata(['text', 'image'], ['image'], ['/images/edits']), unavailable: true }
expect(getAiModelCapabilities(generic, 'unavailable', unavailable)).toMatchObject({ allowedStages: [], reason: '上游当前不可用,暂不能测试或开放' })
})
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: 'asr', allowedStages: [] })
})

View File

@ -30,6 +30,23 @@ test('does not infer private reference uploads from generic image capability or
expect(protocols).toMatchObject([{ id: 'openai-image-generate', supportsImageInput: false }])
})
test('declared image URL arrays admit private data URIs without a redundant pattern', () => {
const entry = { type: 'image', modality: 'image', capabilities: ['image_to_image'], input_modes: ['text', 'image_url'], invocation: { method: 'POST', path: '/v1/images/generations', content_type: 'application/json', headers: {}, input_schema: { properties: { image: { type: 'array', items: { type: 'string', format: 'uri' }, maxItems: 3 } } } } }
expect(parseAiModelProtocols(entry)).toMatchObject([{ id: 'openai-image-generate', supportsImageInput: true, inputField: 'image', maxImages: 3 }])
expect(parseAiModelProtocols({ ...entry, input_modes: ['text'] })).toMatchObject([{ supportsImageInput: false }])
expect(parseAiModelProtocols({ ...entry, invocation: { ...entry.invocation, input_schema: { properties: { image: { type: 'array', items: { type: 'string', pattern: '^https://' } } } } } })).toMatchObject([{ supportsImageInput: false }])
})
test('Images Generations sends declared image array with private bytes and retains its route', async () => {
send.mockResolvedValue(Response.json({ data: [{ b64_json: png.toString('base64') }] }))
const result = await callAiModelProtocol({ ...base, protocol: { id: 'openai-image-generate', path: '/v1/images/generations', headers: { 'X-Relay-Route-Option': 'image-route' }, routeId: 'image-route', supportsImageInput: true, inputField: 'image', maxImages: 2 }, images: [{ buffer: png, mimeType: 'image/png' }] })
expect(send).toHaveBeenCalledTimes(1)
const body = JSON.parse(send.mock.calls[0][3])
expect(body).toMatchObject({ image: [expect.stringMatching(/^data:image\/png;base64,/)], n: 1, response_format: 'b64_json' })
expect(send.mock.calls[0][5]).toEqual({ 'X-Relay-Route-Option': 'image-route' })
expect((await sharp(result.image).metadata()).width).toBe(32)
})
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([])
})
@ -72,6 +89,22 @@ test('rejected requests that echo credentials stay definite failures without exp
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('explicit final upstream rejection is failed, while an opaque 502 retains its request for investigation', async () => {
send.mockResolvedValueOnce(Response.json({ error: { code: 'service_rejected' } }, { status: 502, headers: { 'x-request-id': 'ended-request' } }))
await expect(callAiModelProtocol({ ...base, protocol: chat })).rejects.toMatchObject({ outcomeUnknown: false, requestId: 'ended-request', message: '上游拒绝模型请求(HTTP 502),测试未通过' })
send.mockResolvedValueOnce(new Response('<html>Bad Gateway</html>', { status: 502, headers: { 'x-request-id': 'opaque-request' } }))
await expect(callAiModelProtocol({ ...base, protocol: chat })).rejects.toMatchObject({ outcomeUnknown: true, requestId: 'opaque-request', message: '上游返回异常响应(502),请核查请求结果;勿重复提交' })
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 })
send.mockResolvedValueOnce(Response.json({ error: { code: 'service_rejected' } }, { status: 502 }))
await expect(callAiModelProtocol({ ...base, protocol: asyncImage, resumeTaskId: 'accepted-task', onTaskSubmitted: jest.fn() })).rejects.toMatchObject({ outcomeUnknown: true, taskId: 'accepted-task' })
expect(send).toHaveBeenCalledTimes(2)
})
test('persists async id before any polling, sends private inline image, decodes typed output', async () => {
const events: string[] = []
send.mockImplementation(async (_base, _key, path) => {

View File

@ -123,6 +123,19 @@ test('old passed rules require retest; task recovery preserves context and canno
expect(aiModelVerificationSummary([row], modelKey, 'analysis', 'changed')).toMatchObject({ status: 'unknown', canResume: false, canReconcile: true })
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash, false)).toMatchObject({ status: 'unknown', canResume: false, canReconcile: true })
})
test('unknown outcome retains its safe cause; an expired running record uses the interruption warning', () => {
row.message = '上游返回异常响应(502),请核查请求结果;勿重复提交'
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash)).toMatchObject({ status: 'unknown', message: row.message, canReconcile: true })
row.status = 'running'; row.message = '正在执行'
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash)).toMatchObject({ status: 'unknown', message: '请求结果尚未确认,已暂停重复测试;请核查上游请求记录' })
})
test('a reconciled failed upstream request exposes the checked cause without passing or enabling it', () => {
row.status = 'failed'; row.evidence = { reconciliation: proof }
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash)).toMatchObject({ status: 'failed', reconciled: true, canResume: false, canReconcile: false, message: `已核查上游执行失败:${proof.note}` })
expect(aiModelVerificationSummary([row], modelKey, 'analysis', 'changed')).toMatchObject({ status: 'stale', message: '连接、路由或测试规则已改变,请重新测试' })
})
test('randomized reference image validation rejects swapped layout and wrong image interpretation', async () => {
const challenge = createVerificationChallenge(), correct = await verificationReferenceImage(challenge)
expect(challenge.topLeft).not.toBe(challenge.bottomRight)

View File

@ -132,6 +132,25 @@ test('directory synchronization never creates verification evidence or enables n
expect(statements.some(sql => sql.includes('SET present=FALSE'))).toBe(true)
expect(aiModelAudit).toHaveBeenCalledWith(expect.anything(), 'admin', 'provider.sync', legacyId, expect.objectContaining({ addedCount: 1 }))
})
test('scoped capability synchronization never rewrites unrelated models or creates verification evidence', async () => {
await syncAiModelProvider('admin', legacyId, { revision: 1, modelKeys: [legacyModelKey] })
const updates = mockDb.$executeRawUnsafe.mock.calls
expect(updates.find(([sql]) => sql.includes('present=FALSE'))).toEqual([expect.stringContaining('AND id=ANY($2::text[])'), legacyId, [legacyModelKey]])
const inserted = updates.filter(([sql]) => sql.startsWith('INSERT INTO ai_model_catalog'))
expect(inserted).toHaveLength(1)
expect(inserted[0][3]).toBe('vendor/shared-model')
expect(JSON.stringify(updates)).not.toContain('brand/new-model')
expect(updates.some(([sql]) => sql.includes('INSERT INTO ai_model_verifications'))).toBe(false)
expect(aiModelAudit).toHaveBeenCalledWith(expect.anything(), 'admin', 'provider.sync', legacyId, expect.objectContaining({ modelKeys: [legacyModelKey], addedCount: 0 }))
})
test('a scoped update cannot use another provider model or an empty selection', async () => {
await expect(syncAiModelProvider('admin', legacyId, { revision: 1, modelKeys: [secondModelKey] })).rejects.toMatchObject({ code: 'catalog_selection_invalid' })
await expect(syncAiModelProvider('admin', legacyId, { revision: 1, modelKeys: [] })).rejects.toMatchObject({ code: 'invalid_request' })
expect(requestAiModel).not.toHaveBeenCalled()
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('concurrent provider updates during catalog response reject all catalog writes', async () => {
;(requestAiModel as jest.Mock).mockImplementation(async (_base, _key, _endpoint, _body, _idempotency, beforeRequest) => { await beforeRequest(); providers[0].revision++; return Response.json({ data: [{ id: 'new' }] }) })
await expect(syncAiModelProvider('admin', legacyId, { revision: 1 })).rejects.toMatchObject({ code: 'revision_conflict' })

View File

@ -81,6 +81,16 @@ test.each([false, true])('protocol outcomeUnknown=%s is persisted accurately, ne
expect(terminal[2]).toBe(unknown ? 'unknown' : 'failed')
expect(JSON.stringify(terminal)).not.toContain('synthetic-test-secret')
})
test.each([false, true])('safe protocol diagnostics are preserved for outcomeUnknown=%s', async unknown => {
const message = unknown ? '上游返回异常响应(502),请核查请求结果;勿重复提交' : '上游拒绝模型请求(HTTP 403),测试未通过'
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { await request.beforeRequest(); throw new AiModelProtocolError(message, unknown, 'safe-request-id') })
await verifyAiModel('admin', providerId, modelKey, input())
const terminal = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))!
expect(terminal).toContain(message)
expect(terminal[2]).toBe(unknown ? 'unknown' : 'failed')
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
})
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)

View File

@ -134,10 +134,17 @@ export function getAiModelCapabilities(baseUrl: string, modelId: string, metadat
// the analysis protocol; its explicit input flag may differ on legacy catalogs.
const transport = (protocol: AiModelProtocol | null) => protocol && JSON.stringify({ ...protocol, supportsImageInput: undefined })
const combinedTextTest = allowedStages.includes('analysis') && allowedStages.includes('copy') && !!analysisProtocol?.supportsImageInput && transport(analysisProtocol) === transport(copyProtocol)
const reason = evidence.unavailable ? '上游当前不可用,暂不能测试或开放'
: allowedStages.length ? '接口已适配;通过对应用途的实际测试后可开放'
: kind === 'image' ? evidence.inputDeclared && !imageInput && !imageEdit ? '仅支持文生图,不能接收产品参考图'
: protocols.some(protocol => protocol.id === 'openai-image-generate') ? '上游未提供可用的参考图参数,暂不能接收产品参考图'
: '上游尚未提供已适配的参考图接口'
: ['text', 'vision'].includes(kind) ? '输入输出或接口不符合详情页分析、文案要求'
: '该类型不适用于详情页的分析、文案或生图'
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 }]
})), reason: evidence.unavailable ? '上游已停用该模型' : allowedStages.length ? '接口已适配;通过对应用途的实际测试后可开放' : kind === 'image' ? '当前路由尚不支持私有产品参考图' : ['text', 'vision'].includes(kind) ? '该接口或明确输入能力尚不匹配详情页' : '该类型不适用于详情页的三个用途' }
})), reason }
}
export function getAiModelStageProtocol(metadata: unknown, stage: AiModelStage): AiModelProtocol | null {

View File

@ -56,11 +56,17 @@ function parseInvocation(model: Record<string, any>, offer: Record<string, any>,
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.
// A declared image-URL array accepts a data URI unless its schema excludes it.
// Keep private image bytes inline; never expose a product file through public storage.
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
if (items?.type !== 'string' || !(property?.type === 'array' || Array.isArray(property?.type) && property.type.includes('array'))) return false
if (items.format !== undefined && !['uri', 'uri-reference'].includes(items.format)) return false
const declaredImageInput = modes.some(mode => ['image', 'image_url', 'image_base64'].includes(mode))
if (modes.length && !declaredImageInput) return false
if (pattern === undefined) return declaredImageInput && [...capabilities, ...list(model.capabilities)].includes('image_to_image')
if (typeof pattern !== 'string' || pattern.length > 512 || !pattern.includes('data:image/')) return false
try { return new RegExp(pattern).test('data:image/png;base64,AA==') } catch { return false }
})
supportsImageInput = Boolean(inputField)
@ -132,16 +138,17 @@ function safeRequestId(response: Response, data: any, apiKey: string): string |
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) }
try { data = JSON.parse(text) } catch { throw new AiModelProtocolError(`上游返回异常响应(${response.status}),请核查请求结果;勿重复提交`, true, safeRequestId(response, null, apiKey), 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 definiteRejection = ['service_rejected', 'model_temporarily_unavailable'].includes(code)
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 ? '模型请求结果待核查,已暂停;请勿重复提交' : `模型服务拒绝请求(${response.status})`, unknown, requestId, taskId, pending)
throw new AiModelProtocolError(unknown ? '模型请求结果待核查,已暂停;请勿重复提交' : code === 'model_temporarily_unavailable' ? '上游模型暂不可用,请稍后重新测试' : `上游拒绝模型请求(HTTP ${response.status}),测试未通过`, unknown, requestId, taskId, pending)
}
if (text.includes(apiKey)) throw new AiModelProtocolError('模型响应含敏感内容,已暂停', true, requestId, taskId)
return { data, requestId }

View File

@ -140,8 +140,12 @@ export function parseAiModelCatalog(value: unknown): string[] {
export async function syncAiModelProvider(userId: string, providerId: string, body: unknown): Promise<AiModelAdminState> {
await assertAiModelAdmin(userId)
assertAiModelId(providerId); const input = parse(z.object({ revision: revision.min(1) }).strict(), body)
assertAiModelId(providerId); const input = parse(z.object({ revision: revision.min(1), modelKeys: z.array(id).min(1).max(5000).optional() }).strict(), body)
const source = await getAiModelProviderRow(providerId); assertAiModelRevision(source.revision, input.revision)
if (input.modelKeys) {
const owned = (await listAiModelRows()).filter(model => model.providerId === providerId)
if (input.modelKeys.some(key => !owned.some(model => model.key === key))) throw new AiModelError('所选模型不属于当前上游,未更新目录', 400, 'catalog_selection_invalid')
}
const apiKey = providerKey(source)
let response: Response
try { response = await requestAiModel(source.baseUrl, apiKey, '/models', undefined, undefined, async () => { const current = await getAiModelProviderRow(providerId); assertAiModelRevision(current.revision, source.revision); providerKey(current); await assertAiModelAdmin(userId) }) } catch (error) { if (error instanceof AiModelError) throw error; throw new AiModelError('上游模型目录连接失败,请检查服务地址和密钥', 502, 'provider_connection_failed') }
@ -170,11 +174,17 @@ export async function syncAiModelProvider(userId: string, providerId: string, bo
await assertAiModelAdminForWrite(userId, db)
const current = await getAiModelProviderRow(providerId, db); assertAiModelRevision(current.revision, source.revision)
const prior = (await listAiModelRows(db)).filter(model => model.providerId === providerId)
const catalogChanged = prior.some(model => model.present !== models.includes(model.id)) || models.some(model => !prior.some(entry => entry.id === model && aiModelCapabilityFingerprint(entry.capabilityMetadata) === aiModelCapabilityFingerprint(metadata.get(model))))
await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET present=FALSE WHERE provider_id=$1`, providerId)
for (const modelId of models) await db.$executeRawUnsafe(`INSERT INTO ai_model_catalog (id,provider_id,model_id,capability_metadata) VALUES ($1,$2,$3,$4::jsonb) ON CONFLICT (provider_id,model_id) DO UPDATE SET present=TRUE,capability_metadata=EXCLUDED.capability_metadata`, randomUUID(), providerId, modelId, JSON.stringify(metadata.get(modelId) ?? null))
const selected = input.modelKeys && new Set(input.modelKeys)
if (selected && Array.from(selected).some(key => !prior.some(model => model.key === key))) throw new AiModelError('所选模型不属于当前上游,未更新目录', 400, 'catalog_selection_invalid')
const scopedPrior = selected ? prior.filter(model => selected.has(model.key)) : prior
const selectedIds = selected && new Set(scopedPrior.map(model => model.id))
const scopedModels = selectedIds ? models.filter(model => selectedIds.has(model)) : models
const catalogChanged = scopedPrior.some(model => model.present !== scopedModels.includes(model.id)) || scopedModels.some(model => !scopedPrior.some(entry => entry.id === model && aiModelCapabilityFingerprint(entry.capabilityMetadata) === aiModelCapabilityFingerprint(metadata.get(model))))
if (selected) await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET present=FALSE WHERE provider_id=$1 AND id=ANY($2::text[])`, providerId, Array.from(selected))
else await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET present=FALSE WHERE provider_id=$1`, providerId)
for (const modelId of scopedModels) await db.$executeRawUnsafe(`INSERT INTO ai_model_catalog (id,provider_id,model_id,capability_metadata) VALUES ($1,$2,$3,$4::jsonb) ON CONFLICT (provider_id,model_id) DO UPDATE SET present=TRUE,capability_metadata=EXCLUDED.capability_metadata`, randomUUID(), providerId, modelId, JSON.stringify(metadata.get(modelId) ?? null))
await db.$executeRawUnsafe(`UPDATE ai_model_providers SET synced_at=NOW(),revision=revision+$2,updated_at=NOW() WHERE id=$1`, providerId, catalogChanged ? 1 : 0)
await aiModelAudit(db, userId, 'provider.sync', providerId, { modelCount: models.length, addedCount: models.filter(model => !prior.some(entry => entry.id === model)).length, catalogChanged })
await aiModelAudit(db, userId, 'provider.sync', providerId, { modelCount: scopedModels.length, addedCount: scopedModels.filter(model => !prior.some(entry => entry.id === model)).length, catalogChanged, ...(selected ? { modelKeys: Array.from(selected) } : {}) })
})
return getAiModelAdminState()
}

View File

@ -48,7 +48,10 @@ export function aiModelVerificationSummary(rows: AiModelVerificationRow[], model
const effective = verificationEffectiveStatus(row)
const status = effective === 'unknown' ? 'unknown' : row.contextHash !== contextHash || effective === 'passed' && row.evidence?.ruleVersion !== AI_MODEL_VERIFICATION_RULE_VERSION ? 'stale' : effective
const canResume = Boolean(queryAvailable && row.taskId && row.contextHash === contextHash && effective === 'unknown')
const reconciliation = row.evidence?.reconciliation as { outcome?: unknown; note?: unknown } | undefined
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,
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.message }
message: status === 'stale' ? '连接、路由或测试规则已改变,请重新测试' : status === 'unknown' && (row.status !== 'unknown' || !row.message) ? '请求结果尚未确认,已暂停重复测试;请核查上游请求记录' : message }
}

View File

@ -3,7 +3,7 @@ import { z } from 'zod'
import { AiModelError, assertAiModelAdmin, assertAiModelAdminForWrite } 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_ADAPTER_VERSION, AiModelProtocolError, callAiModelProtocol } from './ai-model-protocol'
import { AI_MODEL_TEST_PURPOSES, aiModelTestStages } from './ai-model-types'
import { AI_MODEL_VERIFICATION_COLUMNS, lockAiModelVerificationExecution, verificationExecutionId, type AiModelVerificationRow } from './ai-model-verification-state'
import { prisma } from './prisma'
@ -97,7 +97,8 @@ 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 message = unknown ? '请求结果尚未确认,请核查上游记录;禁止重复提交' : error instanceof AiModelError ? error.message : '模型测试未通过'
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 => {
for (const item of rows) {
const updated = 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'`, item.id, unknown ? 'unknown' : 'failed', safeId(outcome.requestId, apiKey), safeId(outcome.taskId || knownTaskId, apiKey), message)

View File

@ -0,0 +1,114 @@
/** Real upstream acceptance, restricted to the named local QA database. No production configuration writes. */
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 } 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 output = 'D:/AI Code/gongxiangp2/output/ai-image-adapter-20261015', base = 'http://127.0.0.1:3125'
const targets = ['gpt-image-2', 'gpt-image-2-vip', 'gpt-image-2.5', 'gpt-image-2.5-flare', 'gpt-image-2.5-sunburst']
const prisma = new PrismaClient({ log: [] })
async function save(name: string, data: unknown) { await mkdir(output, { recursive: true }); await writeFile(`${output}/${name}.json`, JSON.stringify(data, null, 2)) }
async function main() {
const jar = new Map<string, string>()
function store(r: Response) { for (const entry of r.headers.getSetCookie()) { const raw = entry.split(';')[0], at = raw.indexOf('='); jar.set(raw.slice(0, at), raw.slice(at + 1)) } }
const cookie = () => Array.from(jar).map(([key, value]) => `${key}=${value}`).join('; ')
const csrf = await fetch(base + '/api/auth/csrf'); store(csrf)
const signed = await fetch(base + '/api/auth/callback/credentials', { method: 'POST', redirect: 'manual', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookie() }, body: new URLSearchParams({ csrfToken: (await csrf.json()).csrfToken, email: 'ai-admin-1@example.invalid', password: 'AiAdminLocalTest-20261012', callbackUrl: base + '/admin', json: 'true' }) }); store(signed); assert.equal(signed.status, 200)
async function request(path = '', method = 'GET', body?: unknown) {
const r = await fetch(base + '/api/admin/ai-models' + path, { method, headers: { Cookie: cookie(), 'Content-Type': 'application/json' }, ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(195000) })
const text = await r.text(); assert.ok(!process.env.DETAIL_GENERATOR_API_KEY || !text.includes(process.env.DETAIL_GENERATOR_API_KEY), 'Credential leaked')
const data = JSON.parse(text); assert.equal(r.status, 200, JSON.stringify({ status: r.status, code: data.code, error: data.error })); return data as AiModelAdminState
}
let state = await request(), provider = state.providers.find(p => p.credentialSource === 'environment')!
assert.ok(provider)
const phase = process.argv[2] || 'inspect'
if (phase === 'check') {
const flags = (s: AiModelAdminState) => s.providers.map(p => ({ id: p.id, models: p.models.map(m => ({ key: m.key, enabled: m.enabled, stages: m.stages })) }))
const beforeFlags = flags(state), beforeDefaults = state.defaults
const count = async () => (await prisma.$queryRawUnsafe<Array<{ count: number }>>('SELECT COUNT(*)::int AS count FROM ai_model_verifications'))[0].count
const beforeCount = await count()
async function reject(path: string, body: unknown, status: number, token = cookie()) {
const r = await fetch(base + '/api/admin/ai-models' + path, { method: 'POST', headers: { Cookie: token, 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const data = await r.json(); assert.equal(r.status, status, JSON.stringify({ status: r.status, code: data.code })); return data
}
for (const modelId of ['nano-banana-2-2k-cl', 'nano-banana-2-4k-cl']) {
const m = provider.models.find(m => m.id === modelId)!
assert.ok(m); assert.equal(m.capabilities.reason, '仅支持文生图,不能接收产品参考图')
const result = await reject(`/providers/${provider.id}/models/${m.key}/verify`, { stage: 'image', revision: provider.revision, idempotencyKey: randomUUID() }, 400)
assert.equal(result.code, 'model_capability_unsupported')
}
for (const modelId of targets) {
const receipt = JSON.parse(await readFile(`${output}/test-${modelId}.json`, 'utf8'))
await request(`/providers/${provider.id}/models/${receipt.modelKey}/verify`, 'POST', { stage: 'image', revision: provider.revision, idempotencyKey: receipt.idempotencyKey })
}
assert.equal(await count(), beforeCount, 'Rejected or replayed submissions must not create any new model execution')
const configuration = (s: AiModelAdminState, key: string, enabled: boolean, stages: Array<'image'>) => ({ revision: s.revision, defaults: s.defaults, providers: s.providers.map(p => ({ id: p.id, revision: p.revision, models: p.models.map(m => ({ key: m.key, enabled: m.key === key ? enabled : m.enabled, stages: m.key === key ? stages : m.stages })) })) })
for (const modelId of targets.filter(id => id.includes('2.5'))) {
const m = provider.models.find(m => m.id === modelId)!
assert.equal(m.verification.image.status, 'failed'); assert.equal(m.enabled, false)
const r = await fetch(base + '/api/admin/ai-models/configuration', { method: 'PUT', headers: { Cookie: cookie(), 'Content-Type': 'application/json' }, body: JSON.stringify(configuration(state, m.key, true, ['image'])) })
assert.equal(r.status, 400); assert.equal((await r.json()).code, 'model_verification_required')
}
const passed = provider.models.find(m => m.id === 'gpt-image-2-vip')!
assert.equal(passed.verification.image.status, 'passed'); assert.equal(passed.enabled, false); assert.deepEqual(passed.stages, [])
state = await request('/configuration', 'PUT', configuration(state, passed.key, true, ['image']))
assert.ok(state.providers.find(p => p.id === provider.id)!.models.find(m => m.key === passed.key)!.enabled)
state = await request('/configuration', 'PUT', configuration(state, passed.key, false, []))
assert.deepEqual(flags(state), beforeFlags); assert.deepEqual(state.defaults, beforeDefaults)
const anonymous = await fetch(base + '/api/admin/ai-models'); assert.equal(anonymous.status, 401)
const otherJar = new Map<string, string>()
const absorb = (r: Response) => { for (const item of r.headers.getSetCookie()) { const raw = item.split(';')[0], at = raw.indexOf('='); otherJar.set(raw.slice(0, at), raw.slice(at + 1)) } }
const otherCookie = () => Array.from(otherJar).map(([key, value]) => `${key}=${value}`).join('; ')
const otherCsrf = await fetch(base + '/api/auth/csrf'); absorb(otherCsrf)
const otherSigned = await fetch(base + '/api/auth/callback/credentials', { method: 'POST', redirect: 'manual', headers: { Cookie: otherCookie(), 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ csrfToken: (await otherCsrf.json()).csrfToken, email: 'ai-admin-2@example.invalid', password: 'AiAdminLocalTest-20261012', callbackUrl: base + '/admin', json: 'true' }) }); absorb(otherSigned); assert.equal(otherSigned.status, 200)
provider = state.providers.find(p => p.id === provider.id)!
await reject(`/providers/${provider.id}/models/${passed.key}/verify`, { stage: 'image', revision: provider.revision, idempotencyKey: randomUUID() }, 403, otherCookie())
assert.equal(await count(), beforeCount)
await save('native-check', { textOnlyRejectedBeforeCall: true, idempotentReplays: targets.length, rejectedEnable: 3, passedEnableSaveAndRestore: true, unrelatedSettingsPreserved: true, anonymous: 401, ordinaryAdmin: 403, verificationCountUnchanged: true, productionTouched: false })
console.log('Native API acceptance passed: reference-image eligibility, replay safety, enable gating, settings restoration and authorization.'); return
}
if (phase === 'reconcile') {
const modelId = process.argv[3]; assert.ok(targets.includes(modelId))
const receipt = JSON.parse(await readFile(`${output}/test-${modelId}.json`, 'utf8'))
assert.equal(receipt.status, 'unknown')
const script = await readFile(`${output}/upstream-trace-request.sh`, 'utf8')
assert.ok(script.includes(`client:${receipt.idempotencyKey}`))
const log = await readFile(`${output}/upstream-trace-request.sh.log`, 'utf8')
const proof = JSON.parse(log.split(/\r?\n/).find(line => line.startsWith('{'))!)
assert.equal(proof.status, 'failed_final'); assert.equal(proof.httpStatus, 502); assert.equal(proof.errorCode, 'service_rejected')
assert.match(proof.requestId, /^[a-f0-9-]{36}$/)
state = await request(`/providers/${provider.id}/verifications/${receipt.verification.id}/reconcile`, 'POST', { outcome: 'failed', reference: proof.requestId, note: `已按本次请求编号只读核查上游记录,最终状态 failed_final,HTTP 502,service_rejected,请求已结束。`, confirmed: true })
const verification = state.providers.find(p => p.id === provider.id)!.models.find(m => m.key === receipt.modelKey)!.verification.image
assert.equal(verification.status, 'failed'); assert.ok(verification.reconciled)
await save(`reconciled-${modelId}`, { proof, verification, productionTouched: false }); console.log(JSON.stringify({ modelId, proof, verification })); return
}
if (phase === 'sync') {
const models = provider.models.filter(m => targets.includes(m.id)); assert.equal(models.length, targets.length)
const preserved = provider.models.filter(m => !targets.includes(m.id)).map(m => ({ key: m.key, enabled: m.enabled, stages: m.stages, verification: m.verification }))
state = await request(`/providers/${provider.id}/sync`, 'POST', { revision: provider.revision, modelKeys: models.map(m => m.key) })
provider = state.providers.find(p => p.id === provider.id)!
assert.deepEqual(provider.models.filter(m => !targets.includes(m.id)).map(m => ({ key: m.key, enabled: m.enabled, stages: m.stages, verification: m.verification })), preserved)
const result = provider.models.filter(m => targets.includes(m.id)).map(m => ({ id: m.id, capabilities: m.capabilities, verification: m.verification.image.status }))
assert.ok(result.every(m => m.capabilities.allowedStages.includes('image')))
await save('sync', { productionTouched: false, unrelatedModelsPreserved: true, models: result }); console.log(JSON.stringify(result)); return
}
if (phase === 'test') {
const modelId = process.argv[3]; assert.ok(targets.includes(modelId))
try { await readFile(`${output}/test-${modelId}.json`); throw new Error('This model already has a test receipt; inspect it instead of repeating a paid call') } catch (error: any) { if (error.code !== 'ENOENT') throw error }
const model = provider.models.find(m => m.id === modelId)!; assert.ok(model.capabilities.allowedStages.includes('image'))
const actor = await prisma.user.findUniqueOrThrow({ where: { email: 'ai-admin-1@example.invalid' } }); assert.ok(actor.isSuperAdmin)
const idempotencyKey = randomUUID(), startedAt = new Date().toISOString()
await save(`test-${modelId}`, { modelId, modelKey: model.key, providerId: provider.id, idempotencyKey, startedAt, status: 'submitting' })
state = await request(`/providers/${provider.id}/models/${model.key}/verify`, 'POST', { stage: 'image', revision: provider.revision, idempotencyKey })
const fresh = state.providers.find(p => p.id === provider.id)!.models.find(m => m.key === model.key)!
const rows = await prisma.$queryRawUnsafe<any[]>('SELECT id,stage,status,request_id,task_id,evidence,message FROM ai_model_verifications WHERE model_key=$1 AND actor_id=$2 AND idempotency_key=$3', model.key, actor.id, idempotencyKey)
assert.equal(rows.length, 1)
await save(`test-${modelId}`, { modelId, modelKey: model.key, providerId: provider.id, idempotencyKey, startedAt, status: rows[0].status, verification: fresh.verification.image, record: rows[0], productionTouched: false })
console.log(JSON.stringify({ modelId, status: rows[0].status, verification: fresh.verification.image, evidence: rows[0].evidence }))
assert.equal(rows[0].status, 'passed', 'Real reference-image test must pass before release'); return
}
console.log(JSON.stringify(provider.models.filter(m => targets.includes(m.id) || m.id.endsWith('-cl')).map(m => ({ id: m.id, enabled: m.enabled, reason: m.capabilities.reason, stages: m.capabilities.allowedStages, verification: m.verification.image }))))
}
main().catch(error => { console.error(error instanceof Error ? error.message : 'Image acceptance failed'); process.exitCode = 1 }).finally(() => prisma.$disconnect())