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

439 lines
34 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([
[{ 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 })
})