gongxiangp2/scripts/verify-ai-image-adapters.ts

115 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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