import type { IBinaryData, IDataObject, IExecuteFunctions, IHttpRequestOptions, INodeExecutionData, INodeType, INodeTypeDescription, } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; import { modelLoader } from '../shared/loadModels'; import { errorMessage, hintForError, normalizeBaseUrl, parseExtraBody, type TeamorouterModel, } from '../shared/teamorouter'; interface ChatContentPart extends IDataObject { type?: string; text?: string; image_url?: { url?: string }; } interface ChatCompletionResponse extends IDataObject { model?: string; choices?: Array<{ finish_reason?: string; message?: { content?: string | null | ChatContentPart[]; images?: Array<{ image_url?: { url?: string }; url?: string }>; }; }>; usage?: IDataObject; } interface ImageResponse extends IDataObject { data?: Array<{ b64_json?: string; url?: string }>; } interface ChatOptions { systemPrompt?: string; temperature?: number; maxTokens?: number; topP?: number; forceJsonResponse?: boolean; serviceTier?: 'auto' | 'fast'; extraBody?: unknown; timeout?: number; } interface ImageOptions { size?: string; quality?: string; numberOfImages?: number; outputFormat?: string; outputCompression?: number; background?: string; moderation?: string; inputFidelity?: string; timeout?: number; } interface InputImage { source?: string; binaryProperty?: string; url?: string; } interface BinaryPart { buffer: Buffer; fileName: string; mimeType: string; } interface MultipartPart { name: string; value?: string; buffer?: Buffer; fileName?: string; mimeType?: string; } const MIME_BY_FORMAT: Record = { png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp', }; const EXTENSION_BY_FORMAT: Record = { png: 'png', jpeg: 'jpeg', webp: 'webp' }; /** Reads the binary property behind an input item and turns it into a data URL. */ async function binaryDataUrl( context: IExecuteFunctions, itemIndex: number, propertyName: string, ): Promise { const binary = await readBinary(context, itemIndex, propertyName); return `data:${binary.mimeType};base64,${binary.buffer.toString('base64')}`; } async function readBinary( context: IExecuteFunctions, itemIndex: number, propertyName: string, ): Promise { const metadata = context.getInputData()[itemIndex]?.binary?.[propertyName]; if (metadata === undefined) { throw new NodeOperationError( context.getNode(), `No binary data on the input item field "${propertyName}"`, { itemIndex }, ); } return { buffer: await context.helpers.getBinaryDataBuffer(itemIndex, propertyName), fileName: metadata.fileName ?? `${propertyName}.png`, mimeType: metadata.mimeType ?? 'image/png', }; } /** * Hand-rolled multipart body: n8n's httpRequest wants the `form-data` package, which a community * node cannot rely on, and /v1/images/edits is documented with `-F image=@file`. */ function buildMultipart(parts: MultipartPart[]): { body: Buffer; contentType: string } { const boundary = `----n8nTeamorouter${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`; const chunks: Buffer[] = []; for (const part of parts) { let header = `--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"`; if (part.fileName !== undefined) header += `; filename="${part.fileName}"`; header += '\r\n'; if (part.mimeType !== undefined) header += `Content-Type: ${part.mimeType}\r\n`; chunks.push( Buffer.from(`${header}\r\n`, 'utf8'), part.buffer ?? Buffer.from(part.value ?? '', 'utf8'), Buffer.from('\r\n', 'utf8'), ); } chunks.push(Buffer.from(`--${boundary}--\r\n`, 'utf8')); return { body: Buffer.concat(chunks), contentType: `multipart/form-data; boundary=${boundary}` }; } function splitDataUrl(url: string): { mimeType: string; base64: string } | undefined { const match = /^data:([^;,]+);base64,(.*)$/s.exec(url); return match === null ? undefined : { mimeType: match[1], base64: match[2] }; } /** Adds TeamoRouter specific hints to failed requests and keeps the original error otherwise. */ async function callApi(context: IExecuteFunctions, requestOptions: IHttpRequestOptions): Promise { try { return (await context.helpers.httpRequestWithAuthentication.call( context, 'teamorouterApi', requestOptions, )) as T; } catch (error) { const hint = hintForError(error); if (hint === undefined) throw error; throw new NodeOperationError(context.getNode(), `${hint} ${errorMessage(error)}`); } } /** Text plus optional images, in the shape every OpenAI-compatible vision model accepts. */ async function buildUserContent( context: IExecuteFunctions, itemIndex: number, prompt: string, images: InputImage[], ): Promise { if (images.length === 0) return prompt; const content: IDataObject[] = []; if (prompt !== '') content.push({ type: 'text', text: prompt }); for (const image of images) { const url = image.source === 'url' ? (image.url ?? '').trim() : await binaryDataUrl(context, itemIndex, image.binaryProperty ?? 'data'); if (url === '') { throw new NodeOperationError(context.getNode(), 'An input image has no URL', { itemIndex }); } content.push({ type: 'image_url', image_url: { url } }); } return content; } function buildChatBody( context: IExecuteFunctions, model: string, content: string | IDataObject[], options: ChatOptions, ): IDataObject { const messages: IDataObject[] = []; if (typeof options.systemPrompt === 'string' && options.systemPrompt !== '') { messages.push({ role: 'system', content: options.systemPrompt }); } messages.push({ role: 'user', content }); const body: IDataObject = { model, messages }; if (typeof options.temperature === 'number') body.temperature = options.temperature; if (typeof options.topP === 'number') body.top_p = options.topP; if (typeof options.maxTokens === 'number' && options.maxTokens > 0) body.max_tokens = options.maxTokens; if (options.forceJsonResponse === true) body.response_format = { type: 'json_object' }; if (options.serviceTier === 'fast') body.service_tier = 'fast'; return Object.assign(body, parseExtraBody(options.extraBody, context.getNode())); } /** The image parameters shared by /images/generations and /images/edits. */ function buildImageBody(model: string, prompt: string, options: ImageOptions): IDataObject { const body: IDataObject = { model, prompt }; if (options.size !== undefined && options.size !== '') body.size = options.size; if (options.quality !== undefined && options.quality !== '') body.quality = options.quality; if (typeof options.numberOfImages === 'number' && options.numberOfImages > 1) { body.n = options.numberOfImages; } if (options.outputFormat !== undefined && options.outputFormat !== '') { body.output_format = options.outputFormat; } if (typeof options.outputCompression === 'number' && options.outputCompression >= 0) { body.output_compression = options.outputCompression; } if (options.background !== undefined && options.background !== '') body.background = options.background; if (options.moderation !== undefined && options.moderation !== '') body.moderation = options.moderation; if (options.inputFidelity !== undefined && options.inputFidelity !== '') { body.input_fidelity = options.inputFidelity; } return body; } function extractChatText(response: ChatCompletionResponse): string { const content = response.choices?.[0]?.message?.content; if (typeof content === 'string') return content; if (Array.isArray(content)) { return content .filter((part) => typeof part?.text === 'string') .map((part) => part.text ?? '') .join(''); } return ''; } /** Image URLs a multimodal chat model may answer with, in either common response shape. */ function extractChatImages(response: ChatCompletionResponse): string[] { const message = response.choices?.[0]?.message; const urls: string[] = []; for (const image of message?.images ?? []) { const url = image?.image_url?.url ?? image?.url; if (typeof url === 'string' && url !== '') urls.push(url); } if (Array.isArray(message?.content)) { for (const part of message.content) { const url = part?.image_url?.url; if (typeof url === 'string' && url !== '') urls.push(url); } } return urls; } export class Teamorouter implements INodeType { description: INodeTypeDescription = { displayName: 'TeamoRouter', codex: { categories: ['AI'], subcategories: { AI: ['Root Nodes'], }, }, name: 'teamorouter', icon: 'file:teamorouter.svg', group: ['transform'], version: 1, subtitle: '={{$parameter["operation"] + " " + $parameter["resource"]}}', description: 'Call the TeamoRouter gateway: chat completions with text and images, image generation and editing, model list (GPT, Claude, Gemini, DeepSeek, GLM, Grok, Kimi)', defaults: { name: 'TeamoRouter', }, usableAsTool: true, inputs: ['main'], outputs: ['main'], credentials: [ { name: 'teamorouterApi', required: true, }, ], properties: [ { displayName: 'Resource', name: 'resource', type: 'options', noDataExpression: true, options: [ { name: 'Chat', value: 'chat', description: 'Send text and images to a chat model and get its answer', }, { name: 'Image', value: 'image', description: 'Generate an image from a prompt, or edit an existing image', }, { name: 'Model', value: 'model', description: 'Read the model list from the router', }, ], default: 'chat', }, // ---------------------------------------------------------------- chat { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, displayOptions: { show: { resource: ['chat'] } }, options: [ { name: 'Message a Model', value: 'message', description: 'Send a prompt and get the completion', action: 'Send a chat completion request', }, ], default: 'message', }, { displayName: 'Model', name: 'model', type: 'options', typeOptions: { loadOptionsMethod: 'getChatModels', }, default: '', required: true, displayOptions: { show: { resource: ['chat'] } }, description: 'Model ID to use. The list is read live from GET /v1/models. For an ID that is missing from the list, switch this field to expression mode and enter the ID.', }, { displayName: 'Prompt', name: 'prompt', type: 'string', default: '', typeOptions: { rows: 4 }, displayOptions: { show: { resource: ['chat'] } }, description: 'Message to send to the model. Optional when input images are attached.', }, { displayName: 'Input Images', name: 'images', type: 'fixedCollection', typeOptions: { multipleValues: true }, placeholder: 'Add Image', default: {}, displayOptions: { show: { resource: ['chat'] } }, description: 'Images to send along with the prompt. Needs a vision model, e.g. GPT, Claude or Gemini.', options: [ { name: 'image', displayName: 'Image', values: [ { displayName: 'Source', name: 'source', type: 'options', default: 'binary', options: [ { name: 'Binary File', value: 'binary', description: 'An image coming from an earlier node in the workflow', }, { name: 'Image URL', value: 'url', description: 'A link to the image, or a data:image/... URL', }, ], }, { displayName: 'Binary Property', name: 'binaryProperty', type: 'string', default: 'data', description: 'Name of the binary property that holds the image. Used when Source is Binary File.', }, { displayName: 'Image URL', name: 'url', type: 'string', default: '', placeholder: 'https://example.com/cat.png', description: 'Publicly reachable image link, or a data:image/...;base64,... URL. Used when Source is Image URL.', }, ], }, ], }, { displayName: 'Options', name: 'options', placeholder: 'Add Option', type: 'collection', default: {}, displayOptions: { show: { resource: ['chat'] } }, options: [ { displayName: 'Extra Body', name: 'extraBody', type: 'json', default: '', description: 'Extra JSON properties to merge into the request body, e.g. {"top_k": 40}. Overrides the fields set by the options above.', }, { displayName: 'Force JSON Response', name: 'forceJsonResponse', type: 'boolean', default: false, description: 'Whether to request a JSON object from the model. The prompt must mention JSON.', }, { displayName: 'Maximum Number of Tokens', name: 'maxTokens', type: 'number', default: -1, description: 'Maximum number of tokens to generate. -1 leaves it to the model.', }, { displayName: 'Sampling Temperature', name: 'temperature', type: 'number', default: 0.7, typeOptions: { maxValue: 2, minValue: 0, numberPrecision: 1 }, description: 'Controls randomness: lower values make the output more deterministic and repetitive', }, { displayName: 'Service Tier', name: 'serviceTier', type: 'options', default: 'auto', options: [ { name: 'Auto', value: 'auto', description: 'Standard processing' }, { name: 'Fast', value: 'fast', description: 'TeamoRouter Fast mode: up to 2.5x faster, billed at 2x the standard rate', }, ], description: 'Processing mode requested from TeamoRouter', }, { displayName: 'System Prompt', name: 'systemPrompt', type: 'string', default: '', typeOptions: { rows: 3 }, description: 'Optional instruction that sets the behaviour of the assistant', }, { displayName: 'Timeout', name: 'timeout', type: 'number', default: 600000, description: 'Time to wait for an answer in milliseconds. TeamoRouter allows responses up to 600 seconds.', }, { displayName: 'Top P', name: 'topP', type: 'number', default: 1, typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 2 }, description: 'Controls diversity via nucleus sampling. Change this or the temperature, not both.', }, ], }, // --------------------------------------------------------------- image { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, displayOptions: { show: { resource: ['image'] } }, options: [ { name: 'Generate an Image', value: 'generate', description: 'Create an image from a text prompt', action: 'Generate an image', }, { name: 'Edit an Image', value: 'edit', description: 'Change an existing image with a text instruction', action: 'Edit an image', }, ], default: 'generate', }, { displayName: 'Model', name: 'imageModel', type: 'options', typeOptions: { loadOptionsMethod: 'getImageModels', }, default: 'gpt-image-2', required: true, displayOptions: { show: { resource: ['image'] } }, description: 'Image model ID. The list is read live from GET /v1/models. For an ID that is missing from the list, switch this field to expression mode and enter the ID.', }, { displayName: 'Prompt', name: 'imagePrompt', type: 'string', default: '', required: true, typeOptions: { rows: 4 }, displayOptions: { show: { resource: ['image'] } }, description: 'Description of the image to generate, or of the change to apply when editing', }, { displayName: 'Input Image (Binary Property)', name: 'inputBinaryProperty', type: 'string', default: 'data', required: true, displayOptions: { show: { resource: ['image'], operation: ['edit'] } }, description: 'Name of the binary property that holds the image to edit', }, { displayName: 'Mask (Binary Property)', name: 'maskBinaryProperty', type: 'string', default: '', displayOptions: { show: { resource: ['image'], operation: ['edit'] } }, description: 'Optional binary property with a mask: transparent pixels mark the area to edit', }, { displayName: 'Put Output File in Field', name: 'binaryPropertyName', type: 'string', default: 'data', required: true, displayOptions: { show: { resource: ['image'] } }, description: 'Name of the binary property that holds the generated image. With several images, the next ones are suffixed with a number.', }, { displayName: 'Options', name: 'options', placeholder: 'Add Option', type: 'collection', default: {}, displayOptions: { show: { resource: ['image'] } }, options: [ { displayName: 'Background', name: 'background', type: 'options', default: 'auto', options: [ { name: 'Auto', value: 'auto', description: 'Let the model decide' }, { name: 'Opaque', value: 'opaque', description: 'Solid background, supported by every output format', }, { name: 'Transparent', value: 'transparent', description: 'Needs the PNG or WebP output format', }, ], description: 'Transparency of the generated image', }, { displayName: 'Input Fidelity', name: 'inputFidelity', type: 'options', default: 'high', options: [ { name: 'High', value: 'high', description: 'Keep the details of the source image, e.g. faces', }, { name: 'Low', value: 'low', description: 'Allow the model to reinterpret the source image more freely', }, ], description: 'How closely the edit follows the source image. Only used when editing an image.', }, { displayName: 'Moderation', name: 'moderation', type: 'options', default: 'auto', options: [ { name: 'Auto', value: 'auto', description: 'Standard content filter' }, { name: 'Low', value: 'low', description: 'Relaxed content filter' }, ], description: 'Content filter applied by TeamoRouter. Only used when generating an image.', }, { displayName: 'Number of Images', name: 'numberOfImages', type: 'number', default: 1, typeOptions: { minValue: 1 }, description: 'How many images to generate from this prompt', }, { displayName: 'Output Compression', name: 'outputCompression', type: 'number', default: -1, typeOptions: { minValue: -1, maxValue: 100 }, description: 'Compression level 0-100 for JPEG and WebP. -1 leaves the default.', }, { displayName: 'Output Format', name: 'outputFormat', type: 'options', default: 'png', options: [ { name: 'PNG', value: 'png', description: 'Lossless, supports transparency' }, { name: 'JPEG', value: 'jpeg', description: 'Smaller files, no transparency' }, { name: 'WebP', value: 'webp', description: 'Small files, supports transparency' }, ], description: 'File format the router returns', }, { displayName: 'Quality', name: 'quality', type: 'options', default: 'auto', options: [ { name: 'Auto', value: 'auto' }, { name: 'Low', value: 'low', description: 'Fastest and cheapest' }, { name: 'Medium', value: 'medium' }, { name: 'High', value: 'high' }, ], description: 'Rendering effort spent on the image', }, { displayName: 'Size', name: 'size', type: 'options', default: '1024x1024', options: [ { name: 'Auto', value: 'auto', description: 'Let the model pick a size' }, { name: '1024x1024 (Square)', value: '1024x1024' }, { name: '1536x1024 (Landscape)', value: '1536x1024' }, { name: '1024x1536 (Portrait)', value: '1024x1536' }, { name: '2048x2048', value: '2048x2048' }, ], description: 'Image size. Other values are allowed through an expression: both sides must be multiples of 16, the longest side at most 3840 and the aspect ratio at most 3:1.', }, { displayName: 'Timeout', name: 'timeout', type: 'number', default: 300000, description: 'Time to wait for the image in milliseconds. Generation usually takes 20-40 seconds.', }, ], }, { displayName: 'Image generation can take a while. The request waits up to 5 minutes before it fails.', name: 'imageNotice', type: 'notice', default: '', displayOptions: { show: { resource: ['image'] } }, }, // --------------------------------------------------------------- model { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, displayOptions: { show: { resource: ['model'] } }, options: [ { name: 'List Models', value: 'list', description: 'Read the models currently offered by TeamoRouter', action: 'List models', }, ], default: 'list', }, // -------------------------------------------------------------- output { displayName: 'Chat Reply', name: 'chatReply', type: 'boolean', default: false, noDataExpression: true, displayOptions: { show: { resource: ['chat', 'image'] } }, description: 'Whether to put the answer into an output field that a Chat Trigger sends back to the chat: the model text, or the generated image as markdown.', }, ], }; methods = { loadOptions: { getChatModels: modelLoader('chat'), getImageModels: modelLoader('image'), }, }; async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; const credentials = await this.getCredentials('teamorouterApi'); const baseURL = normalizeBaseUrl(credentials.baseUrl as string); for (let i = 0; i < items.length; i++) { try { const resource = this.getNodeParameter('resource', i) as string; const chatReply = this.getNodeParameter('chatReply', i, false) as boolean; if (resource === 'chat') { const model = this.getNodeParameter('model', i) as string; const prompt = (this.getNodeParameter('prompt', i, '') as string) ?? ''; const images = (this.getNodeParameter('images', i, {}) as { image?: InputImage[] }) .image ?? []; const options = this.getNodeParameter('options', i, {}) as ChatOptions; if (prompt.trim() === '' && images.length === 0) { throw new NodeOperationError( this.getNode(), 'Enter a Prompt or attach at least one input image', { itemIndex: i }, ); } const response = await callApi(this, { method: 'POST', baseURL, url: '/chat/completions', body: buildChatBody( this, model, await buildUserContent(this, i, prompt, images), options, ), json: true, timeout: typeof options.timeout === 'number' && options.timeout > 0 ? options.timeout : 600000, }); const text = extractChatText(response); const imageUrls = extractChatImages(response); const json: IDataObject = { text, model: response.model ?? model }; if (response.usage !== undefined) json.usage = response.usage; const finishReason = response.choices?.[0]?.finish_reason; if (typeof finishReason === 'string') json.finishReason = finishReason; const binary: Record = {}; const markdown: string[] = []; for (const [index, url] of imageUrls.entries()) { const inline = splitDataUrl(url); if (inline === undefined) { markdown.push(`![image](${url})`); continue; } const propertyName = index === 0 ? 'data' : `data${index}`; binary[propertyName] = await this.helpers.prepareBinaryData( Buffer.from(inline.base64, 'base64'), `teamo-image-${Date.now()}-${index}.${EXTENSION_BY_FORMAT[inline.mimeType.split('/')[1]] ?? 'png'}`, inline.mimeType, ); markdown.push(`![image](${url})`); } if (imageUrls.length > 0) json.images = imageUrls; const reply = [text, ...markdown].filter((part) => part !== '').join('\n\n'); if (chatReply) json.output = reply; returnData.push({ json, ...(Object.keys(binary).length > 0 ? { binary } : {}), pairedItem: { item: i }, }); continue; } if (resource === 'image') { const operation = this.getNodeParameter('operation', i) as string; const imageModel = this.getNodeParameter('imageModel', i) as string; const prompt = this.getNodeParameter('imagePrompt', i) as string; const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i) as string; const options = this.getNodeParameter('options', i, {}) as ImageOptions; const timeout = typeof options.timeout === 'number' && options.timeout > 0 ? options.timeout : 300000; const requestBody: IHttpRequestOptions = { method: 'POST', baseURL, url: '', json: true, timeout, }; if (operation === 'edit') { // The edits endpoint wants the picture itself, so it always goes as multipart. const parts: MultipartPart[] = []; for (const [name, value] of Object.entries(buildImageBody(imageModel, prompt, options))) { parts.push({ name, value: String(value) }); } const input = await readBinary( this, i, this.getNodeParameter('inputBinaryProperty', i, 'data') as string, ); parts.push({ name: 'image', ...input }); const maskProperty = this.getNodeParameter('maskBinaryProperty', i, '') as string; if (maskProperty !== '') { parts.push({ name: 'mask', ...(await readBinary(this, i, maskProperty)) }); } const multipart = buildMultipart(parts); requestBody.url = '/images/edits'; requestBody.body = multipart.body; requestBody.headers = { 'content-type': multipart.contentType }; } else { requestBody.url = '/images/generations'; requestBody.body = buildImageBody(imageModel, prompt, options); } const response = await callApi(this, requestBody); const results = (response.data ?? []).filter( (entry) => (typeof entry?.b64_json === 'string' && entry.b64_json !== '') || (typeof entry?.url === 'string' && entry.url !== ''), ); if (results.length === 0) { throw new NodeOperationError( this.getNode(), 'TeamoRouter returned no image data (expected data[].b64_json)', { itemIndex: i }, ); } const format = options.outputFormat ?? 'png'; const mimeType = MIME_BY_FORMAT[format] ?? 'image/png'; const extension = EXTENSION_BY_FORMAT[format] ?? 'png'; const markdown: string[] = []; const binary: Record = {}; for (const [index, result] of results.entries()) { const propertyName = index === 0 ? binaryPropertyName : `${binaryPropertyName}${index}`; if (typeof result.b64_json === 'string' && result.b64_json !== '') { binary[propertyName] = await this.helpers.prepareBinaryData( Buffer.from(result.b64_json, 'base64'), `teamo-image-${Date.now()}-${index}.${extension}`, mimeType, ); markdown.push(`![image](data:${mimeType};base64,${result.b64_json})`); } else if (typeof result.url === 'string') { markdown.push(`![image](${result.url})`); } } if (chatReply) { returnData.push({ json: { model: imageModel, prompt, output: markdown.join('\n\n') }, ...(Object.keys(binary).length > 0 ? { binary } : {}), pairedItem: { item: i }, }); continue; } results.forEach((result, index) => { const propertyName = index === 0 ? binaryPropertyName : `${binaryPropertyName}${index}`; const json: IDataObject = { model: imageModel, prompt }; if (results.length > 1) json.index = index; if (typeof result.url === 'string' && result.url !== '') json.url = result.url; returnData.push({ json, ...(binary[propertyName] !== undefined ? { binary: { [propertyName]: binary[propertyName] } } : {}), pairedItem: { item: i }, }); }); continue; } const response = await callApi<{ data?: TeamorouterModel[] } | TeamorouterModel[]>(this, { method: 'GET', baseURL, url: '/models', json: true, timeout: 60000, }); const models = Array.isArray(response) ? response : response?.data ?? []; returnData.push( ...this.helpers.constructExecutionMetaData( this.helpers.returnJsonArray(models as unknown as IDataObject[]), { itemData: { item: i } }, ), ); } catch (error) { if (this.continueOnFail()) { returnData.push( ...this.helpers.constructExecutionMetaData( this.helpers.returnJsonArray({ error: errorMessage(error) }), { itemData: { item: i } }, ), ); continue; } throw error; } } return [returnData]; } }