158 lines
13 KiB
TypeScript
158 lines
13 KiB
TypeScript
/** @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 })
|
|
})
|