diff --git a/components/admin/AiModelManagementPanel.test.tsx b/components/admin/AiModelManagementPanel.test.tsx
index d71e5f5..9e36361 100644
--- a/components/admin/AiModelManagementPanel.test.tsx
+++ b/components/admin/AiModelManagementPanel.test.tsx
@@ -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()
+ 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()
})
diff --git a/components/admin/AiModelManagementPanel.tsx b/components/admin/AiModelManagementPanel.tsx
index adbe136..4002dd6 100644
--- a/components/admin/AiModelManagementPanel.tsx
+++ b/components/admin/AiModelManagementPanel.tsx
@@ -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
-
{model.id}
{capabilityLabels[model.capabilities.kind]}{model.capabilities.kind !== 'unknown' && !model.capabilities.allowedStages.length ? ' · 待适配' : ''} · {provider.name}{!provider.enabled ? ' · 上游已停用' : ''}{!model.present ? ' · 已离开上游目录' : ''}
{model.capabilities.reason}
+
{model.id}
{capabilityLabels[model.capabilities.kind]} · {provider.name}{!provider.enabled ? ' · 上游已停用' : ''}{!model.present ? ' · 已离开上游目录' : ''}
{model.capabilities.reason}
}
- > : {model.capabilities.kind === 'unknown' ? '能力待识别,暂不可测试' : '暂未接入详情页用途'}
}
+ {!testing && !attempt && !expandedDetails[detailsId] && ['failed', 'stale', 'unknown'].includes(result.status) && result.message && {result.message}
}
+ > : {blockedReason}
}
})}
diff --git a/docs/ai-image-reference-acceptance-2026-10-15.md b/docs/ai-image-reference-acceptance-2026-10-15.md
new file mode 100644
index 0000000..debaa32
--- /dev/null
+++ b/docs/ai-image-reference-acceptance-2026-10-15.md
@@ -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 测试记录,不自动启用新模型,不改变现有默认模型。生产真实复测若仍失败,必须保持失败或待核查并保留原请求,不能为发布而伪造通过。
diff --git a/lib/__tests__/ai-model-capabilities.test.ts b/lib/__tests__/ai-model-capabilities.test.ts
index 23cf4bd..59e7846 100644
--- a/lib/__tests__/ai-model-capabilities.test.ts
+++ b/lib/__tests__/ai-model-capabilities.test.ts
@@ -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: [] })
})
diff --git a/lib/__tests__/ai-model-protocol.test.ts b/lib/__tests__/ai-model-protocol.test.ts
index e4a2215..b1754d3 100644
--- a/lib/__tests__/ai-model-protocol.test.ts
+++ b/lib/__tests__/ai-model-protocol.test.ts
@@ -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('Bad Gateway', { 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) => {
diff --git a/lib/__tests__/ai-model-recovery.test.ts b/lib/__tests__/ai-model-recovery.test.ts
index ca9a488..490050f 100644
--- a/lib/__tests__/ai-model-recovery.test.ts
+++ b/lib/__tests__/ai-model-recovery.test.ts
@@ -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)
diff --git a/lib/__tests__/ai-model-service.test.ts b/lib/__tests__/ai-model-service.test.ts
index eea27c5..4fdad3a 100644
--- a/lib/__tests__/ai-model-service.test.ts
+++ b/lib/__tests__/ai-model-service.test.ts
@@ -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' })
diff --git a/lib/__tests__/ai-model-verification.test.ts b/lib/__tests__/ai-model-verification.test.ts
index fc0ac45..b958a39 100644
--- a/lib/__tests__/ai-model-verification.test.ts
+++ b/lib/__tests__/ai-model-verification.test.ts
@@ -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)
diff --git a/lib/ai-model-capabilities.ts b/lib/ai-model-capabilities.ts
index 7e87e63..2925225 100644
--- a/lib/ai-model-capabilities.ts
+++ b/lib/ai-model-capabilities.ts
@@ -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 {
diff --git a/lib/ai-model-protocol.ts b/lib/ai-model-protocol.ts
index b1361a4..a2c9df4 100644
--- a/lib/ai-model-protocol.ts
+++ b/lib/ai-model-protocol.ts
@@ -56,11 +56,17 @@ function parseInvocation(model: Record, offer: Record,
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 }
diff --git a/lib/ai-model-service.ts b/lib/ai-model-service.ts
index 75695dc..c09292f 100644
--- a/lib/ai-model-service.ts
+++ b/lib/ai-model-service.ts
@@ -140,8 +140,12 @@ export function parseAiModelCatalog(value: unknown): string[] {
export async function syncAiModelProvider(userId: string, providerId: string, body: unknown): Promise {
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()
}
diff --git a/lib/ai-model-verification-state.ts b/lib/ai-model-verification-state.ts
index 452fb5c..935fa3d 100644
--- a/lib/ai-model-verification-state.ts
+++ b/lib/ai-model-verification-state.ts
@@ -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 }
}
diff --git a/lib/ai-model-verification.ts b/lib/ai-model-verification.ts
index 41db9d8..39a4246 100644
--- a/lib/ai-model-verification.ts
+++ b/lib/ai-model-verification.ts
@@ -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)
diff --git a/scripts/verify-ai-image-adapters.ts b/scripts/verify-ai-image-adapters.ts
new file mode 100644
index 0000000..0c42b3c
--- /dev/null
+++ b/scripts/verify-ai-image-adapters.ts
@@ -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()
+ 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>('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()
+ 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('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())