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

191 lines
16 KiB
TypeScript
Raw 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.

/** @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('declared image URL arrays admit private data URIs without a redundant pattern', () => {
const entry = { type: 'image', modality: 'image', capabilities: ['image_to_image'], input_modes: ['text', 'image_url'], invocation: { method: 'POST', path: '/v1/images/generations', content_type: 'application/json', headers: {}, input_schema: { properties: { image: { type: 'array', items: { type: 'string', format: 'uri' }, maxItems: 3 } } } } }
expect(parseAiModelProtocols(entry)).toMatchObject([{ id: 'openai-image-generate', supportsImageInput: true, inputField: 'image', maxImages: 3 }])
expect(parseAiModelProtocols({ ...entry, input_modes: ['text'] })).toMatchObject([{ supportsImageInput: false }])
expect(parseAiModelProtocols({ ...entry, invocation: { ...entry.invocation, input_schema: { properties: { image: { type: 'array', items: { type: 'string', pattern: '^https://' } } } } } })).toMatchObject([{ supportsImageInput: false }])
})
test('Images Generations sends declared image array with private bytes and retains its route', async () => {
send.mockResolvedValue(Response.json({ data: [{ b64_json: png.toString('base64') }] }))
const result = await callAiModelProtocol({ ...base, protocol: { id: 'openai-image-generate', path: '/v1/images/generations', headers: { 'X-Relay-Route-Option': 'image-route' }, routeId: 'image-route', supportsImageInput: true, inputField: 'image', maxImages: 2 }, images: [{ buffer: png, mimeType: 'image/png' }] })
expect(send).toHaveBeenCalledTimes(1)
const body = JSON.parse(send.mock.calls[0][3])
expect(body).toMatchObject({ image: [expect.stringMatching(/^data:image\/png;base64,/)], n: 1, response_format: 'b64_json' })
expect(send.mock.calls[0][5]).toEqual({ 'X-Relay-Route-Option': 'image-route' })
expect((await sharp(result.image).metadata()).width).toBe(32)
})
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('explicit final upstream rejection is failed, while an opaque 502 retains its request for investigation', async () => {
send.mockResolvedValueOnce(Response.json({ error: { code: 'service_rejected' } }, { status: 502, headers: { 'x-request-id': 'ended-request' } }))
await expect(callAiModelProtocol({ ...base, protocol: chat })).rejects.toMatchObject({ outcomeUnknown: false, requestId: 'ended-request', message: '上游拒绝模型请求(HTTP 502),测试未通过' })
send.mockResolvedValueOnce(new Response('<html>Bad Gateway</html>', { status: 502, headers: { 'x-request-id': 'opaque-request' } }))
await expect(callAiModelProtocol({ ...base, protocol: chat })).rejects.toMatchObject({ outcomeUnknown: true, requestId: 'opaque-request', message: '上游返回异常响应(502),请核查请求结果;勿重复提交' })
expect(send).toHaveBeenCalledTimes(2)
})
test('review status and an accepted task override an otherwise definite rejection code', async () => {
send.mockResolvedValueOnce(Response.json({ status: 'pending_review', error: { code: 'service_rejected' } }, { status: 502 }))
await expect(callAiModelProtocol({ ...base, protocol: chat })).rejects.toMatchObject({ outcomeUnknown: true })
send.mockResolvedValueOnce(Response.json({ error: { code: 'service_rejected' } }, { status: 502 }))
await expect(callAiModelProtocol({ ...base, protocol: asyncImage, resumeTaskId: 'accepted-task', onTaskSubmitted: jest.fn() })).rejects.toMatchObject({ outcomeUnknown: true, taskId: 'accepted-task' })
expect(send).toHaveBeenCalledTimes(2)
})
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 })
})