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.id} 的用户可选用途{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
{selectedStage.id === 'analysis-copy' &&

{stages.find(entry => entry.id === stage)!.label}

}{value.message &&

{value.message}

}{value.checkedAt &&

{new Date(value.checkedAt).toLocaleString('zh-CN')}

}{value.requestId &&

请求编号:{value.requestId}

}
})} {result.taskId &&

上游任务:{result.taskId}

}
} - :

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