From 94ea82e7cbb06eb7a5563d392e15dcd7a46a5dc7 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 13 Oct 2026 12:28:41 +0800 Subject: [PATCH] feat(ai): verify model capabilities through real protocol tests --- Dockerfile | 4 +- .../[id]/models/[modelKey]/verify/route.ts | 8 + .../[verificationId]/refresh/route.ts | 7 + .../admin/AiModelManagementPanel.test.tsx | 206 ++++++++++++++-- components/admin/AiModelManagementPanel.tsx | 114 +++++++-- docs/ai-model-management.md | 50 +++- ...odel-verification-acceptance-2026-10-13.md | 24 ++ docs/生产数据库安全增量迁移SOP-2026-08-12.md | 16 +- lib/__tests__/ai-model-capabilities.test.ts | 16 +- lib/__tests__/ai-model-protocol-http.test.ts | 71 ++++++ lib/__tests__/ai-model-protocol.test.ts | 157 ++++++++++++ lib/__tests__/ai-model-service.test.ts | 44 +++- lib/__tests__/ai-model-verification.test.ts | 133 ++++++++++ .../detail-generator-backend.test.ts | 6 +- lib/__tests__/detail-generator-jobs.test.ts | 60 ++++- lib/ai-model-capabilities.ts | 74 ++++-- lib/ai-model-http.ts | 33 ++- lib/ai-model-protocol.ts | 230 ++++++++++++++++++ lib/ai-model-repository.ts | 4 +- lib/ai-model-service.ts | 71 ++++-- lib/ai-model-types.ts | 19 +- lib/ai-model-verification-state.ts | 37 +++ lib/ai-model-verification.ts | 136 +++++++++++ lib/detail-generator/jobs.ts | 36 ++- lib/detail-generator/provider.ts | 61 ++--- lib/detail-generator/repository.ts | 21 +- lib/detail-generator/upstream-task.ts | 15 ++ lib/detail-generator/validation.ts | 2 +- .../migration.sql | 24 ++ prisma/schema.prisma | 22 ++ scripts/ai-model-migration.test.mjs | 11 +- .../fixtures/ai-model-registry-evidence.mjs | 16 +- scripts/git-incremental-deploy.ps1 | 4 +- scripts/git-incremental-deploy.test.mjs | 16 +- scripts/safe-production-migrate.mjs | 22 +- scripts/safe-production-migrate.test.mjs | 8 +- scripts/verify-ai-model-integration.ts | 84 +++++++ 37 files changed, 1660 insertions(+), 202 deletions(-) create mode 100644 app/api/admin/ai-models/providers/[id]/models/[modelKey]/verify/route.ts create mode 100644 app/api/admin/ai-models/providers/[id]/verifications/[verificationId]/refresh/route.ts create mode 100644 docs/ai-model-verification-acceptance-2026-10-13.md create mode 100644 lib/__tests__/ai-model-protocol-http.test.ts create mode 100644 lib/__tests__/ai-model-protocol.test.ts create mode 100644 lib/__tests__/ai-model-verification.test.ts create mode 100644 lib/ai-model-protocol.ts create mode 100644 lib/ai-model-verification-state.ts create mode 100644 lib/ai-model-verification.ts create mode 100644 lib/detail-generator/upstream-task.ts create mode 100644 prisma/migrations/20261013_ai_model_verifications/migration.sql create mode 100644 scripts/verify-ai-model-integration.ts diff --git a/Dockerfile b/Dockerfile index 91dafbc..54b1bd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -120,7 +120,8 @@ RUN test -f "/app/prisma/migrations/20261008_permission_group_department_identit test -f "/app/prisma/migrations/20261009_material_purge_outbox/migration.sql" && \ test -f "/app/prisma/migrations/20261009_material_purge_reference_indexes/migration.sql" && \ test -f "/app/prisma/migrations/20261012_ai_model_registry/migration.sql" && \ - test -f "/app/prisma/migrations/20261013_ai_model_capabilities/migration.sql" + test -f "/app/prisma/migrations/20261013_ai_model_capabilities/migration.sql" && \ + test -f "/app/prisma/migrations/20261013_ai_model_verifications/migration.sql" # 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失, # 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。 @@ -284,6 +285,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260924_add_certif COPY --from=builder --chown=root:root /app/prisma/migrations/20261012_detail_generator ./prisma/migrations/20261012_detail_generator COPY --from=builder --chown=root:root /app/prisma/migrations/20261012_ai_model_registry ./prisma/migrations/20261012_ai_model_registry COPY --from=builder --chown=root:root /app/prisma/migrations/20261013_ai_model_capabilities ./prisma/migrations/20261013_ai_model_capabilities +COPY --from=builder --chown=root:root /app/prisma/migrations/20261013_ai_model_verifications ./prisma/migrations/20261013_ai_model_verifications COPY --from=builder --chown=root:root /app/lib ./lib COPY --from=builder --chown=root:root /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py COPY --from=builder --chown=root:root /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs diff --git a/app/api/admin/ai-models/providers/[id]/models/[modelKey]/verify/route.ts b/app/api/admin/ai-models/providers/[id]/models/[modelKey]/verify/route.ts new file mode 100644 index 0000000..10a763d --- /dev/null +++ b/app/api/admin/ai-models/providers/[id]/models/[modelKey]/verify/route.ts @@ -0,0 +1,8 @@ +import { aiModelAdminRoute } from '@/lib/ai-model-access' +import { verifyAiModel } from '@/lib/ai-model-verification' +import { parseDetailJson } from '@/lib/detail-generator/validation' +export const runtime = 'nodejs' +export const maxDuration = 300 +export async function POST(request: Request, context: { params: { id: string; modelKey: string } }) { + return aiModelAdminRoute(async userId => verifyAiModel(userId, context.params.id, context.params.modelKey, await parseDetailJson(request))) +} diff --git a/app/api/admin/ai-models/providers/[id]/verifications/[verificationId]/refresh/route.ts b/app/api/admin/ai-models/providers/[id]/verifications/[verificationId]/refresh/route.ts new file mode 100644 index 0000000..faf117f --- /dev/null +++ b/app/api/admin/ai-models/providers/[id]/verifications/[verificationId]/refresh/route.ts @@ -0,0 +1,7 @@ +import { aiModelAdminRoute } from '@/lib/ai-model-access' +import { refreshAiModelVerification } from '@/lib/ai-model-verification' +export const runtime = 'nodejs' +export const maxDuration = 300 +export async function POST(_request: Request, context: { params: { id: string; verificationId: string } }) { + return aiModelAdminRoute(async userId => refreshAiModelVerification(userId, context.params.id, context.params.verificationId)) +} diff --git a/components/admin/AiModelManagementPanel.test.tsx b/components/admin/AiModelManagementPanel.test.tsx index 9f3cc5d..8302dc9 100644 --- a/components/admin/AiModelManagementPanel.test.tsx +++ b/components/admin/AiModelManagementPanel.test.tsx @@ -1,16 +1,17 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import AiModelManagementPanel from './AiModelManagementPanel' -import type { AiModelAdminState } from '@/lib/ai-model-types' +import type { AiModelAdminState, AiModelVerificationSummary } from '@/lib/ai-model-types' function response(body: unknown, status = 200) { return { ok: status < 400, status, json: async () => body } as Response } const initial = (): AiModelAdminState => ({ providers: [], revision: 0, defaults: { analysis: null, copy: null, image: null }, encryptionConfigured: true, legacyAvailable: true }) +const verification = (status: AiModelVerificationSummary['status'] = 'untested') => ({ analysis: { status, id: null, checkedAt: null, requestId: null, message: null }, copy: { status, id: null, checkedAt: null, requestId: null, message: null }, image: { status, id: null, checkedAt: null, requestId: null, message: null } }) const configured = (): AiModelAdminState => ({ ...initial(), revision: 2, legacyAvailable: false, providers: [{ id: 'provider-1', name: '现有上游', baseUrl: 'https://api.example.com', enabled: true, credentialSource: 'environment', hasApiKey: true, revision: 3, syncedAt: null, models: [ - { key: 'model-text', id: 'gemini-2.5-flash', enabled: true, stages: ['analysis', 'copy'], present: true, compatibility: 'confirmed', capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'verified', reason: '支持图片理解与文字输出' } }, - { key: 'model-image', id: 'nano-banana-2', enabled: true, stages: ['image'], present: true, compatibility: 'confirmed', capabilities: { kind: 'image', allowedStages: ['image'], source: 'verified', reason: '支持图片生成' } }, - { key: 'model-new', id: 'new-vision-model', enabled: false, stages: [], present: true, compatibility: 'unconfirmed', capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'upstream', reason: '上游声明图片输入、文字输出' } }, - { key: 'model-missing', id: 'removed-model', enabled: false, stages: [], present: false, compatibility: 'unconfirmed', capabilities: { kind: 'unknown', allowedStages: [], source: 'unknown', reason: '目录未提供能力信息,待核验' } }, + { key: 'model-text', id: 'gemini-2.5-flash', enabled: true, stages: ['analysis', 'copy'], present: true, verification: verification('passed'), capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'verified', reason: '支持图片理解与文字输出' } }, + { key: 'model-image', id: 'nano-banana-2', enabled: true, stages: ['image'], present: true, verification: verification('passed'), capabilities: { kind: 'image', allowedStages: ['image'], source: 'verified', reason: '支持图片生成' } }, + { key: 'model-new', id: 'new-vision-model', enabled: false, stages: [], present: true, verification: verification(), capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'upstream', reason: '上游声明图片输入、文字输出' } }, + { key: 'model-missing', id: 'removed-model', enabled: false, stages: [], present: false, verification: verification(), capabilities: { kind: 'unknown', allowedStages: [], source: 'unknown', reason: '目录未提供能力信息,待核验' } }, ] }], defaults: { analysis: 'model-text', copy: 'model-text', image: 'model-image' }, }) @@ -33,11 +34,12 @@ test('显式迁入当前连接并同步上游目录,使用服务器版本号', const call = (global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/sync')) expect(call[1].method).toBe('POST') expect(JSON.parse(call[1].body)).toEqual({ revision: 3 }) - expect((global.fetch as jest.Mock).mock.calls.every(([url]) => !url.includes('generate') && !url.includes('chat'))).toBe(true) + expect((global.fetch as jest.Mock).mock.calls.every(([url]) => !url.includes('generate') && !url.includes('chat') && !url.endsWith('/verify'))).toBe(true) }) test('搜索与状态筛选仅影响显示,配置保存包含全部模型和各版本号', async () => { const next = configured() + next.providers[0].models[2].verification.analysis.status = 'passed' global.fetch = jest.fn(async (_url: string, init: RequestInit = {}) => response(init.method === 'PUT' ? { ...next, revision: 3 } : next)) as typeof fetch render() await screen.findByText('new-vision-model') @@ -45,7 +47,7 @@ test('搜索与状态筛选仅影响显示,配置保存包含全部模型和 expect(screen.queryByText('removed-model')).not.toBeInTheDocument() const model = screen.getByLabelText('new-vision-model · 现有上游', { selector: 'div' }) expect(within(model).getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).toBeDisabled() - fireEvent.click(within(model).getByRole('checkbox', { name: '确认 new-vision-model · 现有上游 接口兼容' })) + expect(within(model).queryByRole('checkbox', { name: /接口兼容/ })).not.toBeInTheDocument() fireEvent.click(within(model).getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' })) fireEvent.click(within(model).getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })) fireEvent.change(screen.getByLabelText('资料分析默认模型', { exact: false }), { target: { value: 'model-new' } }) @@ -58,7 +60,8 @@ test('搜索与状态筛选仅影响显示,配置保存包含全部模型和 expect(body.defaults.analysis).toBe('model-new') expect(body.providers[0].revision).toBe(3) expect(body.providers[0].models).toHaveLength(4) - expect(body.providers[0].models.find((entry: any) => entry.key === 'model-new')).toEqual({ key: 'model-new', enabled: true, stages: ['analysis'], compatibility: 'confirmed' }) + expect(body.providers[0].models.find((entry: any) => entry.key === 'model-new')).toEqual({ key: 'model-new', enabled: true, stages: ['analysis'] }) + expect(JSON.stringify(body)).not.toMatch(/compatibility|verification/) expect(JSON.stringify(body)).not.toContain('apiKey') fireEvent.change(screen.getByLabelText('搜索模型或上游'), { target: { value: '' } }) fireEvent.change(screen.getByLabelText('按状态筛选模型'), { target: { value: 'missing' } }) @@ -107,7 +110,7 @@ test('目录同步进行时禁止重复请求和配置修改', async () => { const sync = await screen.findByRole('button', { name: '同步 现有上游 的模型目录' }) fireEvent.click(sync); fireEvent.click(sync) expect(sync).toBeDisabled() - expect(screen.getByRole('checkbox', { name: '确认 new-vision-model · 现有上游 接口兼容' })).toBeDisabled() + expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).toBeDisabled() expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/sync'))).toHaveLength(1) finish(response(configured())) await waitFor(() => expect(sync).toBeEnabled()) @@ -123,7 +126,7 @@ test('缺少加密配置时禁止新增密钥,仍可显式迁入服务端连 test('目录按 20 条分页,搜索回第一页,保存仍包含不可见模型', async () => { const next = configured() - next.providers[0].models = Array.from({ length: 45 }, (_, index) => ({ key: `key-${index}`, id: `catalog-model-${String(index).padStart(2, '0')}`, enabled: false, stages: [], present: true, compatibility: 'unconfirmed', capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'upstream', reason: '支持图片理解与文字输出' } })) + next.providers[0].models = Array.from({ length: 45 }, (_, index) => ({ key: `key-${index}`, id: `catalog-model-${String(index).padStart(2, '0')}`, enabled: false, stages: [], present: true, verification: verification('passed'), capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'upstream', reason: '支持图片理解与文字输出' } })) next.defaults = { analysis: null, copy: null, image: null } global.fetch = jest.fn(async () => response(next)) as typeof fetch render() @@ -134,7 +137,7 @@ test('目录按 20 条分页,搜索回第一页,保存仍包含不可见模 expect(screen.getByLabelText('catalog-model-20 · 现有上游', { selector: 'div' })).toBeInTheDocument() fireEvent.change(screen.getByLabelText('搜索模型或上游'), { target: { value: 'catalog-model-44' } }) expect(screen.getByText('1 个模型 · 第 1 / 1 页')).toBeInTheDocument() - fireEvent.click(screen.getByRole('checkbox', { name: '确认 catalog-model-44 · 现有上游 接口兼容' })) + fireEvent.click(screen.getByRole('checkbox', { name: 'catalog-model-44 · 现有上游 用于资料分析' })) fireEvent.click(screen.getByRole('button', { name: '保存配置' })) await screen.findByText('模型配置已保存') const call = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init.method === 'PUT') @@ -143,7 +146,7 @@ test('目录按 20 条分页,搜索回第一页,保存仍包含不可见模 test('按能力筛选未开放模型,纯对话不能分配生图或图片分析', async () => { const next = configured() - next.providers[0].models[2] = { ...next.providers[0].models[2], id: 'text-chat-only', capabilities: { kind: 'text', allowedStages: ['copy'], source: 'upstream', reason: '仅支持文字输入与文字输出' } } + next.providers[0].models[2] = { ...next.providers[0].models[2], id: 'text-chat-only', verification: verification('passed'), capabilities: { kind: 'text', allowedStages: ['copy'], source: 'upstream', reason: '仅支持文字输入与文字输出' } } global.fetch = jest.fn(async () => response(next)) as typeof fetch render() fireEvent.change(await screen.findByLabelText('按能力筛选模型'), { target: { value: 'text' } }) @@ -152,11 +155,10 @@ test('按能力筛选未开放模型,纯对话不能分配生图或图片分 expect(screen.queryByText('new-vision-model')).not.toBeInTheDocument() const image = screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于图片生成' }) expect(image).toBeDisabled() - expect(image).toHaveAccessibleDescription('仅支持文字输入与文字输出') - expect(image.closest('label')).toHaveAttribute('title', '不支持图片输出') + expect(image).toHaveAccessibleDescription('此模型不支持图片生成') + expect(image.closest('label')).toHaveAttribute('title', '此模型不支持图片生成') expect(screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于资料分析' })).toBeDisabled() expect(screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于卖点文案' })).toBeEnabled() - fireEvent.click(screen.getByRole('checkbox', { name: '确认 text-chat-only · 现有上游 接口兼容' })) expect(image).toBeDisabled() expect(screen.getByRole('checkbox', { name: '开放 text-chat-only · 现有上游 给用户' })).toBeDisabled() fireEvent.click(screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于卖点文案' })) @@ -167,12 +169,14 @@ test('按能力筛选未开放模型,纯对话不能分配生图或图片分 expect(within(copy).getByRole('option', { name: 'text-chat-only · 现有上游' })).toBeInTheDocument() }) -test('能力待核验模型不能通过人工兼容确认解锁任何用途', async () => { +test('能力待识别模型没有人工兼容开关,不能解锁用途或实际测试', async () => { const next = configured() next.providers[0].models[2].capabilities = { kind: 'unknown', allowedStages: [], source: 'unknown', reason: '目录未提供能力信息,待核验' } global.fetch = jest.fn(async () => response(next)) as typeof fetch render() - fireEvent.click(await screen.findByRole('checkbox', { name: '确认 new-vision-model · 现有上游 接口兼容' })) + await screen.findByText('new-vision-model') + expect(screen.queryByRole('checkbox', { name: /接口兼容/ })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /测试 new-vision-model/ })).not.toBeInTheDocument() for (const label of ['资料分析', '卖点文案', '图片生成']) expect(screen.getByRole('checkbox', { name: `new-vision-model · 现有上游 用于${label}` })).toBeDisabled() expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).toBeDisabled() fireEvent.change(screen.getByLabelText('按能力筛选模型'), { target: { value: 'unknown' } }) @@ -187,7 +191,7 @@ test('已识别生图能力但协议未适配时显示待适配,不误称无 render() const image = await screen.findByRole('checkbox', { name: 'image-other-protocol · 现有上游 用于图片生成' }) expect(image).toBeDisabled() - expect(image.closest('label')).toHaveAttribute('title', '当前详情页未适配此用途') + expect(image.closest('label')).toHaveAttribute('title', '具备图片输出能力,当前参考图协议待适配') expect(image).toHaveAccessibleDescription('具备图片输出能力,当前参考图协议待适配') expect(screen.getByText('生图 · 待适配')).toBeInTheDocument() fireEvent.change(screen.getByLabelText('按能力筛选模型'), { target: { value: 'image' } }) @@ -241,3 +245,169 @@ test('旧配置中能力不符的用途允许取消,取消后不能重新选 expect(image).toBeDisabled() expect(screen.getByRole('checkbox', { name: '开放 gemini-2.5-flash · 现有上游 给用户' })).toBeChecked() }) + +test('实际测试需明确确认费用,取消不调用模型;通过后仍需管理员开放', async () => { + const next = configured() + const passed = configured() + passed.providers[0].models[2].verification.analysis = { status: 'passed', id: 'verification-1', checkedAt: '2026-10-13T01:00:00Z', requestId: 'request-1', message: '结构化分析与图片输入已验证' } + global.fetch = jest.fn(async (url: string) => response(url.endsWith('/verify') ? passed : next)) as typeof fetch + render() + const testButton = await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' }) + expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' })).toBeDisabled() + fireEvent.click(testButton) + let dialog = await screen.findByRole('dialog', { name: '测试资料分析' }) + expect(within(dialog).getByText('将实际调用模型,可能产生上游费用。')).toBeInTheDocument() + fireEvent.click(within(dialog).getByRole('button', { name: '取消' })) + await waitFor(() => expect(testButton).toBeEnabled()) + expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(0) + fireEvent.click(testButton) + dialog = await screen.findByRole('dialog', { name: '测试资料分析' }) + fireEvent.click(within(dialog).getByRole('button', { name: '开始测试' })) + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' })).toBeEnabled()) + const calls = (global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify')) + expect(calls).toHaveLength(1) + expect(calls[0][0]).toBe('/api/admin/ai-models/providers/provider-1/models/model-new/verify') + expect(JSON.parse(calls[0][1].body)).toEqual({ stage: 'analysis', revision: 3, idempotencyKey: expect.stringMatching(/^[0-9a-f-]{36}$/i) }) + expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).not.toBeChecked() + expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于卖点文案' })).toBeDisabled() + expect(screen.getByRole('button', { name: '保存配置' })).toBeDisabled() + expect(screen.getByLabelText('new-vision-model · 现有上游 测试用途')).toHaveValue('analysis') +}) + +test('未保存配置禁止测试,撤销后允许;每次仅提交所选用途', async () => { + const next = configured() + global.fetch = jest.fn(async () => response(next)) as typeof fetch + render() + fireEvent.click(await screen.findByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' })) + expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: '撤销' })) + fireEvent.change(screen.getByLabelText('new-vision-model · 现有上游 测试用途'), { target: { value: 'copy' } }) + fireEvent.click(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的卖点文案' })) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' })) + await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1)) + const call = (global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/verify')) + expect(JSON.parse(call[1].body).stage).toBe('copy') + await waitFor(() => expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的卖点文案' })).toBeEnabled()) +}) + +test('测试处理中禁止重复请求;未知结果暂停整个模型并可只读刷新', async () => { + const next = configured() + const unknown = configured() + unknown.providers[0].models[2].verification.analysis = { status: 'unknown', id: 'verification-unknown', checkedAt: '2026-10-13T01:00:00Z', requestId: 'upstream-request-unknown', message: '上游响应中断,需先核查执行结果' } + let finish!: (value: Response) => void + global.fetch = jest.fn((url: string) => url.endsWith('/verify') ? new Promise((resolve) => { finish = resolve }) : Promise.resolve(response(next))) as typeof fetch + render() + const testButton = await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' }) + fireEvent.click(testButton) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' })) + await screen.findByText('正在调用模型,请稍候') + fireEvent.click(testButton) + expect(testButton).toBeDisabled() + expect(screen.getByRole('button', { name: '同步 现有上游 的模型目录' })).toBeDisabled() + expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1) + finish(response(unknown)) + await screen.findByText('结果未知,暂停核查') + expect(testButton).toBeDisabled() + fireEvent.change(screen.getByLabelText('new-vision-model · 现有上游 测试用途'), { target: { value: 'copy' } }) + expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).toBeDisabled() + expect(screen.getByText('请求编号:upstream-request-unknown')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '刷新测试状态' })) + await screen.findByText('测试状态已刷新') + expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1) +}) + +test('付费请求响应丢失后仅查询状态,查询也失败则本地暂停而不重试', async () => { + const next = configured() + let reads = 0 + global.fetch = jest.fn(async (url: string) => { + if (url.endsWith('/verify')) throw new Error('网络中断') + if (++reads > 1) throw new Error('状态不可达') + return response(next) + }) as typeof fetch + render() + fireEvent.click(await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' })) + await screen.findByText('结果未知,暂停核查') + expect(screen.getByRole('alert')).toHaveTextContent('网络中断;已停止提交,请核查测试状态。') + expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).toBeDisabled() + expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1) + expect(reads).toBe(2) +}) + +test('响应丢失且 GET 仍为旧记录时保持暂停,手动刷新也不重复提交', async () => { + global.fetch = jest.fn(async (url: string) => { + if (url.endsWith('/verify')) throw new Error('连接中断') + return response(configured()) + }) as typeof fetch + render() + fireEvent.click(await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' })) + await screen.findByText('结果未知,暂停核查') + const call = (global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/verify')) + expect(screen.getByText(`请求编号:${JSON.parse(call[1].body).idempotencyKey}`)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '刷新测试状态' })) + await screen.findByText('测试状态已刷新') + expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).toBeDisabled() + expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1) +}) + +test('已有上游任务仅查询结果,无费用弹窗,不发送新验证请求', async () => { + const next = configured() + next.providers[0].models[2].verification.analysis = { status: 'unknown', id: 'existing-verification', checkedAt: null, requestId: 'existing-task', message: '上游任务仍在执行', canResume: true } + const completed = configured() + completed.providers[0].models[2].verification.analysis = { status: 'passed', id: 'existing-verification', checkedAt: '2026-10-13T01:00:00Z', requestId: 'existing-task', message: '测试通过' } + global.fetch = jest.fn(async (url: string) => response(url.endsWith('/refresh') ? completed : next)) as typeof fetch + render() + expect(await screen.findByText('等待上游结果,可查询')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '查询 new-vision-model · 现有上游 的资料分析结果' })) + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' })).toBeEnabled()) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + const calls = (global.fetch as jest.Mock).mock.calls.filter(([, init]) => init.method === 'POST') + expect(calls).toHaveLength(1) + expect(calls[0][0]).toBe('/api/admin/ai-models/providers/provider-1/verifications/existing-verification/refresh') + expect(calls[0][1].body).toBeUndefined() + expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).not.toBeChecked() +}) + +test('每个用途显示服务端绑定协议和路由,详情默认折叠且可键盘聚焦', async () => { + const next = configured() + next.providers[0].models[2].capabilities.protocols = { + analysis: { id: 'openai-chat', path: '/v1/chat/completions', scopeId: 'scope-a', routeId: 'vision-route', operationId: 'analyze' }, + copy: { id: 'openai-responses', path: '/v1/responses', routeId: 'copy-route' }, + } + global.fetch = jest.fn(async () => response(next)) as typeof fetch + render() + const details = await screen.findByRole('button', { name: 'new-vision-model · 现有上游 资料分析测试详情' }) + expect(details).toHaveAttribute('aria-expanded', 'false') + expect(screen.getByText('协议:openai-chat')).not.toBeVisible() + details.focus() + expect(details).toHaveFocus() + fireEvent.click(details) + expect(details).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('接口:/v1/chat/completions')).toBeVisible() + expect(screen.getByText('路由:scope-a / vision-route / analyze')).toBeVisible() + fireEvent.change(screen.getByLabelText('new-vision-model · 现有上游 测试用途'), { target: { value: 'copy' } }) + expect(screen.queryByText('协议:openai-chat')).not.toBeInTheDocument() + const copyDetails = screen.getByRole('button', { name: 'new-vision-model · 现有上游 卖点文案测试详情' }) + expect(copyDetails).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(copyDetails) + expect(screen.getByText('协议:openai-responses')).toBeVisible() + expect(screen.getByText('路由:copy-route')).toBeVisible() + expect((global.fetch as jest.Mock).mock.calls).toHaveLength(1) +}) + +test.each([['tts', '语音合成'], ['asr', '语音识别'], ['music', '音乐'], ['video', '视频'], ['embedding', '向量'], ['image-tool', '图像工具'], ['three-d', '3D']] as const)('目录可筛选 %s,但未接入详情页的类型不能测试或开放', async (kind, label) => { + const next = configured() + next.providers[0].models[2].capabilities = { kind, allowedStages: [], source: 'upstream', reason: '当前详情页未接入该类型' } + global.fetch = jest.fn(async () => response(next)) as typeof fetch + render() + const filter = await screen.findByLabelText('按能力筛选模型') + expect(within(filter).getByRole('option', { name: label })).toBeInTheDocument() + fireEvent.change(filter, { target: { value: kind } }) + expect(screen.getByText('new-vision-model')).toBeInTheDocument() + expect(screen.queryByText('gemini-2.5-flash')).not.toBeInTheDocument() + expect(screen.getByText('暂未接入详情页用途')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /测试 new-vision-model/ })).not.toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).toBeDisabled() +}) diff --git a/components/admin/AiModelManagementPanel.tsx b/components/admin/AiModelManagementPanel.tsx index 9e58391..b009a40 100644 --- a/components/admin/AiModelManagementPanel.tsx +++ b/components/admin/AiModelManagementPanel.tsx @@ -1,7 +1,8 @@ 'use client' import { useEffect, useMemo, useRef, useState } from 'react' -import { Plus, RefreshCw, Save, Search } from 'lucide-react' +import { Info, Plus, RefreshCw, Save, Search } from 'lucide-react' +import { useConfirmActionDialog } from '@/components/ConfirmActionDialog' import type { AiModelAdminState, AiModelCatalogEntry, AiModelProvider, AiModelStage } from '@/lib/ai-model-types' const stages: Array<{ id: AiModelStage; label: string; shortLabel: string }> = [ @@ -11,19 +12,22 @@ const fieldClass = 'min-h-11 w-full min-w-0 rounded-lg border border-[#cfd8e3] b const buttonClass = 'inline-flex min-h-11 shrink-0 items-center justify-center gap-2 rounded-lg border border-[#cfd8e3] bg-white px-3 text-sm font-medium text-[#344054] transition-colors hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50' const primaryClass = `${buttonClass} !border-[#0050cb] !bg-[#0050cb] !text-white hover:!bg-[#003da3]` const checkboxClass = 'h-4 w-4 shrink-0 accent-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed' -const capabilityLabels = { text: '对话', vision: '视觉理解', image: '生图', other: '其他', unknown: '待核验' } as const +const capabilityLabels = { text: '对话', vision: '视觉理解', image: '生图', tts: '语音合成', asr: '语音识别', music: '音乐', video: '视频', embedding: '向量', 'image-tool': '图像工具', 'three-d': '3D', other: '其他', unknown: '待识别' } as const +const verificationLabels = { untested: '待测试', running: '测试中', passed: '已通过', failed: '未通过', unknown: '暂停核查', stale: '需重新测试' } as const interface ProviderForm { id: string | null; revision: number; name: string; baseUrl: string; apiKey: string } +class ModelRequestError extends Error { constructor(message: string, readonly status: number) { super(message) } } async function request(path = '', init: RequestInit = {}): Promise { const response = await fetch(`/api/admin/ai-models${path}`, { ...init, cache: 'no-store', headers: { 'Content-Type': 'application/json', ...init.headers } }) const body = await response.json().catch(() => ({})) - if (!response.ok) throw new Error(typeof body.error === 'string' ? body.error : body.error?.message || '操作未完成,请重试') + if (!response.ok) throw new ModelRequestError(typeof body.error === 'string' ? body.error : body.error?.message || '操作未完成,请重试', response.status) + if (!Array.isArray(body.providers)) throw new Error('未能读取服务端状态') return body as AiModelAdminState } function usable(provider: AiModelProvider, model: AiModelCatalogEntry, stage: AiModelStage) { - return provider.enabled && provider.hasApiKey && model.present && model.enabled && model.compatibility === 'confirmed' && model.stages.includes(stage) && model.capabilities.allowedStages.includes(stage) + return provider.enabled && provider.hasApiKey && model.present && model.enabled && model.verification[stage].status === 'passed' && model.stages.includes(stage) && model.capabilities.allowedStages.includes(stage) } export default function AiModelManagementPanel({ onStateChange }: { onStateChange?: (state: { dirty: boolean; busy: boolean }) => void }) { @@ -39,10 +43,23 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang const [capabilityFilter, setCapabilityFilter] = useState('all') const [statusFilter, setStatusFilter] = useState('all') const [page, setPage] = useState(1) + const [testStages, setTestStages] = useState>({}) + const [expandedDetails, setExpandedDetails] = useState>({}) const busyRef = useRef(false) + const uncertainAttempts = useRef(new Map()) + const { confirmAction, confirmActionDialog } = useConfirmActionDialog() const dirty = !!state && !!draft && JSON.stringify(state) !== JSON.stringify(draft) - function accept(next: AiModelAdminState) { setState(next); setDraft(next) } + function accept(next: AiModelAdminState) { + const resolved = { ...next, providers: next.providers.map((provider) => ({ ...provider, models: provider.models.map((model) => { + const attempt = uncertainAttempts.current.get(model.key) + if (!attempt) return model + const result = model.verification[attempt.stage] + if (result.id && result.id !== attempt.previousId) { uncertainAttempts.current.delete(model.key); return model } + return { ...model, verification: { ...model.verification, [attempt.stage]: { ...result, status: 'unknown' as const, requestId: attempt.requestId, canResume: false, message: '尚未获取本次测试记录,请核查请求编号后再操作' } } } + }) })) } + setState(resolved); setDraft(resolved) + } useEffect(() => { let cancelled = false @@ -57,11 +74,11 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang useEffect(() => () => { onStateChange?.({ dirty: false, busy: false }) }, [onStateChange]) useEffect(() => { - if (!dirty && !form) return + if (!dirty && !form && !busy.startsWith('verify:')) return const prevent = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' } window.addEventListener('beforeunload', prevent) return () => window.removeEventListener('beforeunload', prevent) - }, [dirty, form]) + }, [dirty, form, busy]) async function run(identity: string, action: () => Promise, success: string, closeForm = false) { if (busyRef.current) return @@ -103,6 +120,32 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang void run('provider-save', () => request(form.id ? `/providers/${encodeURIComponent(form.id)}` : '/providers', { method: form.id ? 'PATCH' : 'POST', body: JSON.stringify(body) }), form.id ? '上游已保存' : '上游已添加,请同步模型目录', true) } + async function testModel(provider: AiModelProvider, model: AiModelCatalogEntry, stage: AiModelStage) { + if (busyRef.current || dirty || form || !provider.enabled || !provider.hasApiKey || !model.present || !model.capabilities.allowedStages.includes(stage) || Object.values(model.verification).some((result) => result.status === 'running' || result.status === 'unknown')) return + busyRef.current = true + setBusy(`confirm:${model.key}`) + setTestStages((current) => ({ ...current, [model.key]: stage })) + const label = stages.find((item) => item.id === stage)!.label + const confirmation = await confirmAction({ title: `测试${label}`, description: <>{model.id}
将实际调用模型,可能产生上游费用。, confirmLabel: '开始测试', tone: 'primary' }) + if (!confirmation.confirmed) { busyRef.current = false; setBusy(''); return } + setBusy(`verify:${model.key}:${stage}`); setError(''); setNotice('') + let idempotencyKey = '' + try { + idempotencyKey = crypto.randomUUID() + const next = await request(`/providers/${encodeURIComponent(provider.id)}/models/${encodeURIComponent(model.key)}/verify`, { method: 'POST', body: JSON.stringify({ stage, revision: provider.revision, idempotencyKey }) }) + accept(next) + } catch (cause) { + // Refresh state after a lost response; never repeat a potentially billable request. + const message = cause instanceof Error ? cause.message : '测试请求未完成' + if (idempotencyKey && (!(cause instanceof ModelRequestError) || cause.status >= 500)) uncertainAttempts.current.set(model.key, { stage, previousId: model.verification[stage].id, requestId: idempotencyKey }) + try { accept(await request()) } catch { + const markUnknown = (current: AiModelAdminState | null) => current && ({ ...current, providers: current.providers.map((entry) => entry.id !== provider.id ? entry : { ...entry, models: entry.models.map((item) => item.key !== model.key ? item : { ...item, verification: { ...item.verification, [stage]: { ...item.verification[stage], status: 'unknown' as const, message: '未能获取测试结果,请刷新状态核查' } } }) }) }) + setState(markUnknown); setDraft(markUnknown) + } + setError(`${message};已停止提交,请核查测试状态。`) + } finally { busyRef.current = false; setBusy('') } + } + const rows = useMemo(() => (draft?.providers || []).flatMap((provider) => provider.models.map((model) => ({ provider, model }))), [draft]) const visibleRows = rows.filter(({ provider, model }) => { const query = search.trim().toLowerCase() @@ -111,13 +154,15 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang if (statusFilter === 'enabled' && !model.enabled) return false if (statusFilter === 'disabled' && model.enabled) return false if (statusFilter === 'missing' && model.present) return false - if (statusFilter === 'unconfirmed' && model.compatibility !== 'unconfirmed') return false + if (statusFilter === 'untested' && !model.capabilities.allowedStages.some((stage) => ['untested', 'stale'].includes(model.verification[stage].status))) return false + if (statusFilter === 'attention' && !Object.values(model.verification).some((result) => ['failed', 'unknown', 'running'].includes(result.status))) return false return true }) const providerBlocked = !!busy || dirty || !!form const pageCount = Math.max(1, Math.ceil(visibleRows.length / 20)) const currentPage = Math.min(page, pageCount) const pageRows = visibleRows.slice((currentPage - 1) * 20, currentPage * 20) + const needsStatusRefresh = rows.some(({ model }) => Object.values(model.verification).some((result) => result.status === 'running' || result.status === 'unknown')) if (loading) return
if (!draft) return

{error || 'AI 模型配置未能加载'}

@@ -126,6 +171,7 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang

AI 模型接入

+ {needsStatusRefresh && } {draft.legacyAvailable && }
@@ -149,7 +195,7 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang }
-

