439 lines
34 KiB
TypeScript
439 lines
34 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('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([
|
||
[{ enum: ['auto', 'low', 'medium', 'high'] }, 'medium'],
|
||
[{ enum: ['medium', 'high'], default: 'medium' }, 'medium'],
|
||
[{ enum: ['low', 'high'] }, undefined],
|
||
[{ type: 'string' }, undefined],
|
||
[{ enum: ['medium', 'high'], default: 'high' }, undefined],
|
||
[{ enum: ['medium', 'high'], const: 'high' }, undefined],
|
||
])('only chooses an explicitly supported image working quality: %j', (quality, expected) => {
|
||
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: { quality, image: { type: 'array', items: { type: 'string', format: 'uri' } } } } } }
|
||
expect(parseAiModelProtocols(entry)[0].imageQuality).toBe(expected)
|
||
})
|
||
|
||
test('working quality survives storage and reaches the selected image route without changing references', async () => {
|
||
const protocol: AiModelProtocol = { id: 'openai-image-generate', path: '/v1/images/generations', headers: { 'X-Relay-Route-Option': 'same-route' }, routeId: 'same-route', supportsImageInput: true, inputField: 'image', imageQuality: 'medium' }
|
||
expect(validateStoredAiModelProtocols(JSON.parse(JSON.stringify([protocol])))).toEqual([protocol])
|
||
send.mockResolvedValue(Response.json({ data: [{ b64_json: png.toString('base64') }] }))
|
||
await callAiModelProtocol({ ...base, protocol, images: [{ buffer: png, mimeType: 'image/png' }] })
|
||
expect(send).toHaveBeenCalledTimes(1)
|
||
expect(JSON.parse(send.mock.calls[0][3])).toMatchObject({ model: base.modelId, quality: 'medium', image: [expect.stringMatching(/^data:image\/png;base64,/)], n: 1 })
|
||
expect(send.mock.calls[0][5]).toEqual(protocol.headers)
|
||
expect(validateStoredAiModelProtocols([{ ...protocol, imageQuality: 'high' }])).toEqual([])
|
||
expect(validateStoredAiModelProtocols([{ ...chat, imageQuality: 'medium' }])).toEqual([])
|
||
})
|
||
|
||
const fileRouteModel = (schema: any = {}, example: any = {}) => ({
|
||
type: 'image', modality: 'image', capabilities: ['text_to_image', 'openai.images.generations'],
|
||
default_service_scope_id: 'scope-one', default_route_option_id: 'text-default',
|
||
service_offers: [{ service_scope: { id: 'scope-one' }, public_operation_id: 'openai.images.generations.create', route_options: [
|
||
{ id: 'text-default', input_modes: ['text'], invocation: invocation('/v1/images/generations', 'text-default') },
|
||
{ id: 'file-route', input_modes: ['file', 'image_url', 'text'], invocation: {
|
||
...invocation('/v1/images/generations', 'file-route', { ...schema, properties: {
|
||
image: { type: 'array', items: { type: 'string', pattern: '^https://' }, anyOf: [{ type: 'string' }, { type: 'array', maxItems: 4 }] },
|
||
n: { const: 1 }, response_format: { enum: ['url', 'b64_json'] }, ...schema.properties,
|
||
} }), content_type: 'multipart/form-data', request_example: example,
|
||
} },
|
||
] }],
|
||
})
|
||
|
||
test('chooses explicit file reference transport in the default scope and preserves a fixed 4K request', () => {
|
||
const model = fileRouteModel({ properties: { size: { enum: ['4096x4096'] }, stream: { enum: [false] } } }, { size: '4096x4096', stream: false })
|
||
model.service_offers.unshift({ service_scope: { id: 'other-scope' }, public_operation_id: 'openai.images.generations.create', route_options: [
|
||
{ id: 'wrong-scope', input_modes: ['file', 'image_url'], invocation: invocation('/v1/images/generations', 'wrong-scope', { properties: { image: { type: 'array' } } }) } as any,
|
||
] })
|
||
const parsed = parseAiModelProtocols(model)
|
||
expect(parsed).toEqual([{
|
||
id: 'openai-image-generate', path: '/v1/images/generations', headers: { 'X-Relay-Route-Option': 'file-route' },
|
||
supportsImageInput: true, inputField: 'image', maxImages: 4, scopeId: 'scope-one', routeId: 'file-route', operationId: 'openai.images.generations.create',
|
||
referenceTransport: 'multipart', requestParameters: { n: 1, response_format: 'b64_json', size: '4096x4096', stream: false },
|
||
}])
|
||
expect(validateStoredAiModelProtocols(JSON.parse(JSON.stringify(parsed)))).toEqual(parsed)
|
||
})
|
||
|
||
test('published JSON reference route is preferred without changing service scope', () => {
|
||
const model = fileRouteModel()
|
||
Object.assign(model.service_offers[0].route_options[0], { input_modes: ['image_url', 'text'], invocation: invocation('/v1/images/generations', 'text-default', { properties: { image: { type: 'array', items: { type: 'string' } } } }) })
|
||
model.capabilities.push('image_to_image')
|
||
expect(parseAiModelProtocols(model)[0]).toMatchObject({ routeId: 'text-default', referenceTransport: 'json-inline' })
|
||
})
|
||
|
||
test('file mode does not override the JSON invocation contract or require a combined image_to_image tag', async () => {
|
||
const model = fileRouteModel({ properties: {
|
||
image: { type: 'array', items: { type: 'string', format: 'uri' }, maxItems: 2 },
|
||
size: { const: '4096x4096' }, version: { const: 'flare' },
|
||
} })
|
||
model.service_offers[0].route_options[1].invocation.content_type = 'application/json'
|
||
const protocol = parseAiModelProtocols(model)[0]
|
||
expect(protocol).toMatchObject({ referenceTransport: 'json-inline', inputField: 'image', maxImages: 2, requestParameters: { size: '4096x4096', version: 'flare' }, routeId: 'file-route', scopeId: 'scope-one' })
|
||
expect(validateStoredAiModelProtocols(JSON.parse(JSON.stringify([protocol])))).toEqual([protocol])
|
||
send.mockResolvedValue(Response.json({ data: [{ b64_json: png.toString('base64') }] }))
|
||
await callAiModelProtocol({ ...base, protocol, images: [{ buffer: png, mimeType: 'image/png' }] })
|
||
expect(send).toHaveBeenCalledTimes(1)
|
||
expect(typeof send.mock.calls[0][3]).toBe('string')
|
||
expect(JSON.parse(send.mock.calls[0][3])).toMatchObject({ image: [expect.stringMatching(/^data:image\/png;base64,/)], size: '4096x4096', version: 'flare', n: 1, response_format: 'b64_json' })
|
||
expect(send.mock.calls[0][5]).toEqual({ 'X-Relay-Route-Option': 'file-route' })
|
||
})
|
||
|
||
test('JSON-only routes reject HTTPS-only or conflicting reference item constraints before any invocation', () => {
|
||
for (const schema of [
|
||
{ properties: { image: { type: 'array', items: { type: 'string', pattern: '^https://' } } } },
|
||
{ properties: { image: { type: 'array', items: { type: 'string' } } }, allOf: [{ properties: { image: { type: 'array', items: { type: 'string', pattern: '^https://' } } } }] },
|
||
{ properties: { image: { type: 'array', items: { type: 'string', format: 'url' } } } },
|
||
]) {
|
||
const model = fileRouteModel(schema)
|
||
model.service_offers[0].route_options[1].invocation.content_type = 'application/json'
|
||
expect(parseAiModelProtocols(model)).toMatchObject([{ routeId: 'text-default', supportsImageInput: false }])
|
||
}
|
||
})
|
||
|
||
test('JSON file_urls without a declared image input mode does not become a reference adapter', () => {
|
||
const model = fileRouteModel({ properties: { image: false, file_urls: { type: 'array', items: { type: 'string', pattern: '^(https://|data:image/)' } } } })
|
||
const route = model.service_offers[0].route_options[1]
|
||
route.invocation.content_type = 'application/json'
|
||
route.input_modes = ['file', 'text']
|
||
expect(parseAiModelProtocols(model)).toMatchObject([{ routeId: 'text-default', supportsImageInput: false }])
|
||
})
|
||
|
||
test('a JSON reference rejection does not retry with multipart or another route', async () => {
|
||
const model = fileRouteModel({ properties: { image: { type: 'array', items: { type: 'string' } } } })
|
||
model.service_offers[0].route_options[1].invocation.content_type = 'application/json'
|
||
send.mockResolvedValue(Response.json({ error: { code: 'invalid_request' } }, { status: 400, headers: { 'x-request-id': 'json-rejected' } }))
|
||
await expect(callAiModelProtocol({ ...base, protocol: parseAiModelProtocols(model)[0], images: [{ buffer: png, mimeType: 'image/png' }] })).rejects.toMatchObject({ outcomeUnknown: false, requestId: 'json-rejected' })
|
||
expect(send).toHaveBeenCalledTimes(1)
|
||
expect(typeof send.mock.calls[0][3]).toBe('string')
|
||
expect(send.mock.calls[0][5]).toEqual({ 'X-Relay-Route-Option': 'file-route' })
|
||
})
|
||
|
||
test('multipart generations sends binary images and the exact declared size, not private data URIs', async () => {
|
||
const protocol = parseAiModelProtocols(fileRouteModel({ properties: { size: { enum: ['4096x4096'] }, version: { const: 'sunburst' }, response_format: { const: 'url' }, background: { enum: ['opaque'] } } }, { background: 'opaque' }))[0]
|
||
const generated = await sharp({ create: { width: 4096, height: 4096, channels: 3, background: '#00ff00' } }).png().toBuffer()
|
||
send.mockResolvedValue(Response.json({ data: [{ b64_json: generated.toString('base64') }] }))
|
||
const result = await callAiModelProtocol({ ...base, protocol, images: [{ buffer: png, mimeType: 'image/png' }, { buffer: png, mimeType: 'image/png' }] })
|
||
expect(send).toHaveBeenCalledTimes(1)
|
||
const body = send.mock.calls[0][3] as FormData
|
||
expect(body).toBeInstanceOf(FormData)
|
||
expect(body.get('size')).toBe('4096x4096')
|
||
expect(body.get('version')).toBe('sunburst')
|
||
expect(body.get('response_format')).toBe('url')
|
||
expect(body.get('background')).toBe('opaque')
|
||
expect(body.get('n')).toBe('1')
|
||
expect(body.has('file_urls')).toBe(false)
|
||
expect(body.getAll('image')).toHaveLength(2)
|
||
for (const file of body.getAll('image')) {
|
||
expect(file).toBeInstanceOf(Blob)
|
||
expect((file as File).type).toBe('image/png')
|
||
expect((await sharp(Buffer.from(await (file as File).arrayBuffer())).metadata()).width).toBe(32)
|
||
}
|
||
expect(send.mock.calls[0][5]).toEqual({ 'X-Relay-Route-Option': 'file-route' })
|
||
expect(result.summary).toMatchObject({ referenceTransport: 'multipart', requestParameters: { size: '4096x4096' }, generatedWidth: 4096, generatedHeight: 4096, storedWidth: 1440, storedHeight: 1440 })
|
||
})
|
||
|
||
test('selects the smallest declared generation size and honors allOf fixed values', () => {
|
||
const protocol = parseAiModelProtocols(fileRouteModel({
|
||
allOf: [{ properties: { version: { const: 'flare' }, quality: { enum: ['auto'] } } }],
|
||
properties: { size: { enum: ['4096x4096', '2048x2048', '1024x1024'] }, version: { type: 'string' }, quality: { enum: ['auto', 'high'] } },
|
||
}, { size: '4096x4096', version: 'sunburst', quality: 'high' }))[0]
|
||
expect(protocol.requestParameters).toMatchObject({ size: '1024x1024', version: 'flare', quality: 'auto' })
|
||
})
|
||
|
||
test.each([
|
||
{ properties: { n: { const: 2 } } },
|
||
{ properties: { n: false } },
|
||
{ properties: { n: { type: 'integer', not: { const: 1 } } } },
|
||
{ properties: { n: { oneOf: [{ const: 1 }, { type: 'integer' }] } } },
|
||
{ not: { required: ['image'] } },
|
||
{ oneOf: [{ required: ['image'] }, { required: ['model'] }] },
|
||
{ properties: { size: { enum: ['8192x8192'] } } },
|
||
{ required: ['callback_url'], properties: { callback_url: { type: 'string' } } },
|
||
{ allOf: [{ properties: { version: { const: 'a' } } }], properties: { version: { const: 'b' } } },
|
||
])('does not offer a reference route whose mandatory request constraints cannot be satisfied: %j', schema => {
|
||
expect(parseAiModelProtocols(fileRouteModel(schema))).toMatchObject([{ routeId: 'text-default', supportsImageInput: false }])
|
||
})
|
||
|
||
test.each([
|
||
{ type: 'array', items: { type: 'string', not: { pattern: '^data:' } } },
|
||
{ type: 'array', maxItems: 0, items: { type: 'string' } },
|
||
{ type: 'array', items: { type: 'string' }, not: { minItems: 1 } },
|
||
])('explicit negative array and item constraints never admit inline references: %j', image => {
|
||
const model = fileRouteModel({ properties: { image } })
|
||
model.service_offers[0].route_options[1].invocation.content_type = 'application/json'
|
||
expect(parseAiModelProtocols(model)).toMatchObject([{ routeId: 'text-default', supportsImageInput: false }])
|
||
})
|
||
|
||
test('file mode alone and undeclared reference fields remain awaiting adaptation', () => {
|
||
const model = fileRouteModel({ properties: { image: false } })
|
||
expect(parseAiModelProtocols(model)).toMatchObject([{ routeId: 'text-default', supportsImageInput: false }])
|
||
model.service_offers[0].route_options[1].input_modes = ['image_url', 'text']
|
||
expect(parseAiModelProtocols(model)).toMatchObject([{ supportsImageInput: false }])
|
||
})
|
||
|
||
test('stored multipart request validation rejects arbitrary fields, type changes and impossible transport combinations', () => {
|
||
const protocol = parseAiModelProtocols(fileRouteModel())[0]
|
||
for (const requestParameters of [{ ...protocol.requestParameters, callback_url: 'https://example.com' }, { ...protocol.requestParameters, n: 2 }, { ...protocol.requestParameters, size: '8192x8192' }, { ...protocol.requestParameters, response_format: true }]) {
|
||
expect(validateStoredAiModelProtocols([{ ...protocol, requestParameters }])).toEqual([])
|
||
}
|
||
expect(validateStoredAiModelProtocols([{ ...protocol, referenceTransport: 'json' }])).toEqual([])
|
||
expect(validateStoredAiModelProtocols([{ ...protocol, inputField: 'file_urls' }])).toEqual([])
|
||
expect(validateStoredAiModelProtocols([{ ...chat, referenceTransport: 'multipart', requestParameters: protocol.requestParameters }])).toEqual([])
|
||
expect(validateStoredAiModelProtocols([{ ...protocol, requestParameters: undefined }])).toEqual([])
|
||
})
|
||
|
||
const probeModel = (schema: any = { additionalProperties: true, properties: {} }, supported_fields: any[] = []) => ({
|
||
type: 'image', modality: 'image', capabilities: ['openai.images.generations', 'text_to_image'],
|
||
default_service_scope_id: 'scope-probe', default_route_option_id: 'route-probe',
|
||
service_offers: [{ service_scope: { id: 'scope-probe' }, public_operation_id: 'openai.images.generations.create', route_options: [{
|
||
id: 'route-probe', input_modes: ['text'], invocation: { ...invocation('/v1/images/generations', 'route-probe', schema), supported_fields },
|
||
}] }],
|
||
})
|
||
|
||
test('an explicitly open standard generations schema permits one bounded reference test with a persistent candidate marker', async () => {
|
||
const protocol = parseAiModelProtocols(probeModel())[0]
|
||
expect(protocol).toMatchObject({ id: 'openai-image-generate', supportsImageInput: true, inputField: 'image', referenceTransport: 'json-inline', referenceContract: 'probe-required', maxImages: 1, routeId: 'route-probe', scopeId: 'scope-probe', requestParameters: { n: 1, response_format: 'b64_json' } })
|
||
expect(validateStoredAiModelProtocols(JSON.parse(JSON.stringify([protocol])))).toEqual([protocol])
|
||
send.mockResolvedValue(Response.json({ data: [{ b64_json: png.toString('base64') }] }))
|
||
const result = await callAiModelProtocol({ ...base, protocol, images: [{ buffer: png, mimeType: 'image/png' }] })
|
||
expect(send).toHaveBeenCalledTimes(1)
|
||
expect(JSON.parse(send.mock.calls[0][3])).toMatchObject({ image: [expect.stringMatching(/^data:image\/png;base64,/)] })
|
||
expect(result.summary).toMatchObject({ referenceContract: 'probe-required', referenceTransport: 'json-inline', routeId: 'route-probe' })
|
||
})
|
||
|
||
test.each([
|
||
{},
|
||
{ additionalProperties: false },
|
||
{ additionalProperties: true, properties: { image: false } },
|
||
{ additionalProperties: true, properties: { image: { type: 'array', items: { type: 'string', pattern: '^https://' } } } },
|
||
{ additionalProperties: true, properties: { file_urls: { type: 'array', items: { type: 'string' } } } },
|
||
{ additionalProperties: true, allOf: [{ additionalProperties: false }] },
|
||
{ additionalProperties: true, allOf: [{ properties: { image: false } }] },
|
||
{ additionalProperties: true, patternProperties: { '^image': false } },
|
||
{ additionalProperties: true, propertyNames: { enum: ['model', 'prompt'] } },
|
||
])('a candidate never bypasses an unknown or negative reference constraint: %j', schema => {
|
||
expect(parseAiModelProtocols(probeModel(schema))).toMatchObject([{ supportsImageInput: false }])
|
||
})
|
||
|
||
test('whole-request negations are rejected before declared or candidate reference mapping', () => {
|
||
expect(parseAiModelProtocols(probeModel({ additionalProperties: true, not: { required: ['image'] } }))).toEqual([])
|
||
})
|
||
|
||
test('an explicitly disabled reference field and a nondefault route do not become probe candidates', () => {
|
||
expect(parseAiModelProtocols(probeModel(undefined, [{ name: 'image', state: 'offer_disabled' }]))).toMatchObject([{ supportsImageInput: false }])
|
||
const model = probeModel()
|
||
model.default_route_option_id = 'not-the-offered-route'
|
||
expect(parseAiModelProtocols(model)).toMatchObject([{ supportsImageInput: false }])
|
||
model.default_service_scope_id = 'different-scope'
|
||
expect(parseAiModelProtocols(model)).toEqual([])
|
||
})
|
||
|
||
test('deeply nested unknown constraints fail closed rather than disappearing from probe evidence', () => {
|
||
let schema: any = { properties: { image: false } }
|
||
for (let index = 0; index < 7; index++) schema = { allOf: [schema] }
|
||
expect(parseAiModelProtocols(probeModel({ additionalProperties: true, allOf: [schema] }))).toEqual([])
|
||
})
|
||
|
||
test('a declared reference route takes precedence over an open-schema default candidate', () => {
|
||
const model = fileRouteModel()
|
||
model.service_offers[0].route_options[0].invocation.input_schema = { additionalProperties: true, properties: {} }
|
||
expect(parseAiModelProtocols(model)).toMatchObject([{ routeId: 'file-route', referenceTransport: 'multipart' }])
|
||
expect(parseAiModelProtocols(model)[0].referenceContract).toBeUndefined()
|
||
})
|
||
|
||
test('candidate storage cannot switch transport, endpoint, count or identity, and multi-reference probes never reach the API', async () => {
|
||
const protocol = parseAiModelProtocols(probeModel())[0]
|
||
for (const mutation of [{ referenceContract: 'verified' }, { referenceTransport: 'multipart' }, { maxImages: 8 }, { scopeId: undefined }, { routeId: undefined }, { inputField: 'file_urls' }, { id: 'openai-chat', path: '/v1/chat/completions' }]) {
|
||
expect(validateStoredAiModelProtocols([{ ...protocol, ...mutation }])).toEqual([])
|
||
}
|
||
await expect(callAiModelProtocol({ ...base, protocol, images: [{ buffer: png, mimeType: 'image/png' }, { buffer: png, mimeType: 'image/png' }] })).rejects.toMatchObject({ outcomeUnknown: false, message: '当前模型最多可接收 1 张参考图,请减少图片' })
|
||
expect(send).not.toHaveBeenCalled()
|
||
})
|
||
|
||
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, message: '参考图传入方式尚未适配,请重新同步模型目录' })
|
||
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 })
|
||
})
|