gongxiangp2/lib/__tests__/ai-model-service.test.ts

334 lines
28 KiB
TypeScript

/** @jest-environment node */
import { prisma } from '@/lib/prisma'
import { AiModelError } from '@/lib/ai-model-access'
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 { 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() }))
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(), $queryRawUnsafe: jest.fn(async () => [{ isSuperAdmin: true, isActive: true, status: 'active', archivedAt: null }]) }
jest.mock('@/lib/ai-model-repository', () => ({
...jest.requireActual('@/lib/ai-model-repository'), aiModelTransaction: jest.fn(async callback => callback(mockDb)),
listAiModelProviderRows: jest.fn(), listAiModelRows: jest.fn(), getAiModelConfiguration: jest.fn(), getAiModelProviderRow: jest.fn(), aiModelAudit: jest.fn(), saveAiModelDefaults: jest.fn(), updateAiModelProvider: jest.fn(),
}))
const originalEnv = { ...process.env }
const legacyId = '11111111-1111-4111-8111-111111111111'
const secondId = '22222222-2222-4222-8222-222222222222'
const legacyModelKey = '33333333-3333-4333-8333-333333333333'
const secondModelKey = '44444444-4444-4444-8444-444444444444'
let providers: AiModelProviderRow[]
let models: AiModelRow[]
beforeEach(() => {
jest.clearAllMocks()
process.env.API_KEY_ENCRYPTION_KEY = 'synthetic-ai-encryption-key-32-bytes-minimum'
process.env.DETAIL_GENERATOR_API_KEY = 'synthetic-legacy-secret'
process.env.DETAIL_GENERATOR_BASE_URL = 'https://legacy.example.com/v1'
providers = [
{ id: legacyId, name: '原上游', baseUrl: 'https://legacy.example.com/v1', enabled: true, credentialSource: 'environment', secretCiphertext: null, secretNonce: null, secretAuthTag: null, legacyBinding: true, revision: 1, syncedAt: null },
{ 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, 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 })
;(listAiModelProviderRows as jest.Mock).mockImplementation(async () => providers)
;(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', evidence: { ruleVersion: 2 }, 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 })
test('same raw model names remain isolated by catalog UUID and legacy names bind only their explicit imported provider', async () => {
expect((await resolveAiModel('analysis', legacyModelKey)).binding.providerId).toBe(legacyId)
expect((await resolveAiModel('analysis', secondModelKey)).binding.providerId).toBe(secondId)
expect((await resolveAiModel('analysis', 'vendor/shared-model')).binding.providerId).toBe(legacyId)
providers[0].legacyBinding = false
await expect(resolveAiModel('analysis', 'vendor/shared-model')).rejects.toMatchObject({ code: 'model_unavailable' })
})
test.each(['disabled-provider', 'disabled-model', 'missing-model', 'unconfirmed', 'wrong-stage'])('rejects unavailable runtime selection: %s', async reason => {
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') (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' })
expect(saveAiModelDefaults).not.toHaveBeenCalled()
})
test('changed connection revisions stop existing bindings before any model request', async () => {
const binding = (await resolveAiModel('image', secondModelKey)).binding
providers[1].revision++
await expect(resolveAiModel('image', secondModelKey, binding)).rejects.toMatchObject({ code: 'model_binding_changed' })
expect(requestAiModel).not.toHaveBeenCalled()
})
test('environment URL drift cannot send a replacement environment key to the original imported endpoint', async () => {
process.env.DETAIL_GENERATOR_BASE_URL = 'https://other.example.com/v1'
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()
})
test('admin DTO strips all encrypted material and does not return plaintext or raw environment variables', async () => {
const state = await getAiModelAdminState()
expect(state.providers[0].hasApiKey).toBe(true)
expect(state.providers[1].hasApiKey).toBe(true)
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()
expect(state.configured).toBe(false)
expect(state.defaults).toEqual({ analysis: '', copy: '', image: '' })
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 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'))
expect(upserts).toHaveLength(2)
expect(upserts.every(sql => !sql.includes('stages=') && !sql.includes('enabled=') && !sql.includes('compatibility='))).toBe(true)
expect(statements.some(sql => sql.includes('SET present=FALSE'))).toBe(true)
expect(aiModelAudit).toHaveBeenCalledWith(expect.anything(), 'admin', 'provider.sync', legacyId, expect.objectContaining({ addedCount: 1 }))
})
test('scoped capability synchronization never rewrites unrelated models or creates verification evidence', async () => {
await syncAiModelProvider('admin', legacyId, { revision: 1, modelKeys: [legacyModelKey] })
const updates = mockDb.$executeRawUnsafe.mock.calls
expect(updates.find(([sql]) => sql.includes('present=FALSE'))).toEqual([expect.stringContaining('AND id=ANY($2::text[])'), legacyId, [legacyModelKey]])
const inserted = updates.filter(([sql]) => sql.startsWith('INSERT INTO ai_model_catalog'))
expect(inserted).toHaveLength(1)
expect(inserted[0][3]).toBe('vendor/shared-model')
expect(JSON.stringify(updates)).not.toContain('brand/new-model')
expect(updates.some(([sql]) => sql.includes('INSERT INTO ai_model_verifications'))).toBe(false)
expect(aiModelAudit).toHaveBeenCalledWith(expect.anything(), 'admin', 'provider.sync', legacyId, expect.objectContaining({ modelKeys: [legacyModelKey], addedCount: 0 }))
})
test('a scoped update cannot use another provider model or an empty selection', async () => {
await expect(syncAiModelProvider('admin', legacyId, { revision: 1, modelKeys: [secondModelKey] })).rejects.toMatchObject({ code: 'catalog_selection_invalid' })
await expect(syncAiModelProvider('admin', legacyId, { revision: 1, modelKeys: [] })).rejects.toMatchObject({ code: 'invalid_request' })
expect(requestAiModel).not.toHaveBeenCalled()
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('concurrent provider updates during catalog response reject all catalog writes', async () => {
;(requestAiModel as jest.Mock).mockImplementation(async (_base, _key, _endpoint, _body, _idempotency, beforeRequest) => { await beforeRequest(); providers[0].revision++; return Response.json({ data: [{ id: 'new' }] }) })
await expect(syncAiModelProvider('admin', legacyId, { revision: 1 })).rejects.toMatchObject({ code: 'revision_conflict' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('an upstream credential echo in model IDs is rejected before persistence or public DTO response', async () => {
;(requestAiModel as jest.Mock).mockResolvedValue(Response.json({ data: [{ id: 'synthetic-legacy-secret' }] }))
await expect(syncAiModelProvider('admin', legacyId, { revision: 1 })).rejects.toMatchObject({ code: 'catalog_invalid' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
expect(aiModelAudit).not.toHaveBeenCalled()
})
test.each([{ data: [{ id: '\nunsafe' }] }, { data: [{ id: 1 }] }, { data: 'incorrect' }, { data: [{ id: '' }] }])('invalid model catalogs are rejected: %o', value => {
expect(() => parseAiModelCatalog(value)).toThrow(AiModelError)
})
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, stages: ['image'] }] }] }
await expect(configureAiModels('admin', input)).rejects.toMatchObject({ code: 'model_capability_unsupported' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('forged read-only capability metadata is rejected before writes', async () => {
await expect(configureAiModels('admin', { revision: 1, defaults: { analysis: null, copy: null, image: null }, providers: [{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: true, compatibility: 'confirmed', stages: ['image'], capabilities: { allowedStages: ['image'] } }] }] })).rejects.toThrow('格式无效')
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('unchanged withdrawn model settings do not block saving another model and are not rewritten', async () => {
models[0].present = false
models[0].capabilityMetadata = null
await configureAiModels('admin', { revision: 1, defaults: { analysis: null, copy: null, image: null }, providers: [
{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: true, stages: [...models[0].stages] }] },
{ id: secondId, revision: 1, models: [{ key: secondModelKey, enabled: false, stages: [...models[1].stages] }] },
] })
const updates = mockDb.$executeRawUnsafe.mock.calls.filter(call => String(call[0]).startsWith('UPDATE ai_model_catalog'))
expect(updates).toHaveLength(1)
expect(updates[0].slice(1)).toEqual([secondModelKey, false, models[1].stages])
})
test('withdrawn model can be disabled while retaining prior selections, but cannot be re-enabled', async () => {
models[0].present = false
models[0].capabilityMetadata = null
const input = { revision: 1, defaults: { analysis: null, copy: null, image: null }, providers: [{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: false, stages: [...models[0].stages] }] }] }
await configureAiModels('admin', input)
expect(mockDb.$executeRawUnsafe).toHaveBeenCalledWith(expect.stringContaining('UPDATE ai_model_catalog'), legacyModelKey, false, models[0].stages)
models[0].enabled = false
input.providers[0].models[0].enabled = true
await expect(configureAiModels('admin', input)).rejects.toMatchObject({ code: 'model_capability_unsupported' })
})
test('disabling a model cannot add an unsupported purpose to its prior selections', async () => {
models[0].stages = ['copy']
models[0].capabilityMetadata = parseAiModelCapabilityMetadata({ input_modalities: ['text'], output_modalities: ['text'], endpoints: ['/chat/completions'] })
await expect(configureAiModels('admin', { revision: 1, defaults: { analysis: null, copy: null, image: null }, providers: [{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: false, stages: ['copy', 'image'] }] }] })).rejects.toMatchObject({ code: 'model_capability_unsupported' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('stale enabled rows are filtered from menus, defaults and old project execution after capability withdrawal', async () => {
const binding = (await resolveAiModel('image', legacyModelKey)).binding
models[0].capabilityMetadata = parseAiModelCapabilityMetadata({ input_modalities: ['text'], output_modalities: ['text'], endpoints: ['/chat/completions'] })
const state = await getAiModelRuntimeState()
expect(state.models.image).not.toContain(legacyModelKey)
expect(state.defaults.image).toBe('')
await expect(resolveAiModel('image', legacyModelKey, binding)).rejects.toMatchObject({ code: 'model_unavailable' })
await expect(configureAiModels('admin', { revision: 1, providers: [], defaults: { analysis: null, copy: null, image: legacyModelKey } })).rejects.toMatchObject({ code: 'default_unavailable' })
expect(requestAiModel).not.toHaveBeenCalled()
})
function referenceCandidateMetadata() {
return { ...parseAiModelCapabilityMetadata({ input_modalities: ['text'], output_modalities: ['image'], endpoints: ['/images/generations'] })!,
protocols: [{ id: 'openai-image-generate', path: '/v1/images/generations', headers: { 'X-Relay-Route-Option': 'candidate-route' },
supportsImageInput: true, inputField: 'image', referenceTransport: 'json-inline', referenceContract: 'probe-required',
maxImages: 1, scopeId: 'candidate-scope', routeId: 'candidate-route', requestParameters: { n: 1, response_format: 'b64_json' } }],
}
}
test.each(['untested', 'old-fingerprint'] as const)('a standard reference candidate stays unavailable and cannot be enabled with %s evidence', async state => {
const previous = await listAiModelVerifications()
const metadata = referenceCandidateMetadata()
models[0].capabilityMetadata = metadata; models[0].stages = ['image']
const priorMetadata = { ...metadata, protocols: metadata.protocols.map(({ referenceContract: _probe, ...protocol }) => protocol) }
const oldContext = aiModelVerificationContext(providers[0].baseUrl, models[0].id, priorMetadata, 'synthetic-legacy-secret', getAiModelStageProtocol(priorMetadata, 'image')!)
const currentContext = aiModelVerificationContext(providers[0].baseUrl, models[0].id, metadata, 'synthetic-legacy-secret', getAiModelStageProtocol(metadata, 'image')!)
expect(oldContext).not.toBe(currentContext)
const oldPassed = { ...previous.find(value => value.modelKey === legacyModelKey && value.stage === 'image')!, contextHash: oldContext }
;(listAiModelVerifications as jest.Mock).mockResolvedValue([
...previous.filter(value => value.modelKey !== legacyModelKey), ...(state === 'old-fingerprint' ? [oldPassed] : []),
])
const admin = (await getAiModelAdminState()).providers[0].models[0]
expect(admin.capabilities.allowedStages).toEqual(['image']) // Testing admission is separate from runtime permission.
expect(admin.verification.image.status).toBe(state === 'untested' ? 'untested' : 'stale')
const runtime = await getAiModelRuntimeState()
expect(runtime.models.image).not.toContain(legacyModelKey)
expect(runtime.modelOptions.image.some(option => option.value === legacyModelKey)).toBe(false)
expect(runtime.defaults.image).toBe('')
await expect(resolveAiModel('image', legacyModelKey)).rejects.toMatchObject({ code: 'model_unavailable' })
await expect(resolveAiModel('image', models[0].id)).rejects.toMatchObject({ code: 'model_unavailable' })
models[0].enabled = false; models[0].stages = []
await expect(configureAiModels('admin', { revision: 1, defaults: { analysis: null, copy: null, image: null },
providers: [{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: true, stages: ['image'] }] }],
})).rejects.toMatchObject({ code: 'model_verification_required' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
expect(saveAiModelDefaults).not.toHaveBeenCalled()
expect(requestAiModel).not.toHaveBeenCalled()
})
test('only current passed reference-candidate evidence permits explicit enablement and image selection', async () => {
const previous = await listAiModelVerifications()
const metadata = referenceCandidateMetadata()
models[0].capabilityMetadata = metadata; models[0].enabled = false; models[0].stages = []
const contextHash = aiModelVerificationContext(providers[0].baseUrl, models[0].id, metadata, 'synthetic-legacy-secret', getAiModelStageProtocol(metadata, 'image')!)
const passed = { ...previous.find(value => value.modelKey === legacyModelKey && value.stage === 'image')!, id: 'candidate-current-pass', contextHash,
evidence: { ruleVersion: 2, referenceImageAccepted: true, referenceLayoutMatched: true, imageDecoded: true },
}
;(listAiModelVerifications as jest.Mock).mockResolvedValue([...previous.filter(value => value.modelKey !== legacyModelKey), passed])
expect((await getAiModelAdminState()).providers[0].models[0].verification.image.status).toBe('passed')
expect((await getAiModelRuntimeState()).models.image).not.toContain(legacyModelKey)
await expect(resolveAiModel('image', legacyModelKey)).rejects.toMatchObject({ code: 'model_unavailable' })
await configureAiModels('admin', { revision: 1, defaults: { analysis: null, copy: null, image: null },
providers: [{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: true, stages: ['image'] }] }],
})
expect(mockDb.$executeRawUnsafe).toHaveBeenCalledWith(expect.stringContaining('UPDATE ai_model_catalog SET enabled=$2'), legacyModelKey, true, ['image'])
// Reflect the asserted database update in this repository mock for the subsequent read.
models[0].enabled = true; models[0].stages = ['image']
const runtime = await getAiModelRuntimeState()
expect(runtime.models.image).toContain(legacyModelKey)
expect(runtime.modelOptions.image).toContainEqual(expect.objectContaining({ value: legacyModelKey }))
expect(runtime.defaults.image).toBe(legacyModelKey)
await expect(resolveAiModel('image', legacyModelKey)).resolves.toMatchObject({
protocol: { referenceContract: 'probe-required', referenceTransport: 'json-inline' },
binding: { modelKey: legacyModelKey, verificationId: passed.id },
})
expect(requestAiModel).not.toHaveBeenCalled()
})
test.each([2, 4, 8])('runtime binds only the %i references independently validated, even when the platform ceiling is 8', async count => {
const previous = await listAiModelVerifications(), metadata = referenceCandidateMetadata()
metadata.protocols[0].maxImages = 8
models[0].capabilityMetadata = metadata; models[0].stages = ['image']
const passed = { ...previous.find(value => value.modelKey === legacyModelKey && value.stage === 'image')!,
contextHash: aiModelVerificationContext(providers[0].baseUrl, models[0].id, metadata, 'synthetic-legacy-secret', getAiModelStageProtocol(metadata, 'image')!),
evidence: { ruleVersion: 2, referenceImageCount: count, multiReferenceRuleVersion: 1, allReferenceImagesMatched: true, referenceMatches: Array(count).fill(true) } }
;(listAiModelVerifications as jest.Mock).mockResolvedValue([...previous.filter(value => value.modelKey !== legacyModelKey), passed])
await expect(resolveAiModel('image', legacyModelKey)).resolves.toMatchObject({ protocol: { maxImages: count } })
const state = (await getAiModelAdminState()).providers[0].models[0]
expect(state.verification.image.verifiedReferenceImageCount).toBe(count)
expect(state.capabilities.protocols?.image).toMatchObject({ maxImages: 8, referenceLimitSource: 'verification' })
passed.evidence.referenceMatches[0] = false
await expect(resolveAiModel('image', legacyModelKey)).rejects.toMatchObject({ code: 'model_unavailable' })
expect(requestAiModel).not.toHaveBeenCalled()
})
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: 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()
})
test('typed public catalog is restricted to models authorized by the private directory', async () => {
process.env.DETAIL_GENERATOR_BASE_URL = providers[0].baseUrl = 'https://api.vip98k.com/v1'
;(requestAiModel as jest.Mock).mockResolvedValue(Response.json({ data: [{ id: 'allowed-model' }] }))
;(requestAiModelCapabilityCatalog as jest.Mock).mockResolvedValue(Response.json({ code: 0, data: { schema_version: '2.2', sections: [{ id: 'chat', models: ['allowed-model', 'unauthorized-model'].map(id => ({ id, type: 'model', modality: 'text', invocation: { method: 'POST', path: '/v1/chat/completions', content_type: 'application/json', headers: {} } })) }] } }))
await syncAiModelProvider('admin', legacyId, { revision: 1 })
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: ['/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 () => {
process.env.DETAIL_GENERATOR_BASE_URL = providers[0].baseUrl = 'https://api.vip98k.com/v1'
;(requestAiModelCapabilityCatalog as jest.Mock).mockResolvedValue(new Response('', { status: 503 }))
await expect(syncAiModelProvider('admin', legacyId, { revision: 1 })).rejects.toMatchObject({ code: 'capability_catalog_failed' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('identical capabilities do not bump provider revision after JSONB field reordering', async () => {
const declaration = { input_modalities: ['text'], output_modalities: ['text'], endpoints: ['/chat/completions'] }
models = [{ ...models[0], capabilityMetadata: Object.fromEntries(Object.entries(parseAiModelCapabilityMetadata(declaration, providers[0].baseUrl + '/models')!).reverse()) }]
;(requestAiModel as jest.Mock).mockResolvedValue(Response.json({ data: [{ id: models[0].id, ...declaration }] }))
await syncAiModelProvider('admin', legacyId, { revision: 1 })
const revisionUpdate = mockDb.$executeRawUnsafe.mock.calls.find(call => String(call[0]).includes('synced_at=NOW()'))
expect(revisionUpdate?.[2]).toBe(0)
})