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

160 lines
14 KiB
TypeScript

/** @jest-environment node */
import { aiModelCapabilityFingerprint, getAiModelCapabilities, mergeAiModelCapabilityMetadata, parseAiModelCapabilityMetadata, parseAiModelPublicCapabilityCatalog } from '@/lib/ai-model-capabilities'
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() } } }))
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, invocation: invocation(), ...overrides })
const publicMetadata = (entry: ReturnType<typeof model>, 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', () => {
const capability = getAiModelCapabilities(generic, 'nano-banana-image-sounding-name', metadata(['text'], ['text'], ['/chat/completions']))
expect(capability).toMatchObject({ kind: 'text', allowedStages: ['copy'], source: 'upstream' })
})
test('image understanding and image output require separate interfaces', () => {
expect(getAiModelCapabilities(generic, 'vision', metadata(['text', 'image'], ['text'], ['/chat/completions'])).allowedStages).toEqual(['analysis', 'copy'])
expect(getAiModelCapabilities(generic, 'generator', metadata(['text'], ['image'], ['/images/generations']))).toMatchObject({ kind: 'image', allowedStages: [] })
expect(getAiModelCapabilities(generic, 'editor', metadata(['text', 'image'], ['image'], ['/images/edits']))).toMatchObject({ kind: 'image', allowedStages: ['image'] })
})
test('image models explain pending adaptation without treating missing metadata as proof of no reference support', () => {
expect(getAiModelCapabilities(generic, 'text-image', metadata(['text'], ['image'], ['/images/generations']))).toMatchObject({ allowedStages: [], reason: '参考图待适配:当前目录尚未确认可用的参考图传入方式' })
expect(getAiModelCapabilities(generic, 'reference-image', metadata(['text', 'image'], ['image'], ['/images/generations']))).toMatchObject({ allowedStages: [], reason: '参考图待适配:目录已声明图片输入,当前传入方式尚未接入' })
const undeclaredReference = metadata(['text'], ['image'], ['/images/generations'])
undeclaredReference.inputDeclared = false
undeclaredReference.protocols = [{ id: 'openai-image-generate', path: '/v1/images/generations', headers: {}, supportsImageInput: false }]
expect(getAiModelCapabilities(generic, 'undeclared-reference', undeclaredReference)).toMatchObject({ allowedStages: [], reason: '参考图待适配:当前目录尚未确认可用的参考图传入方式' })
const unavailable = { ...metadata(['text', 'image'], ['image'], ['/images/edits']), unavailable: true }
expect(getAiModelCapabilities(generic, 'unavailable', unavailable)).toMatchObject({ allowedStages: [], reason: '上游当前不可用,暂不能测试或开放' })
})
test('selected reference route declarations override the default offer summary without claiming a real test passed', () => {
const referenceRoute = { id: 'reference-route', input_modes: ['text', 'image_url'], output_modes: ['image'], invocation: {
...invocation('/v1/images/edits', { 'X-Relay-Route-Option': 'reference-route' }),
input_schema: { properties: { 'image[]': { type: 'array' } } },
} }
const entry = model({ type: 'image', modality: 'image', capabilities: ['text_to_image', 'image_to_image'], input_modes: ['text'], output_modes: ['text'], service_offers: [
{ is_default: true, service_scope: { id: 'scope-default' }, input_modes: ['text'], output_modes: ['text'], route_options: [
{ id: 'default-route', invocation: { ...invocation('/v1/images/generations', { 'X-Relay-Route-Option': 'default-route' }), content_type: 'application/json' } },
referenceRoute,
] },
] })
const evidence = publicMetadata(entry, 'tool')
expect(evidence).toMatchObject({ inputModalities: ['image', 'text'], outputModalities: ['image'], inputDeclared: true, outputDeclared: true })
expect(getAiModelCapabilities(base, entry.id, evidence)).toMatchObject({ kind: 'image', allowedStages: ['image'], reason: '接口已适配;通过对应用途的实际测试后可开放', protocols: { image: { routeId: 'reference-route' } } })
})
test('a reference adapter remains testable when the directory summary only lists text input', () => {
const entry = model({ type: 'image', modality: 'image', capabilities: ['image_to_image'], input_modes: ['text'], invocation: {
...invocation('/v1/images/edits'), input_schema: { properties: { image: { type: 'string', format: 'binary' } } },
} })
const evidence = publicMetadata(entry, 'tool')
expect(evidence).toMatchObject({ inputModalities: ['image', 'text'], outputModalities: ['image'] })
expect(getAiModelCapabilities(base, entry.id, evidence)).toMatchObject({ kind: 'image', allowedStages: ['image'] })
})
test('generic URL input does not become image input until a concrete reference adapter is available', () => {
const entry = model({ type: 'image', modality: 'image', capabilities: ['text_to_image', 'image_to_image'], input_modes: ['text', 'url'], invocation: {
...invocation('/v1/images/generations'), content_type: 'application/json',
} })
const evidence = publicMetadata(entry, 'tool')
expect(evidence.inputModalities).toEqual(['text', 'url'])
expect(getAiModelCapabilities(base, entry.id, evidence)).toMatchObject({ allowedStages: [], reason: '参考图待适配:当前目录尚未确认可用的参考图传入方式' })
})
test('an explicit non-image output declaration keeps an image route closed with a precise adaptation reason', () => {
const evidence = { ...metadata(['text', 'image'], ['text'], ['/images/edits']), kindHint: 'image' as const }
expect(getAiModelCapabilities(base, 'any-image-model', evidence)).toMatchObject({ allowedStages: [], reason: '参考图待适配:当前路线的输出声明与生图要求不一致' })
})
test('a standard reference probe is testable without claiming a declared or already verified reference contract', () => {
const evidence = { ...metadata(['text'], ['image'], ['/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',
requestParameters: { n: 1, response_format: 'b64_json' }, maxImages: 8, scopeId: 'candidate-scope', routeId: 'candidate-route',
}] }
expect(getAiModelCapabilities(base, 'standard-reference-candidate', evidence)).toMatchObject({
kind: 'image', allowedStages: ['image'], reason: '参考图采用标准图片参数;通过实际测试后可开放',
protocols: { image: { maxImages: 8, referenceLimitSource: 'verification' } },
})
expect(getAiModelCapabilities(base, 'unavailable-candidate', { ...evidence, unavailable: true })).toMatchObject({
allowedStages: [], reason: '上游当前不可用,暂不能测试或开放',
})
expect(aiModelCapabilityFingerprint(evidence)).not.toBe(aiModelCapabilityFingerprint({ ...evidence, protocols: evidence.protocols.map(({ referenceContract: _candidate, ...protocol }) => protocol) }))
})
test('audio transcription is not a copy-writing model just because it returns text over chat', () => {
expect(getAiModelCapabilities(generic, 'asr', metadata(['audio'], ['text'], ['/chat/completions']))).toMatchObject({ kind: 'asr', allowedStages: [] })
})
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([])
})
test('public catalog classifies models dynamically without guessing names', () => {
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('same-route image-and-text models offer a combined test while explicitly text-only models do not', () => {
const multimodal = publicMetadata(model({ modality: 'multimodal', input_modes: ['text', 'image'], output_modes: ['text'] }))
expect(getAiModelCapabilities(base, 'catalog-model', multimodal)).toMatchObject({ allowedStages: ['analysis', 'copy'], combinedTextTest: true })
const textOnly = publicMetadata(model({ input_modes: ['text'], output_modes: ['text'] }))
expect(getAiModelCapabilities(base, 'catalog-model', textOnly)).toMatchObject({ allowedStages: ['copy'], combinedTextTest: false })
})
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: '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 }
expect(getAiModelCapabilities(generic, 'tool', evidence)).toMatchObject({ kind: 'other', allowedStages: [] })
expect(getAiModelCapabilities(base, 'nano-banana-2', evidence).allowedStages).toEqual([])
})
test.each(['header', 'selection', 'non-default'])('route-specific or non-default capability cannot be opened with the default adapter: %s', reason => {
const offer = { is_default: reason !== 'non-default', service_scope: { id: 'scope-default' }, invocation: invocation('/v1/chat/completions', reason === 'header' ? { 'X-Relay-Route-Option': 'route' } : {}) }
const entry = model({ selection_required: reason === 'selection', invocation: invocation(), service_offers: [offer] })
expect(getAiModelCapabilities(base, entry.id, publicMetadata(entry)).allowedStages).toEqual([])
})
test('non-default image editing offers do not override the selected image generation route', () => {
const entry = model({ type: 'image', modality: 'image', capabilities: ['text_to_image', 'openai.images.edits'], input_modes: ['text', 'image'], output_modes: ['image'], service_offers: [
{ is_default: true, service_scope: { id: 'scope-default' }, invocation: invocation('/v1/images/generations') },
{ is_default: false, service_scope: { id: 'scope-other' }, invocation: invocation('/v1/images/edits') },
] })
expect(getAiModelCapabilities(base, entry.id, publicMetadata(entry, 'tool'))).toMatchObject({ kind: 'image', allowedStages: [] })
})
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: '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'])
if (reason === 'unavailable') current.unavailable = true
expect(getAiModelCapabilities(base, 'nano-banana-2', current).allowedStages).toEqual([])
})
test('a disabled selected offer and public negative override positive private catalog metadata', () => {
const entry = model({ id: 'gemini-2.5-flash', service_offers: [{ is_default: true, available: false, service_scope: { id: 'scope-default' }, invocation: invocation() }] })
const merged = mergeAiModelCapabilityMetadata(metadata(['text', 'image'], ['text'], ['/chat/completions']), publicMetadata(entry))
expect(getAiModelCapabilities(base, entry.id, merged).allowedStages).toEqual([])
})
test('metadata fingerprint is stable after PostgreSQL JSONB reorders properties', () => {
const value = metadata(['image', 'text'], ['text'], ['/chat/completions'])
expect(aiModelCapabilityFingerprint(value)).toBe(aiModelCapabilityFingerprint(Object.fromEntries(Object.entries(value).reverse())))
})
test('partial capability declarations and unexpected public schemas fail before synchronization writes', () => {
expect(() => parseAiModelCapabilityMetadata({ input_modalities: ['text'] })).toThrow('能力字段不完整')
expect(() => parseAiModelPublicCapabilityCatalog({ code: 0, data: { schema_version: '3', sections: [] } })).toThrow('能力目录格式不兼容')
})
test('standard catalog unavailable status cannot be overridden by positive protocol metadata', () => {
const unavailable = parseAiModelCapabilityMetadata({ available: false, input_modalities: ['text', 'image'], output_modalities: ['image'], endpoints: ['/images/edits'] })
expect(getAiModelCapabilities(base, 'nano-banana-2', unavailable).allowedStages).toEqual([])
})
test('an ID-only private model entry can still withdraw a publicly available capability', () => {
const unavailable = parseAiModelCapabilityMetadata({ id: 'gemini-2.5-flash', object: 'model', available: false })
const merged = mergeAiModelCapabilityMetadata(unavailable, publicMetadata(model({ id: 'gemini-2.5-flash' })))
expect(getAiModelCapabilities(base, 'gemini-2.5-flash', merged)).toMatchObject({ kind: 'text', allowedStages: [] })
})