n8n-nodes-teamorouter/test/mock-server.mjs
ed0ss 11619aaa7b Send and return images in the TeamoRouter action node
The action node now speaks the whole TeamoRouter surface instead of a
single prompt: chat messages carry text plus input images (binary or URL)
and can hand the answer to a Chat Trigger as an output field, images can
be generated or edited with the full parameter set (size, quality, n,
output format and compression, background, fidelity, mask) and every
result comes back as binary, as markdown for the chat, or both.
2026-10-22 16:40:04 +03:00

196 lines
7.6 KiB
JavaScript

// OpenAI-compatible stand-in for api.teamorouter.com — used by the node tests, no dependencies.
import { createServer } from 'node:http';
// 1x1 transparent PNG.
export const TINY_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==';
export const MODELS = [
{ id: 'gpt-5.6-sol', object: 'model', owned_by: 'openai', pricing: { prompt: 0.00000053, completion: 0.00000318 } },
{ id: 'claude-sonnet-5', object: 'model', owned_by: 'anthropic', pricing: { prompt: 0.0000005, completion: 0.00000252 } },
{ id: 'deepseek-flash-free', object: 'model', owned_by: 'deepseek' },
{ id: 'gemini-3.1-pro-preview', object: 'model', owned_by: 'google', pricing: { input: 0.29, output: 1.73 } },
{ id: 'gpt-image-2', object: 'model', owned_by: 'openai' },
{ id: 'nano-banana-2.1', object: 'model', owned_by: 'google' },
];
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** Minimal multipart reader: enough to check what the node uploaded. */
export function parseMultipart(rawBuffer, contentType) {
const boundary = /boundary=([^;]+)/.exec(contentType ?? '')?.[1];
if (boundary === undefined) return [];
const parts = [];
for (const chunk of rawBuffer.toString('latin1').split(`--${boundary}`)) {
const separator = chunk.indexOf('\r\n\r\n');
if (separator === -1) continue;
const header = chunk.slice(0, separator);
const name = /name="([^"]+)"/.exec(header)?.[1];
if (name === undefined) continue;
parts.push({ name, header, body: chunk.slice(separator + 4).replace(/\r\n$/, '') });
}
return parts;
}
/**
* Models whose IDs start with "fail-" make the mock answer with the matching HTTP status,
* "slow-model" delays the answer, everything else echoes the user message back.
*/
export function createMockServer({ apiKey = 'test-key' } = {}) {
const requests = [];
const handle = async (request, response) => {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const rawBuffer = Buffer.concat(chunks);
const rawBody = rawBuffer.toString('utf8');
let body;
try {
body = rawBody === '' ? undefined : JSON.parse(rawBody);
} catch {
body = undefined;
}
requests.push({ method: request.method, url: request.url, headers: request.headers, body, rawBody, rawBuffer });
const json = (status, payload) => {
response.writeHead(status, { 'content-type': 'application/json' });
response.end(JSON.stringify(payload));
};
if (!request.url.startsWith('/v1/')) {
return json(404, { error: { message: `unknown path ${request.url}` } });
}
if (request.headers.authorization !== `Bearer ${apiKey}`) {
return json(401, { error: { message: 'invalid api key' } });
}
if (request.method === 'GET' && request.url === '/v1/models') {
return json(200, { object: 'list', data: MODELS });
}
if (request.method === 'POST' && request.url === '/v1/chat/completions') {
const model = body?.model ?? '';
if (model.startsWith('fail-')) {
const status = Number(model.replace('fail-', ''));
return json(status, { error: { message: `forced failure ${status}` } });
}
if (model === 'slow-model') await delay(2500);
const userMessage = [...(body?.messages ?? [])].reverse().find((message) => message.role === 'user');
const contentParts = Array.isArray(userMessage?.content) ? userMessage.content : [];
const promptText = Array.isArray(userMessage?.content)
? contentParts.filter((part) => part.type === 'text').map((part) => part.text).join('')
: userMessage?.content ?? '';
const imageCount = contentParts.filter((part) => part.type === 'image_url').length;
const content = `echo: ${promptText}${imageCount === 0 ? '' : ` (+${imageCount} image${imageCount === 1 ? '' : 's'})`}`;
if (body?.stream === true) {
response.writeHead(200, { 'content-type': 'text/event-stream' });
response.write(
`data: ${JSON.stringify({ object: 'chat.completion.chunk', choices: [{ index: 0, delta: { content } }] })}\n\n`,
);
response.write('data: [DONE]\n\n');
return response.end();
}
// Stand-in for a model that answers with an image as well as text.
const images =
model === 'image-answering-model'
? [{ type: 'image_url', image_url: { url: `data:image/png;base64,${TINY_PNG_BASE64}` } }]
: undefined;
return json(200, {
object: 'chat.completion',
model,
choices: [
{
index: 0,
message: { role: 'assistant', content, ...(images === undefined ? {} : { images }) },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 11, completion_tokens: 4, total_tokens: 15 },
});
}
// LangChain's ChatOpenAI serves "gpt-5.6*" and "codex" model IDs through the Responses API.
if (request.method === 'POST' && request.url === '/v1/responses') {
const userMessage = (body?.input ?? []).find?.((message) => message.role === 'user');
const content = `echo: ${typeof body?.input === 'string' ? body.input : userMessage?.content ?? ''}`;
return json(200, {
id: 'resp_mock',
object: 'response',
created_at: 1,
status: 'completed',
model: body?.model,
output: [
{
type: 'message',
id: 'msg_mock',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: content, annotations: [] }],
},
],
usage: { input_tokens: 11, output_tokens: 4, total_tokens: 15 },
});
}
if (request.method === 'POST' && request.url === '/v1/images/generations') {
if (String(body?.prompt ?? '').includes('noimage')) {
return json(400, { error: { message: 'prompt rejected' } });
}
if (String(body?.prompt ?? '').includes('urlonly')) {
return json(200, { created: 1, data: [{ url: 'https://cdn.example.com/cat.png' }] });
}
const count = Number(body?.n ?? 1) > 1 ? Number(body.n) : 1;
return json(200, {
created: 1,
data: Array.from({ length: count }, () => ({ b64_json: TINY_PNG_BASE64 })),
});
}
if (request.method === 'POST' && request.url === '/v1/images/edits') {
const parts = parseMultipart(rawBuffer, request.headers['content-type']);
const field = (name) => parts.find((part) => part.name === name);
if (field('image') === undefined || field('prompt') === undefined) {
return json(400, { error: { message: 'image and prompt are required' } });
}
if (field('prompt').body.includes('noimage')) {
return json(400, { error: { message: 'prompt rejected' } });
}
const count = Number(field('n')?.body ?? 1) > 1 ? Number(field('n').body) : 1;
return json(200, {
created: 1,
data: Array.from({ length: count }, () => ({ b64_json: TINY_PNG_BASE64 })),
});
}
return json(404, { error: { message: `unknown route ${request.method} ${request.url}` } });
};
const server = createServer((request, response) => {
handle(request, response).catch((error) => {
response.writeHead(500, { 'content-type': 'application/json' });
response.end(JSON.stringify({ error: { message: String(error) } }));
});
});
return {
requests,
/** Starts on `port` (0 = random) and returns the base URL, e.g. http://127.0.0.1:4123 */
async listen(port = 0) {
await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve));
return `http://127.0.0.1:${server.address().port}`;
},
async close() {
await new Promise((resolve) => server.close(resolve));
},
};
}
if (process.argv[1]?.endsWith('mock-server.mjs') === true) {
const mock = createMockServer({ apiKey: process.env.MOCK_API_KEY ?? 'test-key' });
const baseUrl = await mock.listen(Number(process.env.PORT ?? 4123));
console.log(`mock TeamoRouter listening on ${baseUrl}/v1 (api key: ${process.env.MOCK_API_KEY ?? 'test-key'})`);
}