用户可选模型

{dirty && 有未保存修改}
+

用户可选模型

{dirty && 有未保存修改}
阶段默认模型 {stages.map((stage) => { @@ -161,25 +207,55 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang })}
{!!rows.length && <> -
-

用户可自行选择已开放的模型;不支持的用途不可勾选。

- +
+

先测试用途,再勾选开放并保存。{dirty ? ' 测试前请先保存或撤销修改。' : ''}

+
{!visibleRows.length &&

没有匹配模型

} - {pageRows.map(({ provider, model }) =>
+ {pageRows.map(({ provider, model }) => { + const testableStages = stages.filter((stage) => model.capabilities.allowedStages.includes(stage.id)) + const selectedStage = testableStages.find((stage) => ['unknown', 'running'].includes(model.verification[stage.id].status)) || testableStages.find((stage) => stage.id === testStages[model.key]) || testableStages.find((stage) => model.verification[stage.id].status !== 'passed') || testableStages[0] + const result = selectedStage ? model.verification[selectedStage.id] : null + const protocol = selectedStage ? model.capabilities.protocols?.[selectedStage.id] : null + const detailsId = `test-details-${model.key}-${selectedStage?.id}` + const canInspect = !!protocol || !!result && !!(result.message || result.checkedAt || result.requestId) + const unsettled = Object.values(model.verification).some((entry) => entry.status === 'running' || entry.status === 'unknown') + const testing = busy.startsWith(`verify:${model.key}:`) + const blockedTest = providerBlocked || !provider.enabled || !provider.hasApiKey || !model.present || !selectedStage || unsettled + return

{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} 的用户可选用途{stages.map((stage) => { - const supported = model.capabilities.allowedStages.includes(stage.id) + const adapted = model.capabilities.allowedStages.includes(stage.id) + const supported = adapted && model.verification[stage.id].status === 'passed' const checked = model.stages.includes(stage.id) - const reason = model.capabilities.kind === 'unknown' ? '能力待核验,暂不能开放此用途' : stage.id === 'image' && (model.capabilities.kind === 'text' || model.capabilities.kind === 'vision') ? '不支持图片输出' : '当前详情页未适配此用途' - return + const reason = supported ? `${stage.label}测试已通过` : adapted ? `${stage.label}:${verificationLabels[model.verification[stage.id].status]},通过后可开放` : model.capabilities.allowedStages.length ? `此模型不支持${stage.label}` : model.capabilities.reason + return })}
- -
)} +
+ {selectedStage && result ? <> +
+ + {result.canResume && result.id ? : } + {canInspect && } +
+

{testing ? '正在调用模型,请稍候' : result.status === 'unknown' ? result.canResume ? '等待上游结果,可查询' : '结果未知,暂停核查' : result.status === 'running' ? '测试进行中,可刷新状态' : `${selectedStage.shortLabel} · ${verificationLabels[result.status]}`}

+ {canInspect && } + :

{model.capabilities.kind === 'unknown' ? '能力待识别,暂不可测试' : '暂未接入详情页用途'}

} +
+
})}
}
+ {confirmActionDialog} } diff --git a/docs/ai-model-management.md b/docs/ai-model-management.md index d175782..632beaa 100644 --- a/docs/ai-model-management.md +++ b/docs/ai-model-management.md @@ -6,30 +6,54 @@ 1. 已有详情页上游可用“迁入当前连接”显式转为数据库配置;迁入操作只保存配置,不调用模型。凭据继续引用固定的服务端 `DETAIL_GENERATOR_API_KEY`,不复制到浏览器。 2. 新增上游填写名称、HTTPS API 地址和 API Key。新增或替换凭据需已有专用 `API_KEY_ENCRYPTION_KEY`,最少 32 字节。凭据使用 AES-256-GCM 加密,接口只返回配置状态;编辑时密钥留空保持现状。 -3. “同步目录”请求上游 `/v1/models`。当前上游还读取其公开的 `/api/v2/catalog/models` 结构化能力目录,只保留当前 Key 目录内的模型。新发现模型默认未启用;重复同步保留仍符合能力限制的人工用途,上游已移除的模型不可用。 -4. 先按对话、多模态、生图、其他及待核验筛选,再勾选允许用户使用的用途并启用。用途由服务端能力证据限制:纯对话不能勾选生图;无图片输入能力不能勾选资料分析;仅支持文生图、专用路由或异步接口的模型不能直接用于当前产品参考图生图。 -5. 为三个阶段选择默认模型并保存。用户可在详情页“模型选择”中自行选择管理员开放的模型,默认值只是新项目的初始选择。不同上游的同名模型以独立编号区分,实际请求仍使用上游原生模型 ID。详情页正式验收前仍只向超级管理员开放。 +3. “同步目录”请求上游 `/v1/models`。当前接入的 `api.vip98k.com` 还读取其公开的 `/api/v2/catalog/models` 结构化能力目录,只保留当前 Key 有权使用的模型。模型数量取决于上游目录、Key 权限和同步结果,不固定为某个数量。新发现模型默认未开放;同步不会调用生成接口,也不会自动批量测试。 +4. 搜索模型或上游,按对话、视觉理解、生图、语音合成、语音识别、音乐、视频、向量、图像工具、3D、其他或待识别筛选。分类由服务端读取能力及协议证据,不按模型名字猜测。类型已识别不代表当前详情页已接入该类型。 +5. 在模型行选择**测试用途**,点击“测试”。确认“将实际调用模型,可能产生上游费用”后,只测试当前模型的当前用途。资料分析、卖点文案、图片生成分别记录结果;一个用途通过,不会替另一个用途背书。存在未保存的配置时,先保存或撤销,再测试。 +6. 用途实际测试通过后,勾选允许用户使用的用途,再勾选“开放”。测试成功不会自动开放模型。为三个阶段选择默认模型,点击“保存配置”后生效;没有有效测试结果、能力不符或尚未适配的用途不能开放。 +7. 用户可在详情页“模型选择”中自行选择管理员已开放的模型,默认值只是新项目的初始选择。不同上游的同名模型以独立编号区分,实际请求仍使用上游原生模型 ID。详情页正式验收前仍只向超级管理员开放。 -管理员“确认接口兼容”是配置确认,不能增加模型的服务端能力。后端在保存、默认值、用户候选列表及每次模型执行前共同复核,直接提交请求也无法越界。上游能力停用后,旧项目和已排队任务不能继续调用该能力,已有内容仍可编辑、保存及导出。 +不再提供“确认接口兼容”人工勾选,配置接口也不接受 `compatibility`。是否可用由服务端的协议适配和当前用途的实际测试结果共同决定。后端在保存配置、设置默认值、提供用户候选列表及执行模型前共同复核,直接提交请求也无法越界。上游停用、能力撤销或验证失效后,旧项目和已排队任务也不能继续调用失效能力;已有内容仍可编辑、保存及导出。 + +## 测试状态与查询 + +| 界面状态 | 含义与操作 | +| --- | --- | +| 待测试 | 已有可执行的用途适配,尚无该用途的实际测试结果。 | +| 测试中 | 测试正在执行,禁止重复提交;“刷新测试状态”只重新读取平台记录。 | +| 已通过 | 当前连接、密钥、模型能力、协议路由和适配版本对应的用途测试通过,可由管理员开放。 | +| 未通过 | 已得到明确失败结果;查看测试详情,处理原因后再显式测试。 | +| 暂停核查 | 请求是否完成尚不明确,禁止直接重测,避免重复计费。 | +| 需重新测试 | 连接、密钥、能力、路由或适配版本已改变,原测试结果不能继续作为开放依据。 | + +异步生图先持久化上游任务编号,再查询任务结果。仅当已有任务编号且当前连接及路由仍匹配时,显示“查询结果”;该操作只查询同一个上游任务,不重新发送生成请求。没有已知任务编号时,保持暂停,按测试详情中的请求编号核查上游记录。 + +浏览器丢失测试响应后只查询状态,不自动重发付费请求;如果状态仍是旧记录,当前页面保留暂停状态。重复的同一幂等请求也不会新建模型调用。真实测试与详情页生成共用执行额度:每人同时 1 个,全局同时 2 个。 + +模型行的详情按钮默认折叠。展开后可查看当前用途实际绑定的协议、接口路径,以及目录提供的服务范围、路由和操作标识;已有结果还显示测试时间、消息和请求编号。这里显示服务端绑定,不允许前端随意修改路由。 ## 当前适配协议 -| 用途 | 输入与接口 | 输出要求 | +| 用途 | 已实现的协议适配 | 实际测试检查 | | --- | --- | --- | -| 资料分析 | `/chat/completions`,文本和 `image_url` 产品图 | 可用的文本分析 | -| 卖点文案 | `/chat/completions`,文本 | 可解析且符合项目校验的大纲 JSON | -| 图片生成 | `/images/edits`,multipart 产品及参考图片 | `data[0].b64_json` 中的有效图片 | +| 资料分析 | 支持图片输入的 OpenAI Chat Completions 或 Anthropic Messages | 能读取测试图片,并正确返回指定位置的颜色 JSON。 | +| 卖点文案 | OpenAI Chat Completions 或 Anthropic Messages | 返回符合详情页大纲校验的一屏 JSON。 | +| 图片生成 | OpenAI Images Edits;明确支持私有参考图内联输入的 Images Generations 或异步图片任务 | 请求可接受参考图,结果可解码、尺寸有效且非空白。 | -音频、视频及图片处理工具归为其他;专用接口需要另行开发适配后才能分配给详情页阶段。模型目录里的名称本身不证明适配能力。能力以明确的输入、输出、默认接口协议为依据;没有元数据时只保留精确上游地址和模型的既有真实验证记录(Gemini 2.5 Flash 分析/文案、Nano Banana 2 参考图生图),不把同名其他上游视为已验证。上游明确停用、撤销模态或接口时优先拒绝。目录同步不等于真实生成验收,不自动批量发送付费测试。 +具体接口以每个模型的服务端协议绑定为准。同步时在目录明确的服务范围内选择可执行路由,并保留对应标识;执行时不会静默换路由。专用协议或异步接口只有符合已实现适配及私有参考图输入要求时,才可进入相应用途测试;纯文生图不因此获得产品参考图能力。 + +当前业务工作台仍只有详情页的三个用途。语音、音乐、视频、向量、图像工具及 3D 可以在目录中分类查看,但本次没有为它们增加生成工作台,也不能因出现在目录就勾选为生图用途。无可靠能力信息的模型保持“待识别”;明确不满足用途输入输出要求的模型不能通过人工确认绕过。 + +界面借鉴家具项目的连接编辑、获取目录和就近反馈方式。家具项目“测试并获取模型”的目录 GET 只证明目录连接,本项目另设的按用途真实调用才产生用途验证记录。目录同步、接口测试通过、真实产品生成质量和完整详情页验收是不同证据;这里不代表所有目录模型均已实测,也不代表品牌风格、PSD 可编辑性或 Photoshop 验收已经完成。 ## 安全与数据 - 只接受 HTTPS 标准端口、无嵌入凭据或查询参数的服务地址;解析的全部 IP 都须为公网地址,并将已检查地址固定到实际 HTTPS 连接。拒绝跳转,防止 API Key 被带到其他地址。 -- 修改 API 地址必须同时填写该服务的密钥,并清除原地址的目录兼容配置。密钥不会进入响应、审计元数据或项目快照。 -- 上游、模型目录、阶段默认值及审计记录保存在 `ai_model_providers`、`ai_model_catalog`、`ai_model_configuration`、`ai_model_audit`。配置事务串行并核对版本,旧配置不能覆盖新修改。 +- 修改 API 地址必须同时填写该服务的密钥,并清除原地址的目录配置。验证上下文包括连接、密钥、能力、协议路由及适配版本;任何相关变化都会使旧通过记录失效。密钥不会进入响应、审计元数据或项目快照。 +- 上游、模型目录、阶段默认值及审计记录保存在 `ai_model_providers`、`ai_model_catalog`、`ai_model_configuration`、`ai_model_audit`。用途测试记录及任务检查点保存在 `ai_model_verifications`。配置事务串行并核对版本,旧配置不能覆盖新修改。 +- 调用上游前先持久化测试记录并复核数据库超级管理员权限。正在执行或结果未知的同一模型用途禁止重新提交;已知异步任务只恢复查询,不重新生成。 - `capability_metadata` 只持久化服务端归一化的能力证据,管理接口不接受客户端修改。公开能力目录无密钥请求,使用与私有请求相同的公网 DNS 固定、拒绝重定向和限时保护;公开目录限 8 MiB,私有模型列表保持 2 MiB。同步任一目录失败时不保存部分结果。 - 详情项目保存可信的上游及模型绑定,模型执行前重新核对可用性、权限和绑定。旧裸模型名只对应显式迁入的原服务,不能静默改到另一供应商。 - 缺少阶段默认模型时暂停生成;已有项目仍可修改文案、保存和导出。 -- 受控迁移:`20261012_ai_model_registry` 新增表,`20261013_ai_model_capabilities` 仅新增可空的能力证据列;不包含真实凭据或自动上游导入。旧行无能力证据时默认不可开放,只有准确匹配的既有实际验证可用;同步后恢复有证据的候选范围。生产发布仍遵循标准备份、受控迁移及精确提交验证;禁止对生产运行 `prisma db push`。 +- 受控迁移:`20261012_ai_model_registry` 新增配置表,`20261013_ai_model_capabilities` 新增能力证据列,`20261013_ai_model_verifications` 新增用途测试记录及唯一约束,并替换原人工兼容检查约束。历史 `compatibility` 值保留但不再参与可用性判定;迁移不会将其转换为“已通过”,也不含真实凭据、自动上游导入或付费测试。生产发布仍遵循标准备份、受控迁移及精确提交验证;禁止对生产运行 `prisma db push`。 -本地真实 HTTP 与隔离数据库验收脚本:`scripts/verify-ai-model-admin.ts`,仅允许 `127.0.0.1:55441/ai_model_test`。目录同步为只读查询,脚本不提交生成任务。 +是否可向用户开放,以当前服务端用途验证记录为准。实际执行过哪些模型及产品验收项目,须另附对应记录,不从目录数量、适配协议数量或部署成功推断。 diff --git a/docs/ai-model-verification-acceptance-2026-10-13.md b/docs/ai-model-verification-acceptance-2026-10-13.md new file mode 100644 index 0000000..7865b31 --- /dev/null +++ b/docs/ai-model-verification-acceptance-2026-10-13.md @@ -0,0 +1,24 @@ +# AI 模型接入实测记录(2026-10-13) + +本轮以家具项目的上游连接、目录同步和就近反馈方式为参考,移除人工兼容确认。目录连接成功不等于模型用途通过;管理端和详情页共同读取服务端实际验证记录。 + +## 已验证 + +- 本地隔离数据库 `ai_model_test`,端口 55441;未使用生产用户、任务或数据库作为测试夹具。 +- 真实上游 `api.vip98k.com`:`gemini-3-flash` 资料分析和卖点文案通过,`nano-banana-2` 私有参考图生图通过。资料分析检查图片指定位置的颜色,文案检查大纲结构,生图检查实际可解码输出。 +- Gemini 分析请求编号 `c6893b1f-c2fa-4248-8bc6-485f2b543b98`,文案 `cb014e22-1f31-499f-8c80-2594051d919e`,Nano `25905516-3c9c-42f0-8906-3a829a481e83`。图片结果通过同一已知任务查询取回,未因等待或解析问题重复提交生成。 +- 实际 HTTP 权限检查:匿名 401;普通用户、普通管理员、撤权前签发的旧会话 403;伪造 `compatibility` 和不匹配的生图用途 400;拒绝路径没有新增模型验证记录。 +- 内置浏览器实测桌面及 390px 手机布局、能力/状态筛选、用途禁选、测试费用确认与取消、未保存配置禁测、撤销和协议详情。没有人工兼容复选框;手机模型控制无横向溢出,顶部既有导航保持横向滚动。 +- 11 组相关 Jest 共 225 项通过(最后一处用途说明调整后单独复跑前端 27 项);迁移及发布门禁 32 项通过;TypeScript 和隔离源码的 Next.js 生产构建通过。 +- 独立审查修复异步任务恢复期间再次撤权的竞争条件:保留已受理的上游任务,标为待核查,不允许重发生成。 +- 真实素材案例:朴美 `GBPM-AD0358`,沿用素材库同款两张参考图。项目 `8b89c9ae-4531-4558-b21f-426f404600d5` 完成分析、人工审阅大纲、首屏生成及单屏重生成;共 2 次文本和 2 次生图 POST,没有重复提交。原始大纲保留;审阅删除缺尺寸依据的“小巧”描述,并把未确认材质改为木纹外观。 +- 中文多行文案编辑、图片替换、保存恢复通过;PNG/JPG/长 PNG 均为 750×1200,已查看实际导出像素,无乱码、遮挡或裁断。PSD 2,338,309 字节,按屏分组,背景和产品场景独立,标题和正文是 2 个文本层,字体 `NotoSansSC-Regular`。这些是文件结构验证,不等同于 Photoshop 应用内验收。 + +最新目录同步发现 Gemini 2.5 Flash/Pro 已下架;目录保留历史条目并明确标注不可用。本地实测默认更新为 Gemini 3 Flash 和 Nano Banana 2,不把下架模型或历史人工确认视为有效验证。 + +## 证据与边界 + +本机原始回执、权限结果和界面截图位于忽略目录 `output/ai-model-integration-v2/`。密钥仅从服务端配置读取,回执不保存密钥、登录 Cookie 或私有图片签名地址。 + +本轮没有批量测试全部目录模型,也没有增加 TTS、音乐、视频或 3D 业务工作台。品牌规范确认、Photoshop 中改字与保存重开仍需单独验收,不能从接口测试、PSD 解析或部署成功推断。 +本次真实产品只验证一屏及其重生成,不据此宣称完整多屏的品牌风格一致性已验收。 diff --git a/docs/生产数据库安全增量迁移SOP-2026-08-12.md b/docs/生产数据库安全增量迁移SOP-2026-08-12.md index 30d0b2e..c9b37e8 100644 --- a/docs/生产数据库安全增量迁移SOP-2026-08-12.md +++ b/docs/生产数据库安全增量迁移SOP-2026-08-12.md @@ -2,7 +2,7 @@ ## 结论 -当前应用发布工具验证三十五份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。目标名称以该精确提交的 `TARGET_MIGRATIONS` 为准,不能沿用历史批次的数量或凭证。 +当前应用发布工具验证三十六份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。目标名称以该精确提交的 `TARGET_MIGRATIONS` 为准,不能沿用历史批次的数量或凭证。 目标 migration: @@ -41,12 +41,15 @@ - `20261012_detail_generator` - `20261012_ai_model_registry` - `20261013_ai_model_capabilities` +- `20261013_ai_model_verifications` `20261012_detail_generator` 仅新增个人详情页项目、私有文件、持久任务和品牌风格草案四张表及其约束/索引,不回填素材库或修改用户权限。正式验收前,启用配置仍只开放给当前有效超级管理员;私有文件目录独立持久挂载。 -`20261012_ai_model_registry` 仅新增模型上游、目录、默认配置、审计四张表及约束/索引;`20261013_ai_model_capabilities` 仅新增目录的可空 `capability_metadata` JSONB 列。受控迁移核对列类型、空值限制、安全默认值、模型唯一性、原连接唯一性、引用限制和凭据完整性约束;切流前复核这两份 SQL 的精确校验和及四表字段。迁移不写入真实密钥、不自动导入或开放模型。 +`20261012_ai_model_registry` 仅新增模型上游、目录、默认配置、审计四张表及约束/索引;`20261013_ai_model_capabilities` 仅新增目录的可空 `capability_metadata` JSONB 列。`20261013_ai_model_verifications` 新增 `ai_model_verifications`,持久保存按用途的真实测试结果、幂等请求及异步任务检查点;它将 `ai_model_enabled_compatible` 替换为 `ai_model_enabled_stages`,不删除历史 `compatibility` 列,也不将历史人工确认值转成已通过测试。 -部署后由当前有效超级管理员显式执行“迁入当前连接”,再“同步目录”。迁入继续引用现有 `DETAIL_GENERATOR_API_KEY` 和精确原地址,保留已验证的 Gemini 2.5 Flash 分析/文案及 Nano Banana 2 生图默认;同步不会开放新发现模型,也不调用付费生成。应核对迁入审计、目录数量、三阶段默认和普通用户拒绝状态后完成验收。现有环境连接不依赖加密主密钥;新增上游或替换密钥才要求已配置至少 32 字节的专用 `API_KEY_ENCRYPTION_KEY`,不要替换已有主密钥或把凭据写进迁移、命令参数、日志。 +受控迁移核对列类型、空值限制、安全默认值、模型唯一性、原连接唯一性、引用限制和凭据完整性约束。验证表还须核对 `stage`、`context_hash`、`status` 检查约束,`task_id` 可空文本及 `evidence` 非空 JSONB,模型外键、幂等唯一索引、未决测试唯一索引和模型用途时间索引;旧人工兼容启用约束必须已移除,新用途启用约束必须存在。切流前复核三份 AI SQL 的精确校验和及五表字段。迁移不写入真实密钥、不自动导入、开放或付费测试模型。 + +部署后,尚未迁入的连接由当前有效超级管理员显式执行“迁入当前连接”,然后“同步目录”;已迁入的连接直接核对配置并同步,不能用导入覆盖现有设置。迁入继续引用现有 `DETAIL_GENERATOR_API_KEY` 和精确原地址。新版本不再把旧人工确认或旧默认值视为用途测试证据;需分别实际测试计划开放的分析、文案和生图用途,通过后再由管理员勾选开放并保存三阶段默认。同步目录只读获取目录,不调用付费生成;实际测试需管理员显式发起,已有异步任务只查询结果,不重新生成。应核对迁入/配置/测试审计、动态目录范围、三阶段可用默认和普通用户拒绝状态。现有环境连接不依赖加密主密钥;新增上游或替换密钥才要求已配置至少 32 字节的专用 `API_KEY_ENCRYPTION_KEY`,不要替换已有主密钥或把凭据写进迁移、命令参数、日志。 已有标准 drain 能力和稳定代理的生产实例,继续使用当前 `direct` 发布策略。维护、备份和迁移期间同时持有发布锁与代理 reconcile 锁,验证三个入口 503、作业及连接排空、旧实例停止后再取备份。完成迁移审计才交接给同 SHA 的标准发布 payload;交接前释放锁,避免 payload 重取时自锁。恢复旧实例时按其实际 IP 生成恢复代理配置,保留原配置并验证三个入口的旧 SHA。 @@ -79,7 +82,7 @@ 1. 构建并固定同一 exact SHA/digest 的 runner 与 migrator; 2. legacy bootstrap `Audit`; 3. legacy bootstrap `Apply`,外部验收 503,确认旧容器停止且保留; -4. 在同一维护窗内运行本文的真实备份、隔离恢复、三十五条目标 migration 和迁移后审计; +4. 在同一维护窗内运行本文的真实备份、隔离恢复、三十六条目标 migration 和迁移后审计; 5. 仅在所有门禁通过后,运行普通同 SHA 应用部署。 顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。 @@ -405,7 +408,7 @@ docker run --rm \ "$MIGRATOR_IMAGE" --apply ``` -工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对三十三条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、业绩转化经营数据快照及同步状态表、字段类型与可空性、检测报告原五个可空字段和独立检测日期字段、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下,同时确认品牌知识预览切片列为非空文本数组且默认空数组;最后原子写审计 JSON。 +工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对三十六条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、业绩转化经营数据快照及同步状态表、字段类型与可空性、检测报告原五个可空字段和独立检测日期字段、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下,同时确认品牌知识预览切片列为非空文本数组且默认空数组;AI 模型五表、能力证据及用途验证约束/索引也须通过前述结构检查,最后原子写审计 JSON。 apply 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写: @@ -421,9 +424,10 @@ test -n "$MIGRATION_AUDIT_SHA256" 只有以下全部成立,才进入应用首次安全上线步骤: - `applied.json` 的 `verified=true`; -- 三十三条 target migration 均 `completed=true`,source/database checksum 一致; +- 三十六条 target migration 均 `completed=true`,source/database checksum 一致; - `_prisma_migrations.finished_at` 非空、`rolled_back_at` 为空; - 视频租约列/索引、任务详情作业租约列/索引、active-only `uploadId` 唯一索引、品牌默认值精确匹配; +- AI 模型配置及验证五表、用途/上下文/状态约束、幂等与未决测试唯一索引和任务检查点字段精确匹配;旧人工兼容启用约束已由新用途约束替换; - 再次运行 `--audit` 仍通过; - 备份 receipt、迁移审计文件、目标 SHA 和 migrator image ID 被记录在当次发布记录中。 diff --git a/lib/__tests__/ai-model-capabilities.test.ts b/lib/__tests__/ai-model-capabilities.test.ts index 0e3b095..7e635f3 100644 --- a/lib/__tests__/ai-model-capabilities.test.ts +++ b/lib/__tests__/ai-model-capabilities.test.ts @@ -9,7 +9,7 @@ const base = 'https://api.vip98k.com/v1' const generic = 'https://example.com/v1' const metadata = (input: string[], output: string[], endpoints: string[]) => parseAiModelCapabilityMetadata({ input_modalities: input, output_modalities: output, endpoints })! const invocation = (path = '/v1/chat/completions', headers = {}) => ({ method: 'POST', path, content_type: path.includes('/images/') ? 'multipart/form-data' : 'application/json', headers }) -const model = (overrides = {}) => ({ id: 'catalog-model', type: 'model', modality: 'text', available: true, status: 'available', default_service_scope_id: 'scope-default', selection_required: false, service_offers: [{ is_default: true, service_scope: { id: 'scope-default' }, invocation: invocation() }], ...overrides }) +const model = (overrides = {}) => ({ id: 'catalog-model', type: 'model', modality: 'text', available: true, status: 'available', default_service_scope_id: 'scope-default', selection_required: false, invocation: invocation(), ...overrides }) const publicMetadata = (entry: ReturnType, section = 'chat') => parseAiModelPublicCapabilityCatalog({ code: 0, data: { schema_version: '2.2', sections: [{ id: section, models: [entry] }] } }).get(entry.id)! test('a plain conversation model cannot analyze product images or generate images', () => { @@ -22,20 +22,20 @@ test('image understanding and image output require separate interfaces', () => { expect(getAiModelCapabilities(generic, 'editor', metadata(['text', 'image'], ['image'], ['/images/edits']))).toMatchObject({ kind: 'image', allowedStages: ['image'] }) }) test('audio transcription is not a copy-writing model just because it returns text over chat', () => { - expect(getAiModelCapabilities(generic, 'asr', metadata(['audio'], ['text'], ['/chat/completions']))).toMatchObject({ kind: 'other', allowedStages: [] }) + expect(getAiModelCapabilities(generic, 'asr', metadata(['audio'], ['text'], ['/chat/completions']))).toMatchObject({ kind: 'asr', allowedStages: [] }) }) test('model names and arbitrary providers cannot inherit verified abilities', () => { expect(getAiModelCapabilities(generic, 'nano-banana-2')).toMatchObject({ source: 'unknown', allowedStages: [] }) expect(getAiModelCapabilities(base, 'nano-banana-unknown')).toMatchObject({ source: 'unknown', allowedStages: [] }) - expect(getAiModelCapabilities(base, 'nano-banana-2').allowedStages).toEqual(['image']) + expect(getAiModelCapabilities(base, 'nano-banana-2').allowedStages).toEqual([]) }) test('public catalog classifies models dynamically without guessing names', () => { - expect(getAiModelCapabilities(base, 'catalog-model', publicMetadata(model()))).toMatchObject({ kind: 'text', allowedStages: ['copy'], source: 'upstream' }) - expect(getAiModelCapabilities(base, 'catalog-model', publicMetadata(model({ modality: 'multimodal' })))).toMatchObject({ kind: 'vision', allowedStages: ['copy'] }) + expect(getAiModelCapabilities(base, 'catalog-model', publicMetadata(model()))).toMatchObject({ kind: 'text', allowedStages: ['analysis', 'copy'], source: 'upstream' }) + expect(getAiModelCapabilities(base, 'catalog-model', publicMetadata(model({ modality: 'multimodal' })))).toMatchObject({ kind: 'vision', allowedStages: ['analysis', 'copy'] }) }) test('public image processing tools do not appear as generative image models', () => { const entry = model({ type: 'image', modality: 'image', capabilities: ['media.tasks'], input_modes: ['file'], output_modes: ['image'] }) - expect(getAiModelCapabilities(base, entry.id, publicMetadata(entry, 'task'))).toMatchObject({ kind: 'other', allowedStages: [] }) + expect(getAiModelCapabilities(base, entry.id, publicMetadata(entry, 'task'))).toMatchObject({ kind: 'image-tool', allowedStages: [] }) }) test('a declared specialist tool is not opened merely because it shares a chat or image protocol', () => { const evidence = { ...metadata(['text', 'image'], ['text', 'image'], ['/chat/completions', '/images/edits']), kindHint: 'other' as const } @@ -54,9 +54,9 @@ test('non-default image editing offers do not override the selected image genera ] }) expect(getAiModelCapabilities(base, entry.id, publicMetadata(entry, 'tool'))).toMatchObject({ kind: 'image', allowedStages: [] }) }) -test('missing public modality details preserve exact verified image-input support', () => { +test('missing public modality details permit a real image probe without claiming historical verification', () => { const entry = model({ id: 'gemini-2.5-flash' }) - expect(getAiModelCapabilities(base, entry.id, publicMetadata(entry))).toMatchObject({ kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'verified' }) + expect(getAiModelCapabilities(base, entry.id, publicMetadata(entry))).toMatchObject({ kind: 'text', allowedStages: ['analysis', 'copy'], source: 'upstream' }) }) test.each(['input', 'output', 'endpoint', 'unavailable'])('explicit withdrawal overrides exact verified evidence: %s', reason => { const current = metadata(reason === 'input' ? ['text'] : ['text', 'image'], reason === 'output' ? ['text'] : ['image'], reason === 'endpoint' ? ['/images/generations'] : ['/images/edits']) diff --git a/lib/__tests__/ai-model-protocol-http.test.ts b/lib/__tests__/ai-model-protocol-http.test.ts new file mode 100644 index 0000000..9c14d0f --- /dev/null +++ b/lib/__tests__/ai-model-protocol-http.test.ts @@ -0,0 +1,71 @@ +/** @jest-environment node */ +import { EventEmitter } from 'node:events' +import { lookup } from 'node:dns/promises' +import { request as httpsRequest } from 'node:https' +import { requestAiModelOperation, requestAiModelOutput } from '@/lib/ai-model-http' + +jest.mock('node:dns/promises', () => ({ lookup: jest.fn() })) +jest.mock('node:https', () => ({ request: jest.fn() })) +jest.mock('@/lib/ai-model-access', () => ({ AiModelError: class extends Error { constructor(message: string, public status = 400, public code = 'bad_request') { super(message) } } })) +let sent: { url: string; options: any }[] +beforeEach(() => { + jest.clearAllMocks(); sent = [] + ;(lookup as jest.Mock).mockResolvedValue([{ address: '93.184.216.34', family: 4 }]) + ;(httpsRequest as jest.Mock).mockImplementation((url, options, callback) => { + sent.push({ url: String(url), options }) + const req = new EventEmitter() as any + req.write = () => true; req.destroy = jest.fn() + req.end = () => queueMicrotask(() => { const response = new EventEmitter() as any; response.statusCode = 200; response.headers = {}; response.destroy = jest.fn(); callback(response); response.emit('data', Buffer.from('{}')); response.emit('end') }) + return req + }) +}) + +test('retains configured /v1 prefix for standard operations and roots the separate extension path', async () => { + await requestAiModelOperation('https://models.example.com/v1', 'synthetic-secret', '/v1/messages', '{}', 'request-1', { 'Anthropic-Version': '2023-06-01', 'X-Relay-Route-Option': 'route-1' }) + await requestAiModelOperation('https://models.example.com/v1', 'synthetic-secret', '/extensions/v1/tasks/task-1/result', undefined, undefined, {}) + expect(sent.map(item => item.url)).toEqual(['https://models.example.com/v1/messages', 'https://models.example.com/extensions/v1/tasks/task-1/result']) + expect(sent[0].options.headers).toMatchObject({ Authorization: 'Bearer synthetic-secret', 'Idempotency-Key': 'request-1', 'Anthropic-Version': '2023-06-01', 'X-Relay-Route-Option': 'route-1' }) + expect(sent[1].options.method).toBe('GET') + expect(sent[1].options.headers['Idempotency-Key']).toBeUndefined() +}) + +test.each(['/v1/unknown', '//169.254.169.254/metadata', '/extensions/v1/tasks/../../secret/result', '/extensions/v1/tasks/task-1/result?url=private'])('rejects unsupported or injected operation %s before DNS', async path => { + await expect(requestAiModelOperation('https://models.example.com/v1', 'secret', path, '{}', 'request-1', {})).rejects.toMatchObject({ code: 'protocol_unsupported' }) + expect(lookup).not.toHaveBeenCalled(); expect(httpsRequest).not.toHaveBeenCalled() +}) + +test.each([{ Authorization: 'Bearer attacker' }, { Host: 'localhost' }, { 'X-Relay-Route-Option': 'route\r\ninjected' }])('rejects arbitrary headers before transport', async headers => { + await expect(requestAiModelOperation('https://models.example.com/v1', 'secret', '/v1/messages', '{}', 'request-1', headers as any)).rejects.toMatchObject({ code: 'protocol_invalid' }) + expect(httpsRequest).not.toHaveBeenCalled() +}) + +test.each(['https://attacker.example/api/external/assets/file', 'http://models.example.com/api/external/assets/file', 'https://models.example.com/admin', 'https://models.example.com/api/external/assets/../admin', 'https://user:pass@models.example.com/api/external/assets/file'])('rejects generated asset target %s without disclosing credentials', async url => { + await expect(requestAiModelOutput('https://models.example.com/v1', 'secret', url)).rejects.toMatchObject({ code: 'output_invalid' }) + expect(httpsRequest).not.toHaveBeenCalled() +}) + +test('same-origin assets keep fixed DNS and fresh authorization checks', async () => { + const guard = jest.fn() + await requestAiModelOutput('https://models.example.com/v1', 'secret', `https://models.example.com/api/external/assets/${'a'.repeat(216)}`, guard) + expect(guard).toHaveBeenCalledTimes(1) + expect(sent[0].options.headers.Authorization).toBe('Bearer secret') + const callback = jest.fn(); sent[0].options.lookup('models.example.com', {}, callback) + expect(callback).toHaveBeenCalledWith(null, '93.184.216.34', 4) +}) + +test('revoked user creates zero generation or asset sockets', async () => { + const guard = async () => { throw new Error('revoked') } + await expect(requestAiModelOperation('https://models.example.com/v1', 'secret', '/extensions/v1/tasks', '{}', 'one', {}, guard)).rejects.toThrow('revoked') + await expect(requestAiModelOutput('https://models.example.com/v1', 'secret', `https://models.example.com/api/external/assets/${'a'.repeat(32)}`, guard)).rejects.toThrow('revoked') + expect(httpsRequest).not.toHaveBeenCalled() +}) + +test.each([32, 216, 8192])('accepts a same-origin encrypted relay asset token of length %i', async length => { + await requestAiModelOutput('https://models.example.com/v1', 'secret', `https://models.example.com/api/external/assets/${'a'.repeat(length)}`) + expect(httpsRequest).toHaveBeenCalledTimes(1) +}) + +test.each([31, 8193])('rejects relay asset token length %i outside its published contract', async length => { + await expect(requestAiModelOutput('https://models.example.com/v1', 'secret', `https://models.example.com/api/external/assets/${'a'.repeat(length)}`)).rejects.toMatchObject({ code: 'output_invalid' }) + expect(lookup).not.toHaveBeenCalled(); expect(httpsRequest).not.toHaveBeenCalled() +}) diff --git a/lib/__tests__/ai-model-protocol.test.ts b/lib/__tests__/ai-model-protocol.test.ts new file mode 100644 index 0000000..e4a2215 --- /dev/null +++ b/lib/__tests__/ai-model-protocol.test.ts @@ -0,0 +1,157 @@ +/** @jest-environment node */ +import sharp from 'sharp' +import { callAiModelProtocol, parseAiModelProtocols, validateStoredAiModelProtocols, type AiModelProtocol } from '@/lib/ai-model-protocol' +import { requestAiModelOperation, requestAiModelOutput } from '@/lib/ai-model-http' + +jest.mock('@/lib/ai-model-http', () => ({ requestAiModelOperation: jest.fn(), requestAiModelOutput: jest.fn() })) +const send = requestAiModelOperation as jest.Mock, output = requestAiModelOutput as jest.Mock +const chat: AiModelProtocol = { id: 'openai-chat', path: '/v1/chat/completions', headers: {}, supportsImageInput: true } +const asyncImage: AiModelProtocol = { id: 'async-image-task', path: '/extensions/v1/tasks', headers: { 'X-Relay-Route-Option': 'route-image' }, routeId: 'route-image', supportsImageInput: true, inputField: 'file_urls' } +const base = { baseUrl: 'https://models.example.com/v1', apiKey: 'synthetic-test-secret', modelId: 'declared-model', prompt: '测试', idempotencyKey: 'test-one' } +const invocation = (path: string, route: string, schema: any = {}) => ({ method: 'POST', path, content_type: 'application/json', headers: { 'X-Relay-Route-Option': route }, input_schema: schema }) +let png: Buffer +beforeAll(async () => { png = await sharp({ create: { width: 32, height: 32, channels: 3, background: '#ff0000' } }).png().toBuffer() }) +beforeEach(() => { jest.clearAllMocks() }) + +test('selects inline-image route within the default scope and keeps its exact operation and route', () => { + const dataSchema = { properties: { file_urls: { type: 'array', maxItems: 4, items: { type: 'string', pattern: '^(https://|data:image/)' } }, prompt: { maxLength: 1200 } } } + const model = { id: 'image-model', modality: 'image', type: 'image', capabilities: ['text_to_image', 'media.tasks'], default_service_scope_id: 'scope-one', default_route_option_id: 'https-only', service_offers: [ + { service_scope: { id: 'scope-other' }, public_operation_id: 'media.tasks.create', route_options: [{ id: 'wrong-scope', invocation: invocation('/extensions/v1/tasks', 'wrong-scope', dataSchema) }] }, + { service_scope: { id: 'scope-one' }, public_operation_id: 'media.tasks.create', route_options: [ + { id: 'https-only', invocation: invocation('/extensions/v1/tasks', 'https-only', { properties: { file_urls: { type: 'array', items: { type: 'string', pattern: '^https://' } } } }) }, + { id: 'inline-image', invocation: invocation('/extensions/v1/tasks', 'inline-image', dataSchema) }, + ] }, + ] } + expect(parseAiModelProtocols(model)).toEqual([{ id: 'async-image-task', path: '/extensions/v1/tasks', headers: { 'X-Relay-Route-Option': 'inline-image' }, supportsImageInput: true, inputField: 'file_urls', scopeId: 'scope-one', routeId: 'inline-image', operationId: 'media.tasks.create', maxImages: 4, maxPromptLength: 1200 }]) +}) + +test('does not infer private reference uploads from generic image capability or URLs', () => { + const protocols = parseAiModelProtocols({ type: 'image', modality: 'image', capabilities: ['image_to_image'], invocation: { method: 'POST', path: '/v1/images/generations', content_type: 'application/json', headers: {}, input_schema: { properties: { image: { type: 'array', items: { type: 'string' } } } } } }) + expect(protocols).toMatchObject([{ id: 'openai-image-generate', supportsImageInput: false }]) +}) + +test.each(['audio', 'video'])('does not turn %s models into chat or detail generation adapters', modality => { + expect(parseAiModelProtocols({ type: modality, modality, invocation: { method: 'POST', path: '/v1/chat/completions', content_type: 'application/json', headers: {} } })).toEqual([]) +}) + +test('untrusted headers, paths, stored field mutations and route mismatches are rejected', () => { + expect(validateStoredAiModelProtocols([{ ...chat, headers: { Authorization: 'injected' } }])).toEqual([]) + expect(validateStoredAiModelProtocols([{ ...chat, path: '//internal/metadata' }])).toEqual([]) + expect(validateStoredAiModelProtocols([{ ...asyncImage, routeId: 'different-route' }])).toEqual([]) + expect(validateStoredAiModelProtocols([{ ...asyncImage, inputField: 'callback_url' }])).toEqual([]) +}) + +test('protocol headers serialize identically after JSONB reorders object keys', () => { + const first = validateStoredAiModelProtocols([{ ...chat, headers: { 'X-Relay-Route-Option': 'route-one', 'Anthropic-Version': '2023-06-01' } }]) + const restored = validateStoredAiModelProtocols([{ ...chat, headers: { 'Anthropic-Version': '2023-06-01', 'X-Relay-Route-Option': 'route-one' } }]) + expect(JSON.stringify(first)).toBe(JSON.stringify(restored)) +}) + +test('Anthropic uses native image content blocks and extracts native text', async () => { + send.mockResolvedValue(Response.json({ content: [{ type: 'text', text: '红色方块' }] })) + const result = await callAiModelProtocol({ ...base, protocol: { ...chat, id: 'anthropic-messages', path: '/v1/messages', headers: { 'Anthropic-Version': '2023-06-01' } }, images: [{ buffer: png, mimeType: 'image/png' }], maxOutputTokens: 128 }) + const body = JSON.parse(send.mock.calls[0][3]) + expect(body).toMatchObject({ max_tokens: 128, stream: false, messages: [{ role: 'user', content: [{ type: 'text' }, { type: 'image', source: { type: 'base64', media_type: 'image/jpeg' } }] }] }) + expect(JSON.stringify(body)).not.toContain('image_url') + expect(result.text).toBe('红色方块') +}) + +test('analysis flattens transparent reference images to white JPEG', async () => { + const transparent = await sharp({ create: { width: 32, height: 32, channels: 4, background: '#00000000' } }).png().toBuffer() + send.mockResolvedValue(Response.json({ choices: [{ message: { content: 'white' } }] })) + await callAiModelProtocol({ ...base, protocol: chat, images: [{ buffer: transparent, mimeType: 'image/png' }] }) + const uri = JSON.parse(send.mock.calls[0][3]).messages[0].content[1].image_url.url + expect(uri).toMatch(/^data:image\/jpeg;base64,/) + const bytes = Buffer.from(uri.split(',')[1], 'base64') + const pixel = await sharp(bytes).raw().toBuffer() + expect(Array.from(pixel.slice(0, 3))).toEqual([255, 255, 255]) +}) + +test('rejected requests that echo credentials stay definite failures without exposing response text', async () => { + send.mockResolvedValue(Response.json({ error: { message: base.apiKey, code: 'invalid_request' } }, { status: 400 })) + try { await callAiModelProtocol({ ...base, protocol: chat }); throw new Error('expected rejection') } catch (error: any) { expect(error.outcomeUnknown).toBe(false); expect(error.message).not.toContain(base.apiKey) } +}) + +test('persists async id before any polling, sends private inline image, decodes typed output', async () => { + const events: string[] = [] + send.mockImplementation(async (_base, _key, path) => { + events.push(path.endsWith('/result') ? 'poll' : 'create') + return path.endsWith('/result') ? Response.json({ id: 'task-1', status: 'succeeded', ready: true, result: { outputs: [{ type: 'image', b64_json: png.toString('base64') }] } }) : Response.json({ id: 'task-1', object: 'extension.task', status: 'accepted' }, { status: 202 }) + }) + const result = await callAiModelProtocol({ ...base, protocol: asyncImage, images: [{ buffer: png, mimeType: 'image/png' }], onTaskSubmitted: async id => { expect(id).toBe('task-1'); events.push('saved') } }) + expect(events).toEqual(['create', 'saved', 'poll']) + expect(JSON.parse(send.mock.calls[0][3]).file_urls[0]).toMatch(/^data:image\/png;base64,/) + expect(send.mock.calls[0][4]).toBe('test-one') + expect(send.mock.calls[1][3]).toBeUndefined() + expect(result.summary.taskId).toBe('task-1') + expect((await sharp(result.image).metadata()).width).toBe(32) +}) + +test('resumes only GET; no POST and no repeated checkpoint', async () => { + send.mockResolvedValue(Response.json({ ready: true, status: 'succeeded', result: { outputs: [{ type: 'image', b64_json: png.toString('base64') }] } })) + const saved = jest.fn() + await callAiModelProtocol({ ...base, protocol: asyncImage, resumeTaskId: 'task-known', onTaskSubmitted: saved }) + expect(send).toHaveBeenCalledTimes(1) + expect(send.mock.calls[0][2]).toBe('/extensions/v1/tasks/task-known/result') + expect(send.mock.calls[0][3]).toBeUndefined() + expect(saved).not.toHaveBeenCalled() +}) + +test('known task network failure is pending, unknown POST outcome is paused, neither retries', async () => { + send.mockRejectedValue(new Error('network')) + await expect(callAiModelProtocol({ ...base, protocol: asyncImage, onTaskSubmitted: jest.fn(), resumeTaskId: 'task-known' })).rejects.toMatchObject({ outcomeUnknown: true, taskPending: true, taskId: 'task-known' }) + await expect(callAiModelProtocol({ ...base, protocol: asyncImage, onTaskSubmitted: jest.fn() })).rejects.toMatchObject({ outcomeUnknown: true, taskPending: false, taskId: null }) + expect(send).toHaveBeenCalledTimes(2) +}) + +test('failed and review tasks are not automatically requeued as pending', async () => { + send.mockResolvedValueOnce(Response.json({ error: { code: 'task_failed' } }, { status: 409 })) + await expect(callAiModelProtocol({ ...base, protocol: asyncImage, resumeTaskId: 'task-failed', onTaskSubmitted: jest.fn() })).rejects.toMatchObject({ outcomeUnknown: false, taskPending: false }) + send.mockResolvedValueOnce(Response.json({ error: { code: 'billing_reconciliation_required' } }, { status: 409 })) + await expect(callAiModelProtocol({ ...base, protocol: asyncImage, resumeTaskId: 'task-review', onTaskSubmitted: jest.fn() })).rejects.toMatchObject({ outcomeUnknown: true, taskPending: false }) +}) + +test.each([401, 403, 404])('a known task read rejected with %i remains unresolved without allowing another submission', async status => { + send.mockResolvedValue(Response.json({ error: { code: status === 404 ? 'task_not_found' : 'access_denied' } }, { status })) + await expect(callAiModelProtocol({ ...base, protocol: asyncImage, resumeTaskId: 'accepted-task', onTaskSubmitted: jest.fn() })).rejects.toMatchObject({ outcomeUnknown: true, taskPending: false, taskId: 'accepted-task' }) + expect(send).toHaveBeenCalledTimes(1) + expect(send.mock.calls[0][2]).toBe('/extensions/v1/tasks/accepted-task/result') + expect(send.mock.calls[0][3]).toBeUndefined() +}) + +test.each([429, 503])('a temporary known task read failure %i permits GET-only polling', async status => { + send.mockResolvedValue(Response.json({ error: { code: 'service_unavailable' } }, { status })) + await expect(callAiModelProtocol({ ...base, protocol: asyncImage, resumeTaskId: 'accepted-task', onTaskSubmitted: jest.fn() })).rejects.toMatchObject({ outcomeUnknown: true, taskPending: true, taskId: 'accepted-task' }) +}) + +test('checkpoint failure preserves the known task and performs no poll', async () => { + send.mockResolvedValue(Response.json({ id: 'task-known' }, { status: 202 })) + await expect(callAiModelProtocol({ ...base, protocol: asyncImage, onTaskSubmitted: async () => { throw new Error('DB unavailable') } })).rejects.toMatchObject({ outcomeUnknown: true, taskId: 'task-known' }) + expect(send).toHaveBeenCalledTimes(1) +}) + +test('typed checkpoint failure also preserves the accepted upstream task', async () => { + send.mockResolvedValue(Response.json({ id: 'task-known' }, { status: 202 })) + await expect(callAiModelProtocol({ ...base, protocol: asyncImage, onTaskSubmitted: async () => { throw Object.assign(new Error('checkpoint'), { status: 409 }) } })).rejects.toMatchObject({ outcomeUnknown: true, taskPending: true, taskId: 'task-known' }) + expect(send).toHaveBeenCalledTimes(1) +}) + +test('unreadable local image is a definite preflight failure with no paid request', async () => { + await expect(callAiModelProtocol({ ...base, protocol: chat, images: [{ buffer: Buffer.from('invalid'), mimeType: 'image/png' }] })).rejects.toMatchObject({ outcomeUnknown: false }) + expect(send).not.toHaveBeenCalled() +}) + +test('rejects async submission without checkpoint and preflights image and prompt limits', async () => { + await expect(callAiModelProtocol({ ...base, protocol: asyncImage })).rejects.toMatchObject({ outcomeUnknown: false }) + await expect(callAiModelProtocol({ ...base, protocol: { ...chat, supportsImageInput: false }, images: [{ buffer: png, mimeType: 'image/png' }] })).rejects.toMatchObject({ outcomeUnknown: false }) + await expect(callAiModelProtocol({ ...base, protocol: { ...chat, maxPromptLength: 1 } })).rejects.toMatchObject({ outcomeUnknown: false }) + expect(send).not.toHaveBeenCalled() +}) + +test('does not treat audio or malformed image data as a successful image result', async () => { + send.mockResolvedValueOnce(Response.json({ ready: true, status: 'succeeded', result: { outputs: [{ type: 'audio', url: 'https://models.example.com/api/external/assets/audio' }] } })) + await expect(callAiModelProtocol({ ...base, protocol: asyncImage, resumeTaskId: 'task-audio', onTaskSubmitted: jest.fn() })).rejects.toMatchObject({ outcomeUnknown: false }) + expect(output).not.toHaveBeenCalled() + send.mockResolvedValueOnce(Response.json({ data: [{ b64_json: Buffer.from('not an image').toString('base64') }] })) + await expect(callAiModelProtocol({ ...base, protocol: { id: 'openai-image-edit', path: '/v1/images/edits', headers: {}, supportsImageInput: true, inputField: 'image[]' }, images: [{ buffer: png, mimeType: 'image/png' }] })).rejects.toMatchObject({ outcomeUnknown: false }) +}) diff --git a/lib/__tests__/ai-model-service.test.ts b/lib/__tests__/ai-model-service.test.ts index 975c966..7f361db 100644 --- a/lib/__tests__/ai-model-service.test.ts +++ b/lib/__tests__/ai-model-service.test.ts @@ -5,7 +5,8 @@ import { isSuperAdmin } from '@/lib/department-permission' import { configureAiModels, editAiModelProvider, getAiModelAdminState, getAiModelRuntimeState, parseAiModelCatalog, resolveAiModel, syncAiModelProvider } from '@/lib/ai-model-service' import { encryptAiModelKey } from '@/lib/ai-model-crypto' import { requestAiModel, requestAiModelCapabilityCatalog } from '@/lib/ai-model-http' -import { parseAiModelCapabilityMetadata } from '@/lib/ai-model-capabilities' +import { getAiModelStageProtocol, parseAiModelCapabilityMetadata } from '@/lib/ai-model-capabilities' +import { aiModelVerificationContext, listAiModelVerifications } from '@/lib/ai-model-verification-state' import { aiModelAudit, getAiModelConfiguration, getAiModelProviderRow, listAiModelProviderRows, listAiModelRows, saveAiModelDefaults, type AiModelProviderRow, type AiModelRow } from '@/lib/ai-model-repository' jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) @@ -13,6 +14,7 @@ jest.mock('@/lib/auth', () => ({ authOptions: {} })) jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() })) jest.mock('@/lib/prisma', () => ({ prisma: { user: { findUnique: jest.fn() } } })) jest.mock('@/lib/ai-model-http', () => ({ ...jest.requireActual('@/lib/ai-model-http'), requestAiModel: jest.fn(), requestAiModelCapabilityCatalog: jest.fn() })) +jest.mock('@/lib/ai-model-verification-state', () => ({ ...jest.requireActual('@/lib/ai-model-verification-state'), listAiModelVerifications: jest.fn() })) const mockDb = { $executeRawUnsafe: jest.fn() } jest.mock('@/lib/ai-model-repository', () => ({ ...jest.requireActual('@/lib/ai-model-repository'), aiModelTransaction: jest.fn(async callback => callback(mockDb)), @@ -35,8 +37,8 @@ beforeEach(() => { { id: secondId, name: '第二上游', baseUrl: 'https://new.example.com/v1', enabled: true, credentialSource: 'encrypted', ...encryptAiModelKey('synthetic-second-secret'), legacyBinding: false, revision: 1, syncedAt: null }, ] models = [ - { key: legacyModelKey, providerId: legacyId, id: 'vendor/shared-model', enabled: true, present: true, compatibility: 'confirmed', stages: ['analysis', 'copy', 'image'], capabilityMetadata: parseAiModelCapabilityMetadata({ input_modalities: ['text', 'image'], output_modalities: ['text', 'image'], endpoints: ['/chat/completions', '/images/edits'] }) }, - { key: secondModelKey, providerId: secondId, id: 'vendor/shared-model', enabled: true, present: true, compatibility: 'confirmed', stages: ['analysis', 'copy', 'image'], capabilityMetadata: parseAiModelCapabilityMetadata({ input_modalities: ['text', 'image'], output_modalities: ['text', 'image'], endpoints: ['/chat/completions', '/images/edits'] }) }, + { key: legacyModelKey, providerId: legacyId, id: 'vendor/shared-model', enabled: true, present: true, stages: ['analysis', 'copy', 'image'], capabilityMetadata: parseAiModelCapabilityMetadata({ input_modalities: ['text', 'image'], output_modalities: ['text', 'image'], endpoints: ['/chat/completions', '/images/edits'] }) }, + { key: secondModelKey, providerId: secondId, id: 'vendor/shared-model', enabled: true, present: true, stages: ['analysis', 'copy', 'image'], capabilityMetadata: parseAiModelCapabilityMetadata({ input_modalities: ['text', 'image'], output_modalities: ['text', 'image'], endpoints: ['/chat/completions', '/images/edits'] }) }, ] ;(isSuperAdmin as jest.Mock).mockResolvedValue(true) ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: true, status: 'active', isActive: true, archivedAt: null }) @@ -44,6 +46,7 @@ beforeEach(() => { ;(listAiModelRows as jest.Mock).mockImplementation(async () => models) ;(getAiModelProviderRow as jest.Mock).mockImplementation(async id => ({ ...providers.find(provider => provider.id === id) })) ;(getAiModelConfiguration as jest.Mock).mockResolvedValue({ revision: 1, defaults: { analysis: legacyModelKey, copy: legacyModelKey, image: legacyModelKey } }) + ;(listAiModelVerifications as jest.Mock).mockResolvedValue(models.flatMap(model => model.stages.map(stage => ({ id: `${model.key}-${stage}`, modelKey: model.key, stage, status: 'passed', requestId: null, taskId: null, message: null, startedAt: new Date(), deadlineAt: new Date(Date.now() + 600000), finishedAt: new Date(), contextHash: aiModelVerificationContext(providers.find(provider => provider.id === model.providerId)!.baseUrl, model.id, model.capabilityMetadata, model.providerId === legacyId ? 'synthetic-legacy-secret' : 'synthetic-second-secret', getAiModelStageProtocol(model.capabilityMetadata, stage)!) })))) ;(requestAiModel as jest.Mock).mockImplementation(async (_base, _key, _endpoint, _body, _idempotency, beforeRequest) => { await beforeRequest(); return Response.json({ data: [{ id: 'vendor/shared-model' }, { id: 'brand/new-model' }] }) }) }) afterAll(() => { process.env = originalEnv }) @@ -59,7 +62,7 @@ test.each(['disabled-provider', 'disabled-model', 'missing-model', 'unconfirmed' if (reason === 'disabled-provider') providers[0].enabled = false if (reason === 'disabled-model') models[0].enabled = false if (reason === 'missing-model') models[0].present = false - if (reason === 'unconfirmed') models[0].compatibility = 'unconfirmed' + if (reason === 'unconfirmed') (listAiModelVerifications as jest.Mock).mockResolvedValue([]) if (reason === 'wrong-stage') models[0].stages = ['copy'] await expect(resolveAiModel('analysis', legacyModelKey)).rejects.toMatchObject({ code: 'model_unavailable' }) await expect(configureAiModels('admin', { revision: 1, providers: [], defaults: { analysis: legacyModelKey, copy: null, image: null } })).rejects.toMatchObject({ code: 'default_unavailable' }) @@ -76,6 +79,24 @@ test('environment URL drift cannot send a replacement environment key to the ori await expect(resolveAiModel('image', legacyModelKey)).rejects.toMatchObject({ code: 'model_unavailable' }) expect((await getAiModelRuntimeState()).legacyModels.image).toEqual([]) }) + +test('environment key rotation followed by new validation cannot reuse an old project binding', async () => { + const binding = (await resolveAiModel('image', legacyModelKey)).binding + const prior = await listAiModelVerifications() + process.env.DETAIL_GENERATOR_API_KEY = 'synthetic-rotated-secret' + await expect(resolveAiModel('image', legacyModelKey, binding)).rejects.toMatchObject({ code: 'model_unavailable' }) + ;(listAiModelVerifications as jest.Mock).mockResolvedValue(prior.map(value => value.modelKey === legacyModelKey && value.stage === 'image' ? { + ...value, id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + contextHash: aiModelVerificationContext(providers[0].baseUrl, models[0].id, models[0].capabilityMetadata, 'synthetic-rotated-secret', getAiModelStageProtocol(models[0].capabilityMetadata, 'image')!), + } : value)) + const fresh = (await resolveAiModel('image', legacyModelKey)).binding + expect(fresh.revision).toBe(binding.revision) + expect(fresh.protocolFingerprint).toBe(binding.protocolFingerprint) + expect(fresh.verificationId).not.toBe(binding.verificationId) + await expect(resolveAiModel('image', legacyModelKey, binding)).rejects.toMatchObject({ code: 'model_binding_changed' }) + await expect(resolveAiModel('image', legacyModelKey, fresh)).resolves.toMatchObject({ binding: fresh }) + expect(requestAiModel).not.toHaveBeenCalled() +}) test('legacy imported endpoint remains immutable even when a new API key is supplied', async () => { await expect(editAiModelProvider('admin', legacyId, { revision: 1, baseUrl: 'https://other.example.com/v1', apiKey: 'synthetic-new-secret' })).rejects.toThrow('保留原服务地址') expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled() @@ -87,6 +108,13 @@ test('admin DTO strips all encrypted material and does not return plaintext or r const serialized = JSON.stringify(state) for (const value of ['synthetic-legacy-secret', 'synthetic-second-secret', 'secretCiphertext', 'secretNonce', 'secretAuthTag', providers[1].secretCiphertext!]) expect(serialized).not.toContain(value) }) + +test('a removed catalog model keeps its record but exposes no testable stages and a clear reason', async () => { + models[0].present = false + const state = await getAiModelAdminState() + expect(state.providers[0].models[0]).toMatchObject({ present: false, enabled: true, capabilities: { allowedStages: [], protocols: {}, reason: '已从当前连接的模型目录下架,暂不可用' } }) + expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled() +}) test('uninitialized defaults produce no hardcoded menu or automatic default choice', async () => { ;(getAiModelConfiguration as jest.Mock).mockResolvedValue({ revision: 0, defaults: { analysis: null, copy: null, image: null } }) const state = await getAiModelRuntimeState() @@ -95,7 +123,7 @@ test('uninitialized defaults produce no hardcoded menu or automatic default choi expect(state.models.analysis).toEqual([legacyModelKey, secondModelKey]) expect(state.modelOptions.analysis.map(value => value.label)).toEqual(['vendor/shared-model', 'vendor/shared-model']) }) -test('directory synchronization preserves manual compatibility while adding new models disabled', async () => { +test('directory synchronization never creates verification evidence or enables new models', async () => { await syncAiModelProvider('admin', legacyId, { revision: 1 }) const statements = mockDb.$executeRawUnsafe.mock.calls.map(call => String(call[0])) const upserts = statements.filter(sql => sql.startsWith('INSERT INTO ai_model_catalog')) @@ -120,7 +148,7 @@ test.each([{ data: [{ id: '\nunsafe' }] }, { data: [{ id: 1 }] }, { data: 'incor }) test('manual confirmation cannot enable image generation for a conversation model', async () => { models[0].capabilityMetadata = parseAiModelCapabilityMetadata({ input_modalities: ['text'], output_modalities: ['text'], endpoints: ['/chat/completions'] }) - const input = { revision: 1, defaults: { analysis: null, copy: null, image: null }, providers: [{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: true, compatibility: 'confirmed', stages: ['image'] }] }] } + const input = { revision: 1, defaults: { analysis: null, copy: null, image: null }, providers: [{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: true, stages: ['image'] }] }] } await expect(configureAiModels('admin', input)).rejects.toMatchObject({ code: 'model_capability_unsupported' }) expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled() }) @@ -141,7 +169,7 @@ test('stale enabled rows are filtered from menus, defaults and old project execu test('admin read removes invalid old stages without writes or raw capability metadata exposure', async () => { models[0].capabilityMetadata = null const state = await getAiModelAdminState() - expect(state.providers[0].models[0]).toMatchObject({ enabled: false, stages: [], capabilities: { kind: 'unknown', allowedStages: [] } }) + expect(state.providers[0].models[0]).toMatchObject({ enabled: true, stages: ['analysis', 'copy', 'image'], capabilities: { kind: 'unknown', allowedStages: [] }, verification: { analysis: { status: 'stale' } } }) expect(JSON.stringify(state)).not.toContain('capabilityMetadata') expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled() }) @@ -153,7 +181,7 @@ test('typed public catalog is restricted to models authorized by the private dir const inserts = mockDb.$executeRawUnsafe.mock.calls.filter(call => String(call[0]).startsWith('INSERT INTO ai_model_catalog')) expect(inserts).toHaveLength(1) expect(inserts[0][3]).toBe('allowed-model') - expect(JSON.parse(inserts[0][4])).toMatchObject({ inputModalities: ['text'], outputModalities: ['text'], endpoints: ['/chat/completions'] }) + expect(JSON.parse(inserts[0][4])).toMatchObject({ inputModalities: ['text'], outputModalities: ['text'], endpoints: ['/v1/chat/completions'] }) expect(requestAiModelCapabilityCatalog).toHaveBeenCalledWith('https://api.vip98k.com/v1', expect.any(Function)) }) test('failure of public capability discovery aborts both catalog and capability writes', async () => { diff --git a/lib/__tests__/ai-model-verification.test.ts b/lib/__tests__/ai-model-verification.test.ts new file mode 100644 index 0000000..92f2bd2 --- /dev/null +++ b/lib/__tests__/ai-model-verification.test.ts @@ -0,0 +1,133 @@ +/** @jest-environment node */ +import { randomUUID } from 'node:crypto' +import { AiModelError, assertAiModelAdmin } from '@/lib/ai-model-access' +import { verifyAiModel, refreshAiModelVerification, validateAiModelVerificationOutput } from '@/lib/ai-model-verification' +import { callAiModelProtocol, AiModelProtocolError } from '@/lib/ai-model-protocol' +import { resolveAiModelVerification } from '@/lib/ai-model-service' +import { prisma } from '@/lib/prisma' +import { detailTransactionLock } from '@/lib/detail-generator/repository' + +jest.mock('@/lib/ai-model-access', () => { const actual = jest.requireActual('@/lib/ai-model-access'); return { ...actual, assertAiModelAdmin: jest.fn() } }) +jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) +jest.mock('@/lib/auth', () => ({ authOptions: {} })) +jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() })) +jest.mock('@/lib/prisma', () => ({ prisma: { $executeRawUnsafe: jest.fn() } })) +jest.mock('@/lib/ai-model-service', () => ({ getAiModelAdminState: jest.fn(async () => ({ providers: [] })), resolveAiModelVerification: jest.fn() })) +jest.mock('@/lib/ai-model-protocol', () => ({ ...jest.requireActual('@/lib/ai-model-protocol'), callAiModelProtocol: jest.fn() })) +jest.mock('@/lib/detail-generator/repository', () => ({ detailTransactionLock: jest.fn() })) +const mockDb = { $queryRawUnsafe: jest.fn(), $executeRawUnsafe: jest.fn() } +jest.mock('@/lib/ai-model-repository', () => ({ aiModelTransaction: jest.fn(async callback => callback(mockDb)), aiModelAudit: jest.fn() })) +const providerId = '11111111-1111-4111-8111-111111111111', modelKey = '22222222-2222-4222-8222-222222222222' +const input = () => ({ stage: 'analysis' as const, revision: 1, idempotencyKey: randomUUID() }) +let row: any, duplicate = false, unresolved = 0, active = 0 +beforeEach(() => { + jest.clearAllMocks(); duplicate = false; unresolved = 0; active = 0 + row = { id: randomUUID(), modelKey, stage: 'analysis', contextHash: 'a'.repeat(64), idempotencyKey: randomUUID(), status: 'running', taskId: null, deadlineAt: new Date(Date.now() - 1) } + ;(assertAiModelAdmin as jest.Mock).mockResolvedValue('admin') + ;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ provider: { baseUrl: 'https://models.example.com/v1' }, model: { id: 'model' }, protocol: { id: 'openai-chat', path: '/v1/chat/completions', headers: {}, supportsImageInput: true }, apiKey: 'synthetic-test-secret', contextHash: row.contextHash }) + mockDb.$queryRawUnsafe.mockImplementation(async (sql: string, ...args: any[]) => { + if (sql.startsWith('INSERT')) { row.idempotencyKey = args[4]; return [row] } + if (sql.includes('idempotency_key=$3')) return duplicate ? [row] : [] + if (sql.includes('FOR UPDATE')) return [row] + if (sql.includes('active_executions')) return [{ count: active, own: 0 }] + if (sql.includes('COUNT(*)')) return [{ count: unresolved }] + return [] + }) + mockDb.$executeRawUnsafe.mockResolvedValue(1) + ;(prisma.$executeRawUnsafe as jest.Mock).mockResolvedValue(1) + ;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { await request.beforeRequest(); return { text: '{"topLeft":"red","bottomRight":"blue"}', requestId: 'request-1', summary: {} } }) +}) +test('a real image-understanding response persists passed evidence without enabling the model', async () => { + await verifyAiModel('admin', providerId, modelKey, input()) + expect(callAiModelProtocol).toHaveBeenCalledTimes(1) + expect((callAiModelProtocol as jest.Mock).mock.calls[0][0].images[0].buffer).toBeInstanceOf(Buffer) + expect(mockDb.$executeRawUnsafe.mock.calls.some(([sql]) => sql.includes("status='passed'"))).toBe(true) + expect(mockDb.$executeRawUnsafe.mock.calls.some(([sql]) => /UPDATE ai_model_catalog|ai_model_configuration/.test(sql))).toBe(false) + expect(detailTransactionLock).toHaveBeenCalledWith(mockDb, 'detail-generator-execution') +}) +test('repeated idempotency keys and unresolved requests cannot submit another model call', async () => { + duplicate = true + await verifyAiModel('admin', providerId, modelKey, input()) + expect(callAiModelProtocol).not.toHaveBeenCalled() + duplicate = false; unresolved = 1 + await expect(verifyAiModel('admin', providerId, modelKey, input())).rejects.toMatchObject({ code: 'verification_unresolved' }) + expect(callAiModelProtocol).not.toHaveBeenCalled() +}) +test('verification respects active generation capacity', async () => { + active = 2 + await expect(verifyAiModel('admin', providerId, modelKey, input())).rejects.toMatchObject({ code: 'verification_busy' }) + expect(callAiModelProtocol).not.toHaveBeenCalled() + const capacity = mockDb.$queryRawUnsafe.mock.calls.find(([sql]) => sql.includes('active_executions'))! + expect(capacity[0]).toContain("status='queued' AND checkpoint->>'dispatchPending'='true'") + expect(capacity[0]).toContain("status='unknown' AND task_id IS NOT NULL") +}) +test('revocation before admission performs no call or insert', async () => { + ;(assertAiModelAdmin as jest.Mock).mockRejectedValue(new AiModelError('forbidden', 403)) + await expect(verifyAiModel('admin', providerId, modelKey, input())).rejects.toMatchObject({ status: 403 }) + expect(callAiModelProtocol).not.toHaveBeenCalled() + expect(mockDb.$queryRawUnsafe).not.toHaveBeenCalled() +}) +test.each([false, true])('protocol outcomeUnknown=%s is persisted accurately, never retried', async unknown => { + ;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { await request.beforeRequest(); throw new AiModelProtocolError('synthetic-test-secret must not escape', unknown, 'req-2', unknown ? 'task-2' : null) }) + await verifyAiModel('admin', providerId, modelKey, input()) + expect(callAiModelProtocol).toHaveBeenCalledTimes(1) + const terminal = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))! + expect(terminal[2]).toBe(unknown ? 'unknown' : 'failed') + expect(JSON.stringify(terminal)).not.toContain('synthetic-test-secret') +}) +test('a persisted task can only be queried through resumeTaskId and its original idempotency key', async () => { + row.status = 'unknown'; row.taskId = 'task-original' + await refreshAiModelVerification('admin', providerId, row.id) + expect(callAiModelProtocol).toHaveBeenCalledWith(expect.objectContaining({ resumeTaskId: 'task-original', idempotencyKey: row.idempotencyKey })) + expect(mockDb.$queryRawUnsafe.mock.calls.some(([sql]) => sql.startsWith('INSERT'))).toBe(false) + expect(mockDb.$queryRawUnsafe.mock.calls.find(([sql]) => sql.includes('active_executions'))?.[2]).toBe(row.id) +}) +test('unknown without a checkpoint cannot be resumed or bypassed with manual confirmation', async () => { + row.status = 'unknown'; row.taskId = null + await expect(refreshAiModelVerification('admin', providerId, row.id)).rejects.toMatchObject({ code: 'verification_not_resumable' }) + await expect(verifyAiModel('admin', providerId, modelKey, { ...input(), compatibility: 'confirmed' })).rejects.toThrow('参数无效') + expect(callAiModelProtocol).not.toHaveBeenCalled() +}) +test('changed context before dispatch records known failure without pretending it passed', async () => { + ;(resolveAiModelVerification as jest.Mock).mockResolvedValueOnce({ ...(await (resolveAiModelVerification as jest.Mock)()), contextHash: row.contextHash }) + ;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { (resolveAiModelVerification as jest.Mock).mockResolvedValue({ contextHash: 'b'.repeat(64) }); await request.beforeRequest(); throw new Error('must not send') }) + await verifyAiModel('admin', providerId, modelKey, input()) + const terminal = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))! + expect(terminal[2]).toBe('failed') +}) + +test('configuration changes immediately after admission persist known failure and make no model call', async () => { + const original = await (resolveAiModelVerification as jest.Mock)() + ;(resolveAiModelVerification as jest.Mock).mockResolvedValueOnce(original).mockRejectedValue(new AiModelError('连接已改变', 409, 'verification_context_changed')) + await verifyAiModel('admin', providerId, modelKey, input()) + expect(callAiModelProtocol).not.toHaveBeenCalled() + expect(mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))?.[2]).toBe('failed') +}) + +test('revocation after an upstream task was accepted preserves unknown and the query checkpoint', async () => { + ;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { + await request.beforeRequest() + await request.onTaskSubmitted('task-accepted') + throw new AiModelError('权限已撤销', 403) + }) + await verifyAiModel('admin', providerId, modelKey, input()) + const terminal = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))! + expect(terminal[2]).toBe('unknown') + expect(terminal[4]).toBe('task-accepted') + expect(callAiModelProtocol).toHaveBeenCalledTimes(1) +}) + +test('a resumed task remains unknown when result lookup fails before the request guard', async () => { + row.status = 'unknown'; row.taskId = 'task-original' + ;(callAiModelProtocol as jest.Mock).mockRejectedValue(new AiModelProtocolError('DNS temporarily unavailable', true, null, 'task-original')) + await refreshAiModelVerification('admin', providerId, row.id) + const terminal = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))! + expect(terminal[2]).toBe('unknown') + expect(terminal[4]).toBe('task-original') + expect(callAiModelProtocol).toHaveBeenCalledWith(expect.objectContaining({ resumeTaskId: 'task-original' })) +}) +test('output acceptance checks image interpretation and actual outline structure', async () => { + await expect(validateAiModelVerificationOutput('analysis', { text: '{"topLeft":"blue","bottomRight":"red"}' })).rejects.toMatchObject({ code: 'verification_output_invalid' }) + await expect(validateAiModelVerificationOutput('copy', { text: '{"text":"ok"}' })).rejects.toMatchObject({ code: 'verification_output_invalid' }) + await expect(validateAiModelVerificationOutput('copy', { text: '{"sellingPoints":["蓝色"],"warnings":[],"screens":[{"title":"蓝色产品","body":"型号TEST","imagePrompt":"蓝色产品,无文字"}]}' })).resolves.toMatchObject({ outlineSchemaVerified: true }) +}) diff --git a/lib/__tests__/detail-generator-backend.test.ts b/lib/__tests__/detail-generator-backend.test.ts index 9bd81cf..1cf9406 100644 --- a/lib/__tests__/detail-generator-backend.test.ts +++ b/lib/__tests__/detail-generator-backend.test.ts @@ -20,12 +20,12 @@ jest.mock('@/lib/ai-model-service', () => ({ captureAiModelBindings: jest.fn(), getAiModelRuntimeState: jest.fn(), resolveAiModel: jest.fn(async (stage, selection) => { if (selection.startsWith('unvalidated')) throw new (require('@/lib/detail-generator/access').DetailGeneratorError)('所选模型未确认兼容') - return { apiKey: 'unit-test-key', binding: { providerId: '11111111-1111-4111-8111-111111111111', modelKey: '22222222-2222-4222-8222-222222222222', modelId: selection, providerName: '测试上游', baseUrl: 'https://model.example.test/v1', revision: 1 } } + return { apiKey: 'unit-test-key', protocol: { id: 'openai-chat', path: '/v1/chat/completions', headers: {}, supportsImageInput: true }, binding: { providerId: '11111111-1111-4111-8111-111111111111', modelKey: '22222222-2222-4222-8222-222222222222', modelId: selection, providerName: '测试上游', baseUrl: 'https://model.example.test/v1', revision: 1 } } }), })) -jest.mock('@/lib/ai-model-http', () => ({ requestAiModel: jest.fn(async (baseUrl, apiKey, endpoint, body, idempotencyKey, beforeRequest) => { +jest.mock('@/lib/ai-model-http', () => ({ requestAiModelOperation: jest.fn(async (baseUrl, apiKey, endpoint, body, idempotencyKey, _headers, beforeRequest) => { await beforeRequest() - return global.fetch(baseUrl + endpoint, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Idempotency-Key': idempotencyKey }, body }) + return global.fetch(baseUrl + endpoint.slice(3), { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Idempotency-Key': idempotencyKey }, body }) }) })) const originalFetch = global.fetch diff --git a/lib/__tests__/detail-generator-jobs.test.ts b/lib/__tests__/detail-generator-jobs.test.ts index cee3878..40065af 100644 --- a/lib/__tests__/detail-generator-jobs.test.ts +++ b/lib/__tests__/detail-generator-jobs.test.ts @@ -2,7 +2,7 @@ import { isSuperAdmin } from '@/lib/department-permission' import { processDetailJob } from '@/lib/detail-generator/jobs' import { analyzeDetailFacts, composeDetailOutline, DetailProviderError, generateDetailImage } from '@/lib/detail-generator/provider' -import { checkpointDetailJob, findDetailAsset, findDetailProject, finishDetailJob, saveDetailJobAsset, saveDetailJobDocument, type StoredDetailJob } from '@/lib/detail-generator/repository' +import { checkpointDetailJob, deferDetailJob, findDetailAsset, findDetailProject, finishDetailJob, saveDetailJobAsset, saveDetailJobDocument, type StoredDetailJob } from '@/lib/detail-generator/repository' import type { DetailDocument, DetailScreen } from '@/lib/detail-generator/types' import { prisma } from '@/lib/prisma' @@ -10,7 +10,7 @@ jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) jest.mock('@/lib/auth', () => ({ authOptions: {} })) jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() })) jest.mock('@/lib/prisma', () => ({ prisma: { user: { findUnique: jest.fn() } } })) -jest.mock('@/lib/detail-generator/repository', () => ({ assertDetailDocumentAssets: jest.fn(), checkpointDetailJob: jest.fn(), claimDetailJob: jest.fn(), findDetailAsset: jest.fn(), findDetailProject: jest.fn(), finishDetailJob: jest.fn(), renewDetailJobLease: jest.fn(), saveDetailJobAsset: jest.fn(), saveDetailJobDocument: jest.fn() })) +jest.mock('@/lib/detail-generator/repository', () => ({ assertDetailDocumentAssets: jest.fn(), checkpointDetailJob: jest.fn(), claimDetailJob: jest.fn(), deferDetailJob: jest.fn(), findDetailAsset: jest.fn(), findDetailProject: jest.fn(), finishDetailJob: jest.fn(), renewDetailJobLease: jest.fn(), saveDetailJobAsset: jest.fn(), saveDetailJobDocument: jest.fn() })) jest.mock('@/lib/detail-generator/storage', () => ({ deleteDetailFile: jest.fn(), readDetailFile: jest.fn().mockResolvedValue(Buffer.from('image')), saveDetailFile: jest.fn().mockResolvedValue('private/image.png') })) jest.mock('@/lib/detail-generator/render', () => ({ renderDetailExport: jest.fn() })) jest.mock('@/lib/detail-generator/provider', () => ({ ...jest.requireActual('@/lib/detail-generator/provider'), analyzeDetailFacts: jest.fn(), composeDetailOutline: jest.fn(), generateDetailImage: jest.fn() })) @@ -83,7 +83,61 @@ test('remaining-screen generation skips completed imagery and stops immediately ;(generateDetailImage as jest.Mock).mockRejectedValue(new DetailProviderError('生成结果未知', true)) await processDetailJob(makeJob('generate')) expect(generateDetailImage).toHaveBeenCalledTimes(1) - expect(generateDetailImage).toHaveBeenCalledWith(expect.anything(), '场景', expect.anything(), 'job-1-screen-second', 'owner-1', bindings.image) + expect(generateDetailImage).toHaveBeenCalledWith(expect.anything(), '场景', expect.anything(), 'job-1-screen-second', 'owner-1', bindings.image, expect.objectContaining({ onTaskSubmitted: expect.any(Function) })) expect(saveDetailJobAsset).not.toHaveBeenCalled() expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'unknown', expect.objectContaining({ stage: 'screen-second' }), '生成结果未知', null) }) + +function pendingImageJob() { + document.approvedAt = new Date().toISOString() + document.analysis = { sellingPoints: [], warnings: [], screens: [] } + document.screens = [{ id: 'first', title: '一', body: '', imagePrompt: '场景', width: 750, height: 1200, backgroundColor: '#FFFFFF', texts: [] }] + return makeJob('generate') +} + +test('known asynchronous image identity is persisted before deferred polling', async () => { + const job = pendingImageJob() + ;(generateDetailImage as jest.Mock).mockImplementation(async (...args) => { + await args[6].onTaskSubmitted('task-1') + expect(checkpointDetailJob).toHaveBeenLastCalledWith('job-1', 'lease-1', expect.objectContaining({ upstreamTask: expect.objectContaining({ id: 'task-1' }), dispatchPending: true }), 0) + throw new DetailProviderError('仍在生成', true, null, 'task-1', true) + }) + await processDetailJob(job) + expect(deferDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', expect.objectContaining({ upstreamTask: expect.objectContaining({ id: 'task-1', polls: 1 }), pollNotBefore: expect.any(Number) })) + expect(finishDetailJob).not.toHaveBeenCalled() +}) + +test('worker recovery passes persisted task identity and does not start another image request', async () => { + const job = pendingImageJob() + job.checkpoint = { stage: 'screen-first', dispatchPending: true, upstreamTask: { id: 'task-1', stage: 'screen-first', submittedAt: Date.now() - 60_000, polls: 1 } } + ;(generateDetailImage as jest.Mock).mockImplementation(async (...args) => { + expect(args[6].resumeTaskId).toBe('task-1') + throw new DetailProviderError('仍在生成', true, null, 'task-1', true) + }) + await processDetailJob(job) + expect(generateDetailImage).toHaveBeenCalledTimes(1) + expect(deferDetailJob).toHaveBeenCalledTimes(1) +}) + +test('revocation immediately before resumed polling preserves the accepted task as unknown', async () => { + const job = pendingImageJob() + const task = { id: 'task-accepted', stage: 'screen-first', submittedAt: Date.now() - 60_000, polls: 1 } + job.checkpoint = { stage: 'screen-first', dispatchPending: true, upstreamTask: task } + ;(isSuperAdmin as jest.Mock).mockResolvedValueOnce(true).mockResolvedValueOnce(true).mockResolvedValueOnce(false) + await processDetailJob(job) + expect(generateDetailImage).not.toHaveBeenCalled() + expect(deferDetailJob).not.toHaveBeenCalled() + expect(checkpointDetailJob).toHaveBeenCalledTimes(1) + expect(checkpointDetailJob).toHaveBeenLastCalledWith('job-1', 'lease-1', expect.objectContaining({ dispatchPending: true, upstreamTask: task }), 0) + expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'unknown', expect.objectContaining({ dispatchPending: true, upstreamTask: task }), expect.stringContaining('超级管理员'), null) +}) + +test.each(['missing', 'expired', 'wrong-screen'])('unsafe asynchronous checkpoint stays paused: %s', async reason => { + const job = pendingImageJob() + job.checkpoint = { stage: 'screen-first', dispatchPending: true } + if (reason !== 'missing') job.checkpoint.upstreamTask = { id: 'task-1', stage: reason === 'wrong-screen' ? 'screen-other' : 'screen-first', submittedAt: Date.now() - 7 * 60 * 60_000, polls: 1 } + await processDetailJob(job) + expect(generateDetailImage).not.toHaveBeenCalled() + expect(deferDetailJob).not.toHaveBeenCalled() + expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'unknown', expect.objectContaining({ dispatchPending: true }), expect.any(String), null) +}) diff --git a/lib/ai-model-capabilities.ts b/lib/ai-model-capabilities.ts index 5a7b816..855834d 100644 --- a/lib/ai-model-capabilities.ts +++ b/lib/ai-model-capabilities.ts @@ -1,5 +1,6 @@ -import type { AiModelCapabilities, AiModelStage } from './ai-model-types' +import { AI_MODEL_KINDS, type AiModelCapabilities, type AiModelStage, type AiModelKind } from './ai-model-types' import { AiModelError } from './ai-model-access' +import { parseAiModelProtocols, validateStoredAiModelProtocols, type AiModelProtocol } from './ai-model-protocol' /** Server-owned evidence. Never accepted from configuration writes or inferred from model names. */ export interface AiModelCapabilityMetadata { @@ -13,6 +14,7 @@ export interface AiModelCapabilityMetadata { outputDeclared?: boolean endpointsDeclared?: boolean unavailable?: boolean + protocols?: AiModelProtocol[] } function object(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null } @@ -32,7 +34,26 @@ export function parseAiModelCapabilityMetadata(value: unknown, sourceUrl: string if (raw.available === false) return { version: 1, inputModalities: [], outputModalities: [], endpoints: [], sourceUrl, unavailable: true } return null } - return { version: 1, inputModalities, outputModalities, endpoints, sourceUrl, inputDeclared: true, outputDeclared: true, endpointsDeclared: true, unavailable: raw.available === false } + const protocols: AiModelProtocol[] = [] + for (const endpoint of endpoints) { + const path = endpoint.startsWith('/v1/') ? endpoint : `/v1${endpoint}` + if (path === '/v1/chat/completions') protocols.push({ id: 'openai-chat', path, headers: {}, supportsImageInput: inputModalities.includes('image') }) + if (path === '/v1/images/edits') protocols.push({ id: 'openai-image-edit', path, headers: {}, inputField: 'image[]', supportsImageInput: inputModalities.includes('image') }) + } + return { version: 1, inputModalities, outputModalities, endpoints, protocols, sourceUrl, inputDeclared: true, outputDeclared: true, endpointsDeclared: true, unavailable: raw.available === false } +} + +function publicKind(model: Record, section: Record, input: string[], output: string[]): AiModelKind { + const capabilities = strings(model.capabilities) || [], tasks = strings(model.task_types) || [] + if (model.category === 'three_d_space' || tasks.includes('interactive_3d') || output.includes('interactive_3d')) return 'three-d' + if (model.category === 'music_generation' || tasks.includes('text_to_music')) return 'music' + if (model.type === 'video' || model.modality === 'video' || output.includes('video')) return 'video' + if (tasks.includes('text_to_speech') || input.includes('text') && output.includes('audio')) return 'tts' + if (capabilities.some(value => ['audio_transcription', 'speech_to_text'].includes(value)) || input.some(value => value.startsWith('audio')) && output.includes('text')) return 'asr' + if (model.type === 'embedding' || model.modality === 'embedding' || capabilities.includes('openai.embeddings') || output.includes('embedding')) return 'embedding' + if (model.type === 'image' || model.modality === 'image') return capabilities.some(value => ['text_to_image', 'image_to_image', 'openai.images.generations', 'openai.images.edits'].includes(value)) ? 'image' : 'image-tool' + if (model.type === 'model' && section.id === 'chat') return model.modality === 'multimodal' ? 'vision' : model.modality === 'text' ? 'text' : 'other' + return 'other' } export const AI_MODEL_PUBLIC_CATALOG_URL = 'https://api.vip98k.com/api/v2/catalog/models' @@ -51,7 +72,6 @@ export function parseAiModelPublicCapabilityCatalog(value: unknown): Map => Boolean(offer)) : [] // Do not combine non-default service offers or route-specific protocols into the default request path. const selected = offers.find(offer => offer.is_default === true && object(offer.service_scope)?.id === model.default_service_scope_id) - const invocation = object(offers.length ? selected?.invocation : model.invocation) const rawInputModes = selected?.input_modes ?? model.input_modes, rawOutputModes = selected?.output_modes ?? model.output_modes const inputModes = strings(rawInputModes), outputModes = strings(rawOutputModes) if (rawInputModes !== undefined && inputModes === null || rawOutputModes !== undefined && outputModes === null) throw new AiModelError('上游能力目录模态字段无效', 502, 'capability_catalog_invalid') @@ -61,16 +81,13 @@ export function parseAiModelPublicCapabilityCatalog(value: unknown): Map Array.from(new Set(modes.map(mode => ['image_url', 'image_base64'].includes(mode) ? 'image' : mode === 'prompt' ? 'text' : mode))).sort() const inputModalities = normalizeModes(inputModes || (textModel ? ['text'] : [])) const outputModalities = normalizeModes(outputModes || (textModel ? ['text'] : imageModel ? ['image'] : [])) - const headers = object(invocation?.headers) - const endpoints: string[] = [] - if (model.selection_required !== true && invocation?.method === 'POST' && headers && Object.keys(headers).length === 0) { - if (invocation.path === '/v1/chat/completions' && invocation.content_type === 'application/json') endpoints.push('/chat/completions') - if (invocation.path === '/v1/images/edits' && invocation.content_type === 'multipart/form-data') endpoints.push('/images/edits') - } - result.set(model.id, { version: 1, inputModalities, outputModalities, endpoints, sourceUrl: AI_MODEL_PUBLIC_CATALOG_URL, + const protocols = parseAiModelProtocols(model) + const endpoints = protocols.map(protocol => protocol.path) + const kindHint = publicKind(model, section, inputModes || [], outputModes || []) + result.set(model.id, { version: 1, inputModalities, outputModalities, endpoints, protocols, sourceUrl: AI_MODEL_PUBLIC_CATALOG_URL, inputDeclared: inputModes !== null, outputDeclared: outputModes !== null, endpointsDeclared: false, unavailable: model.available === false || typeof model.status === 'string' && model.status !== 'available' || selected?.available === false || typeof selected?.status === 'string' && selected.status !== 'available', - kindHint: imageModel ? 'image' : textModel ? model.modality === 'multimodal' ? 'vision' : 'text' : 'other' }) + kindHint }) } } if (result.size > 5000) throw new AiModelError('上游能力目录过大', 502, 'capability_catalog_invalid') @@ -84,7 +101,8 @@ function storedMetadata(value: unknown): AiModelCapabilityMetadata | null { if (!inputModalities || !outputModalities || !endpoints || raw.sourceUrl !== null && typeof raw.sourceUrl !== 'string') return null return { version: 1, inputModalities, outputModalities, endpoints, sourceUrl: raw.sourceUrl as string | null, inputDeclared: raw.inputDeclared === true, outputDeclared: raw.outputDeclared === true, endpointsDeclared: raw.endpointsDeclared === true, unavailable: raw.unavailable === true, - ...(['text', 'vision', 'image', 'other', 'unknown'].includes(String(raw.kindHint)) ? { kindHint: raw.kindHint as AiModelCapabilities['kind'] } : {}) } + protocols: validateStoredAiModelProtocols(raw.protocols), + ...(AI_MODEL_KINDS.includes(raw.kindHint as AiModelKind) ? { kindHint: raw.kindHint as AiModelKind } : {}) } } /** Stable across PostgreSQL JSONB property ordering, so unchanged sync does not invalidate active jobs. */ @@ -93,7 +111,7 @@ export function aiModelCapabilityFingerprint(value: unknown): string { return JS export function mergeAiModelCapabilityMetadata(primary: AiModelCapabilityMetadata | null, additional: AiModelCapabilityMetadata | null): AiModelCapabilityMetadata | null { if (!primary || !additional) return primary || additional if (primary.unavailable && !primary.inputDeclared && !primary.outputDeclared && !primary.endpointsDeclared) return { ...additional, unavailable: true } - return { ...primary, + return { ...primary, kindHint: additional.kindHint || primary.kindHint, protocols: additional.protocols || [], endpoints: additional.endpoints, inputModalities: additional.inputDeclared ? primary.inputModalities.filter(value => additional.inputModalities.includes(value)) : primary.inputModalities, outputModalities: additional.outputDeclared ? primary.outputModalities.filter(value => additional.outputModalities.includes(value)) : primary.outputModalities, unavailable: primary.unavailable === true || additional.unavailable === true } @@ -101,17 +119,27 @@ export function mergeAiModelCapabilityMetadata(primary: AiModelCapabilityMetadat export function getAiModelCapabilities(baseUrl: string, modelId: string, metadata?: unknown): AiModelCapabilities { const evidence = storedMetadata(metadata) - // Missing public fields do not erase real acceptance evidence. Explicit capability withdrawal does. - const verified: AiModelStage[] = baseUrl === 'https://api.vip98k.com/v1' ? modelId === 'gemini-2.5-flash' ? ['analysis', 'copy'] : modelId === 'nano-banana-2' ? ['image'] : [] : [] - const verifiedAllowed = verified.filter(stage => !evidence?.unavailable && evidence?.kindHint !== 'other' && (!evidence?.inputDeclared || evidence.inputModalities.includes('text') && (stage === 'copy' || evidence.inputModalities.includes('image'))) && (!evidence?.outputDeclared || evidence.outputModalities.includes(stage === 'image' ? 'image' : 'text')) && (!evidence?.endpointsDeclared || evidence.endpoints.some(endpoint => endpoint === (stage === 'image' ? '/images/edits' : '/chat/completions') || endpoint === (stage === 'image' ? '/v1/images/edits' : '/v1/chat/completions')))) - if (verifiedAllowed.length) return { kind: verifiedAllowed.includes('image') ? 'image' : verifiedAllowed.includes('analysis') ? 'vision' : 'text', allowedStages: verifiedAllowed, source: 'verified', reason: '依据本平台已完成的实际接口验证' } + void baseUrl; void modelId if (!evidence) return { kind: 'unknown', allowedStages: [], source: 'unknown', reason: '上游未提供可核验能力,暂不能开放' } const textOutput = evidence.outputModalities.includes('text'), imageOutput = evidence.outputModalities.includes('image'), imageInput = evidence.inputModalities.includes('image'), textInput = evidence.inputModalities.includes('text') - const chat = evidence.endpoints.includes('/chat/completions') || evidence.endpoints.includes('/v1/chat/completions') - const imageEdit = evidence.endpoints.includes('/images/edits') || evidence.endpoints.includes('/v1/images/edits') + const protocols = evidence.protocols || [] + const chat = protocols.some(protocol => ['openai-chat', 'anthropic-messages'].includes(protocol.id)) + const imageEdit = protocols.some(protocol => ['openai-image-edit', 'openai-image-generate', 'async-image-task'].includes(protocol.id) && protocol.supportsImageInput) const allowedStages: AiModelStage[] = [] - if (!evidence.unavailable && evidence.kindHint !== 'other' && textInput && textOutput && chat) { if (imageInput) allowedStages.push('analysis'); allowedStages.push('copy') } - if (!evidence.unavailable && evidence.kindHint !== 'other' && textInput && imageOutput && imageInput && imageEdit) allowedStages.push('image') - const kind = evidence.kindHint || (imageOutput ? 'image' : textOutput && textInput ? imageInput ? 'vision' : 'text' : evidence.outputModalities.length ? 'other' : 'unknown') - return { kind, allowedStages, source: 'upstream', reason: evidence.unavailable ? '上游已停用该模型' : allowedStages.length ? '依据上游公布的输入、输出与接口能力' : kind === 'image' ? '图片模型尚不支持当前产品参考图接口' : kind === 'other' ? '当前详情页不支持该模型类型' : '上游能力尚不匹配当前详情页接口' } + const kind = evidence.kindHint || (evidence.outputModalities.includes('audio') && textInput ? 'tts' : evidence.inputModalities.some(value => value.startsWith('audio')) && textOutput ? 'asr' : evidence.outputModalities.includes('video') ? 'video' : evidence.outputModalities.includes('embedding') ? 'embedding' : imageOutput ? 'image' : textOutput && textInput ? imageInput ? 'vision' : 'text' : evidence.outputModalities.length ? 'other' : 'unknown') + if (!evidence.unavailable && ['text', 'vision', 'image'].includes(kind) && chat && (!evidence.inputDeclared || textInput) && (!evidence.outputDeclared || textOutput)) { if (!evidence.inputDeclared || imageInput) allowedStages.push('analysis'); allowedStages.push('copy') } + if (!evidence.unavailable && kind === 'image' && imageEdit && (!evidence.outputDeclared || imageOutput)) allowedStages.push('image') + return { kind, allowedStages, source: 'upstream', protocols: Object.fromEntries(allowedStages.map(stage => { + const protocol = getAiModelStageProtocol(metadata, stage)! + return [stage, { id: protocol.id, path: protocol.path, scopeId: protocol.scopeId, routeId: protocol.routeId, operationId: protocol.operationId }] + })), reason: evidence.unavailable ? '上游已停用该模型' : allowedStages.length ? '接口已适配;通过对应用途的实际测试后可开放' : kind === 'image' ? '当前路由尚不支持私有产品参考图' : ['text', 'vision'].includes(kind) ? '该接口或明确输入能力尚不匹配详情页' : '该类型不适用于详情页的三个用途' } +} + +export function getAiModelStageProtocol(metadata: unknown, stage: AiModelStage): AiModelProtocol | null { + const evidence = storedMetadata(metadata) + if (!evidence || evidence.unavailable) return null + const protocol = (evidence.protocols || []).find(protocol => stage === 'image' + ? ['openai-image-edit', 'openai-image-generate', 'async-image-task'].includes(protocol.id) && protocol.supportsImageInput + : ['openai-chat', 'anthropic-messages'].includes(protocol.id)) || null + return protocol && stage === 'analysis' && !evidence.inputDeclared ? { ...protocol, supportsImageInput: true } : protocol } diff --git a/lib/ai-model-http.ts b/lib/ai-model-http.ts index 53a0428..4854d27 100644 --- a/lib/ai-model-http.ts +++ b/lib/ai-model-http.ts @@ -30,11 +30,38 @@ export async function resolveAiModelPublicAddresses(baseUrl: string, resolver: A } /** Fixed DNS lookup, original hostname TLS verification, no redirects and bounded streamed response. */ -export async function requestAiModel(baseUrl: string, apiKey: string, endpoint: '/models' | '/chat/completions' | '/images/edits', body?: string | FormData, idempotencyKey?: string, beforeRequest?: () => Promise): Promise { +export async function requestAiModel(baseUrl: string, apiKey: string, endpoint: '/models' | '/chat/completions' | '/messages' | '/images/edits' | '/images/generations', body?: string | FormData, idempotencyKey?: string, beforeRequest?: () => Promise): Promise { const normalized = normalizeAiModelBaseUrl(baseUrl) return requestAiModelUrl(normalized, new URL(normalized + endpoint), { Authorization: `Bearer ${apiKey}`, ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}) }, endpoint === '/models', body, beforeRequest) } +/** Only fixed, implemented operations are executable; directory URLs never become arbitrary request targets. */ +export async function requestAiModelOperation(baseUrl: string, apiKey: string, path: string, body: string | FormData | undefined, idempotencyKey: string | undefined, protocolHeaders: Record, beforeRequest?: () => Promise): Promise { + const normalized = normalizeAiModelBaseUrl(baseUrl) + if (!['/v1/chat/completions', '/v1/messages', '/v1/images/edits', '/v1/images/generations', '/extensions/v1/tasks'].includes(path) && !/^\/extensions\/v1\/tasks\/[A-Za-z0-9_-]{1,160}\/result$/.test(path)) throw new AiModelError('模型接口尚未适配', 400, 'protocol_unsupported') + const headers: Record = { Authorization: `Bearer ${apiKey}` } + if (idempotencyKey) { + if (!/^[\x21-\x7e]{1,128}$/.test(idempotencyKey)) throw new AiModelError('模型请求编号无效') + headers['Idempotency-Key'] = idempotencyKey + } + for (const [key, value] of Object.entries(protocolHeaders)) { + if (!['X-Relay-Route-Option', 'Anthropic-Version'].includes(key) || !/^[A-Za-z0-9._-]{1,160}$/.test(value)) throw new AiModelError('模型协议请求头无效', 400, 'protocol_invalid') + headers[key] = value + } + const target = path.startsWith('/v1/') ? new URL(normalized + path.slice(3)) : new URL(path, normalized) + return requestAiModelUrl(normalized, target, headers, false, body, beforeRequest, body === undefined && path.endsWith('/result') ? 10_000 : undefined) +} + +/** Generated private assets stay on the configured provider. No credential-bearing request follows a redirect. */ +export async function requestAiModelOutput(baseUrl: string, apiKey: string, outputUrl: string, beforeRequest?: () => Promise): Promise { + const normalized = normalizeAiModelBaseUrl(baseUrl) + let target: URL + try { target = new URL(outputUrl) } catch { throw new AiModelError('模型产物地址无效', 502, 'output_invalid') } + // Relay assets use an encrypted Base64url reference, not a short database ID. + if (target.origin !== new URL(normalized).origin || target.username || target.password || target.hash || !/^\/api\/external\/assets\/[A-Za-z0-9_-]{32,8192}$/.test(target.pathname)) throw new AiModelError('模型产物不在已适配的私有文件入口', 502, 'output_invalid') + return requestAiModelUrl(normalized, target, { Authorization: `Bearer ${apiKey}` }, false, undefined, beforeRequest) +} + /** This provider publishes a separate typed directory. Its public request never carries the private API key. */ export async function requestAiModelCapabilityCatalog(baseUrl: string, beforeRequest?: () => Promise): Promise { const normalized = normalizeAiModelBaseUrl(baseUrl) @@ -42,7 +69,7 @@ export async function requestAiModelCapabilityCatalog(baseUrl: string, beforeReq return requestAiModelUrl(normalized, new URL('https://api.vip98k.com/api/v2/catalog/models'), {}, true, undefined, beforeRequest) } -async function requestAiModelUrl(normalized: string, target: URL, headers: Record, catalog: boolean, body?: string | FormData, beforeRequest?: () => Promise): Promise { +async function requestAiModelUrl(normalized: string, target: URL, headers: Record, catalog: boolean, body?: string | FormData, beforeRequest?: () => Promise, timeoutMs?: number): Promise { const addresses = await resolveAiModelPublicAddresses(normalized) let requestBody: Readable | null = null if (typeof body === 'string') { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = String(Buffer.byteLength(body)); requestBody = Readable.from([body]) } @@ -76,7 +103,7 @@ async function requestAiModelUrl(normalized: string, target: URL, headers: Recor try { finish(null, new Response([204, 205, 304].includes(res.statusCode || 0) ? null : new Uint8Array(Buffer.concat(chunks)), { status: res.statusCode || 502, headers: responseHeaders })) } catch { finish(new Error('Provider response invalid')) } }) }) - const timer = setTimeout(() => { req.destroy(); finish(new Error('Provider request timed out')) }, catalog ? 20_000 : 180_000) + const timer = setTimeout(() => { req.destroy(); finish(new Error('Provider request timed out')) }, timeoutMs ?? (catalog ? 20_000 : 180_000)) req.on('error', () => finish(new Error('Provider request failed'))) if (requestBody) { requestBody.on('error', () => { req.destroy(); finish(new Error('Provider request body failed')) }); requestBody.pipe(req) } else req.end() }) diff --git a/lib/ai-model-protocol.ts b/lib/ai-model-protocol.ts new file mode 100644 index 0000000..0bb7852 --- /dev/null +++ b/lib/ai-model-protocol.ts @@ -0,0 +1,230 @@ +import sharp from 'sharp' +import { requestAiModelOperation, requestAiModelOutput } from './ai-model-http' + +export const AI_MODEL_ADAPTER_VERSION = 2 +export interface AiModelProtocol { + id: 'openai-chat' | 'anthropic-messages' | 'openai-image-edit' | 'openai-image-generate' | 'async-image-task' + path: string + headers: Record + inputField?: string + supportsImageInput: boolean + scopeId?: string + routeId?: string + operationId?: string + maxImages?: number + maxPromptLength?: number +} +const paths: Record = { 'openai-chat': '/v1/chat/completions', 'anthropic-messages': '/v1/messages', 'openai-image-edit': '/v1/images/edits', 'openai-image-generate': '/v1/images/generations', 'async-image-task': '/extensions/v1/tasks' } +function record(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null } +function list(value: unknown): string[] { return Array.isArray(value) && value.every(item => typeof item === 'string') ? value : [] } +function safeHeaders(value: unknown): Record | null { + const raw = record(value) + if (!raw) return null + const result: Record = {} + for (const [name, item] of Object.entries(raw).sort(([left], [right]) => left.localeCompare(right))) { + if (!['X-Relay-Route-Option', 'Anthropic-Version'].includes(name) || typeof item !== 'string' || !/^[A-Za-z0-9._-]{1,160}$/.test(item)) return null + result[name] = item + } + return result +} +function identifier(value: unknown): value is string { return typeof value === 'string' && /^[A-Za-z0-9._-]{1,160}$/.test(value) } + +/** Parse only a known operation, keeping route evidence separate from a model's combined capabilities. */ +function parseInvocation(model: Record, offer: Record, route: Record): AiModelProtocol | null { + const invocation = record(route.invocation ?? offer.invocation ?? model.invocation) + if (!invocation || invocation.method !== 'POST') return null + const id = (Object.keys(paths) as AiModelProtocol['id'][]).find(key => paths[key] === invocation.path) + const headers = safeHeaders(invocation.headers) + if (!id || !headers) return null + if (invocation.content_type !== (id === 'openai-image-edit' ? 'multipart/form-data' : 'application/json')) return null + const scopeId = record(offer.service_scope)?.id, routeId = route.id + if (scopeId !== undefined && !identifier(scopeId) || routeId !== undefined && !identifier(routeId)) return null + if (routeId && headers['X-Relay-Route-Option'] !== routeId) return null + const schema = record(invocation.input_schema ?? route.input_schema) + const properties = record(schema?.properties) ?? {} + const modes = list(route.input_modes ?? offer.input_modes ?? model.input_modes) + const capabilities = list(offer.capabilities ?? model.capabilities) + const isTextModel = ['text', 'multimodal'].includes(model.modality) && model.type === 'model' + const isImageModel = model.modality === 'image' && [...capabilities, ...list(model.capabilities)].some(value => ['text_to_image', 'image_to_image', 'openai.images.generations', 'openai.images.edits'].includes(value)) + let inputField: string | undefined, supportsImageInput = false + if (id === 'openai-chat' || id === 'anthropic-messages') { + if (!isTextModel) return null + supportsImageInput = modes.some(mode => ['image', 'image_url', 'image_base64'].includes(mode)) || (!modes.length && model.modality === 'multimodal') + } else { + if (!isImageModel) return null + if (id === 'openai-image-edit') { + inputField = properties['image[]'] !== false && properties['image[]'] ? 'image[]' : properties.image !== false && properties.image ? 'image' : undefined + supportsImageInput = Boolean(inputField) + } else { + // JSON image requests are safe for private files only when the route explicitly permits inline data. + inputField = ['file_urls', 'images', 'image'].find(field => { + const property = record(properties[field]), items = record(property?.items) + const pattern = items?.pattern + if (typeof pattern !== 'string' || pattern.length > 512 || !pattern.includes('data:image/') || items?.type !== 'string' || !(property?.type === 'array' || Array.isArray(property?.type) && property.type.includes('array'))) return false + try { return new RegExp(pattern).test('data:image/png;base64,AA==') } catch { return false } + }) + supportsImageInput = Boolean(inputField) + } + if (id === 'async-image-task' && !supportsImageInput) return null + } + const maxImages = inputField && record(properties[inputField])?.maxItems + const maxPromptLength = record(properties.prompt)?.maxLength + return { id, path: paths[id], headers, supportsImageInput, ...(inputField ? { inputField } : {}), + ...(scopeId ? { scopeId } : {}), ...(routeId ? { routeId } : {}), + ...(identifier(offer.public_operation_id) ? { operationId: offer.public_operation_id } : {}), + ...(Number.isInteger(maxImages) && maxImages > 0 ? { maxImages: Math.min(8, maxImages) } : {}), + ...(Number.isInteger(maxPromptLength) && maxPromptLength > 0 ? { maxPromptLength } : {}) } +} + +/** At setup time pick a concrete compatible route in the default scope; no runtime routing fallback. */ +export function parseAiModelProtocols(value: unknown): AiModelProtocol[] { + const model = record(value) + if (!model || model.available === false || model.status && model.status !== 'available') return [] + const offers = Array.isArray(model.service_offers) ? model.service_offers.map(record).filter(Boolean) as Record[] : [] + if (!offers.length) { const protocol = parseInvocation(model, {}, {}); return protocol ? [protocol] : [] } + if (!identifier(model.default_service_scope_id)) return [] + const found: AiModelProtocol[] = [] + for (const offer of offers) { + if (record(offer.service_scope)?.id !== model.default_service_scope_id || offer.available === false) continue + const routes = Array.isArray(offer.route_options) ? offer.route_options.map(record).filter(Boolean) as Record[] : [] + const eligible = routes.filter(route => route.available !== false).sort((a, b) => Number(b.id === model.default_route_option_id) - Number(a.id === model.default_route_option_id)) + const protocols = eligible.map(route => parseInvocation(model, offer, route)).filter(Boolean) as AiModelProtocol[] + const chosen = protocols.find(protocol => protocol.supportsImageInput) ?? protocols[0] + if (chosen && !found.some(protocol => JSON.stringify(protocol) === JSON.stringify(chosen))) found.push(chosen) + } + return found.sort((a, b) => Number(b.supportsImageInput) - Number(a.supportsImageInput)) +} + +export function validateStoredAiModelProtocols(value: unknown): AiModelProtocol[] { + if (!Array.isArray(value) || value.length > 8) return [] + const result: AiModelProtocol[] = [] + for (const entry of value) { + const raw = record(entry), headers = safeHeaders(raw?.headers) + if (!raw || !Object.prototype.hasOwnProperty.call(paths, raw.id) || raw.path !== paths[raw.id as AiModelProtocol['id']] || !headers || typeof raw.supportsImageInput !== 'boolean') return [] + if (['scopeId', 'routeId', 'operationId'].some(field => raw[field] !== undefined && !identifier(raw[field]))) return [] + if (raw.routeId && headers['X-Relay-Route-Option'] !== raw.routeId) return [] + if (raw.inputField !== undefined && !['image', 'image[]', 'images', 'file_urls'].includes(raw.inputField)) return [] + if (['maxImages', 'maxPromptLength'].some(field => raw[field] !== undefined && (!Number.isInteger(raw[field]) || raw[field] < 1 || raw[field] > (field === 'maxImages' ? 8 : 1000000)))) return [] + if (raw.id === 'async-image-task' && (!raw.supportsImageInput || raw.inputField !== 'file_urls') || raw.id === 'openai-image-generate' && raw.supportsImageInput && !raw.inputField || raw.id === 'openai-image-edit' && (!raw.supportsImageInput || !['image', 'image[]'].includes(raw.inputField))) return [] + result.push({ id: raw.id, path: raw.path, headers, supportsImageInput: raw.supportsImageInput, + ...Object.fromEntries(['inputField', 'scopeId', 'routeId', 'operationId', 'maxImages', 'maxPromptLength'].filter(key => raw[key] !== undefined).map(key => [key, raw[key]])) }) + } + return result +} + +export class AiModelProtocolError extends Error { + readonly taskPending: boolean + constructor(message: string, readonly outcomeUnknown: boolean, readonly requestId: string | null = null, readonly taskId: string | null = null, taskPending = outcomeUnknown && Boolean(taskId)) { super(message); this.name = 'AiModelProtocolError'; this.taskPending = taskPending } +} +export interface AiModelProtocolInput { + baseUrl: string; apiKey: string; modelId: string; protocol: AiModelProtocol; prompt: string + images?: Array<{ buffer: Buffer; mimeType: string }> + idempotencyKey: string; beforeRequest?: () => Promise + onTaskSubmitted?: (taskId: string) => Promise; resumeTaskId?: string + maxOutputTokens?: number +} +export interface AiModelProtocolResult { text?: string; image?: Buffer; requestId: string | null; summary: Record } + +function safeRequestId(response: Response, data: any, apiKey: string): string | null { + const id = response.headers.get('x-request-id') || data?.request_id || data?.error?.request_id + return typeof id === 'string' && /^[\w-]{1,120}$/.test(id) && !id.includes(apiKey) ? id : null +} +async function readJson(response: Response, apiKey: string, taskId: string | null = null): Promise<{ data: any; requestId: string | null }> { + const text = await response.text() + let data: any + try { data = JSON.parse(text) } catch { throw new AiModelProtocolError('模型响应无法确认,已暂停;请勿重复提交', true, null, taskId) } + const requestId = safeRequestId(response, data, apiKey) + const code = data?.error?.code ?? data?.code + if (!response.ok || data?.status === 'pending_review') { + const terminalTaskFailure = response.status === 409 && ['task_failed', 'task_cancelled', 'task_expired', 'task_timeout', 'content_policy_violation'].includes(code) + const review = ['billing_reconciliation_required', 'submission_pending_reconciliation', 'image_size_mismatch', 'task_state_unknown'].includes(code) || data?.status === 'pending_review' + // Losing access to an accepted task does not prove that generation failed. + const unknown = Boolean(taskId) ? !terminalTaskFailure : response.status >= 500 || review || code === 'request_outcome_unknown' + const pending = Boolean(taskId) && unknown && !review && (response.status >= 500 || response.status === 429) + throw new AiModelProtocolError(unknown ? '模型请求结果待核查,已暂停;请勿重复提交' : `模型服务拒绝请求(${response.status})`, unknown, requestId, taskId, pending) + } + if (text.includes(apiKey)) throw new AiModelProtocolError('模型响应含敏感内容,已暂停', true, requestId, taskId) + return { data, requestId } +} +async function normalizeImages(images: AiModelProtocolInput['images'], forAnalysis: boolean): Promise { + return Promise.all((images ?? []).map(image => { + const pipeline = sharp(image.buffer, { limitInputPixels: 40_000_000 }).rotate().resize(forAnalysis ? 1024 : 1536, forAnalysis ? 1024 : 1536, { fit: 'inside', withoutEnlargement: true }) + return forAnalysis ? pipeline.flatten({ background: '#ffffff' }).jpeg({ quality: 85 }).toBuffer() : pipeline.png().toBuffer() + })) +} +async function decodeImage(data: any, input: AiModelProtocolInput, taskId: string | null): Promise { + let bytes: Buffer + if (typeof data?.b64_json === 'string' && /^[A-Za-z0-9+/]+={0,2}$/.test(data.b64_json) && data.b64_json.length <= 28 * 1024 * 1024) bytes = Buffer.from(data.b64_json, 'base64') + else if (typeof data?.url === 'string') { + const response = await requestAiModelOutput(input.baseUrl, input.apiKey, data.url, input.beforeRequest) + if (!response.ok) throw new AiModelProtocolError('模型图片暂时无法读取,已保留任务', true, null, taskId) + bytes = Buffer.from(await response.arrayBuffer()) + } else throw new AiModelProtocolError('模型没有返回可保存的图片', false, null, taskId) + try { + const output = await sharp(bytes, { limitInputPixels: 40_000_000 }).rotate().resize(4096, 4096, { fit: 'inside', withoutEnlargement: true }).png().toBuffer() + if (output.length > 20 * 1024 * 1024) throw new Error('size') + return output + } catch { throw new AiModelProtocolError('模型图片无法解码或超出限制', false, null, taskId) } +} + +export async function callAiModelProtocol(input: AiModelProtocolInput): Promise { + const protocol = validateStoredAiModelProtocols([input.protocol])[0] + if (!protocol) throw new AiModelProtocolError('模型协议无效,请重新同步', false) + const imageCount = input.images?.length ?? 0 + if (imageCount > (protocol.maxImages ?? 8) || imageCount && !protocol.supportsImageInput) throw new AiModelProtocolError('该模型接口不支持当前参考图片', false) + if (protocol.maxPromptLength && input.prompt.length > protocol.maxPromptLength) throw new AiModelProtocolError(`该模型提示词最多 ${protocol.maxPromptLength} 字,请精简内容`, false) + if (protocol.id === 'async-image-task' && !input.onTaskSubmitted) throw new AiModelProtocolError('异步任务缺少持久化检查点', false) + if (input.resumeTaskId && (!/^[A-Za-z0-9_-]{1,160}$/.test(input.resumeTaskId) || protocol.id !== 'async-image-task')) throw new AiModelProtocolError('模型任务编号无效', false) + let taskId = input.resumeTaskId ?? null, requestId: string | null = null, dispatched = Boolean(input.resumeTaskId) + const summary = { adapterVersion: AI_MODEL_ADAPTER_VERSION, protocol: protocol.id, routeId: protocol.routeId ?? null } + try { + if (!taskId) { + const forAnalysis = protocol.id === 'openai-chat' || protocol.id === 'anthropic-messages' + const images = await normalizeImages(input.images, forAnalysis) + const imageMimeType = forAnalysis ? 'image/jpeg' : 'image/png' + if (images.some(image => image.length > 10 * 1024 * 1024) || images.reduce((total, image) => total + image.length, 0) > 24 * 1024 * 1024) throw new AiModelProtocolError('参考图片总量超出模型限制,请减少图片', false) + const dataUris = images.map(image => `data:${imageMimeType};base64,${image.toString('base64')}`) + let body: string | FormData + const maxTokens = input.maxOutputTokens && Number.isInteger(input.maxOutputTokens) ? Math.max(32, Math.min(8000, input.maxOutputTokens)) : 8000 + if (protocol.id === 'openai-chat') body = JSON.stringify({ model: input.modelId, messages: [{ role: 'user', content: images.length ? [{ type: 'text', text: input.prompt }, ...dataUris.map(url => ({ type: 'image_url', image_url: { url } }))] : input.prompt }], max_tokens: maxTokens, stream: false }) + else if (protocol.id === 'anthropic-messages') body = JSON.stringify({ model: input.modelId, messages: [{ role: 'user', content: [{ type: 'text', text: input.prompt }, ...images.map(image => ({ type: 'image', source: { type: 'base64', media_type: imageMimeType, data: image.toString('base64') } }))] }], max_tokens: maxTokens, stream: false }) + else if (protocol.id === 'openai-image-edit') { + body = new FormData(); body.set('model', input.modelId); body.set('prompt', input.prompt); body.set('n', '1'); body.set('response_format', 'b64_json') + images.forEach((image, index) => (body as FormData).append(protocol.inputField!, new Blob([new Uint8Array(image)], { type: 'image/png' }), `reference-${index + 1}.png`)) + } else body = JSON.stringify({ model: input.modelId, prompt: input.prompt, ...(images.length ? { [protocol.inputField!]: dataUris } : {}), ...(protocol.id === 'openai-image-generate' ? { n: 1, response_format: 'b64_json' } : {}) }) + dispatched = true + const response = await requestAiModelOperation(input.baseUrl, input.apiKey, protocol.path, body, input.idempotencyKey, protocol.headers, input.beforeRequest) + const parsed = await readJson(response, input.apiKey); requestId = parsed.requestId + if (protocol.id === 'async-image-task') { + if (typeof parsed.data?.id !== 'string' || !/^[A-Za-z0-9_-]{1,160}$/.test(parsed.data.id)) throw new AiModelProtocolError('模型未返回可核查的任务编号,请勿重发', true, requestId) + taskId = parsed.data.id + try { await input.onTaskSubmitted!(taskId!) } catch { throw new AiModelProtocolError('模型任务已受理,但检查点未保存;已暂停后续请求', true, requestId, taskId) } + } else if (protocol.id === 'openai-chat' || protocol.id === 'anthropic-messages') { + const content = protocol.id === 'openai-chat' ? parsed.data?.choices?.[0]?.message?.content : parsed.data?.content + const text = typeof content === 'string' ? content : Array.isArray(content) ? content.filter(item => item?.type === 'text' && typeof item.text === 'string').map(item => item.text).join('\n') : '' + if (!text.trim()) throw new AiModelProtocolError('模型未返回可用文字', false, requestId) + return { text, requestId, summary } + } else return { image: await decodeImage(parsed.data?.data?.[0], input, null), requestId, summary } + } + const deadline = Date.now() + 40_000 + for (let attempt = 0; attempt < 20 && Date.now() < deadline; attempt++) { + const response = await requestAiModelOperation(input.baseUrl, input.apiKey, `/extensions/v1/tasks/${taskId}/result`, undefined, undefined, {}, input.beforeRequest) + const parsed = await readJson(response, input.apiKey, taskId); requestId = parsed.requestId ?? requestId + if (parsed.data?.ready === true) { + if (!['succeeded', 'partially_succeeded'].includes(parsed.data.status)) throw new AiModelProtocolError('模型任务未成功完成', false, requestId, taskId) + const outputs = parsed.data?.result?.outputs + const image = Array.isArray(outputs) ? outputs.find(item => item?.type === 'image' && (typeof item.url === 'string' || typeof item.b64_json === 'string')) : null + if (!image) throw new AiModelProtocolError('模型任务没有图片产物', false, requestId, taskId) + return { image: await decodeImage(image, input, taskId), requestId, summary: { ...summary, taskId } } + } + if (response.status !== 202 || parsed.data?.ready !== false) throw new AiModelProtocolError('模型任务状态无法确认,已保留任务', true, requestId, taskId) + await new Promise(resolve => setTimeout(resolve, 2000)) + } + throw new AiModelProtocolError('模型仍在处理中,已保留任务,可继续查询', true, requestId, taskId) + } catch (error) { + if (error instanceof AiModelProtocolError) throw error + // Authentication/revision guards fail before the transport opens a socket. + if (error && typeof error === 'object' && 'status' in error && [400, 401, 403, 409].includes(Number(error.status))) throw error + throw new AiModelProtocolError(dispatched ? '模型请求结果无法确认,已暂停;请勿重复提交' : '参考图片无法读取,请更换图片', dispatched, requestId, taskId) + } +} diff --git a/lib/ai-model-repository.ts b/lib/ai-model-repository.ts index c2cf8e6..89ac2db 100644 --- a/lib/ai-model-repository.ts +++ b/lib/ai-model-repository.ts @@ -10,7 +10,7 @@ export interface AiModelProviderRow { secretCiphertext: string | null; secretNonce: string | null; secretAuthTag: string | null; legacyBinding: boolean; revision: number; syncedAt: Date | null } -export interface AiModelRow extends Omit { providerId: string; capabilityMetadata?: unknown } +export interface AiModelRow extends Omit { providerId: string; capabilityMetadata?: unknown } export interface AiModelConfigurationRow { revision: number; defaults: AiModelDefaults } const providerColumns = `id, name, base_url AS "baseUrl", enabled, credential_source AS "credentialSource", secret_ciphertext AS "secretCiphertext", secret_nonce AS "secretNonce", secret_auth_tag AS "secretAuthTag", legacy_binding AS "legacyBinding", revision, synced_at AS "syncedAt"` @@ -23,7 +23,7 @@ export async function getAiModelProviderRow(id: string, db: AiModelDatabase = pr return rows[0] } export async function listAiModelRows(db: AiModelDatabase = prisma): Promise { - return db.$queryRawUnsafe(`SELECT id AS key, provider_id AS "providerId", model_id AS id, enabled, stages, present, compatibility, capability_metadata AS "capabilityMetadata" FROM ai_model_catalog ORDER BY model_id, id`) + return db.$queryRawUnsafe(`SELECT id AS key, provider_id AS "providerId", model_id AS id, enabled, stages, present, capability_metadata AS "capabilityMetadata" FROM ai_model_catalog ORDER BY model_id, id`) } export async function getAiModelConfiguration(db: AiModelDatabase = prisma): Promise { const rows = await db.$queryRawUnsafe>(`SELECT revision, default_analysis AS analysis, default_copy AS copy, default_image AS image FROM ai_model_configuration WHERE id = 'detail-generator'`) diff --git a/lib/ai-model-service.ts b/lib/ai-model-service.ts index d170652..f87fdc1 100644 --- a/lib/ai-model-service.ts +++ b/lib/ai-model-service.ts @@ -1,12 +1,13 @@ -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { z } from 'zod' import { AiModelError, assertAiModelAdmin } from './ai-model-access' import { decryptAiModelKey, encryptAiModelKey, isAiModelEncryptionConfigured } from './ai-model-crypto' import { normalizeAiModelBaseUrl, requestAiModel, requestAiModelCapabilityCatalog } from './ai-model-http' -import { aiModelCapabilityFingerprint, getAiModelCapabilities, mergeAiModelCapabilityMetadata, parseAiModelCapabilityMetadata, parseAiModelPublicCapabilityCatalog, type AiModelCapabilityMetadata } from './ai-model-capabilities' +import { aiModelCapabilityFingerprint, getAiModelCapabilities, getAiModelStageProtocol, mergeAiModelCapabilityMetadata, parseAiModelCapabilityMetadata, parseAiModelPublicCapabilityCatalog, type AiModelCapabilityMetadata } from './ai-model-capabilities' import { aiModelAudit, aiModelTransaction, assertAiModelRevision, encryptedAiModelFields, getAiModelConfiguration, getAiModelProviderRow, insertAiModelProvider, listAiModelProviderRows, listAiModelRows, saveAiModelDefaults, updateAiModelProvider, type AiModelDatabase, type AiModelProviderRow, type AiModelRow } from './ai-model-repository' import { AI_MODEL_STAGES, type AiModelAdminState, type AiModelBinding, type AiModelBindings, type AiModelConfigurationInput, type AiModelDefaults, type AiModelRuntimeState, type AiModelStage } from './ai-model-types' import { DETAIL_MODEL_DEFAULTS } from './detail-generator/types' +import { aiModelVerificationContext, aiModelVerificationSummary, listAiModelVerifications, type AiModelVerificationRow } from './ai-model-verification-state' const id = z.string().uuid() const revision = z.number().int().min(0) @@ -14,7 +15,7 @@ const key = z.string().trim().min(1).max(4096).regex(/^[\x21-\x7e]+$/) const createSchema = z.object({ name: z.string().trim().min(1).max(100), baseUrl: z.string().trim().min(1).max(500), apiKey: key, enabled: z.boolean().optional() }).strict() const updateSchema = z.object({ revision: revision.min(1), name: z.string().trim().min(1).max(100).optional(), baseUrl: z.string().trim().min(1).max(500).optional(), apiKey: key.optional(), enabled: z.boolean().optional() }).strict() const defaultsSchema = z.object({ analysis: id.nullable(), copy: id.nullable(), image: id.nullable() }).strict() -const configurationSchema = z.object({ revision, defaults: defaultsSchema, providers: z.array(z.object({ id, revision: revision.min(1), models: z.array(z.object({ key: id, enabled: z.boolean(), stages: z.array(z.enum(AI_MODEL_STAGES)).max(3), compatibility: z.enum(['confirmed', 'unconfirmed']) }).strict()).max(5000) }).strict()).max(100) }).strict() +const configurationSchema = z.object({ revision, defaults: defaultsSchema, providers: z.array(z.object({ id, revision: revision.min(1), models: z.array(z.object({ key: id, enabled: z.boolean(), stages: z.array(z.enum(AI_MODEL_STAGES)).max(3) }).strict()).max(5000) }).strict()).max(100) }).strict() function parse(schema: z.ZodType, value: unknown): T { const parsed = schema.safeParse(value); if (!parsed.success) throw new AiModelError('提交的模型配置格式无效'); return parsed.data } export function assertAiModelId(value: string): string { if (!id.safeParse(value).success) throw new AiModelError('模型服务编号无效', 404); return value } function hasCredential(provider: AiModelProviderRow): boolean { @@ -28,14 +29,22 @@ function providerKey(provider: AiModelProviderRow): string { else if (provider.secretCiphertext && provider.secretNonce && provider.secretAuthTag) return decryptAiModelKey({ secretCiphertext: provider.secretCiphertext, secretNonce: provider.secretNonce, secretAuthTag: provider.secretAuthTag }) throw new AiModelError('模型服务尚未配置可用密钥', 503, 'credential_unavailable') } -function modelAvailable(model: AiModelRow, provider: AiModelProviderRow, stage: AiModelStage): boolean { return provider.enabled && hasCredential(provider) && model.present && model.enabled && model.compatibility === 'confirmed' && model.stages.includes(stage) && getAiModelCapabilities(provider.baseUrl, model.id, model.capabilityMetadata).allowedStages.includes(stage) } +function modelContext(model: AiModelRow, provider: AiModelProviderRow, stage: AiModelStage): string | null { + const protocol = getAiModelStageProtocol(model.capabilityMetadata, stage) + return protocol && hasCredential(provider) ? aiModelVerificationContext(provider.baseUrl, model.id, model.capabilityMetadata, providerKey(provider), protocol) : null +} +function modelVerified(model: AiModelRow, provider: AiModelProviderRow, stage: AiModelStage, verifications: AiModelVerificationRow[]): boolean { + return aiModelVerificationSummary(verifications, model.key, stage, modelContext(model, provider, stage)).status === 'passed' +} +function modelAvailable(model: AiModelRow, provider: AiModelProviderRow, stage: AiModelStage, verifications: AiModelVerificationRow[]): boolean { return provider.enabled && hasCredential(provider) && model.present && model.enabled && model.stages.includes(stage) && getAiModelCapabilities(provider.baseUrl, model.id, model.capabilityMetadata).allowedStages.includes(stage) && modelVerified(model, provider, stage, verifications) } export async function getAiModelAdminState(): Promise { - const [providers, models, configuration] = await Promise.all([listAiModelProviderRows(), listAiModelRows(), getAiModelConfiguration()]) + const [providers, models, configuration, verifications] = await Promise.all([listAiModelProviderRows(), listAiModelRows(), getAiModelConfiguration(), listAiModelVerifications()]) return { providers: providers.map(provider => ({ id: provider.id, name: provider.name, baseUrl: provider.baseUrl, enabled: provider.enabled, credentialSource: provider.credentialSource, hasApiKey: hasCredential(provider), revision: provider.revision, syncedAt: provider.syncedAt?.toISOString() || null, models: models.filter(model => model.providerId === provider.id).map(({ providerId: _providerId, capabilityMetadata, ...model }) => { - const capabilities = getAiModelCapabilities(provider.baseUrl, model.id, capabilityMetadata) - const stages = model.stages.filter(stage => capabilities.allowedStages.includes(stage)) - return { ...model, stages, enabled: model.enabled && stages.length > 0, capabilities } + const storedCapabilities = getAiModelCapabilities(provider.baseUrl, model.id, capabilityMetadata) + const capabilities = model.present ? storedCapabilities : { ...storedCapabilities, allowedStages: [], protocols: {}, reason: '已从当前连接的模型目录下架,暂不可用' } + const verification = Object.fromEntries(AI_MODEL_STAGES.map(stage => [stage, aiModelVerificationSummary(verifications, model.key, stage, modelContext({ ...model, providerId: provider.id, capabilityMetadata }, provider, stage))])) as import('./ai-model-types').AiModelCatalogEntry['verification'] + return { ...model, capabilities, verification } }) })), ...configuration, encryptionConfigured: isAiModelEncryptionConfigured(), legacyAvailable: Boolean(process.env.DETAIL_GENERATOR_API_KEY?.trim()) && !providers.some(provider => provider.legacyBinding) } } @@ -62,7 +71,7 @@ export async function editAiModelProvider(userId: string, providerId: string, bo if (baseUrl !== current.baseUrl && !encrypted) throw new AiModelError('更改服务地址须同时填写该服务的密钥,避免把旧密钥发送到新地址') const next = { ...current, ...(input.name !== undefined ? { name: input.name } : {}), ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), baseUrl, ...(encrypted ? { ...encrypted, credentialSource: 'encrypted' as const } : {}) } await updateAiModelProvider(db, next) - if (baseUrl !== current.baseUrl) await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET present=FALSE,enabled=FALSE,stages=ARRAY[]::text[],compatibility='unconfirmed',capability_metadata=NULL WHERE provider_id=$1`, providerId) + if (baseUrl !== current.baseUrl) await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET present=FALSE,enabled=FALSE,stages=ARRAY[]::text[],capability_metadata=NULL WHERE provider_id=$1`, providerId) await aiModelAudit(db, userId, 'provider.update', providerId, { name: next.name, baseUrl, enabled: next.enabled, credentialChanged: Boolean(encrypted), addressChanged: baseUrl !== current.baseUrl }) }) return getAiModelAdminState() @@ -95,7 +104,7 @@ export async function importAiModelEnvironment(userId: string): Promise stages[stage].includes(modelId) && allowed.includes(stage)) - await db.$executeRawUnsafe(`INSERT INTO ai_model_catalog (id,provider_id,model_id,enabled,stages,present,compatibility) VALUES ($1,$2,$3,$4,$5::text[],TRUE,$6)`, modelKey, provider.id, modelId, selected.length > 0, selected, selected.length ? 'confirmed' : 'unconfirmed') + await db.$executeRawUnsafe(`INSERT INTO ai_model_catalog (id,provider_id,model_id,enabled,stages,present) VALUES ($1,$2,$3,FALSE,$4::text[],TRUE)`, modelKey, provider.id, modelId, selected) } const current = await getAiModelConfiguration(db) const defaultFor = (stage: AiModelStage) => current.defaults[stage] || (getAiModelCapabilities(provider.baseUrl, DETAIL_MODEL_DEFAULTS[stage]).allowedStages.includes(stage) ? imported.get(DETAIL_MODEL_DEFAULTS[stage]) || null : null) @@ -155,11 +164,11 @@ export async function syncAiModelProvider(userId: string, providerId: string, bo } async function validateDefaults(db: AiModelDatabase, defaults: AiModelDefaults): Promise { - const [providers, models] = await Promise.all([listAiModelProviderRows(db), listAiModelRows(db)]) + const [providers, models, verifications] = await Promise.all([listAiModelProviderRows(db), listAiModelRows(db), listAiModelVerifications(db)]) for (const stage of AI_MODEL_STAGES) { if (!defaults[stage]) continue const model = models.find(value => value.key === defaults[stage]); const provider = model && providers.find(value => value.id === model.providerId) - if (!model || !provider || !modelAvailable(model, provider, stage)) throw new AiModelError('默认模型须来自已启用服务,且已确认对应阶段接口兼容', 400, 'default_unavailable') + if (!model || !provider || !modelAvailable(model, provider, stage, verifications)) throw new AiModelError('默认模型须已启用,并通过当前连接对应用途的实际测试', 400, 'default_unavailable') } } @@ -170,7 +179,8 @@ export async function configureAiModels(userId: string, body: unknown): Promise< await assertAiModelAdmin(userId) assertAiModelRevision((await getAiModelConfiguration(db)).revision, input.revision) const prior = await listAiModelRows(db) - const changes: Array<{ key: string; providerId: string; enabled: boolean; stages: AiModelStage[]; compatibility: string }> = [] + const verifications = await listAiModelVerifications(db) + const changes: Array<{ key: string; providerId: string; enabled: boolean; stages: AiModelStage[] }> = [] for (const provider of input.providers) { const current = await getAiModelProviderRow(provider.id, db); assertAiModelRevision(current.revision, provider.revision) if (new Set(provider.models.map(model => model.key)).size !== provider.models.length) throw new AiModelError('模型编号重复') @@ -179,10 +189,10 @@ export async function configureAiModels(userId: string, body: unknown): Promise< const existing = prior.find(value => value.key === model.key && value.providerId === provider.id) if (!existing) throw new AiModelError('模型不属于当前服务') const capabilities = getAiModelCapabilities(current.baseUrl, existing.id, existing.capabilityMetadata) - if (model.stages.some(stage => !capabilities.allowedStages.includes(stage))) throw new AiModelError('所选用途超出模型已确认的能力范围,管理员确认不能扩展模型能力', 400, 'model_capability_unsupported') - if (new Set(model.stages).size !== model.stages.length || model.enabled && (!model.stages.length || model.compatibility !== 'confirmed' || !existing.present)) throw new AiModelError('启用模型前须选择用途、确认接口兼容,并确认仍在上游目录中') - if (existing.enabled !== model.enabled || existing.compatibility !== model.compatibility || JSON.stringify([...existing.stages].sort()) !== JSON.stringify([...model.stages].sort())) { changed = true; changes.push({ ...model, providerId: provider.id }) } - await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET enabled=$2,stages=$3::text[],compatibility=$4,updated_at=NOW() WHERE id=$1`, model.key, model.enabled, model.stages, model.compatibility) + if (model.stages.some(stage => !capabilities.allowedStages.includes(stage))) throw new AiModelError('所选用途不适用于该模型的当前接口与能力', 400, 'model_capability_unsupported') + if (new Set(model.stages).size !== model.stages.length || model.enabled && (!model.stages.length || !existing.present || model.stages.some(stage => !modelVerified(existing, current, stage, verifications)))) throw new AiModelError('启用前须通过所选用途的实际测试,并确认模型仍在上游目录中', 400, 'model_verification_required') + if (existing.enabled !== model.enabled || JSON.stringify([...existing.stages].sort()) !== JSON.stringify([...model.stages].sort())) { changed = true; changes.push({ ...model, providerId: provider.id }) } + await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET enabled=$2,stages=$3::text[],updated_at=NOW() WHERE id=$1`, model.key, model.enabled, model.stages) } if (changed) await db.$executeRawUnsafe(`UPDATE ai_model_providers SET revision=revision+1,updated_at=NOW() WHERE id=$1`, provider.id) } @@ -194,12 +204,12 @@ export async function configureAiModels(userId: string, body: unknown): Promise< } export async function getAiModelRuntimeState(): Promise { - const [providers, models, config] = await Promise.all([listAiModelProviderRows(), listAiModelRows(), getAiModelConfiguration()]) + const [providers, models, config, verifications] = await Promise.all([listAiModelProviderRows(), listAiModelRows(), getAiModelConfiguration(), listAiModelVerifications()]) const result: AiModelRuntimeState = { configured: true, defaults: { analysis: '', copy: '', image: '' }, models: { analysis: [], copy: [], image: [] }, modelOptions: { analysis: [], copy: [], image: [] }, legacyModels: { analysis: [], copy: [], image: [] } } for (const stage of AI_MODEL_STAGES) { for (const model of models) { const provider = providers.find(value => value.id === model.providerId) - if (!provider || !modelAvailable(model, provider, stage)) continue + if (!provider || !modelAvailable(model, provider, stage, verifications)) continue result.models[stage].push(model.key) result.modelOptions[stage].push({ value: model.key, label: model.id, providerName: provider.name }) if (provider.legacyBinding) result.legacyModels[stage].push(model.id) @@ -210,14 +220,25 @@ export async function getAiModelRuntimeState(): Promise { return result } -export async function resolveAiModel(stage: AiModelStage, selection: string, expected?: AiModelBinding): Promise<{ binding: AiModelBinding; apiKey: string }> { - const [providers, models] = await Promise.all([listAiModelProviderRows(), listAiModelRows()]) +export async function resolveAiModel(stage: AiModelStage, selection: string, expected?: AiModelBinding) { + const [providers, models, verifications] = await Promise.all([listAiModelProviderRows(), listAiModelRows(), listAiModelVerifications()]) const model = models.find(value => value.key === selection) || models.find(value => value.id === selection && providers.some(provider => provider.id === value.providerId && provider.legacyBinding)) const provider = model && providers.find(value => value.id === model.providerId) - if (!model || !provider || !modelAvailable(model, provider, stage)) throw new AiModelError('所选模型当前不可用,请检查超级管理员的模型配置', 400, 'model_unavailable') - const binding: AiModelBinding = { providerId: provider.id, modelKey: model.key, modelId: model.id, providerName: provider.name, baseUrl: provider.baseUrl, revision: provider.revision } - if (expected && (expected.providerId !== binding.providerId || expected.modelKey !== binding.modelKey || expected.modelId !== binding.modelId || expected.baseUrl !== binding.baseUrl || expected.revision !== binding.revision)) throw new AiModelError('模型连接配置已改变,请刷新模型选择后重新分析;当前任务已停止', 409, 'model_binding_changed') - return { binding, apiKey: providerKey(provider) } + if (!model || !provider || !modelAvailable(model, provider, stage, verifications)) throw new AiModelError('所选模型当前不可用,请检查超级管理员的模型配置及实际测试结果', 400, 'model_unavailable') + const protocolFingerprint = createHash('sha256').update(aiModelCapabilityFingerprint(model.capabilityMetadata)).update(JSON.stringify(getAiModelStageProtocol(model.capabilityMetadata, stage))).digest('hex') + // Pin the server-owned validation generation too: environment key rotation does not change the provider revision. + const verificationId = aiModelVerificationSummary(verifications, model.key, stage, modelContext(model, provider, stage)).id! + const binding: AiModelBinding = { providerId: provider.id, modelKey: model.key, modelId: model.id, providerName: provider.name, baseUrl: provider.baseUrl, revision: provider.revision, protocolFingerprint, verificationId } + if (expected && (expected.providerId !== binding.providerId || expected.modelKey !== binding.modelKey || expected.modelId !== binding.modelId || expected.baseUrl !== binding.baseUrl || expected.revision !== binding.revision || expected.protocolFingerprint !== binding.protocolFingerprint || expected.verificationId !== binding.verificationId)) throw new AiModelError('模型连接、路由或测试记录已改变,请刷新模型选择后重新分析;当前任务已停止', 409, 'model_binding_changed') + return { binding, apiKey: providerKey(provider), protocol: getAiModelStageProtocol(model.capabilityMetadata, stage)! } +} +export async function resolveAiModelVerification(providerId: string, modelKey: string, stage: AiModelStage, expectedRevision?: number) { + const provider = await getAiModelProviderRow(assertAiModelId(providerId)) + if (expectedRevision !== undefined) assertAiModelRevision(provider.revision, expectedRevision) + const model = (await listAiModelRows()).find(row => row.key === assertAiModelId(modelKey) && row.providerId === providerId) + const protocol = model && getAiModelStageProtocol(model.capabilityMetadata, stage) + if (!model || !protocol || !provider.enabled || !model.present || !hasCredential(provider) || !getAiModelCapabilities(provider.baseUrl, model.id, model.capabilityMetadata).allowedStages.includes(stage)) throw new AiModelError('该模型的当前连接或用途暂不支持测试', 400, 'model_capability_unsupported') + return { provider, model, protocol, apiKey: providerKey(provider), contextHash: modelContext(model, provider, stage)! } } export async function captureAiModelBindings(models: Record): Promise { const values = await Promise.all(AI_MODEL_STAGES.map(async stage => [stage, (await resolveAiModel(stage, models[stage])).binding] as const)) diff --git a/lib/ai-model-types.ts b/lib/ai-model-types.ts index 4e099e0..5554d8d 100644 --- a/lib/ai-model-types.ts +++ b/lib/ai-model-types.ts @@ -1,11 +1,22 @@ export const AI_MODEL_STAGES = ['analysis', 'copy', 'image'] as const export type AiModelStage = typeof AI_MODEL_STAGES[number] export type AiModelDefaults = Record +export const AI_MODEL_KINDS = ['text', 'vision', 'image', 'tts', 'asr', 'music', 'video', 'embedding', 'image-tool', 'three-d', 'other', 'unknown'] as const +export type AiModelKind = typeof AI_MODEL_KINDS[number] +export interface AiModelVerificationSummary { + status: 'untested' | 'running' | 'passed' | 'failed' | 'unknown' | 'stale' + id: string | null + checkedAt: string | null + requestId: string | null + message: string | null + canResume?: boolean +} export interface AiModelCapabilities { - kind: 'text' | 'vision' | 'image' | 'other' | 'unknown' + kind: AiModelKind allowedStages: AiModelStage[] source: 'upstream' | 'verified' | 'unknown' reason: string + protocols?: Partial> } export interface AiModelCatalogEntry { key: string @@ -13,7 +24,7 @@ export interface AiModelCatalogEntry { enabled: boolean stages: AiModelStage[] present: boolean - compatibility: 'confirmed' | 'unconfirmed' + verification: Record capabilities: AiModelCapabilities } export interface AiModelProvider { @@ -37,7 +48,7 @@ export interface AiModelAdminState { export interface AiModelConfigurationInput { revision: number defaults: AiModelDefaults - providers: Array<{ id: string; revision: number; models: Array<{ key: string; enabled: boolean; stages: AiModelStage[]; compatibility: 'confirmed' | 'unconfirmed' }> }> + providers: Array<{ id: string; revision: number; models: Array<{ key: string; enabled: boolean; stages: AiModelStage[] }> }> } export interface AiModelChoice { value: string; label: string; providerName: string } export interface AiModelRuntimeState { @@ -47,5 +58,5 @@ export interface AiModelRuntimeState { modelOptions: Record legacyModels: Record } -export interface AiModelBinding { providerId: string; modelKey: string; modelId: string; providerName: string; baseUrl: string; revision: number } +export interface AiModelBinding { providerId: string; modelKey: string; modelId: string; providerName: string; baseUrl: string; revision: number; protocolFingerprint?: string; verificationId?: string } export type AiModelBindings = Record diff --git a/lib/ai-model-verification-state.ts b/lib/ai-model-verification-state.ts new file mode 100644 index 0000000..62555e6 --- /dev/null +++ b/lib/ai-model-verification-state.ts @@ -0,0 +1,37 @@ +import { createHash } from 'node:crypto' +import { prisma } from './prisma' +import type { AiModelDatabase } from './ai-model-repository' +import type { AiModelStage, AiModelVerificationSummary } from './ai-model-types' +import { aiModelCapabilityFingerprint } from './ai-model-capabilities' +import { AI_MODEL_ADAPTER_VERSION, type AiModelProtocol } from './ai-model-protocol' + +export interface AiModelVerificationRow { + id: string; modelKey: string; stage: AiModelStage; contextHash: string; idempotencyKey: string; actorId: string; + status: 'running' | 'passed' | 'failed' | 'unknown'; requestId: string | null; taskId: string | null; + message: string | null; evidence: Record; startedAt: Date; deadlineAt: Date; finishedAt: Date | null; +} +export const AI_MODEL_VERIFICATION_COLUMNS = `id,model_key AS "modelKey",stage,context_hash AS "contextHash",idempotency_key AS "idempotencyKey",actor_id AS "actorId",status,request_id AS "requestId",task_id AS "taskId",message,evidence,started_at AS "startedAt",deadline_at AS "deadlineAt",finished_at AS "finishedAt"` + +export async function listAiModelVerifications(db: AiModelDatabase = prisma): Promise { + return db.$queryRawUnsafe(`SELECT DISTINCT ON (model_key,stage) ${AI_MODEL_VERIFICATION_COLUMNS} FROM ai_model_verifications ORDER BY model_key,stage,started_at DESC,id DESC`) +} + +/** Private, never returned through DTOs. A key rotation invalidates evidence without depending on UI revisions. */ +export function aiModelVerificationContext(baseUrl: string, modelId: string, metadata: unknown, apiKey: string, protocol: AiModelProtocol): string { + return createHash('sha256').update(JSON.stringify({ baseUrl, modelId, capability: aiModelCapabilityFingerprint(metadata), + credential: createHash('sha256').update(apiKey).digest('hex'), adapter: AI_MODEL_ADAPTER_VERSION, protocol })).digest('hex') +} + +export function verificationEffectiveStatus(row: AiModelVerificationRow): AiModelVerificationRow['status'] { + return row.status === 'running' && new Date(row.deadlineAt).getTime() <= Date.now() ? 'unknown' : row.status +} + +export function aiModelVerificationSummary(rows: AiModelVerificationRow[], modelKey: string, stage: AiModelStage, contextHash: string | null): AiModelVerificationSummary { + const row = rows.find(value => value.modelKey === modelKey && value.stage === stage) + if (!row) return { status: 'untested', id: null, checkedAt: null, requestId: null, message: null } + const effective = verificationEffectiveStatus(row) + const status = effective === 'unknown' ? 'unknown' : row.contextHash !== contextHash ? 'stale' : effective + return { status, id: row.id, checkedAt: row.finishedAt?.toISOString() || row.startedAt.toISOString(), requestId: row.requestId, + canResume: Boolean(row.taskId && row.contextHash === contextHash && ['unknown', 'running'].includes(effective)), + message: status === 'stale' ? '连接、路由或模型能力已改变,请重新测试' : status === 'unknown' ? '请求结果尚未确认,已暂停重复测试;请核查上游请求记录' : row.message } +} diff --git a/lib/ai-model-verification.ts b/lib/ai-model-verification.ts new file mode 100644 index 0000000..6e8e3d3 --- /dev/null +++ b/lib/ai-model-verification.ts @@ -0,0 +1,136 @@ +import { randomUUID } from 'node:crypto' +import sharp from 'sharp' +import { z } from 'zod' +import { AiModelError, assertAiModelAdmin } from './ai-model-access' +import { aiModelAudit, aiModelTransaction } from './ai-model-repository' +import { getAiModelAdminState, resolveAiModelVerification } from './ai-model-service' +import { AI_MODEL_ADAPTER_VERSION, callAiModelProtocol } from './ai-model-protocol' +import { AI_MODEL_STAGES, type AiModelStage } from './ai-model-types' +import { AI_MODEL_VERIFICATION_COLUMNS, type AiModelVerificationRow } from './ai-model-verification-state' +import { prisma } from './prisma' +import { detailAnalysisSchema } from './detail-generator/validation' +import { detailTransactionLock } from './detail-generator/repository' +import type { AiModelDatabase } from './ai-model-repository' + +const requestSchema = z.object({ stage: z.enum(AI_MODEL_STAGES), revision: z.number().int().positive(), idempotencyKey: z.string().uuid() }).strict() +function safeId(value: unknown, secret: string): string | null { return typeof value === 'string' && /^[\w.-]{1,160}$/.test(value) && !value.includes(secret) ? value : null } +async function assertExecutionSlot(db: AiModelDatabase, actorId: string, resumeId: string | null = null): Promise { + await detailTransactionLock(db, 'detail-generator-execution') + const rows = await db.$queryRawUnsafe>(`SELECT COUNT(*)::int AS count,COUNT(*) FILTER (WHERE actor_id=$1)::int AS own FROM ( + SELECT actor_id FROM ai_model_verifications WHERE ($2::text IS NULL OR id<>$2) AND (status='running' OR status='unknown' AND task_id IS NOT NULL) + UNION ALL SELECT owner_id AS actor_id FROM detail_generator_jobs WHERE status='running' AND lease_expires_at>NOW() + OR status='queued' AND checkpoint->>'dispatchPending'='true' AND COALESCE(checkpoint #>> '{upstreamTask,id}', '') ~ '^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,199}$' + ) AS active_executions`, actorId, resumeId) + if (rows[0]?.count >= 2 || rows[0]?.own >= 1) throw new AiModelError('已有生成或模型测试正在执行,请等待完成', 409, 'verification_busy') +} +async function referenceImage(): Promise { + return sharp({ create: { width: 512, height: 512, channels: 3, background: '#ffffff' } }).composite([ + { input: await sharp({ create: { width: 180, height: 180, channels: 3, background: '#ff0000' } }).png().toBuffer(), left: 32, top: 32 }, + { input: await sharp({ create: { width: 180, height: 180, channels: 3, background: '#0000ff' } }).png().toBuffer(), left: 300, top: 300 }, + ]).png().toBuffer() +} +function parseJson(text: string | undefined): any { + if (!text) throw new AiModelError('模型未返回可验证的内容', 422, 'verification_output_invalid') + try { return JSON.parse(text.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '')) } catch { throw new AiModelError('模型未返回要求的 JSON 内容', 422, 'verification_output_invalid') } +} +export async function validateAiModelVerificationOutput(stage: AiModelStage, result: { text?: string; image?: Buffer }): Promise> { + if (stage === 'analysis') { + const value = parseJson(result.text) + if (value.topLeft !== 'red' || value.bottomRight !== 'blue') throw new AiModelError('图片内容识别未通过,不能开放资料分析', 422, 'verification_output_invalid') + return { imageInputVerified: true, colorPositionsMatched: true, textCharacters: result.text!.length } + } + if (stage === 'copy') { + const value = parseJson(result.text) + if (Array.isArray(value?.screens)) value.screens = value.screens.map((screen: any) => ({ ...screen, id: randomUUID() })) + if (!detailAnalysisSchema.safeParse(value).success || value.screens.length !== 1) throw new AiModelError('文案大纲结构未通过,不能开放卖点文案', 422, 'verification_output_invalid') + return { outlineSchemaVerified: true, screenCount: 1, textCharacters: result.text!.length } + } + if (!result.image) throw new AiModelError('模型未返回图片', 422, 'verification_output_invalid') + const image = sharp(result.image, { limitInputPixels: 40_000_000 }), metadata = await image.metadata(), stats = await image.stats() + if (!metadata.width || !metadata.height || metadata.width < 64 || metadata.height < 64 || !stats.channels.some(channel => channel.stdev > 1)) throw new AiModelError('模型返回的图片为空白或无效', 422, 'verification_output_invalid') + return { referenceImageAccepted: true, imageDecoded: true, width: metadata.width, height: metadata.height, bytes: result.image.length } +} + +async function executeVerification(row: AiModelVerificationRow, userId: string, providerId: string, resume: boolean): Promise { + let sent = false, apiKey = '', knownTaskId = row.taskId + const beforeRequest = async () => { + await assertAiModelAdmin(userId) + const current = await resolveAiModelVerification(providerId, row.modelKey, row.stage) + if (current.contextHash !== row.contextHash) throw new AiModelError('连接或路由已改变,测试已停止', 409, 'verification_context_changed') + sent = true + } + try { + const context = await resolveAiModelVerification(providerId, row.modelKey, row.stage) + apiKey = context.apiKey + if (context.contextHash !== row.contextHash) throw new AiModelError('验证对应的连接或路由已改变,不能继续此任务', 409, 'verification_context_changed') + const prompt = row.stage === 'analysis' ? '观察图片:左上方方块与右下方方块分别是什么颜色?只输出 JSON:{"topLeft":"英文小写颜色","bottomRight":"英文小写颜色"}。不要根据文字猜测,必须读取图片。' + : row.stage === 'copy' ? '真实产品资料:型号TEST,颜色蓝色。不要编造其他规格。写一屏中文详情页,严格只输出 JSON:{"sellingPoints":["卖点"],"warnings":[],"screens":[{"title":"标题","body":"正文","imagePrompt":"无文字的场景描述"}]}。' + : '依据参考图片生成一张简洁的产品展示图,保留两个方块的颜色与相对位置,白色背景,不要加入文字或标志。' + const result = await callAiModelProtocol({ baseUrl: context.provider.baseUrl, apiKey: context.apiKey, modelId: context.model.id, protocol: context.protocol, + prompt, ...(row.stage !== 'copy' ? { images: [{ buffer: await referenceImage(), mimeType: 'image/png' }] } : {}), maxOutputTokens: row.stage === 'copy' ? 1200 : 1024, + idempotencyKey: row.idempotencyKey, beforeRequest, ...(resume ? { resumeTaskId: row.taskId! } : {}), + onTaskSubmitted: async taskId => { + if (!safeId(taskId, context.apiKey)) throw new AiModelError('上游任务编号无效', 502, 'verification_task_invalid') + knownTaskId = taskId + const updated = await prisma.$executeRawUnsafe(`UPDATE ai_model_verifications SET task_id=$2 WHERE id=$1 AND status='running' AND (task_id IS NULL OR task_id=$2)`, row.id, taskId) + if (updated !== 1) throw new AiModelError('任务检查点未保存,已暂停后续请求', 409, 'verification_checkpoint_failed') + } }) + const evidence = await validateAiModelVerificationOutput(row.stage, result) + await assertAiModelAdmin(userId) + await aiModelTransaction(async db => { + const current = await resolveAiModelVerification(providerId, row.modelKey, row.stage) + if (current.contextHash !== row.contextHash) throw new AiModelError('验证期间连接或路由已改变', 409, 'verification_context_changed') + await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status='passed',request_id=$2,message='实际接口测试通过',evidence=$3::jsonb,finished_at=NOW() WHERE id=$1 AND status='running'`, row.id, safeId(result.requestId, context.apiKey), JSON.stringify({ ...evidence, adapterVersion: AI_MODEL_ADAPTER_VERSION, protocol: context.protocol.id, scopeId: context.protocol.scopeId, routeId: context.protocol.routeId, operationId: context.protocol.operationId })) + await aiModelAudit(db, userId, 'model.verification-passed', providerId, { verificationId: row.id, modelKey: row.modelKey, stage: row.stage, evidence }) + }) + } catch (error) { + const outcome = error as { outcomeUnknown?: boolean; taskId?: string; requestId?: string } + const unknown = typeof outcome.outcomeUnknown === 'boolean' ? (sent || Boolean(knownTaskId)) && outcome.outcomeUnknown : Boolean(knownTaskId) || sent && !(error instanceof AiModelError) + const message = unknown ? '请求结果尚未确认,请核查上游记录;禁止重复提交' : error instanceof AiModelError ? error.message : '模型测试未通过' + await aiModelTransaction(async db => { + await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status=$2,request_id=$3,task_id=COALESCE(task_id,$4),message=$5,finished_at=NOW() WHERE id=$1 AND status='running'`, row.id, unknown ? 'unknown' : 'failed', safeId(outcome.requestId, apiKey), safeId(outcome.taskId || knownTaskId, apiKey), message) + await aiModelAudit(db, userId, unknown ? 'model.verification-unknown' : 'model.verification-failed', providerId, { verificationId: row.id, modelKey: row.modelKey, stage: row.stage }) + }) + } +} + +export async function verifyAiModel(userId: string, providerId: string, modelKey: string, body: unknown) { + const parsed = requestSchema.safeParse(body) + if (!parsed.success) throw new AiModelError('测试参数无效') + await assertAiModelAdmin(userId) + const input = parsed.data, context = await resolveAiModelVerification(providerId, modelKey, input.stage, input.revision) + const created = await aiModelTransaction(async db => { + await assertAiModelAdmin(userId) + const prior = await db.$queryRawUnsafe(`SELECT ${AI_MODEL_VERIFICATION_COLUMNS} FROM ai_model_verifications WHERE model_key=$1 AND stage=$2 AND idempotency_key=$3`, modelKey, input.stage, input.idempotencyKey) + if (prior[0]) return null // A repeated HTTP request never repeats a model call. + await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status='unknown',message='进程或请求中断,结果需核查',finished_at=NOW() WHERE status='running' AND deadline_at<=NOW()`) + const active = await db.$queryRawUnsafe>(`SELECT COUNT(*)::int AS count FROM ai_model_verifications WHERE model_key=$1 AND stage=$2 AND status IN ('running','unknown')`, modelKey, input.stage) + if (active[0]?.count) throw new AiModelError('该用途存在进行中或结果未知的请求,不能重复测试;请先核查原请求', 409, 'verification_unresolved') + await assertExecutionSlot(db, userId) + const rows = await db.$queryRawUnsafe(`INSERT INTO ai_model_verifications (id,model_key,stage,context_hash,idempotency_key,actor_id,status) VALUES ($1,$2,$3,$4,$5,$6,'running') RETURNING ${AI_MODEL_VERIFICATION_COLUMNS}`, randomUUID(), modelKey, input.stage, context.contextHash, input.idempotencyKey, userId) + await aiModelAudit(db, userId, 'model.verification-start', providerId, { verificationId: rows[0].id, modelKey, stage: input.stage }) + return rows[0] + }) + if (created) await executeVerification(created, userId, providerId, false) + return getAiModelAdminState() +} + +/** Only fetch an already-created upstream task. This endpoint can never submit a new generation. */ +export async function refreshAiModelVerification(userId: string, providerId: string, verificationId: string) { + if (!z.string().uuid().safeParse(verificationId).success) throw new AiModelError('测试编号无效') + await assertAiModelAdmin(userId) + const row = await aiModelTransaction(async db => { + const rows = await db.$queryRawUnsafe(`SELECT ${AI_MODEL_VERIFICATION_COLUMNS} FROM ai_model_verifications WHERE id=$1 FOR UPDATE`, verificationId) + const current = rows[0] + if (!current?.taskId || !['unknown','running'].includes(current.status)) throw new AiModelError('没有可以查询的上游任务', 409, 'verification_not_resumable') + if (current.status === 'running' && current.deadlineAt.getTime() > Date.now()) throw new AiModelError('测试仍在执行,请等待', 409, 'verification_busy') + const context = await resolveAiModelVerification(providerId, current.modelKey, current.stage) + if (context.contextHash !== current.contextHash) throw new AiModelError('原连接或路由已改变,请在上游核查原任务', 409, 'verification_context_changed') + await assertExecutionSlot(db, userId, current.id) + await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status='running',actor_id=$2,deadline_at=NOW()+INTERVAL '10 minutes',finished_at=NULL WHERE id=$1`, current.id, userId) + await aiModelAudit(db, userId, 'model.verification-query', providerId, { verificationId: current.id, modelKey: current.modelKey, stage: current.stage }) + return current + }) + await executeVerification(row, userId, providerId, true) + return getAiModelAdminState() +} diff --git a/lib/detail-generator/jobs.ts b/lib/detail-generator/jobs.ts index 6ef425e..fda2d92 100644 --- a/lib/detail-generator/jobs.ts +++ b/lib/detail-generator/jobs.ts @@ -2,11 +2,12 @@ import { randomUUID } from 'crypto' import sharp from 'sharp' import { assertDetailGeneratorUser, DetailGeneratorError } from './access' import { analyzeDetailFacts, composeDetailOutline, DetailProviderError, generateDetailImage, type DetailProviderImage } from './provider' -import { assertDetailDocumentAssets, checkpointDetailJob, claimDetailJob, findDetailAsset, findDetailProject, finishDetailJob, renewDetailJobLease, saveDetailJobAsset, saveDetailJobDocument, type StoredDetailJob } from './repository' +import { assertDetailDocumentAssets, checkpointDetailJob, claimDetailJob, deferDetailJob, findDetailAsset, findDetailProject, finishDetailJob, renewDetailJobLease, saveDetailJobAsset, saveDetailJobDocument, type StoredDetailJob } from './repository' import { deleteDetailFile, readDetailFile, saveDetailFile } from './storage' import { renderDetailExport } from './render' import { type DetailDocument, type DetailExportFormat } from './types' import { detailModelBindingsSchema } from './validation' +import { canPollDetailUpstreamTask, readDetailUpstreamTask } from './upstream-task' async function inputImages(document: DetailDocument, ownerId: string, projectId: string): Promise { const ids = [...document.product.assetIds, ...document.product.referenceAssetIds].slice(0, 8) @@ -55,12 +56,15 @@ export async function processDetailJob(current: StoredDetailJob): Promise checkpoint = { ...checkpoint, stage, dispatchPending: true, dispatchKey: key } await persistCheckpoint(progress) try { await assertDetailGeneratorUser(current.ownerId) } catch (error) { - checkpoint = { ...checkpoint, dispatchPending: false } - await persistCheckpoint(progress) + // Revocation stops polling but cannot turn an already accepted generation into a safe retry. + if (!readDetailUpstreamTask(checkpoint)) { + checkpoint = { ...checkpoint, dispatchPending: false } + await persistCheckpoint(progress) + } throw error } try { return await call(key) } catch (error) { - if (error instanceof DetailProviderError && !error.outcomeUnknown || error instanceof DetailGeneratorError) { + if (error instanceof DetailProviderError && !error.outcomeUnknown || error instanceof DetailGeneratorError && !readDetailUpstreamTask(checkpoint)) { checkpoint = { ...checkpoint, dispatchPending: false } await persistCheckpoint(progress) } @@ -79,7 +83,8 @@ export async function processDetailJob(current: StoredDetailJob): Promise document.modelBindings = parsedBindings.data } await assertDetailDocumentAssets(document, current.projectId, current.ownerId) - if (checkpoint.dispatchPending === true) throw new DetailProviderError('模型请求结果未知,任务已暂停;请核查服务商记录', true) + const upstreamTask = readDetailUpstreamTask(checkpoint) + if (checkpoint.dispatchPending === true && (!upstreamTask || !['generate', 'regenerate'].includes(current.kind) || !canPollDetailUpstreamTask(upstreamTask))) throw new DetailProviderError('模型请求结果未知,任务已暂停;请核查服务商记录', true) if (checkpoint.stage === 'complete') { await finishDetailJob(current.id, token, 'succeeded', checkpoint) return @@ -105,11 +110,22 @@ export async function processDetailJob(current: StoredDetailJob): Promise const target = current.kind === 'regenerate' ? document.screens.filter(screen => screen.id === current.payload.screenId) : document.screens if (!target.length) throw new DetailGeneratorError('没有待生成屏幕') const completed = new Set(Array.isArray(checkpoint.completedScreenIds) ? checkpoint.completedScreenIds as string[] : []) + if (checkpoint.dispatchPending === true && !target.some(screen => `screen-${screen.id}` === upstreamTask?.stage && !completed.has(screen.id) && !(current.kind === 'generate' && screen.image))) throw new DetailProviderError('待查询的图片任务与当前屏幕不一致,已暂停', true) for (let index = 0; index < target.length; index++) { const screen = target[index] if (completed.has(screen.id) || (current.kind === 'generate' && screen.image)) continue const progress = Math.round(index / target.length * 90) - const buffer = await dispatch(`screen-${screen.id}`, progress, key => generateDetailImage(document, screen.imagePrompt, images, key, current.ownerId, document.modelBindings!.image)) + const stage = `screen-${screen.id}` + const resume = readDetailUpstreamTask(checkpoint) + const buffer = await dispatch(stage, progress, key => generateDetailImage(document, screen.imagePrompt, images, key, current.ownerId, document.modelBindings!.image, { + resumeTaskId: resume?.stage === stage ? resume.id : undefined, + onTaskSubmitted: async id => { + const saved = { id, stage, submittedAt: Date.now(), polls: 0 } + if (!readDetailUpstreamTask({ stage, upstreamTask: saved })) throw new DetailProviderError('上游任务编号无效,已暂停核查', true) + checkpoint = { ...checkpoint, upstreamTask: saved } + await persistCheckpoint(progress) + }, + })) const metadata = await sharp(buffer).metadata() const assetId = randomUUID() const storageKey = await saveDetailFile(current.projectId, buffer, 'png') @@ -117,7 +133,7 @@ export async function processDetailJob(current: StoredDetailJob): Promise const replaced = { ...screen, image: { assetId, ...area } } document = { ...document, screens: document.screens.map(value => value.id === screen.id ? replaced : value) } completed.add(screen.id) - const receivedCheckpoint = { ...checkpoint, completedScreenIds: Array.from(completed), stage: 'rendering', dispatchPending: false } + const receivedCheckpoint = { ...checkpoint, completedScreenIds: Array.from(completed), stage: 'rendering', dispatchPending: false, upstreamTask: undefined, pollNotBefore: undefined } try { await saveDetailJobAsset(current.id, token, { id: assetId, projectId: current.projectId, ownerId: current.ownerId, kind: 'generated', name: `${index + 1}-${screen.title}.png`, mimeType: 'image/png', storageKey, byteSize: buffer.length, width: metadata.width, height: metadata.height }, document, receivedCheckpoint, Math.round((index + 1) / target.length * 95)) checkpoint = receivedCheckpoint @@ -146,6 +162,12 @@ export async function processDetailJob(current: StoredDetailJob): Promise checkpoint = { ...checkpoint, stage: 'complete', dispatchPending: false } await finishDetailJob(current.id, token, 'succeeded', checkpoint) } catch (error) { + const upstreamTask = readDetailUpstreamTask(checkpoint) + if (error instanceof DetailProviderError && error.taskPending && upstreamTask && canPollDetailUpstreamTask(upstreamTask) && !leaseLost) { + checkpoint = { ...checkpoint, upstreamTask: { ...upstreamTask, polls: upstreamTask.polls + 1 }, pollNotBefore: Date.now() + 30_000 } + await deferDetailJob(current.id, token, checkpoint) + return + } const unknown = error instanceof DetailProviderError ? error.outcomeUnknown : checkpoint.dispatchPending === true const safeMessage = error instanceof DetailProviderError || error instanceof DetailGeneratorError ? error.message : unknown ? '模型结果尚未确认,请核查请求记录,勿重复提交' : '任务执行失败,请检查输入资料或服务端配置' const requestId = error instanceof DetailProviderError ? error.requestId : null diff --git a/lib/detail-generator/provider.ts b/lib/detail-generator/provider.ts index a342cb6..22ca83c 100644 --- a/lib/detail-generator/provider.ts +++ b/lib/detail-generator/provider.ts @@ -3,14 +3,14 @@ import sharp from 'sharp' import { assertDetailGeneratorUser, DetailGeneratorError } from './access' import { DETAIL_MAX_SCREENS, type DetailAnalysis, type DetailDocument } from './types' import { captureAiModelBindings, getAiModelRuntimeState, resolveAiModel } from '@/lib/ai-model-service' -import { requestAiModel } from '@/lib/ai-model-http' +import { callAiModelProtocol } from '@/lib/ai-model-protocol' import type { AiModelBinding, AiModelStage } from '@/lib/ai-model-types' import { assertAiModelAdmin } from '@/lib/ai-model-access' import { detailAnalysisSchema } from './validation' export interface DetailProviderImage { buffer: Buffer; mimeType: string } export class DetailProviderError extends Error { - constructor(message: string, readonly outcomeUnknown: boolean, readonly requestId: string | null = null) { super(message); this.name = 'DetailProviderError' } + constructor(message: string, readonly outcomeUnknown: boolean, readonly requestId: string | null = null, readonly taskId: string | null = null, readonly taskPending = false) { super(message); this.name = 'DetailProviderError' } } export async function getDetailModels(): Promise<{ analysis: string[]; copy: string[]; image: string[] }> { @@ -28,30 +28,21 @@ function requestIdFrom(response: Response, data: any, apiKey?: string): string | return typeof value === 'string' && /^[\w-]{1,120}$/.test(value) && (!apiKey || !value.includes(apiKey)) ? value : null } -async function callProvider(stage: AiModelStage, selection: string, endpoint: '/chat/completions' | '/images/edits', body: string | FormData, idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise { +async function callProvider(stage: AiModelStage, selection: string, prompt: string, images: DetailProviderImage[], idempotencyKey: string, ownerId: string, expected?: AiModelBinding, execution?: { onTaskSubmitted?: (taskId: string) => Promise; resumeTaskId?: string }) { await assertDetailGeneratorUser(ownerId) - const { apiKey, binding } = await resolveAiModel(stage, selection, expected) - if (typeof body === 'string') body = JSON.stringify({ ...JSON.parse(body), model: binding.modelId }) - else body.set('model', binding.modelId) + const { apiKey, binding, protocol } = await resolveAiModel(stage, selection, expected) try { - const response = await requestAiModel(binding.baseUrl, apiKey, endpoint, body, idempotencyKey, async () => { - await resolveAiModel(stage, selection, binding) - await assertDetailGeneratorUser(ownerId) - await assertAiModelAdmin(ownerId) - }) - const bytes = await readDetailResponseBytes(response, 32 * 1024 * 1024) - let data: any - try { data = JSON.parse(bytes.toString('utf8')) } catch { throw new DetailProviderError('模型响应格式无法确认,请核查请求结果', true, requestIdFrom(response, null, apiKey)) } - const requestId = requestIdFrom(response, data, apiKey) - if (!response.ok) { - const unknown = response.status >= 500 || data?.error?.code === 'request_outcome_unknown' || data?.code === 'request_outcome_unknown' - throw new DetailProviderError(unknown ? '模型请求结果未知,已暂停;请核查服务商请求记录,勿重复提交' : `模型服务拒绝了请求(${response.status})`, unknown, requestId) - } - if (bytes.includes(Buffer.from(apiKey))) throw new DetailProviderError('模型响应含敏感内容,已暂停;请核查服务商请求记录', true, requestId) - return data + return await callAiModelProtocol({ baseUrl: binding.baseUrl, apiKey, modelId: binding.modelId, protocol, prompt, images, + idempotencyKey, maxOutputTokens: stage === 'copy' ? 8000 : 3000, ...execution, + beforeRequest: async () => { + await resolveAiModel(stage, selection, binding) + await assertDetailGeneratorUser(ownerId) + await assertAiModelAdmin(ownerId) + } }) } catch (error) { if (error instanceof DetailProviderError || error instanceof DetailGeneratorError) throw error - throw new DetailProviderError('模型请求结果未知,已暂停;请核查服务商请求记录,勿重复提交', true) + const upstream = error as { outcomeUnknown?: boolean; requestId?: string | null; taskId?: string | null; taskPending?: boolean } + throw new DetailProviderError(upstream.outcomeUnknown === false ? '模型服务拒绝或未完成当前请求' : '模型请求结果未知,已暂停;请核查服务商请求记录,勿重复提交', upstream.outcomeUnknown !== false, upstream.requestId || null, upstream.taskId || null, upstream.taskPending === true) } } @@ -81,16 +72,16 @@ async function imageContent(images: DetailProviderImage[]) { export async function analyzeDetailFacts(document: DetailDocument, images: DetailProviderImage[], extractedText: string, idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise { const prompt = `你是产品资料分析员。只提取用户明确提供的产品事实,图片只能判断可见外观,不得推测材质、尺寸、认证、价格或性能。识别冲突和缺失信息,不要编造。返回 JSON,字段 facts(事实字符串数组)、warnings(待确认字符串数组)。\n产品:${JSON.stringify(document.product)}\n用户资料:${extractedText.slice(0, 60000)}` - const data = await callProvider('analysis', document.models.analysis, '/chat/completions', JSON.stringify({ model: document.models.analysis, messages: [{ role: 'user', content: [{ type: 'text', text: prompt }, ...await imageContent(images)] }], temperature: 0.2, max_tokens: 3000 }), idempotencyKey, ownerId, expected) - const content = data?.choices?.[0]?.message?.content + const data = await callProvider('analysis', document.models.analysis, prompt, images, idempotencyKey, ownerId, expected) + const content = data.text if (typeof content !== 'string' || !content.trim()) throw new DetailProviderError('模型未返回可用的产品分析', false) return content.slice(0, 30000) } export async function composeDetailOutline(document: DetailDocument, facts: string, idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise { const prompt = `根据给出的真实产品资料提炼卖点和电商详情页大纲,不添加未经提供的参数、认证、性能或促销承诺。缺少的信息写入 warnings,不用于卖点。每屏标题和正文使用中文;imagePrompt 描述不含文字、标志的场景,保持产品结构和外观。品牌是草案,不能声称已获品牌认可。返回严格 JSON:{"sellingPoints":["..."],"warnings":["..."],"screens":[{"title":"...","body":"...","imagePrompt":"..."}]},最多${DETAIL_MAX_SCREENS}屏,建议4至6屏。\n产品资料:${JSON.stringify(document.product)}\n分析:${facts}\n品牌风格草案:${JSON.stringify(document.brand)}` - const data = await callProvider('copy', document.models.copy, '/chat/completions', JSON.stringify({ model: document.models.copy, messages: [{ role: 'user', content: prompt }], temperature: 0.3, max_tokens: 8000 }), idempotencyKey, ownerId, expected) - const content = data?.choices?.[0]?.message?.content + const data = await callProvider('copy', document.models.copy, prompt, [], idempotencyKey, ownerId, expected) + const content = data.text let value: any try { if (typeof content !== 'string') throw new Error('empty') @@ -102,20 +93,12 @@ export async function composeDetailOutline(document: DetailDocument, facts: stri return parsed.data } -export async function generateDetailImage(document: DetailDocument, prompt: string, images: DetailProviderImage[], idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise { +export async function generateDetailImage(document: DetailDocument, prompt: string, images: DetailProviderImage[], idempotencyKey: string, ownerId: string, expected?: AiModelBinding, execution?: { onTaskSubmitted?: (taskId: string) => Promise; resumeTaskId?: string }): Promise { if (!images.length) throw new DetailGeneratorError('请先上传或关联产品图片') - const body = new FormData() - body.set('model', document.models.image) - body.set('prompt', `生成用于同一产品详情页的一张高质量场景图片。严格保持产品参考中的具体款式、颜色、结构和比例;不得替换为相似产品。全套品牌调性一致:${document.brand.tone || '简洁、真实、清晰的产品摄影'};配色:${document.brand.primaryColor}、${document.brand.backgroundColor}。图片中不要生成文字、价格、标志或水印。第一张是实际产品;其余为该产品或风格参考,不得将参考中的其他产品混入。\n${prompt}`) - body.set('n', '1'); body.set('size', '1024x1024'); body.set('response_format', 'b64_json') - for (let index = 0; index < Math.min(images.length, 8); index++) { - const image = await sharp(images[index].buffer, { limitInputPixels: 40_000_000 }).rotate().resize(1536, 1536, { fit: 'inside', withoutEnlargement: true }).png().toBuffer() - body.append(images.length === 1 ? 'image' : 'image[]', new Blob([new Uint8Array(image)], { type: 'image/png' }), `reference-${index + 1}.png`) - } - const data = await callProvider('image', document.models.image, '/images/edits', body, idempotencyKey, ownerId, expected) - const encoded = data?.data?.[0]?.b64_json - if (typeof encoded !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length > 24 * 1024 * 1024) throw new DetailProviderError('模型未返回可保存的图片', false) - const buffer = Buffer.from(encoded, 'base64') + const fullPrompt = `生成用于同一产品详情页的一张高质量场景图片。严格保持产品参考中的具体款式、颜色、结构和比例;不得替换为相似产品。全套品牌调性一致:${document.brand.tone || '简洁、真实、清晰的产品摄影'};配色:${document.brand.primaryColor}、${document.brand.backgroundColor}。图片中不要生成文字、价格、标志或水印。第一张是实际产品;其余为该产品或风格参考,不得将参考中的其他产品混入。\n${prompt}` + const data = await callProvider('image', document.models.image, fullPrompt, images, idempotencyKey, ownerId, expected, execution) + const buffer = data.image + if (!buffer) throw new DetailProviderError('模型未返回可保存的图片', false) try { const output = await sharp(buffer, { limitInputPixels: 40_000_000 }).rotate().resize(4096, 4096, { fit: 'inside', withoutEnlargement: true }).png().toBuffer() if (output.length > 20 * 1024 * 1024) throw new Error('size') diff --git a/lib/detail-generator/repository.ts b/lib/detail-generator/repository.ts index 402eca3..f3a8e85 100644 --- a/lib/detail-generator/repository.ts +++ b/lib/detail-generator/repository.ts @@ -154,10 +154,17 @@ export async function enqueueDetailJob(input: { projectId: string; ownerId: stri export async function claimDetailJob(): Promise { return prisma.$transaction(async tx => { await detailTransactionLock(tx, 'detail-generator-execution') - await tx.$executeRawUnsafe(`UPDATE detail_generator_jobs SET status = CASE WHEN checkpoint->>'dispatchPending' = 'true' THEN 'unknown' ELSE 'queued' END, error = CASE WHEN checkpoint->>'dispatchPending' = 'true' THEN '执行中断,模型请求结果未知;请核查服务商记录,勿重复提交' ELSE NULL END, lease_token = NULL, lease_expires_at = NULL, updated_at = NOW() WHERE status = 'running' AND lease_expires_at < NOW()`) - const count = await tx.$queryRawUnsafe('SELECT COUNT(*)::int AS count FROM detail_generator_jobs WHERE status = \'running\' AND lease_expires_at >= NOW()') - if (Number(count[0]?.count) >= 2) return null - const candidates = await tx.$queryRawUnsafe(`SELECT ${jobColumns} FROM detail_generator_jobs j WHERE status = 'queued' AND EXISTS (SELECT 1 FROM detail_generator_projects p WHERE p.id = j.project_id AND p.deleted_at IS NULL) AND NOT EXISTS (SELECT 1 FROM detail_generator_jobs other WHERE other.owner_id = j.owner_id AND other.status = 'running' AND other.lease_expires_at >= NOW()) ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED`) + const unresolved = `(checkpoint->>'dispatchPending' = 'true' AND NOT ( + kind IN ('generate','regenerate') + AND COALESCE(checkpoint #>> '{upstreamTask,id}', '') ~ '^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,199}$' + AND COALESCE(checkpoint #>> '{upstreamTask,stage}', '') = checkpoint->>'stage' + AND checkpoint->>'stage' LIKE 'screen-%'))` + await tx.$executeRawUnsafe(`UPDATE detail_generator_jobs SET status = CASE WHEN ${unresolved} THEN 'unknown' ELSE 'queued' END, error = CASE WHEN ${unresolved} THEN '执行中断,模型请求结果未知;请核查服务商记录,勿重复提交' ELSE NULL END, lease_token = NULL, lease_expires_at = NULL, updated_at = NOW() WHERE status = 'running' AND lease_expires_at < NOW()`) + const pendingTask = `checkpoint->>'dispatchPending' = 'true' AND COALESCE(checkpoint #>> '{upstreamTask,id}', '') ~ '^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,199}$'` + const activeVerification = `((status='running' AND deadline_at>NOW()) OR (status IN ('running','unknown') AND task_id IS NOT NULL))` + const count = await tx.$queryRawUnsafe(`SELECT ((SELECT COUNT(*) FROM detail_generator_jobs WHERE (status='running' AND lease_expires_at>=NOW()) OR (status='queued' AND ${pendingTask})) + (SELECT COUNT(*) FROM ai_model_verifications WHERE ${activeVerification}))::int AS count`) + // A queued known upstream task already owns its slot. Let it poll even when both slots are occupied. + const candidates = await tx.$queryRawUnsafe(`SELECT ${jobColumns} FROM detail_generator_jobs j WHERE status = 'queued' AND ($1::boolean OR (${pendingTask})) AND (CASE WHEN jsonb_typeof(checkpoint->'pollNotBefore') = 'number' THEN (checkpoint->>'pollNotBefore')::numeric ELSE 0 END) <= EXTRACT(EPOCH FROM NOW()) * 1000 AND EXISTS (SELECT 1 FROM detail_generator_projects p WHERE p.id = j.project_id AND p.deleted_at IS NULL) AND NOT EXISTS (SELECT 1 FROM detail_generator_jobs other WHERE other.owner_id = j.owner_id AND other.status = 'running' AND other.lease_expires_at >= NOW()) AND NOT EXISTS (SELECT 1 FROM ai_model_verifications v WHERE v.actor_id=j.owner_id AND ${activeVerification}) ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED`, Number(count[0]?.count) < 2) if (!candidates[0]) return null const rows = await tx.$queryRawUnsafe(`UPDATE detail_generator_jobs SET status = 'running', lease_token = $2, lease_expires_at = NOW() + INTERVAL '10 minutes', started_at = COALESCE(started_at, NOW()), updated_at = NOW() WHERE id = $1 RETURNING ${jobColumns}`, candidates[0].id, randomUUID()) return job(rows[0]) @@ -174,6 +181,12 @@ export async function checkpointDetailJob(id: string, token: string, checkpoint: if (count !== 1) throw new DetailGeneratorError('任务执行租约已失效', 409, 'lease_lost') } +/** Re-admit the same task for read-only polling; its persisted identity prevents another POST. */ +export async function deferDetailJob(id: string, token: string, checkpoint: Record): Promise { + const count = await prisma.$executeRawUnsafe(`UPDATE detail_generator_jobs SET status = 'queued', checkpoint = $3::jsonb, lease_token = NULL, lease_expires_at = NULL, updated_at = NOW() WHERE id = $1 AND lease_token = $2 AND status = 'running' AND lease_expires_at >= NOW()`, id, token, JSON.stringify(checkpoint)) + if (count !== 1) throw new DetailGeneratorError('任务执行租约已失效', 409, 'lease_lost') +} + export async function saveDetailJobDocument(jobId: string, token: string, document: DetailDocument, checkpoint: Record, progress: number): Promise { return prisma.$transaction(async tx => { const rows = await tx.$queryRawUnsafe(`SELECT ${jobColumns} FROM detail_generator_jobs WHERE id = $1 AND lease_token = $2 AND status = 'running' AND lease_expires_at >= NOW() FOR UPDATE`, jobId, token) diff --git a/lib/detail-generator/upstream-task.ts b/lib/detail-generator/upstream-task.ts new file mode 100644 index 0000000..e51e284 --- /dev/null +++ b/lib/detail-generator/upstream-task.ts @@ -0,0 +1,15 @@ +/** A persisted upstream identity permits result reads, never another create request. */ +export interface DetailUpstreamTask { id: string; stage: string; submittedAt: number; polls: number } + +export function readDetailUpstreamTask(checkpoint: Record): DetailUpstreamTask | null { + const value = checkpoint.upstreamTask as Partial | undefined + if (!value || typeof value.id !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,199}$/.test(value.id) + || typeof value.stage !== 'string' || !value.stage.startsWith('screen-') || value.stage !== checkpoint.stage + || typeof value.submittedAt !== 'number' || !Number.isFinite(value.submittedAt) || value.submittedAt <= 0 + || !Number.isInteger(value.polls) || Number(value.polls) < 0) return null + return value as DetailUpstreamTask +} + +export function canPollDetailUpstreamTask(task: DetailUpstreamTask, now = Date.now()): boolean { + return task.submittedAt <= now && now - task.submittedAt < 6 * 60 * 60 * 1000 && task.polls < 240 +} diff --git a/lib/detail-generator/validation.ts b/lib/detail-generator/validation.ts index 640a1ae..92c4e66 100644 --- a/lib/detail-generator/validation.ts +++ b/lib/detail-generator/validation.ts @@ -7,7 +7,7 @@ const shortText = z.string().trim().max(200) const color = z.string().regex(/^#[0-9a-fA-F]{6}$/, '颜色须为六位十六进制') const imageLayer = z.object({ assetId: id, x: z.number().min(0).max(750), y: z.number().min(0).max(2400), width: z.number().min(1).max(750), height: z.number().min(1).max(2400) }).strict() const imageArea = z.object({ x: z.number().min(0).max(750), y: z.number().min(0).max(2400), width: z.number().min(1).max(750), height: z.number().min(1).max(2400) }).strict() -const modelBinding = z.object({ providerId: id, modelKey: id, modelId: z.string().min(1).max(200), providerName: z.string().min(1).max(100), baseUrl: z.string().max(500), revision: z.number().int().min(1) }).strict() +const modelBinding = z.object({ providerId: id, modelKey: id, modelId: z.string().min(1).max(200), providerName: z.string().min(1).max(100), baseUrl: z.string().max(500), revision: z.number().int().min(1), protocolFingerprint: z.string().regex(/^[0-9a-f]{64}$/).optional(), verificationId: id.optional() }).strict() export const detailModelBindingsSchema = z.object({ analysis: modelBinding, copy: modelBinding, image: modelBinding }).strict() const textLayer = z.object({ id: z.string().min(1).max(100), text: z.string().max(3000), x: z.number().min(0).max(750), y: z.number().min(0).max(2400), diff --git a/prisma/migrations/20261013_ai_model_verifications/migration.sql b/prisma/migrations/20261013_ai_model_verifications/migration.sql new file mode 100644 index 0000000..41587e3 --- /dev/null +++ b/prisma/migrations/20261013_ai_model_verifications/migration.sql @@ -0,0 +1,24 @@ +-- Server-observed verification replaces the administrator compatibility checkbox. +-- Historical compatibility values remain inert for a reversible application rollout. +ALTER TABLE ai_model_catalog DROP CONSTRAINT ai_model_enabled_compatible; +ALTER TABLE ai_model_catalog ADD CONSTRAINT ai_model_enabled_stages CHECK (NOT enabled OR cardinality(stages) > 0); + +CREATE TABLE ai_model_verifications ( + id TEXT PRIMARY KEY, + model_key TEXT NOT NULL REFERENCES ai_model_catalog(id) ON DELETE RESTRICT, + stage TEXT NOT NULL CHECK (stage IN ('analysis','copy','image')), + context_hash TEXT NOT NULL CHECK (context_hash ~ '^[0-9a-f]{64}$'), + idempotency_key TEXT NOT NULL, + actor_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('running','passed','failed','unknown')), + request_id TEXT, + task_id TEXT, + message TEXT, + evidence JSONB NOT NULL DEFAULT '{}'::JSONB, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deadline_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes', + finished_at TIMESTAMPTZ, + CONSTRAINT ai_model_verifications_idempotency_key UNIQUE(model_key,stage,idempotency_key) +); +CREATE INDEX ai_model_verifications_model_stage_idx ON ai_model_verifications(model_key,stage,started_at DESC); +CREATE UNIQUE INDEX ai_model_verifications_unresolved_key ON ai_model_verifications(model_key,stage) WHERE status IN ('running','unknown'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9b36fb4..587814f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -106,10 +106,32 @@ model AiModelCatalog { analysisDefaults AiModelConfiguration[] @relation("AiModelDefaultAnalysis") copyDefaults AiModelConfiguration[] @relation("AiModelDefaultCopy") imageDefaults AiModelConfiguration[] @relation("AiModelDefaultImage") + verifications AiModelVerification[] @@unique([providerId, modelId], map: "ai_model_catalog_provider_model_key") @@map("ai_model_catalog") } +model AiModelVerification { + id String @id + modelKey String @map("model_key") + stage String + contextHash String @map("context_hash") + idempotencyKey String @map("idempotency_key") + actorId String @map("actor_id") + status String + requestId String? @map("request_id") + taskId String? @map("task_id") + message String? + evidence Json @default("{}") + startedAt DateTime @default(now()) @db.Timestamptz(6) @map("started_at") + deadlineAt DateTime @default(dbgenerated("(now() + '00:10:00'::interval)")) @db.Timestamptz(6) @map("deadline_at") + finishedAt DateTime? @db.Timestamptz(6) @map("finished_at") + model AiModelCatalog @relation(fields: [modelKey], references: [id], onDelete: Restrict) + @@unique([modelKey, stage, idempotencyKey], map: "ai_model_verifications_idempotency_key") + @@index([modelKey, stage, startedAt(sort: Desc)], map: "ai_model_verifications_model_stage_idx") + @@map("ai_model_verifications") +} + model AiModelConfiguration { id String @id defaultAnalysis String? @map("default_analysis") diff --git a/scripts/ai-model-migration.test.mjs b/scripts/ai-model-migration.test.mjs index a23eaae..03a687f 100644 --- a/scripts/ai-model-migration.test.mjs +++ b/scripts/ai-model-migration.test.mjs @@ -27,9 +27,18 @@ test('AI registry gate preserves catalog and original connection uniqueness', () test('AI registry gate refuses missing, weakened or cascading credential and model constraints', () => { const evidence = aiModelRegistryEvidence() - for (const name of ['ai_model_credential_complete', 'ai_model_enabled_compatible', 'ai_model_catalog_stages_check']) { + for (const name of ['ai_model_credential_complete', 'ai_model_enabled_stages', 'ai_model_catalog_stages_check', 'ai_model_verifications_status_check', 'ai_model_verifications_stage_check', 'ai_model_verifications_context_hash_check']) { assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, constraints: evidence.constraints.filter(row => row.constraint_name !== name) }), /constraint mismatch/) assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, constraints: evidence.constraints.map(row => row.constraint_name === name ? { ...row, expression: 'true' } : row) }), /expression mismatch/) } assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, constraints: evidence.constraints.map(row => row.contype === 'f' ? { ...row, delete_action: 'c' } : row) }), /deletion mismatch/) }) + +test('verification gate refuses lost idempotency and weakened unresolved-request protection', () => { + const evidence = aiModelRegistryEvidence() + for (const name of ['ai_model_verifications_idempotency_key', 'ai_model_verifications_unresolved_key', 'ai_model_verifications_model_stage_idx']) { + assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, indexes: evidence.indexes.filter(row => row.indexname !== name) }), /index/) + } + assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, indexes: evidence.indexes.map(row => row.indexname === 'ai_model_verifications_unresolved_key' ? { ...row, predicate: "status = 'running'::text" } : row) }), /predicate mismatch/) + assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, columns: evidence.columns.filter(row => row.table_name !== 'ai_model_verifications' || row.column_name !== 'task_id') }), /ai_model_verifications.task_id/) +}) diff --git a/scripts/fixtures/ai-model-registry-evidence.mjs b/scripts/fixtures/ai-model-registry-evidence.mjs index 3b28e1d..c54528d 100644 --- a/scripts/fixtures/ai-model-registry-evidence.mjs +++ b/scripts/fixtures/ai-model-registry-evidence.mjs @@ -1,7 +1,7 @@ // Schema-only fixture captured from the two additive migrations in disposable PostgreSQL. export function aiModelRegistryEvidence() { const column = (table_name, column_name, data_type, is_nullable = 'NO', column_default = null, udt_name = null) => ({ table_name, column_name, data_type, is_nullable, column_default, udt_name }) - const provider = 'ai_model_providers', catalog = 'ai_model_catalog', config = 'ai_model_configuration', audit = 'ai_model_audit' + const provider = 'ai_model_providers', catalog = 'ai_model_catalog', config = 'ai_model_configuration', audit = 'ai_model_audit', verification = 'ai_model_verifications' const unique = (tablename, indexname, columns, predicate = '') => ({ tablename, indexname, columns, predicate, indisunique: true, indisprimary: false, indisvalid: true, indisready: true, access_method: 'btree' }) const foreign = (tablename, name, referenced_table) => ({ tablename, constraint_name: `${tablename}_${name}_fkey`, contype: 'f', convalidated: true, delete_action: 'r', columns: [name], referenced_table, referenced_columns: ['id'] }) const check = (tablename, constraint_name, columns, expression) => ({ tablename, constraint_name, columns, expression, contype: 'c', convalidated: true }) @@ -20,20 +20,32 @@ export function aiModelRegistryEvidence() { column(config, 'revision', 'integer', 'NO', '1'), column(config, 'updated_at', 'timestamp with time zone', 'NO', 'now()'), ...['id', 'actor_id', 'action'].map(name => column(audit, name, 'text')), column(audit, 'provider_id', 'text', 'YES'), column(audit, 'details', 'jsonb', 'NO', "'{}'::jsonb"), column(audit, 'created_at', 'timestamp with time zone', 'NO', 'now()'), + ...['id', 'model_key', 'stage', 'context_hash', 'idempotency_key', 'actor_id', 'status'].map(name => column(verification, name, 'text')), + ...['request_id', 'task_id', 'message'].map(name => column(verification, name, 'text', 'YES')), + column(verification, 'evidence', 'jsonb', 'NO', "'{}'::jsonb"), + ...['started_at', 'deadline_at'].map(name => column(verification, name, 'timestamp with time zone')), + column(verification, 'finished_at', 'timestamp with time zone', 'YES'), ], indexes: [ unique(provider, 'ai_model_providers_legacy_binding_key', ['legacy_binding'], '(legacy_binding = true)'), unique(catalog, 'ai_model_catalog_provider_model_key', ['provider_id', 'model_id']), { tablename: audit, indexname: 'ai_model_audit_created_idx', indexdef: 'CREATE INDEX ai_model_audit_created_idx ON public.ai_model_audit USING btree (created_at)' }, + unique(verification, 'ai_model_verifications_idempotency_key', ['model_key', 'stage', 'idempotency_key']), + unique(verification, 'ai_model_verifications_unresolved_key', ['model_key', 'stage'], "status = ANY (ARRAY['running'::text, 'unknown'::text])"), + { tablename: verification, indexname: 'ai_model_verifications_model_stage_idx', indexdef: 'CREATE INDEX ai_model_verifications_model_stage_idx ON public.ai_model_verifications USING btree (model_key, stage, started_at DESC)', indisvalid: true, indisready: true, indisunique: false, predicate: '' }, ], constraints: [ foreign(catalog, 'provider_id', provider), ...['default_analysis', 'default_copy', 'default_image'].map(name => foreign(config, name, catalog)), + foreign(verification, 'model_key', catalog), check(provider, 'ai_model_providers_credential_source_check', ['credential_source'], "credential_source = ANY (ARRAY['environment'::text, 'encrypted'::text])"), check(provider, 'ai_model_providers_revision_check', ['revision'], 'revision > 0'), check(provider, 'ai_model_credential_complete', ['credential_source', 'secret_ciphertext', 'secret_nonce', 'secret_auth_tag'], "credential_source = 'environment'::text AND secret_ciphertext IS NULL AND secret_nonce IS NULL AND secret_auth_tag IS NULL OR credential_source = 'encrypted'::text AND secret_ciphertext IS NOT NULL AND secret_nonce IS NOT NULL AND secret_auth_tag IS NOT NULL"), check(catalog, 'ai_model_catalog_stages_check', ['stages'], "stages <@ ARRAY['analysis'::text, 'copy'::text, 'image'::text]"), check(catalog, 'ai_model_catalog_compatibility_check', ['compatibility'], "compatibility = ANY (ARRAY['confirmed'::text, 'unconfirmed'::text])"), - check(catalog, 'ai_model_enabled_compatible', ['enabled', 'stages', 'compatibility'], "NOT enabled OR cardinality(stages) > 0 AND compatibility = 'confirmed'::text"), + check(catalog, 'ai_model_enabled_stages', ['enabled', 'stages'], 'NOT enabled OR cardinality(stages) > 0'), + check(verification, 'ai_model_verifications_stage_check', ['stage'], "stage = ANY (ARRAY['analysis'::text, 'copy'::text, 'image'::text])"), + check(verification, 'ai_model_verifications_context_hash_check', ['context_hash'], "context_hash ~ '^[0-9a-f]{64}$'::text"), + check(verification, 'ai_model_verifications_status_check', ['status'], "status = ANY (ARRAY['running'::text, 'passed'::text, 'failed'::text, 'unknown'::text])"), check(config, 'ai_model_configuration_id_check', ['id'], "id = 'detail-generator'::text"), check(config, 'ai_model_configuration_revision_check', ['revision'], 'revision > 0'), ], diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index 2e3bd80..6c21d4f 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -1254,13 +1254,14 @@ verify_detail_generator_schema() { local name="$1" docker exec -i "$name" node <<'NODE' const fs = require('fs'), crypto = require('crypto'), { PrismaClient } = require('@prisma/client'); -const migrations = ['20261012_detail_generator', '20261012_ai_model_registry', '20261013_ai_model_capabilities']; +const migrations = ['20261012_detail_generator', '20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications']; const required = { detail_generator_projects: ['id','owner_id','document','revision','deleted_at'], detail_generator_assets: ['id','project_id','owner_id','storage_key','byte_size'], detail_generator_jobs: ['id','project_id','owner_id','status','idempotency_key','fingerprint','project_revision','payload','checkpoint','lease_token','lease_expires_at'], detail_generator_brand_profiles: ['id','name','profile','revision'] }; const modelFields = { ai_model_providers: { id:'text', name:'text', base_url:'text', enabled:'boolean', credential_source:'text', secret_ciphertext:'text?', secret_nonce:'text?', secret_auth_tag:'text?', legacy_binding:'boolean', revision:'integer', synced_at:'timestamp with time zone?', created_at:'timestamp with time zone', updated_at:'timestamp with time zone' }, ai_model_catalog: { id:'text', provider_id:'text', model_id:'text', enabled:'boolean', stages:'ARRAY', present:'boolean', compatibility:'text', capability_metadata:'jsonb?', created_at:'timestamp with time zone', updated_at:'timestamp with time zone' }, ai_model_configuration: { id:'text', default_analysis:'text?', default_copy:'text?', default_image:'text?', revision:'integer', updated_at:'timestamp with time zone' }, ai_model_audit: { id:'text', actor_id:'text', action:'text', provider_id:'text?', details:'jsonb', created_at:'timestamp with time zone' }, + ai_model_verifications: { id:'text', model_key:'text', stage:'text', context_hash:'text', idempotency_key:'text', actor_id:'text', status:'text', request_id:'text?', task_id:'text?', message:'text?', evidence:'jsonb', started_at:'timestamp with time zone', deadline_at:'timestamp with time zone', finished_at:'timestamp with time zone?' }, }; const p = new PrismaClient({ log: [] }); (async () => { @@ -1644,6 +1645,7 @@ const expectedMigrationNames = new Set([ '20261012_detail_generator', '20261012_ai_model_registry', '20261013_ai_model_capabilities', + '20261013_ai_model_verifications', ]); if (migrations.length !== expectedMigrationNames.size || migrations.some((item) => !expectedMigrationNames.has(item && item.name) diff --git a/scripts/git-incremental-deploy.test.mjs b/scripts/git-incremental-deploy.test.mjs index 75639f4..2cb8967 100644 --- a/scripts/git-incremental-deploy.test.mjs +++ b/scripts/git-incremental-deploy.test.mjs @@ -82,6 +82,7 @@ test('migration deployment gate recognizes the latest approved schema migration' assert.match(gate, /20261012_detail_generator/) assert.match(gate, /20261012_ai_model_registry/) assert.match(gate, /20261013_ai_model_capabilities/) + assert.match(gate, /20261013_ai_model_verifications/) }) test('direct replacement runs database and dependency gates before switching traffic', () => { @@ -179,7 +180,7 @@ test('enabled containers bind private persistence and verify actual Node22 PSD/P assert.doesNotMatch(runtime, /fetch\(|generateDetailImage|runDetailGeneratorWorker/) }) -test('AI model cutover gate reads both migration checksums and rejects missing or mistyped capability state', async () => { +test('AI model cutover gate checks every migration and rejects missing, mistyped or wrongly nullable verification state', async () => { const code = shellFunction('verify_detail_generator_schema').match(/<<'NODE'\n([\s\S]*?)\nNODE/)[1] const columns = [ ...aiModelRegistryEvidence().columns, @@ -205,15 +206,22 @@ test('AI model cutover gate reads both migration checksums and rejects missing o } const accepted = await run() assert.equal(accepted.exitCode, 0) - assert.deepEqual(accepted.checked, ['20261012_detail_generator', '20261012_ai_model_registry', '20261013_ai_model_capabilities']) - for (const migration of ['20261012_ai_model_registry', '20261013_ai_model_capabilities']) assert.equal((await run(columns, migration)).exitCode, 1) + assert.deepEqual(accepted.checked, ['20261012_detail_generator', '20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications']) + for (const migration of ['20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications']) assert.equal((await run(columns, migration)).exitCode, 1) assert.equal((await run(columns.filter(row => row.column_name !== 'capability_metadata'))).exitCode, 1) assert.equal((await run(columns.map(row => row.column_name === 'capability_metadata' ? { ...row, data_type: 'text' } : row))).exitCode, 1) + assert.equal((await run(columns.filter(row => row.table_name !== 'ai_model_verifications'))).exitCode, 1, 'missing verification table must block cutover') + for (const field of ['stage', 'context_hash', 'task_id', 'evidence']) { + const target = row => row.table_name === 'ai_model_verifications' && row.column_name === field + assert.equal((await run(columns.filter(row => !target(row)))).exitCode, 1, `missing ${field} must block cutover`) + assert.equal((await run(columns.map(row => target(row) ? { ...row, data_type: 'boolean' } : row))).exitCode, 1, `mistyped ${field} must block cutover`) + assert.equal((await run(columns.map(row => target(row) ? { ...row, is_nullable: row.is_nullable === 'YES' ? 'NO' : 'YES' } : row))).exitCode, 1, `wrong nullability for ${field} must block cutover`) + } }) test('AI registry migration files are present in builder and immutable runner verification scope', () => { const dockerfile = readFileSync(new URL('../Dockerfile', import.meta.url), 'utf8') - for (const migration of ['20261012_ai_model_registry', '20261013_ai_model_capabilities']) { + for (const migration of ['20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications']) { assert.ok(dockerfile.includes(`test -f "/app/prisma/migrations/${migration}/migration.sql"`)) assert.ok(dockerfile.includes(`COPY --from=builder --chown=root:root /app/prisma/migrations/${migration} ./prisma/migrations/${migration}`)) } diff --git a/scripts/safe-production-migrate.mjs b/scripts/safe-production-migrate.mjs index 37c9b18..8329889 100644 --- a/scripts/safe-production-migrate.mjs +++ b/scripts/safe-production-migrate.mjs @@ -44,6 +44,7 @@ const TARGET_MIGRATIONS = Object.freeze([ '20261012_detail_generator', '20261012_ai_model_registry', '20261013_ai_model_capabilities', + '20261013_ai_model_verifications', ]) const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs' const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT' @@ -358,7 +359,7 @@ async function queryEvidence(prisma, localMigrations) { OR (table_name = 'material_permission_pack_items' AND column_name IN ('can_access_private')) OR table_name IN ('material_permission_pack_departments', 'team_function_configs', 'team_function_config_audits', 'material_purge_jobs', 'material_purge_audits') OR table_name IN ('detail_generator_projects', 'detail_generator_assets', 'detail_generator_jobs', 'detail_generator_brand_profiles') - OR table_name IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration', 'ai_model_audit') + OR table_name IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration', 'ai_model_audit', 'ai_model_verifications') OR (table_name = 'api_keys' AND column_name IN ('secret_ciphertext', 'secret_nonce', 'secret_auth_tag')) OR (table_name = 'api_key_reveal_audits' AND column_name IN ('id', 'api_key_id', 'viewer_user_id', 'created_at')) OR (table_name = 'system_settings' AND column_name IN ('headerLogoUrl', 'loginLogoUrl', 'logoUrl')) @@ -430,7 +431,8 @@ async function queryEvidence(prisma, localMigrations) { 'materials_purge_references_idx', 'material_assets_purge_references_idx', 'material_skus_purge_references_idx', 'brand_knowledge_purge_references_idx', 'system_settings_purge_references_idx', 'performance_work_items_purge_references_idx', 'work_review_versions_purge_references_idx', 'package_upload_jobs_purge_references_idx', 'task_detail_changes_purge_references_idx', - 'ai_model_providers_legacy_binding_key', 'ai_model_catalog_provider_model_key', 'ai_model_audit_created_idx' + 'ai_model_providers_legacy_binding_key', 'ai_model_catalog_provider_model_key', 'ai_model_audit_created_idx', + 'ai_model_verifications_idempotency_key', 'ai_model_verifications_unresolved_key', 'ai_model_verifications_model_stage_idx' ) ORDER BY idx.relname`, ) @@ -473,7 +475,7 @@ async function queryEvidence(prisma, localMigrations) { 'team_function_configs_organization_unit_id_fkey', 'team_function_configs_lead_employee_profile_id_fkey', 'team_function_config_audits_config_id_fkey', 'team_function_configs_revision_check', 'material_purge_audits_job_id_fkey' - ) OR cls.relname IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration') + ) OR cls.relname IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration', 'ai_model_verifications') OR (cls.relname IN ('material_logs', 'material_download_logs') AND ref.relname = 'materials')) ORDER BY con.conname`, ) @@ -920,6 +922,7 @@ export function assertAiModelRegistryEvidence(evidence) { ai_model_catalog: { id: 'text', provider_id: 'text', model_id: 'text', enabled: 'boolean', stages: 'ARRAY', present: 'boolean', compatibility: 'text', capability_metadata: 'jsonb?', created_at: 'timestamp with time zone', updated_at: 'timestamp with time zone' }, ai_model_configuration: { id: 'text', default_analysis: 'text?', default_copy: 'text?', default_image: 'text?', revision: 'integer', updated_at: 'timestamp with time zone' }, ai_model_audit: { id: 'text', actor_id: 'text', action: 'text', provider_id: 'text?', details: 'jsonb', created_at: 'timestamp with time zone' }, + ai_model_verifications: { id: 'text', model_key: 'text', stage: 'text', context_hash: 'text', idempotency_key: 'text', actor_id: 'text', status: 'text', request_id: 'text?', task_id: 'text?', message: 'text?', evidence: 'jsonb', started_at: 'timestamp with time zone', deadline_at: 'timestamp with time zone', finished_at: 'timestamp with time zone?' }, } for (const [table, fields] of Object.entries(required)) for (const [column, definition] of Object.entries(fields)) { const nullable = definition.endsWith('?'), type = nullable ? definition.slice(0, -1) : definition @@ -938,8 +941,16 @@ export function assertAiModelRegistryEvidence(evidence) { if (normalizeConstraintExpression(legacy?.predicate) !== 'legacy_binding=true') fail('Database index predicate mismatch: ai_model_providers_legacy_binding_key') assertExactUniqueBtreeIndex(legacy && { ...legacy, predicate: '' }, 'ai_model_providers', 'ai_model_providers_legacy_binding_key', ['legacy_binding']) assertExactBtreeIndex(indexes.find(row => row.indexname === 'ai_model_audit_created_idx')?.indexdef, 'ai_model_audit', ['created_at']) + assertExactUniqueBtreeIndex(indexes.find(row => row.indexname === 'ai_model_verifications_idempotency_key'), 'ai_model_verifications', 'ai_model_verifications_idempotency_key', ['model_key', 'stage', 'idempotency_key']) + const unresolved = indexes.find(row => row.indexname === 'ai_model_verifications_unresolved_key') + if (normalizeConstraintExpression(unresolved?.predicate) !== normalizeConstraintExpression("status = ANY (ARRAY['running', 'unknown'])")) fail('Database index predicate mismatch: ai_model_verifications_unresolved_key') + assertExactUniqueBtreeIndex(unresolved && { ...unresolved, predicate: '' }, 'ai_model_verifications', 'ai_model_verifications_unresolved_key', ['model_key', 'stage']) + const verificationOrder = indexes.find(row => row.indexname === 'ai_model_verifications_model_stage_idx') + if (!verificationOrder || verificationOrder.indisvalid !== true || verificationOrder.indisready !== true || verificationOrder.indisunique !== false || String(verificationOrder.predicate || '') !== '' + || !/^CREATE INDEX \S+ ON (?:public\.)?ai_model_verifications USING btree \(model_key, stage, started_at DESC\)$/.test(String(verificationOrder.indexdef || ''))) fail('Database index mismatch: ai_model_verifications_model_stage_idx') for (const [table, column, referenced] of [ ['ai_model_catalog', 'provider_id', 'ai_model_providers'], + ['ai_model_verifications', 'model_key', 'ai_model_catalog'], ...['default_analysis', 'default_copy', 'default_image'].map(column => ['ai_model_configuration', column, 'ai_model_catalog']), ]) { const name = `${table}_${column}_fkey`, constraint = constraints.find(row => row.constraint_name === name) @@ -952,7 +963,10 @@ export function assertAiModelRegistryEvidence(evidence) { ['ai_model_providers', 'ai_model_credential_complete', ['credential_source', 'secret_ciphertext', 'secret_nonce', 'secret_auth_tag'], "credential_source = 'environment' AND secret_ciphertext IS NULL AND secret_nonce IS NULL AND secret_auth_tag IS NULL OR credential_source = 'encrypted' AND secret_ciphertext IS NOT NULL AND secret_nonce IS NOT NULL AND secret_auth_tag IS NOT NULL"], ['ai_model_catalog', 'ai_model_catalog_stages_check', ['stages'], "stages <@ ARRAY['analysis', 'copy', 'image']"], ['ai_model_catalog', 'ai_model_catalog_compatibility_check', ['compatibility'], "compatibility = ANY (ARRAY['confirmed', 'unconfirmed'])"], - ['ai_model_catalog', 'ai_model_enabled_compatible', ['enabled', 'stages', 'compatibility'], "NOT enabled OR cardinality(stages) > 0 AND compatibility = 'confirmed'"], + ['ai_model_catalog', 'ai_model_enabled_stages', ['enabled', 'stages'], 'NOT enabled OR cardinality(stages) > 0'], + ['ai_model_verifications', 'ai_model_verifications_stage_check', ['stage'], "stage = ANY (ARRAY['analysis', 'copy', 'image'])"], + ['ai_model_verifications', 'ai_model_verifications_context_hash_check', ['context_hash'], "context_hash ~ '^[0-9a-f]{64}$'"], + ['ai_model_verifications', 'ai_model_verifications_status_check', ['status'], "status = ANY (ARRAY['running', 'passed', 'failed', 'unknown'])"], ['ai_model_configuration', 'ai_model_configuration_id_check', ['id'], "id = 'detail-generator'"], ['ai_model_configuration', 'ai_model_configuration_revision_check', ['revision'], 'revision > 0'], ]) assertExactCheckConstraint(constraints.find(row => row.constraint_name === name), table, name, fields, expression) diff --git a/scripts/safe-production-migrate.test.mjs b/scripts/safe-production-migrate.test.mjs index dc754d3..ad41be4 100644 --- a/scripts/safe-production-migrate.test.mjs +++ b/scripts/safe-production-migrate.test.mjs @@ -427,6 +427,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index ['20261012_detail_generator', { checksum: 'ag' }], ['20261012_ai_model_registry', { checksum: 'ah' }], ['20261013_ai_model_capabilities', { checksum: 'ai' }], + ['20261013_ai_model_verifications', { checksum: 'aj' }], ]) const evidence = { migrationRows: [ @@ -701,6 +702,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index evidence.migrationRows.push({ migration_name: '20261009_material_purge_reference_indexes', checksum: 'af', finished_at: 'now', rolled_back_at: null }) evidence.migrationRows.push({ migration_name: '20261012_detail_generator', checksum: 'ag', finished_at: 'now', rolled_back_at: null }) evidence.migrationRows.push({ migration_name: '20261012_ai_model_registry', checksum: 'ah', finished_at: 'now', rolled_back_at: null }, { migration_name: '20261013_ai_model_capabilities', checksum: 'ai', finished_at: 'now', rolled_back_at: null }) + evidence.migrationRows.push({ migration_name: '20261013_ai_model_verifications', checksum: 'aj', finished_at: 'now', rolled_back_at: null }) const aiModels = aiModelRegistryEvidence() for (const field of ['columns', 'indexes', 'constraints']) evidence[field].push(...aiModels[field]) for (const [table, fields] of Object.entries({ @@ -1072,12 +1074,12 @@ test('container supply chain and migrator image are immutable while SOP keeps co assert.match(sop, /-v "\$HOST_RESULT_DIR:\/audit"/) assert.match(sop, /test -f "\$PREFLIGHT_AUDIT_FILE" && test ! -L "\$PREFLIGHT_AUDIT_FILE"/) assert.doesNotMatch(sop, /-v \/root\/gongxiangp2-migration-audit:\/audit/) - assert.match(sop, /三十五份目标 migration/) - assert.match(sop, /三十五条目标 migration/) + assert.match(sop, /三十六份目标 migration/) + assert.match(sop, /三十六条目标 migration/) const targetBlock = migratorSource.match(/const TARGET_MIGRATIONS = Object\.freeze\(\[([\s\S]*?)\]\)/)?.[1] assert.ok(targetBlock, 'Migrator target list must be inspectable') const targets = [...targetBlock.matchAll(/'([^']+)'/g)].map(match => match[1]) - assert.equal(targets.length, 35) + assert.equal(targets.length, 36) for (const migration of targets) assert.ok(sop.includes(`\`${migration}\``), `SOP must name ${migration}`) const applyMigration = migratorSource.indexOf("['migrate', 'deploy'") diff --git a/scripts/verify-ai-model-integration.ts b/scripts/verify-ai-model-integration.ts new file mode 100644 index 0000000..76201fd --- /dev/null +++ b/scripts/verify-ai-model-integration.ts @@ -0,0 +1,84 @@ +/** Isolated HTTP acceptance. Paid tests are explicit, one model + purpose per invocation. */ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { PrismaClient } from '@prisma/client' +import type { AiModelAdminState, AiModelStage } from '../lib/ai-model-types' + +const db = new URL(process.env.DATABASE_URL || '') +assert.equal(db.hostname, '127.0.0.1'); assert.equal(db.port, '55441'); assert.equal(db.pathname, '/ai_model_test') +const base = 'http://localhost:3121', output = 'output/ai-model-integration-v2' +const prisma = new PrismaClient({ log: [] }) +const phase = process.argv[2] || 'inspect' +async function save(name: string, value: unknown) { await mkdir(output, { recursive: true }); await writeFile(`${output}/${name}.json`, JSON.stringify(value, null, 2)) } +async function session(index: number) { + const jar = new Map() + const store = (r: Response) => { for (const c of r.headers.getSetCookie()) { const first = c.split(';')[0], split = first.indexOf('='); jar.set(first.slice(0, split), first.slice(split + 1)) } } + const cookie = () => [...jar].map(([k,v]) => `${k}=${v}`).join('; ') + const csrf = await fetch(base + '/api/auth/csrf'); store(csrf) + const { csrfToken } = await csrf.json() + const response = await fetch(base + '/api/auth/callback/credentials', { method:'POST', redirect:'manual', headers:{'Content-Type':'application/x-www-form-urlencoded', Origin:base, Cookie:cookie()}, body:new URLSearchParams({csrfToken,email:`ai-admin-${index}@example.invalid`,password:'AiAdminLocalTest-20261012',callbackUrl:base+'/admin',json:'true'}) }) + store(response); assert.equal(response.status,200); return cookie() +} +async function request(cookie: string, path = '', method = 'GET', body?: unknown) { + const response = await fetch(base+'/api/admin/ai-models'+path,{method,redirect:'manual',headers:{Cookie:cookie,'Content-Type':'application/json'},...(body===undefined?{}:{body:JSON.stringify(body)})}) + const raw = await response.text() + if(process.env.DETAIL_GENERATOR_API_KEY)assert.ok(!raw.includes(process.env.DETAIL_GENERATOR_API_KEY),'Secret leaked in response') + return {status:response.status,data:raw?JSON.parse(raw):null} +} +function ok(result: Awaited>): AiModelAdminState { assert.equal(result.status,200,JSON.stringify(result.data));return result.data } +async function main() { + const admin = await session(0) + let state = ok(await request(admin)) + if(phase==='bootstrap') { + if(state.legacyAvailable)state=ok(await request(admin,'/import-environment','POST')) + let provider=state.providers.find(p=>p.credentialSource==='environment'); assert.ok(provider) + if(!provider.enabled)state=ok(await request(admin,`/providers/${provider.id}`,'PATCH',{revision:provider.revision,enabled:true})) + provider=state.providers.find(p=>p.id===provider!.id)! + state=ok(await request(admin,`/providers/${provider.id}/sync`,'POST',{revision:provider.revision})) + await save('catalog-state',state) + } else if(phase==='test') { + const stage=process.argv[3] as AiModelStage; assert.ok(['analysis','copy','image'].includes(stage)) + const modelId=stage==='image'?'nano-banana-2':'gemini-3-flash' + const provider=state.providers.find(p=>p.credentialSource==='environment'); assert.ok(provider) + const model=provider.models.find(m=>m.id===modelId); assert.ok(model) + assert.ok(model.capabilities.allowedStages.includes(stage),`${modelId} has no ${stage} adapter`) + let receipt:{idempotencyKey:string;revision:number;providerId:string;modelKey:string;stage:AiModelStage} + try {receipt=JSON.parse(await readFile(`${output}/live-${stage}-request.json`,'utf8'))} + catch {receipt={idempotencyKey:randomUUID(),revision:provider.revision,providerId:provider.id,modelKey:model.key,stage};await save(`live-${stage}-request`,receipt)} + assert.equal(receipt.providerId,provider.id);assert.equal(receipt.modelKey,model.key) + state=ok(await request(admin,`/providers/${provider.id}/models/${model.key}/verify`,'POST',{stage,revision:receipt.revision,idempotencyKey:receipt.idempotencyKey})) + const result=state.providers.find(p=>p.id===provider.id)!.models.find(m=>m.key===model.key)!.verification[stage] + await save(`live-${stage}-result`,result);console.log(JSON.stringify({model:modelId,stage,...result}));return + } else if(phase==='query') { + const stage=process.argv[3] as AiModelStage;assert.ok(['analysis','copy','image'].includes(stage)) + const provider=state.providers.find(p=>p.credentialSource==='environment')!, model=provider.models.find(m=>m.id===(stage==='image'?'nano-banana-2':'gemini-3-flash'))! + const result=model.verification[stage];assert.ok(result.id&&result.canResume,'No known task to query') + state=ok(await request(admin,`/providers/${provider.id}/verifications/${result.id}/refresh`,'POST')) + const checked=state.providers.find(p=>p.id===provider.id)!.models.find(m=>m.key===model.key)!.verification[stage] + await save(`live-${stage}-result`,checked);console.log(JSON.stringify({stage,...checked}));return + } else if(phase==='configure') { + const provider=state.providers.find(p=>p.credentialSource==='environment')!, gemini=provider.models.find(m=>m.id==='gemini-3-flash')!, image=provider.models.find(m=>m.id==='nano-banana-2')! + for(const [model,stage] of [[gemini,'analysis'],[gemini,'copy'],[image,'image']] as const)assert.equal(model.verification[stage].status,'passed') + state=ok(await request(admin,'/configuration','PUT',{revision:state.revision,defaults:{analysis:gemini.key,copy:gemini.key,image:image.key},providers:state.providers.map(p=>({id:p.id,revision:p.revision,models:p.models.map(m=>({key:m.key,enabled:p.id===provider.id&&[gemini.key,image.key].includes(m.key),stages:p.id===provider.id&&m.key===gemini.key?['analysis','copy']:p.id===provider.id&&m.key===image.key?['image']:[]}))}))})) + await save('configured-state',state) + } else if(phase==='access') { + const counts=await prisma.$queryRawUnsafe>('SELECT COUNT(*)::int AS count FROM ai_model_verifications') + assert.equal((await request('')).status,401) + const provider=state.providers.find(p=>p.credentialSource==='environment')!, model=provider.models.find(m=>m.id==='gemini-3-flash')! + for(const index of [2,3]) { + const cookie=await session(index) + for(const [url,method,body] of [['','GET',undefined],['/configuration','PUT',{}],[`/providers/${provider.id}/sync`,'POST',{revision:provider.revision}],[`/providers/${provider.id}/models/${model.key}/verify`,'POST',{stage:'copy',revision:provider.revision,idempotencyKey:randomUUID()}]] as const)assert.equal((await request(cookie,url,method,body)).status,403) + } + const revoked=await session(1), user=await prisma.user.findUniqueOrThrow({where:{email:'ai-admin-1@example.invalid'}}) + try{await prisma.user.update({where:{id:user.id},data:{isSuperAdmin:false}});assert.equal((await request(revoked)).status,403);assert.equal((await request(revoked,`/providers/${provider.id}/models/${model.key}/verify`,'POST',{stage:'copy',revision:provider.revision,idempotencyKey:randomUUID()})).status,403)}finally{await prisma.user.update({where:{id:user.id},data:{isSuperAdmin:true}})} + assert.equal((await request(admin,'/configuration','PUT',{revision:state.revision,defaults:state.defaults,providers:[{id:provider.id,revision:provider.revision,models:[{key:model.key,enabled:true,stages:['copy'],compatibility:'confirmed'}]}]})).status,400) + assert.equal((await request(admin,`/providers/${provider.id}/models/${model.key}/verify`,'POST',{stage:'image',revision:provider.revision,idempotencyKey:randomUUID()})).status,400) + const after=await prisma.$queryRawUnsafe>('SELECT COUNT(*)::int AS count FROM ai_model_verifications') + assert.equal(after[0].count,counts[0].count) + const evidence={anonymous:401,ordinaryAndRegularAdmin:403,oldSessionRevocation:403,manualCompatibilitySpoof:400,incompatibleImageTest:400,newVerificationRecords:0,productionTouched:false} + await save('http-access-evidence',evidence);console.log(JSON.stringify(evidence));return + } else assert.equal(phase,'inspect') + console.log(JSON.stringify({phase,providers:state.providers.map(p=>({name:p.name,enabled:p.enabled,models:p.models.length,defaults:p.models.filter(m=>['gemini-3-flash','nano-banana-2'].includes(m.id)).map(m=>({id:m.id,capabilities:m.capabilities,verification:m.verification}))}))})) +} +main().catch(error=>{console.error(error instanceof Error?error.message:'Acceptance failed');process.exitCode=1}).finally(()=>prisma.$disconnect())