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

605 lines
49 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() })
const parameterizedAsyncModel = (properties: Record<string, any> = {}, required = ['model', 'file_urls']) => ({
type: 'image', modality: 'image', capabilities: ['image_to_image'], input_modes: ['text', 'image_base64'],
invocation: { method: 'POST', path: '/extensions/v1/tasks', content_type: 'application/json', headers: {},
input_schema: { type: 'object', additionalProperties: false, required, properties: {
model: { type: 'string' }, prompt: { type: 'string' }, file_urls: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string', pattern: '^(https://|data:image/)' } }, ...properties,
} }, request_example: { resolution: '4K' } },
})
test('async image tasks persist and transmit their declared parameters without adding Images-only fields', async () => {
const protocol = parseAiModelProtocols(parameterizedAsyncModel({
size: { enum: ['auto', '1024x1024'], default: 'auto' }, resolution: { enum: ['1K', '2K', '4K'], default: '1K' },
aspect_ratio: { enum: ['1:1', '3:4'], default: '3:4' }, quality: { enum: ['medium', 'high'], default: 'medium' },
n: false, response_format: false,
}))[0]
expect(protocol).toMatchObject({ requestParameters: { size: 'auto', resolution: '1K', aspect_ratio: '3:4', quality: 'medium' } })
expect(validateStoredAiModelProtocols(JSON.parse(JSON.stringify([protocol])))).toEqual([protocol])
send.mockResolvedValueOnce(Response.json({ id: 'accepted-parameter-task' })).mockResolvedValueOnce(Response.json({ ready: true, status: 'succeeded', result: { outputs: [{ type: 'image', b64_json: png.toString('base64') }] } }))
const result = await callAiModelProtocol({ ...base, protocol, images: [{ buffer: png, mimeType: 'image/png' }], onTaskSubmitted: jest.fn() })
const body = JSON.parse(send.mock.calls[0][3])
expect(body).toMatchObject({ size: 'auto', resolution: '1K', aspect_ratio: '3:4', quality: 'medium', file_urls: [expect.stringMatching(/^data:image\/png;base64,/)] })
expect(body.n).toBeUndefined(); expect(body.response_format).toBeUndefined()
expect(send.mock.calls[1].slice(2, 6)).toEqual(['/extensions/v1/tasks/accepted-parameter-task/result', undefined, undefined, {}])
expect(result.summary.requestParameters).toEqual(protocol.requestParameters)
})
test.each([
[{ n: { const: 2 } }, ['model', 'file_urls']],
[{ callback_url: { type: 'string' } }, ['model', 'file_urls', 'callback_url']],
[{ size: { const: '1024x1024' }, resolution: { const: '4K' } }, ['model', 'file_urls']],
] as const)('an async route cannot omit or contradict required public parameters: %j', (properties, required) => {
expect(parseAiModelProtocols(parameterizedAsyncModel(properties, [...required]))).toEqual([])
expect(send).not.toHaveBeenCalled()
})
test('a canonical pixel size omits the optional resolution example instead of sending conflicting aliases', () => {
const protocol = parseAiModelProtocols(parameterizedAsyncModel({ size: { const: '1024x1024' }, resolution: { enum: ['1K', '2K', '4K'] } }))[0]
expect(protocol.requestParameters).toEqual({ size: '1024x1024' })
expect(validateStoredAiModelProtocols([{ ...asyncImage, requestParameters: { resolution: '8K' } }])).toEqual([])
expect(validateStoredAiModelProtocols([{ ...chat, requestParameters: { resolution: '1K' } }])).toEqual([])
})
test('an explicitly published async image operation is bound before its synchronous counterpart', () => {
const image = { type: 'array', items: { type: 'string', pattern: '^(https://|data:image/)' } }
const model = { type: 'image', modality: 'image', capabilities: ['image_to_image'], input_modes: ['text', 'image_base64'],
default_service_scope_id: 'same-scope', default_route_option_id: 'same-route', service_offers: [
{ service_scope: { id: 'same-scope' }, public_operation_id: 'openai.images.generations.create', route_options: [{ id: 'same-route', invocation: invocation('/v1/images/generations', 'same-route', { properties: { image } }) }] },
{ service_scope: { id: 'same-scope' }, public_operation_id: 'media.tasks.create', route_options: [{ id: 'same-route', invocation: invocation('/extensions/v1/tasks', 'same-route', { properties: { file_urls: image } }) }] },
] }
expect(parseAiModelProtocols(model).map(protocol => protocol.id)).toEqual(['async-image-task', 'openai-image-generate'])
expect(parseAiModelProtocols(model).every(protocol => protocol.routeId === 'same-route' && protocol.scopeId === 'same-scope')).toBe(true)
})
test('recoverable tasks are preferred inside one offer but never across service scopes', () => {
const image = { type: 'array', items: { type: 'string', pattern: '^(https://|data:image/)' } }
const model = { type: 'image', modality: 'image', capabilities: ['image_to_image'], input_modes: ['text', 'image_base64'], default_service_scope_id: 'same-scope', default_route_option_id: 'sync-route', service_offers: [
{ service_scope: { id: 'same-scope' }, route_options: [
{ id: 'sync-route', invocation: invocation('/v1/images/generations', 'sync-route', { properties: { image } }) },
{ id: 'async-route', invocation: invocation('/extensions/v1/tasks', 'async-route', { properties: { file_urls: image } }) },
] },
] }
expect(parseAiModelProtocols(model)).toMatchObject([{ id: 'async-image-task', routeId: 'async-route', scopeId: 'same-scope' }])
const separateScope = { ...model, service_offers: [
{ service_scope: { id: 'same-scope' }, route_options: model.service_offers[0].route_options.slice(0, 1) },
{ service_scope: { id: 'other-scope' }, route_options: model.service_offers[0].route_options.slice(1) },
] }
expect(parseAiModelProtocols(separateScope)).toMatchObject([{ id: 'openai-image-generate', routeId: 'sync-route', scopeId: 'same-scope' }])
})
test('retained image response is fetched read-only and decoded through the existing private asset guard', async () => {
const requestId = '1c8c2fa5-5ad0-436b-977f-a047a64fa845', guard = jest.fn()
send.mockResolvedValue(Response.json({ data: [{ url: 'https://models.example.com/api/external/assets/' + 'a'.repeat(64) }] }))
output.mockResolvedValue(new Response(new Uint8Array(png)))
const result = await callAiModelProtocol({ ...base, protocol: { id: 'openai-image-generate', path: '/v1/images/generations', headers: {}, supportsImageInput: false }, resumeRequestId: requestId, beforeRequest: guard })
expect(send).toHaveBeenCalledTimes(1)
expect(send).toHaveBeenCalledWith(base.baseUrl, base.apiKey, `/api/external/requests/${requestId}/result`, undefined, undefined, {}, guard)
expect(output).toHaveBeenCalledWith(base.baseUrl, base.apiKey, expect.stringContaining('/api/external/assets/'), guard)
expect(result.requestId).toBe(requestId)
expect(result.summary).toMatchObject({ retainedResponse: true, storedWidth: 32 })
expect((await sharp(result.image).metadata()).width).toBe(32)
})
test.each([202, 401, 403, 404, 410, 429, 500, 524])('unavailable retained response HTTP %i stays unknown without POST or retry', async status => {
const requestId = '1c8c2fa5-5ad0-436b-977f-a047a64fa845'
send.mockResolvedValue(Response.json({ error: { code: 'not_available' } }, { status }))
await expect(callAiModelProtocol({ ...base, protocol: { id: 'openai-image-generate', path: '/v1/images/generations', headers: {}, supportsImageInput: false }, resumeRequestId: requestId })).rejects.toMatchObject({ outcomeUnknown: true, requestId })
expect(send).toHaveBeenCalledTimes(1); expect(output).not.toHaveBeenCalled()
})
test('invalid retained request id and incompatible asynchronous protocol make no request', async () => {
await expect(callAiModelProtocol({ ...base, protocol: chat, resumeRequestId: '../other' })).rejects.toThrow('原请求编号无效')
await expect(callAiModelProtocol({ ...base, protocol: asyncImage, resumeRequestId: '1c8c2fa5-5ad0-436b-977f-a047a64fa845', onTaskSubmitted: jest.fn() })).rejects.toThrow('原请求编号无效')
expect(send).not.toHaveBeenCalled()
})
test.each(['openai-chat', 'anthropic-messages'] as const)('retained %s text uses exactly one GET and keeps the original request identity', async id => {
const requestId = '1c8c2fa5-5ad0-436b-977f-a047a64fa845', guard = jest.fn()
const protocol = { id, path: id === 'openai-chat' ? '/v1/chat/completions' : '/v1/messages', headers: {}, supportsImageInput: true }
send.mockResolvedValue(Response.json({ model: base.modelId, ...(id === 'openai-chat' ? { choices: [{ finish_reason: 'stop', message: { content: '原有文字' } }] } : { content: [{ type: 'thinking', thinking: 'do not import' }, { type: 'text', text: '原有文字' }], stop_reason: 'end_turn' }) }, { headers: { 'x-request-id': 'this-get-has-another-id' } }))
const result = await callAiModelProtocol({ ...base, protocol, images: [{ buffer: Buffer.from('not-needed-for-get'), mimeType: 'image/png' }], resumeRequestId: requestId, beforeRequest: guard })
expect(send).toHaveBeenCalledTimes(1)
expect(send).toHaveBeenCalledWith(base.baseUrl, base.apiKey, `/api/external/requests/${requestId}/result`, undefined, undefined, {}, guard)
expect(result).toMatchObject({ text: '原有文字', requestId, summary: { retainedResponse: true } })
expect(output).not.toHaveBeenCalled()
})
test('retained GET does not validate, decode or transmit the inputs of a new generation', async () => {
send.mockResolvedValue(Response.json({ model: base.modelId, choices: [{ message: { content: '原有结果' }, finish_reason: 'stop' }] }))
await expect(callAiModelProtocol({ ...base, protocol: { ...chat, maxPromptLength: 1, maxImages: 1, supportsImageInput: false }, prompt: '这些新提示词不能妨碍原结果读取', images: [{ buffer: Buffer.from('not-an-image'), mimeType: 'image/png' }, { buffer: Buffer.from('not-an-image'), mimeType: 'image/png' }], resumeRequestId: '1c8c2fa5-5ad0-436b-977f-a047a64fa845' })).resolves.toMatchObject({ text: '原有结果' })
expect(send).toHaveBeenCalledTimes(1)
expect(send.mock.calls[0][3]).toBeUndefined()
})
test.each(['length', 'max_tokens'])('a completed truncated %s response is a known failure and is never resent', async reason => {
const protocol = reason === 'length' ? chat : { id: 'anthropic-messages' as const, path: '/v1/messages', headers: {}, supportsImageInput: true }
send.mockResolvedValue(Response.json({ model: base.modelId, choices: [{ finish_reason: reason, message: { content: '{"incomplete":' } }], stop_reason: reason, content: [{ type: 'text', text: '{"incomplete":' }] }))
await expect(callAiModelProtocol({ ...base, protocol, resumeRequestId: '1c8c2fa5-5ad0-436b-977f-a047a64fa845' })).rejects.toMatchObject({ outcomeUnknown: false, requestId: '1c8c2fa5-5ad0-436b-977f-a047a64fa845', message: expect.stringContaining('结果不完整') })
expect(send).toHaveBeenCalledTimes(1)
expect(send.mock.calls[0][3]).toBeUndefined()
})
test.each(['tool_calls', 'function_call', 'tool_use'])('a %s result cannot execute tools or pass as completed text', async reason => {
const protocol = reason === 'tool_use' ? { id: 'anthropic-messages' as const, path: '/v1/messages', headers: {}, supportsImageInput: true } : chat
send.mockResolvedValue(Response.json({ choices: [{ finish_reason: reason, message: { content: '{"facts":["not-final"]}' } }], stop_reason: reason, content: [{ type: 'text', text: '{"facts":["not-final"]}' }] }))
await expect(callAiModelProtocol({ ...base, protocol })).rejects.toMatchObject({ outcomeUnknown: false, message: expect.stringContaining('未启用工具执行') })
expect(send).toHaveBeenCalledTimes(1)
})
test('parameter diagnostics reveal an allowlisted field without exposing upstream error text or internal names', async () => {
send.mockResolvedValueOnce(Response.json({ error: { code: 'unsupported_parameter', param: 'max_tokens', message: 'private supplier detail' } }, { status: 400 }))
await expect(callAiModelProtocol({ ...base, protocol: chat })).rejects.toMatchObject({ outcomeUnknown: false, message: '上游拒绝参数 max_tokens(HTTP 400);请核对该模型的参数合同' })
send.mockResolvedValueOnce(Response.json({ error: { code: 'invalid_parameter', param: 'internal_supplier_key', message: 'private supplier detail' } }, { status: 400 }))
await expect(callAiModelProtocol({ ...base, protocol: chat })).rejects.toMatchObject({ message: '上游拒绝调用参数(HTTP 400);请核对该模型的参数合同' })
})
test.each([
{ model: 'different-model', choices: [{ message: { content: 'foreign result' } }] },
{ choices: [{ message: { content: 'unbound result' } }] },
{ model: base.modelId, content: [{ type: 'text', text: 'wrong protocol' }] },
])('a foreign or unbound retained text response cannot resolve the original execution', async body => {
send.mockResolvedValue(Response.json(body))
await expect(callAiModelProtocol({ ...base, protocol: chat, resumeRequestId: '1c8c2fa5-5ad0-436b-977f-a047a64fa845' })).rejects.toMatchObject({ outcomeUnknown: true })
expect(send).toHaveBeenCalledTimes(1)
})
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 bounded reference tests 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: 8, 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('JSON image arrays retain all eight distinct image payloads in input order', async () => {
const protocol = parseAiModelProtocols(probeModel())[0]
const images = await Promise.all(Array.from({ length: 8 }, async (_, i) => ({ buffer: await sharp({ create: { width: 32, height: 32, channels: 3, background: { r: 20 + i * 20, g: 30, b: 240 - i * 20 } } }).png().toBuffer(), mimeType: 'image/png' })))
send.mockResolvedValue(Response.json({ data: [{ b64_json: png.toString('base64') }] }))
await callAiModelProtocol({ ...base, protocol, images })
const payloads: string[] = JSON.parse(send.mock.calls[0][3]).image
expect(payloads).toHaveLength(8); expect(new Set(payloads).size).toBe(8)
for (let i = 0; i < images.length; i++) {
const source = await sharp(images[i].buffer).raw().toBuffer(), sent = await sharp(Buffer.from(payloads[i].split(',')[1], 'base64')).raw().toBuffer()
expect(sent).toEqual(source)
}
expect(send).toHaveBeenCalledTimes(1)
})
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 or identity, and requests above the tested binding never reach the API', async () => {
const protocol = parseAiModelProtocols(probeModel())[0]
for (const mutation of [{ referenceContract: 'verified' }, { referenceTransport: 'multipart' }, { maxImages: 9 }, { scopeId: undefined }, { routeId: undefined }, { inputField: 'file_urls' }, { id: 'openai-chat', path: '/v1/chat/completions' }]) {
expect(validateStoredAiModelProtocols([{ ...protocol, ...mutation }])).toEqual([])
}
await expect(callAiModelProtocol({ ...base, protocol: { ...protocol, maxImages: 1 }, 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('a public load rejection is shown as busy without changing uncertain failures into retryable rejections', async () => {
send.mockResolvedValueOnce(Response.json({ error: { code: 'invalid_request', message: 'The model load is too high, please try again later' } }, { status: 400, headers: { 'x-request-id': 'busy-request' } }))
await expect(callAiModelProtocol({ ...base, protocol: chat })).rejects.toMatchObject({ outcomeUnknown: false, requestId: 'busy-request', message: '上游模型负载过高,本次请求已被拒绝,请稍后重新测试' })
send.mockResolvedValueOnce(Response.json({ error: { code: 'invalid_request', message: 'The model load is too high' } }, { status: 503 }))
await expect(callAiModelProtocol({ ...base, protocol: chat })).rejects.toMatchObject({ outcomeUnknown: true, message: '模型请求结果待核查,已暂停;请勿重复提交' })
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 })
})