159 lines
14 KiB
TypeScript
159 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: 1, scopeId: 'candidate-scope', routeId: 'candidate-route',
|
|
}] }
|
|
expect(getAiModelCapabilities(base, 'standard-reference-candidate', evidence)).toMatchObject({
|
|
kind: 'image', allowedStages: ['image'], reason: '参考图采用标准图片参数;通过实际测试后可开放',
|
|
})
|
|
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: [] })
|
|
})
|