Add TeamoRouter nodes for n8n: chat model sub-node, chat/image/model action node, credentials, tests

This commit is contained in:
agent 2026-10-22 14:02:23 +03:00
commit 775ba0a6d9
18 changed files with 3294 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
*.tgz
npm-debug.log*
.DS_Store

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ITO Service
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

173
README.md Normal file
View file

@ -0,0 +1,173 @@
# n8n-nodes-teamorouter
Кастомные узлы n8n для шлюза **TeamoRouter** (`https://api.teamorouter.com/v1`) — OpenAI-совместимого роутера к GPT, Claude, Gemini, DeepSeek, GLM, Grok, Kimi и другим моделям.
Пакет содержит два узла и один тип учётных данных:
| Узел | Тип | Назначение |
| --- | --- | --- |
| **TeamoRouter Chat Model** (`lmChatTeamRouter`) | sub-node, `ai_languageModel` | Подключается к **AI Agent**, **Basic LLM Chain**, **Question and Answer Chain** и любым другим узлам, которые принимают Language Model |
| **TeamoRouter** (`teamorouter`) | обычный узел | Три операции: `Chat → Message a Model`, `Image → Generate an Image`, `Model → List Models` |
| **TeamoRouter API** (`teamorouterApi`) | credentials | API-ключ + Base URL |
---
## 1. Установка
### Вариант А. Через интерфейс n8n (рекомендуется)
Community nodes в n8n ставятся из npm, поэтому пакет должен быть опубликован:
1. Выгрузите папку в свой git-репозиторий.
2. Опубликуйте в npm:
```bash
npm install
npm run build
npm publish --access public
```
3. В n8n: **Settings → Community nodes → Install a community node**, введите `n8n-nodes-teamorouter`, подтвердите предупреждение.
4. Перезапускать n8n не нужно — узлы появляются сразу.
Нужна роль Owner/Admin и включённые community nodes. Community nodes ставятся на весь инстанс.
### Вариант Б. Копированием папки (если есть доступ к серверу n8n)
1. `npm install && npm run build` — на выходе нужна папка `dist/`.
2. Положите **всю папку пакета** (в ней обязателен `package.json`) в каталог пользовательских расширений, например:
* в Docker: `/home/node/.n8n/custom/n8n-nodes-teamorouter/`
* через переменную окружения: `N8N_CUSTOM_EXTENSIONS=/path/to/custom-nodes`, а внутри уже папка пакета
3. Внутри папки пакета один раз выполните `npm install --omit=dev`, чтобы `n8n-workflow` (peer-зависимость) был доступен из вашей папки.
4. Перезапустите n8n.
### Вариант В. Локальная разработка
```bash
npm install
npm run build:watch # tsc в режиме наблюдения
export N8N_CUSTOM_EXTENSIONS="$(pwd)"
npx n8n start
```
---
## 2. Учётные данные
**Settings → Credentials → New → TeamoRouter API**
| Поле | Значение |
| --- | --- |
| **API Key** | ключ из панели TeamoRouter, начинается с `sk-teamo-` |
| **Base URL** | `https://api.teamorouter.com/v1` (по умолчанию; меняйте только для зеркала `.cn` или своего роутера) |
Кнопка проверки делает `GET /v1/models` с заголовком `Authorization: Bearer <ключ>`.
---
## 3. TeamoRouter Chat Model (для AI Agent)
1. Добавьте **AI Agent** (или **Basic LLM Chain**).
2. В слот **Chat Model** добавьте **TeamoRouter Chat Model**.
3. Выберите модель из списка — он читается вживую из `GET /v1/models`. В подписи к модели видны провайдер и цена за 1M токенов (если роутер их отдаёт).
### Опции
| Опция | Что делает |
| --- | --- |
| **Sampling Temperature** | случайность ответа, 0…2 |
| **Maximum Number of Tokens** | лимит генерации; `-1` = решает модель |
| **Top P** | nucleus sampling; меняйте либо temperature, либо topP |
| **Force JSON Response** | просит модель отвечать объектом JSON (в промпте должно быть слово JSON) |
| **Service Tier → Fast** | режим Fast у TeamoRouter: до 2,5× быстрее, тарифицируется ×2 |
| **Extra Body** | произвольные JSON-поля в тело запроса, например `{"top_k": 40}`; перекрывает всё выше |
| **Timeout** | по умолчанию 600000 мс (роутер допускает ответы до 600 с) |
| **Max Retries** | повторы при ошибке, по умолчанию 2 |
### Модель, которой нет в списке
Поле **Model** можно переключить в режим выражения (переключатель `Expression` в правом верхнем углу поля) и вписать ID руками:
```
{{ 'some-new-model-id' }}
```
Это пригодится, если модель появилась у роутера, а список в UI ещё не перечитан.
---
## 4. Узел TeamoRouter (обычные операции)
### Chat → Message a Model
Отправляет `POST /v1/chat/completions` и возвращает JSON-ответ как есть.
| Поле | Описание |
| --- | --- |
| **Model** | список читается из `GET /v1/models` (только текстовые модели; картинные скрыты) |
| **Prompt** | сообщение пользователя |
| **Options → System Prompt** | системная инструкция |
| **Options →** остальное | temperature, topP, max tokens, JSON-ответ, Service Tier Fast, Extra Body, Timeout |
Текст ответа доступен как `{{ $json.choices[0].message.content }}`, расход токенов — `{{ $json.usage }}`.
### Image → Generate an Image
Отправляет `POST /v1/images/generations` и кладёт результат **в бинарное поле** (по умолчанию `data`, PNG).
| Поле | Описание |
| --- | --- |
| **Model** | по умолчанию `gpt-image-2`, список — только картинные модели; можно вписать ID выражением |
| **Prompt** | описание картинки |
| **Put Output File in Field** | имя бинарного свойства |
Дальше файл уходит в **Write Binary File**, в Telegram, в S3 и т. д. Долгие генерации узел ждёт до 5 минут.
### Model → List Models
Возвращает модели роутера: по одному элементу на модель (`id`, `object`, `owned_by`, `pricing` и др.). Удобно, чтобы построить собственный справочник или посчитать цены.
---
## 5. Известные особенности и ограничения
* **Ответы идут на `/v1/chat/completions`.** Исключение — модель из семейства `gpt-5.6*` и всё, что содержит `codex`: LangChain внутри n8n сам переключает такие ID на `/v1/responses` (это поведение зашито в `@langchain/openai` и не отключается настройками). TeamoRouter поддерживает `/v1/responses`, но **только для GPT-моделей**, поэтому схема рабочая. Для Claude/Gemini запросы идут обычным Chat Completions.
* **Claude через OpenAI-формат не отдаёт prompt caching.** Это ограничение формата роутера, а не узла: кэширование промпта доступно только через нативный протокол Anthropic (в README TeamoRouter это указано отдельно).
* **`max_tokens`, а не `max_completion_tokens`.** Узел отправляет именно `max_tokens` — как в примере из документации TeamoRouter. Если конкретная модель требует `max_completion_tokens`, добавьте его через **Extra Body**.
* **Формат ответа JSON** передаётся через `response_format` — это параметр Chat Completions. Для моделей, которые уходят на `/v1/responses`, он может игнорироваться.
* **Список картинных моделей** определяется по имени: `*-image`, `gpt-image*`, `nano-banana*`, `dall-e*`, `imagen*`. Если роутер назовёт модель иначе, она попадёт в список чата (или наоборот); при этом поле всегда можно заполнить вручную выражением.
* **Цены в списке моделей** — подсказка. Роутер может отдавать цену за токен или уже за 1M токенов; узел считает значения меньше `0.005` как «за токен». Это только отображение, на запросы не влияет.
* У полей **Model** список загружается при открытии, кэша нет: если модель только что появилась, нажмите перезагрузить список или используйте выражение.
---
## 6. Разработка и проверки
```bash
npm install
npm run build # tsc + копирование иконок в dist/
npm test # проверка структуры пакета + тесты узлов на мок-сервере
npm run typecheck
npm run mock # мок TeamoRouter на http://127.0.0.1:4123/v1 (ключ test-key)
```
`npm test` запускает два набора:
1. `test/validate-nodes.mjs` — имитирует загрузку пакета n8n: проверяет описание узлов, наличие иконок, реализацию `loadOptionsMethod`, ссылки на credential, тип соединения `ai_languageModel` у саб-узла.
2. `test/run-node-tests.mjs` — поднимает мок роутера (`test/mock-server.mjs`, без зависимостей) и прогоняет скомпилированные узлы с настоящим HTTP и настоящим `@langchain/openai`: список моделей и фильтрация картинных, `supplyData` + реальный вызов модели, проброс опций (`service_tier`, `extra_body`, лимиты), обе операции у обычного узла, генерация изображения в бинарном виде, разбор ошибок 401/402/404, `Continue On Fail` и таймаут.
Тесты не требуют ключа TeamoRouter. Модели `fail-401`, `fail-402`, `fail-404`, `fail-500` в моке принудительно отдают ошибку, `slow-model` отвечает через 2,5 с.
---
## 7. Первый запуск и диагностика
* Пакет собран и проверен автотестами (см. `npm test`), но **в живом n8n ещё не запускался** — первый запуск делайте на тестовом воркфлоу.
* При установке через `Settings → Community nodes` интерфейс может показать предупреждение о сообщественном узле — это нормально.
* Если n8n пишет, что узел не загрузился, проверьте `Help → About` (версия n8n) и логи контейнера: чаще всего причина — недоступная peer-зависимость `n8n-workflow` (Вариант Б, шаг 3).
## Лицензия
MIT

View file

@ -0,0 +1,51 @@
import type {
IAuthenticateGeneric,
ICredentialTestRequest,
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class TeamorouterApi implements ICredentialType {
name = 'teamorouterApi';
displayName = 'TeamoRouter API';
documentationUrl = 'https://api.teamorouter.com/docs/api-integration';
properties: INodeProperties[] = [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string',
typeOptions: { password: true },
required: true,
default: '',
description: 'API key from the TeamoRouter dashboard. Starts with sk-teamo-',
},
{
displayName: 'Base URL',
name: 'baseUrl',
type: 'string',
required: true,
default: 'https://api.teamorouter.com/v1',
description: 'OpenAI-compatible base URL. Change it only for the .cn mirror or a self-hosted router',
},
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
headers: {
Authorization: '=Bearer {{$credentials.apiKey}}',
},
},
};
test: ICredentialTestRequest = {
request: {
baseURL: '={{ $credentials.baseUrl }}',
url: '/models',
method: 'GET',
},
};
}

View file

@ -0,0 +1,200 @@
import type {
INodeType,
INodeTypeDescription,
ISupplyDataFunctions,
SupplyData,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { loadChatOpenAi, loadN8nLlmTracing } from '../shared/langchainModules';
import { modelLoader } from '../shared/loadModels';
import { errorMessage, normalizeBaseUrl, parseExtraBody } from '../shared/teamorouter';
interface ChatModelOptions {
temperature?: number;
maxTokens?: number;
topP?: number;
forceJsonResponse?: boolean;
serviceTier?: 'auto' | 'fast';
extraBody?: unknown;
timeout?: number;
maxRetries?: number;
}
export class LmChatTeamRouter implements INodeType {
description: INodeTypeDescription = {
displayName: 'TeamoRouter Chat Model',
name: 'lmChatTeamRouter',
icon: 'file:teamorouter.svg',
group: ['transform'],
version: 1,
description:
'Chat model served by the TeamoRouter gateway (GPT, Claude, Gemini, DeepSeek, GLM, Grok, Kimi)',
defaults: {
name: 'TeamoRouter Chat Model',
},
inputs: [],
outputs: [NodeConnectionTypes.AiLanguageModel],
outputNames: ['Model'],
credentials: [
{
name: 'teamorouterApi',
required: true,
},
],
properties: [
{
displayName: 'Model',
name: 'model',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getChatModels',
},
default: '',
required: true,
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: 'Options',
name: 'options',
placeholder: 'Add Option',
type: 'collection',
default: {},
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: 'Max Retries',
name: 'maxRetries',
type: 'number',
default: 2,
description: 'Maximum number of retries for failed requests',
},
{
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: '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.',
},
],
},
],
};
methods = {
loadOptions: {
getChatModels: modelLoader('chat'),
},
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('teamorouterApi');
const modelName = this.getNodeParameter('model', itemIndex) as string;
if (typeof modelName !== 'string' || modelName === '') {
throw new NodeOperationError(
this.getNode(),
'No model selected. Pick one from the list or switch the field to expression mode and enter the model ID.',
);
}
const options = this.getNodeParameter('options', itemIndex, {}) as ChatModelOptions;
const modelKwargs: Record<string, unknown> = {};
if (options.forceJsonResponse === true) modelKwargs.response_format = { type: 'json_object' };
if (options.serviceTier === 'fast') modelKwargs.service_tier = 'fast';
Object.assign(modelKwargs, parseExtraBody(options.extraBody, this.getNode()));
const config: Record<string, unknown> = {
apiKey: credentials.apiKey,
model: modelName,
configuration: { baseURL: normalizeBaseUrl(credentials.baseUrl as string) },
timeout: typeof options.timeout === 'number' && options.timeout > 0 ? options.timeout : 600000,
maxRetries: typeof options.maxRetries === 'number' ? options.maxRetries : 2,
// Keep the plain Chat Completions endpoint unless the installed LangChain decides otherwise
// (it always uses the Responses API for "gpt-5.6*" and "codex" IDs, which TeamoRouter supports too).
useResponsesApi: false,
};
if (typeof options.temperature === 'number') config.temperature = options.temperature;
if (typeof options.topP === 'number') config.topP = options.topP;
if (typeof options.maxTokens === 'number' && options.maxTokens > 0) {
config.maxTokens = options.maxTokens;
}
if (Object.keys(modelKwargs).length > 0) config.modelKwargs = modelKwargs;
let model: unknown;
try {
const ChatOpenAi = loadChatOpenAi();
model = new ChatOpenAi(config);
} catch (error) {
throw new NodeOperationError(this.getNode(), errorMessage(error));
}
const tracing = loadN8nLlmTracing(this);
if (tracing !== undefined) {
(model as { callbacks?: unknown[] }).callbacks = [tracing];
}
return { response: model };
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60" fill="none"><rect x="6" y="6" width="48" height="48" rx="12" fill="#4F46E5"/><circle cx="20" cy="20" r="4" fill="#fff"/><circle cx="40" cy="20" r="4" fill="#fff"/><circle cx="30" cy="40" r="4" fill="#fff"/><path d="M20 20h20M20 20l10 20M40 20L30 40" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 397 B

View file

@ -0,0 +1,435 @@
import type {
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 ChatCompletionResponse extends IDataObject {
choices?: Array<{ message?: { content?: string | null } }>;
}
interface ImageResponse extends IDataObject {
data?: Array<{ b64_json?: string }>;
}
interface ChatOptions {
systemPrompt?: string;
temperature?: number;
maxTokens?: number;
topP?: number;
forceJsonResponse?: boolean;
serviceTier?: 'auto' | 'fast';
extraBody?: unknown;
timeout?: number;
}
/** Adds TeamoRouter specific hints to failed requests and keeps the original error otherwise. */
async function callApi<T>(context: IExecuteFunctions, requestOptions: IHttpRequestOptions): Promise<T> {
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)}`);
}
}
function buildChatBody(
context: IExecuteFunctions,
model: string,
prompt: string,
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: prompt });
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()));
}
export class Teamorouter implements INodeType {
description: INodeTypeDescription = {
displayName: 'TeamoRouter',
name: 'teamorouter',
icon: 'file:teamorouter.svg',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description:
'Call the TeamoRouter gateway: chat completions, model list and image generation (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 a prompt to a chat model',
},
{
name: 'Image',
value: 'image',
description: 'Generate an 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: '',
required: true,
typeOptions: { rows: 4 },
displayOptions: { show: { resource: ['chat'] } },
description: 'Message to send to the model',
},
{
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',
},
],
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: 'Text description of the image to generate',
},
{
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',
},
{
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',
},
],
};
methods = {
loadOptions: {
getChatModels: modelLoader('chat'),
getImageModels: modelLoader('image'),
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
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;
if (resource === 'chat') {
const options = this.getNodeParameter('options', i, {}) as ChatOptions;
const response = await callApi<ChatCompletionResponse>(this, {
method: 'POST',
baseURL,
url: '/chat/completions',
body: buildChatBody(
this,
this.getNodeParameter('model', i) as string,
this.getNodeParameter('prompt', i) as string,
options,
),
json: true,
timeout: typeof options.timeout === 'number' && options.timeout > 0 ? options.timeout : 600000,
});
returnData.push(
...this.helpers.constructExecutionMetaData(this.helpers.returnJsonArray(response), {
itemData: { item: i },
}),
);
continue;
}
if (resource === 'image') {
const imageModel = this.getNodeParameter('imageModel', i) as string;
const prompt = this.getNodeParameter('imagePrompt', i) as string;
const response = await callApi<ImageResponse>(this, {
method: 'POST',
baseURL,
url: '/images/generations',
body: { model: imageModel, prompt },
json: true,
timeout: 300000,
});
const base64 = response?.data?.[0]?.b64_json;
if (typeof base64 !== 'string' || base64 === '') {
throw new NodeOperationError(
this.getNode(),
'TeamoRouter returned no image data (expected data[0].b64_json)',
{ itemIndex: i },
);
}
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i) as string;
const binary = await this.helpers.prepareBinaryData(
Buffer.from(base64, 'base64'),
`teamo-image-${Date.now()}.png`,
'image/png',
);
returnData.push({
json: { model: imageModel, prompt },
binary: { [binaryPropertyName]: binary },
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];
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60" fill="none"><rect x="6" y="6" width="48" height="48" rx="12" fill="#4F46E5"/><circle cx="20" cy="20" r="4" fill="#fff"/><circle cx="40" cy="20" r="4" fill="#fff"/><circle cx="30" cy="40" r="4" fill="#fff"/><path d="M20 20h20M20 20l10 20M40 20L30 40" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 397 B

View file

@ -0,0 +1,76 @@
import { dirname, join } from 'node:path';
/**
* n8n bundles `@langchain/openai` and `@n8n/ai-utilities` inside its own installation, and those
* modules are only reachable by a plain `require` when the package was installed as a community
* node (npm installs it below `~/.n8n/nodes`). When the package is mounted as a custom extension
* the resolution starts from our own folder, so we additionally look next to n8n itself
* (the directory of `require.main.filename` is n8n's CLI entry point).
*/
function candidateModuleDirs(): string[] {
const dirs = new Set<string>();
const addChain = (start: string | undefined): void => {
if (!start) return;
let current = start;
for (;;) {
dirs.add(join(current, 'node_modules'));
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
};
addChain(__dirname);
addChain(process.cwd());
if (require.main?.filename) addChain(dirname(require.main.filename));
return [...dirs];
}
export function loadN8nModule<T = unknown>(moduleName: string): T | undefined {
try {
return require(moduleName) as T;
} catch {
// Not reachable from our own folder - fall through to the n8n installation.
}
for (const dir of candidateModuleDirs()) {
try {
return require(join(dir, moduleName)) as T;
} catch {
// Keep looking.
}
}
return undefined;
}
export type ChatOpenAiConstructor = new (config: Record<string, unknown>) => unknown;
export function loadChatOpenAi(): ChatOpenAiConstructor {
const loaded = loadN8nModule<{ ChatOpenAI?: ChatOpenAiConstructor }>('@langchain/openai');
if (typeof loaded?.ChatOpenAI !== 'function') {
throw new Error(
'Could not load "@langchain/openai" from the n8n installation. The Chat Model node needs the LangChain packages that ship with n8n (@n8n/n8n-nodes-langchain must be installed).',
);
}
return loaded.ChatOpenAI;
}
/**
* Token/cost accounting in the execution log is provided by n8n's own tracer. It is an internal
* helper, so we use it when it is reachable and stay silent when it is not - the model itself
* works either way.
*/
export function loadN8nLlmTracing(context: unknown): unknown | undefined {
for (const moduleName of ['@n8n/ai-utilities', 'n8n-core']) {
const loaded = loadN8nModule<{ N8nLlmTracing?: new (context: unknown) => unknown }>(moduleName);
if (typeof loaded?.N8nLlmTracing === 'function') {
try {
return new loaded.N8nLlmTracing(context);
} catch {
return undefined;
}
}
}
return undefined;
}

View file

@ -0,0 +1,19 @@
import type { ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
import { normalizeBaseUrl, toModelOptions, type TeamorouterModel } from './teamorouter';
/** Dropdown content for the model field, read live from `GET /v1/models`. */
export function modelLoader(kind: 'chat' | 'image' | 'all') {
return async function (this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const credentials = await this.getCredentials('teamorouterApi');
const response = (await this.helpers.httpRequestWithAuthentication.call(this, 'teamorouterApi', {
method: 'GET',
baseURL: normalizeBaseUrl(credentials.baseUrl as string),
url: '/models',
json: true,
})) as { data?: TeamorouterModel[] } | TeamorouterModel[];
const models = Array.isArray(response) ? response : response?.data ?? [];
return toModelOptions(models, kind);
};
}

104
nodes/shared/teamorouter.ts Normal file
View file

@ -0,0 +1,104 @@
import type { INode, INodePropertyOptions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
export const DEFAULT_BASE_URL = 'https://api.teamorouter.com/v1';
export interface TeamorouterModel {
id: string;
owned_by?: string;
pricing?: Record<string, unknown>;
}
export function normalizeBaseUrl(baseUrl?: string): string {
const value = (baseUrl ?? '').trim();
return (value === '' ? DEFAULT_BASE_URL : value).replace(/\/+$/, '');
}
const IMAGE_MODEL_PATTERNS = [/(^|-)image(-|$)/i, /gpt-image/i, /nano-banana/i, /dall-e/i, /imagen/i];
export function isImageModel(modelId: string): boolean {
return IMAGE_MODEL_PATTERNS.some((pattern) => pattern.test(modelId));
}
function pricePerMillion(value: unknown): string | undefined {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined;
// The router may report prices per token (0.00000053) or already per 1M tokens (0.29).
// Below 0.005 only the per-token reading makes sense.
const perMillion = value < 0.005 ? value * 1_000_000 : value;
return `$${Number(perMillion.toFixed(perMillion < 1 ? 4 : 2))}`;
}
function modelDescription(model: TeamorouterModel): string | undefined {
const parts: string[] = [];
if (typeof model.owned_by === 'string' && model.owned_by !== '') parts.push(model.owned_by);
const pricing = model.pricing;
if (pricing !== null && typeof pricing === 'object') {
const input = pricePerMillion(pricing.prompt ?? pricing.input);
const output = pricePerMillion(pricing.completion ?? pricing.output);
if (input || output) parts.push(`${input ?? '?'} in / ${output ?? '?'} out per 1M tokens`);
}
return parts.length > 0 ? parts.join(' · ') : undefined;
}
export function toModelOptions(
models: TeamorouterModel[],
kind: 'chat' | 'image' | 'all',
): INodePropertyOptions[] {
const usable = models.filter((model) => typeof model?.id === 'string' && model.id !== '');
const matching = usable.filter((model) => isImageModel(model.id) === (kind === 'image'));
// Never hand back an empty dropdown because of our own naming heuristic.
const selected = kind === 'all' || matching.length === 0 ? usable : matching;
return selected
.map((model) => ({
name: model.id,
value: model.id,
description: modelDescription(model),
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
function errorStatus(error: unknown): number | undefined {
if (error === null || typeof error !== 'object') return undefined;
const candidate = error as { httpCode?: unknown; statusCode?: unknown; response?: { status?: unknown } };
const status = candidate.httpCode ?? candidate.statusCode ?? candidate.response?.status;
return typeof status === 'number' ? status : undefined;
}
export function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return typeof error === 'string' ? error : 'Unknown error';
}
/** Turns the router's status codes into something a workflow author can act on. */
export function hintForError(error: unknown): string | undefined {
switch (errorStatus(error)) {
case 401:
return 'TeamoRouter rejected the API key (401). Check the key and that the Base URL ends with /v1.';
case 402:
return 'TeamoRouter reports insufficient balance (402). Top up the account or pick a model with the "-free" suffix.';
case 404:
return 'Endpoint or model not found (404). Verify the model ID with the List Models operation and that the Base URL ends with /v1.';
case 429:
return 'TeamoRouter rate limit reached (429). Lower the request rate or retry later.';
default:
return undefined;
}
}
export function parseExtraBody(extraBody: unknown, node: INode): Record<string, unknown> {
if (extraBody === undefined || extraBody === null || extraBody === '') return {};
if (typeof extraBody === 'object' && !Array.isArray(extraBody)) return extraBody as Record<string, unknown>;
try {
const parsed = JSON.parse(String(extraBody)) as unknown;
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('expected a JSON object');
}
return parsed as Record<string, unknown>;
} catch (error) {
throw new NodeOperationError(node, `Extra Body must be a JSON object: ${errorMessage(error)}`);
}
}

1508
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

52
package.json Normal file
View file

@ -0,0 +1,52 @@
{
"name": "n8n-nodes-teamorouter",
"version": "0.1.0",
"description": "n8n nodes for TeamoRouter — OpenAI-compatible LLM router (chat model for AI Agent, chat completions, model list, image generation)",
"keywords": [
"n8n-community-node-package",
"n8n",
"teamorouter",
"llm",
"openai-compatible"
],
"license": "MIT",
"author": {
"name": "ITO Service",
"url": "https://itoservice.ru"
},
"repository": {
"type": "git",
"url": "https://github.com/REPLACE-ME/n8n-nodes-teamorouter.git"
},
"scripts": {
"build": "tsc && node scripts/copy-assets.mjs",
"build:watch": "tsc --watch",
"typecheck": "tsc --noEmit",
"test": "node test/validate-nodes.mjs && node test/run-node-tests.mjs",
"mock": "node test/mock-server.mjs",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"n8n": {
"n8nNodesApiVersion": 1,
"credentials": [
"dist/credentials/TeamorouterApi.credentials.js"
],
"nodes": [
"dist/nodes/LmChatTeamRouter/LmChatTeamRouter.node.js",
"dist/nodes/Teamorouter/Teamorouter.node.js"
]
},
"peerDependencies": {
"n8n-workflow": "*"
},
"devDependencies": {
"@langchain/core": "^1.2.0",
"@langchain/openai": "^1.6.0",
"@types/node": "^22.10.0",
"n8n-workflow": "*",
"typescript": "^5.9.0"
}
}

19
scripts/copy-assets.mjs Normal file
View file

@ -0,0 +1,19 @@
// tsc does not copy static assets: icons next to compiled nodes are required by `icon: 'file:...'`.
import { cp, mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const assets = [
['nodes/LmChatTeamRouter/teamorouter.svg', 'dist/nodes/LmChatTeamRouter/teamorouter.svg'],
['nodes/Teamorouter/teamorouter.svg', 'dist/nodes/Teamorouter/teamorouter.svg'],
];
for (const [from, to] of assets) {
const target = join(root, to);
await mkdir(dirname(target), { recursive: true });
await cp(join(root, from), target);
}
console.log(`Copied ${assets.length} asset(s) to dist/`);

139
test/mock-server.mjs Normal file
View file

@ -0,0 +1,139 @@
// 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));
/**
* 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 rawBody = Buffer.concat(chunks).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 });
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 content = `echo: ${userMessage?.content ?? ''}`;
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();
}
return json(200, {
object: 'chat.completion',
model,
choices: [{ index: 0, message: { role: 'assistant', content }, 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' } });
}
return json(200, { created: 1, data: [{ 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'})`);
}

348
test/run-node-tests.mjs Normal file
View file

@ -0,0 +1,348 @@
// Exercises the compiled nodes against the mock router: real HTTP, real LangChain model.
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createMockServer, TINY_PNG_BASE64 } from './mock-server.mjs';
const require = createRequire(import.meta.url);
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const { LmChatTeamRouter } = require(join(root, 'dist/nodes/LmChatTeamRouter/LmChatTeamRouter.node.js'));
const { Teamorouter } = require(join(root, 'dist/nodes/Teamorouter/Teamorouter.node.js'));
const { TeamorouterApi } = require(join(root, 'dist/credentials/TeamorouterApi.credentials.js'));
const apiKey = 'test-key';
const mock = createMockServer({ apiKey });
const baseUrl = await mock.listen();
/** Mirrors n8n's httpRequestWithAuthentication closely enough for the code paths under test. */
async function httpRequest(_credentialName, options) {
const url = `${options.baseURL ?? baseUrl}${options.url}`;
const timeout = typeof options.timeout === 'number' && options.timeout > 0 ? options.timeout : undefined;
let response;
try {
response = await fetch(url, {
method: options.method ?? 'GET',
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
body: options.body === undefined ? undefined : JSON.stringify(options.body),
signal: timeout === undefined ? undefined : AbortSignal.timeout(timeout),
});
} catch (error) {
const wrapped = new Error(error?.cause?.message ?? error.message);
wrapped.httpCode = 504;
throw wrapped;
}
const text = await response.text();
let parsed = text;
try {
parsed = JSON.parse(text);
} catch {
// keep the raw text
}
if (!response.ok) {
const error = new Error(`Request failed with status code ${response.status}`);
error.httpCode = response.status;
error.response = { status: response.status, data: parsed };
throw error;
}
return parsed;
}
function makeContext(parameters, { continueOnFail = false } = {}) {
return {
getCredentials: async () => ({ apiKey, baseUrl: `${baseUrl}/v1` }),
getNodeParameter: (name, _itemIndex, fallback) =>
parameters[name] === undefined ? fallback : parameters[name],
getNode: () => ({
name: 'TeamoRouter',
type: 'n8n-nodes-teamorouter',
typeVersion: 1,
position: [0, 0],
parameters,
}),
continueOnFail: () => continueOnFail,
getInputData: () => [{ json: {} }],
logger: { debug() {}, info() {}, warn() {}, error() {} },
helpers: {
httpRequestWithAuthentication: httpRequest,
prepareBinaryData: async (data, fileName, mimeType) => {
const buffer = Buffer.from(data);
return {
data: buffer.toString('base64'),
fileName,
mimeType,
fileExtension: 'png',
fileSize: buffer.length,
};
},
returnJsonArray: (data) => (Array.isArray(data) ? data : [data]).map((json) => ({ json })),
constructExecutionMetaData: (items) => items,
},
};
}
const lastRequest = () => mock.requests[mock.requests.length - 1];
const chatModel = new LmChatTeamRouter();
const actionNode = new Teamorouter();
let failures = 0;
async function test(name, fn) {
try {
await fn();
console.log(`ok ${name}`);
} catch (error) {
failures += 1;
console.log(`FAIL ${name}\n ${error.message}`);
}
}
await test('credential points at GET /v1/models and the endpoint enforces the key', async () => {
const test = new TeamorouterApi().test;
assert.equal(test.request.url, '/models');
assert.match(test.request.baseURL, /\$credentials\.baseUrl/);
const ok = await fetch(`${baseUrl}/v1/models`, { headers: { authorization: `Bearer ${apiKey}` } });
assert.equal(ok.status, 200);
const denied = await fetch(`${baseUrl}/v1/models`, { headers: { authorization: 'Bearer wrong' } });
assert.equal(denied.status, 401);
});
await test('chat model dropdown lists chat models with prices and hides image models', async () => {
const options = await chatModel.methods.loadOptions.getChatModels.call(makeContext({}));
const ids = options.map((option) => option.value);
assert.ok(ids.includes('gpt-5.6-sol'), 'gpt-5.6-sol missing');
assert.ok(ids.includes('gemini-3.1-pro-preview'), 'gemini missing');
assert.ok(!ids.includes('gpt-image-2'), 'image model leaked into the chat list');
assert.ok(!ids.includes('nano-banana-2.1'), 'image model leaked into the chat list');
const sol = options.find((option) => option.value === 'gpt-5.6-sol');
assert.match(sol.description, /openai/);
assert.match(sol.description, /\$0\.53 in \/ \$3\.18 out per 1M tokens/);
const gemini = options.find((option) => option.value === 'gemini-3.1-pro-preview');
assert.match(gemini.description, /\$0\.29 in \/ \$1\.73 out per 1M tokens/);
assert.deepEqual(ids, [...ids].sort(), 'options must be sorted');
});
await test('image dropdown lists only image models', async () => {
const options = await actionNode.methods.loadOptions.getImageModels.call(makeContext({}));
assert.deepEqual(
options.map((option) => option.value),
['gpt-image-2', 'nano-banana-2.1'],
);
});
await test('supplyData returns a working LangChain chat model', async () => {
const { response: model } = await chatModel.supplyData.call(
makeContext({ model: 'claude-sonnet-5', options: {} }),
0,
);
const answer = await model.invoke('ping');
assert.equal(answer.content, 'echo: ping');
const request = lastRequest();
assert.equal(request.url, '/v1/chat/completions');
assert.equal(request.headers.authorization, `Bearer ${apiKey}`);
assert.equal(request.body.model, 'claude-sonnet-5');
assert.equal(request.body.messages.at(-1).content, 'ping');
assert.equal(request.body.max_tokens, undefined, 'maxTokens -1 must not be sent');
});
await test('supplyData works for GPT models served through the Responses API', async () => {
const { response: model } = await chatModel.supplyData.call(
makeContext({ model: 'gpt-5.6-sol', options: {} }),
0,
);
const answer = await model.invoke('ping');
// The Responses API path returns content blocks instead of a plain string.
const text = Array.isArray(answer.content)
? answer.content.map((block) => block.text).join('')
: answer.content;
assert.equal(text, 'echo: ping');
const request = lastRequest();
// LangChain routes gpt-5.6* to /v1/responses on its own; TeamoRouter serves that endpoint for GPT models.
assert.ok(
['/v1/chat/completions', '/v1/responses'].includes(request.url),
`unexpected endpoint ${request.url}`,
);
assert.equal(request.body.model, 'gpt-5.6-sol');
// The router answers both endpoints, so the model must not care which one was used.
assert.match(request.url, /^\/v1\/(chat\/completions|responses)$/);
});
await test('options reach the request body (service tier, extra body, limits)', async () => {
const { response: model } = await chatModel.supplyData.call(
makeContext({
model: 'claude-sonnet-5',
options: {
temperature: 0.2,
maxTokens: 128,
topP: 0.5,
serviceTier: 'fast',
forceJsonResponse: true,
extraBody: '{"top_k":7}',
},
}),
0,
);
await model.invoke('json please');
const body = lastRequest().body;
assert.equal(body.model, 'claude-sonnet-5');
assert.equal(body.temperature, 0.2);
assert.equal(body.top_p, 0.5);
assert.equal(body.max_tokens, 128);
assert.equal(body.service_tier, 'fast');
assert.deepEqual(body.response_format, { type: 'json_object' });
assert.equal(body.top_k, 7);
});
await test('chat model rejects a broken Extra Body JSON', async () => {
await assert.rejects(
async () => {
await chatModel.supplyData.call(
makeContext({ model: 'gpt-5.6-sol', options: { extraBody: 'not-json' } }),
0,
);
},
(error) => {
assert.match(error.message, /Extra Body must be a JSON object/);
return true;
},
);
});
await test('chat model rejects an empty model selection', async () => {
await assert.rejects(
async () => {
await chatModel.supplyData.call(makeContext({ model: '', options: {} }), 0);
},
(error) => {
assert.match(error.message, /No model selected/);
return true;
},
);
});
await test('action node: message a model returns the completion with usage', async () => {
const context = makeContext({
resource: 'chat',
operation: 'message',
model: 'claude-sonnet-5',
prompt: 'привет',
options: { systemPrompt: 'be brief' },
});
const [items] = await actionNode.execute.call(context);
assert.equal(items.length, 1);
assert.equal(items[0].json.choices[0].message.content, 'echo: привет');
assert.equal(items[0].json.usage.total_tokens, 15);
const body = lastRequest().body;
assert.deepEqual(body.messages[0], { role: 'system', content: 'be brief' });
assert.deepEqual(body.messages[1], { role: 'user', content: 'привет' });
assert.equal(lastRequest().url, '/v1/chat/completions');
});
await test('action node: list models returns the router payload', async () => {
const [items] = await actionNode.execute.call(
makeContext({ resource: 'model', operation: 'list' }),
);
assert.equal(items.length, 6);
assert.equal(items[0].json.id, 'gpt-5.6-sol');
assert.equal(lastRequest().url, '/v1/models');
});
await test('action node: generate image writes a PNG binary', async () => {
const [items] = await actionNode.execute.call(
makeContext({
resource: 'image',
operation: 'generate',
imageModel: 'gpt-image-2',
imagePrompt: 'orange cat',
binaryPropertyName: 'data',
}),
);
const binary = items[0].binary.data;
assert.equal(binary.mimeType, 'image/png');
assert.equal(binary.fileExtension, 'png');
assert.equal(binary.data, TINY_PNG_BASE64);
assert.equal(Buffer.from(binary.data, 'base64').subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
assert.deepEqual(lastRequest().body, { model: 'gpt-image-2', prompt: 'orange cat' });
});
await test('action node: router errors become actionable messages', async () => {
const cases = [
['fail-401', /API key/],
['fail-402', /insufficient balance/],
['fail-404', /not found/],
];
for (const [model, expected] of cases) {
await assert.rejects(
async () => {
await actionNode.execute.call(
makeContext({ resource: 'chat', operation: 'message', model, prompt: 'hi', options: {} }),
);
},
(error) => {
assert.match(error.message, expected);
return true;
},
`${model} did not produce a hint`,
);
}
});
await test('action node: image without b64_json fails clearly', async () => {
await assert.rejects(
async () => {
await actionNode.execute.call(
makeContext({
resource: 'image',
operation: 'generate',
imageModel: 'gpt-image-2',
imagePrompt: 'noimage please',
binaryPropertyName: 'data',
}),
);
},
// 400 from the router: no hint, but the request must fail instead of returning empty binary.
(error) => {
assert.match(error.message, /400/);
return true;
},
);
});
await test('action node: continueOnFail returns the error as item data', async () => {
const [items] = await actionNode.execute.call(
makeContext(
{ resource: 'chat', operation: 'message', model: 'fail-500', prompt: 'hi', options: {} },
{ continueOnFail: true },
),
);
assert.equal(items.length, 1);
assert.match(items[0].json.error, /500/);
});
await test('action node: timeout is applied to the request', async () => {
await assert.rejects(async () => {
await actionNode.execute.call(
makeContext({
resource: 'chat',
operation: 'message',
model: 'slow-model',
prompt: 'hi',
options: { timeout: 300 },
}),
);
});
});
await mock.close();
console.log(`\n${failures === 0 ? 'All node tests passed' : `${failures} test(s) failed`}`);
process.exitCode = failures === 0 ? 0 : 1;

118
test/validate-nodes.mjs Normal file
View file

@ -0,0 +1,118 @@
// Loads the compiled node package the way n8n does and fails loudly on anything n8n would reject.
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const packageJson = require(join(root, 'package.json'));
const loaded = {
nodes: packageJson.n8n.nodes.map((file) => {
const module = require(join(root, file));
const exported = Object.values(module).find((value) => typeof value === 'function');
assert.ok(exported, `${file} exports no class`);
return { file, instance: new exported() };
}),
credentials: packageJson.n8n.credentials.map((file) => {
const module = require(join(root, file));
const exported = Object.values(module).find((value) => typeof value === 'function');
assert.ok(exported, `${file} exports no class`);
return { file, instance: new exported() };
}),
};
const credentialNames = new Set(loaded.credentials.map(({ instance }) => instance.name));
for (const { file, instance } of loaded.credentials) {
assert.equal(typeof instance.name, 'string', `${file}: credential has no name`);
assert.equal(typeof instance.displayName, 'string', `${file}: credential has no displayName`);
assert.ok(Array.isArray(instance.properties) && instance.properties.length > 0, `${file}: no properties`);
assert.ok(instance.authenticate, `${file}: no authenticate block`);
assert.ok(instance.test?.request, `${file}: no credential test request`);
const keyProperty = instance.properties.find((property) => property.name === 'apiKey');
assert.ok(keyProperty?.typeOptions?.password, `${file}: apiKey must be a password field`);
}
for (const { file, instance } of loaded.nodes) {
const description = instance.description;
const label = `${file}: `;
assert.ok(description, `${label}no description`);
assert.equal(typeof description.displayName, 'string', `${label}displayName missing`);
assert.match(description.name, /^[a-zA-Z][a-zA-Z0-9]*$/, `${label}name must be camelCase`);
assert.ok(description.version !== undefined, `${label}version missing`);
assert.ok(description.defaults?.name, `${label}defaults.name missing`);
assert.ok(Array.isArray(description.properties), `${label}properties missing`);
assert.ok(description.inputs !== undefined, `${label}inputs missing`);
assert.ok(description.outputs !== undefined, `${label}outputs missing`);
assert.ok(
existsSync(join(dirname(join(root, file)), description.icon.replace('file:', ''))),
`${label}icon ${description.icon} not found next to the compiled node`,
);
const names = description.properties.map((property) => property.name);
// The same name may be reused for different resources (n8n's own nodes do this with "operation"),
// but never for the same visible state.
const seen = new Map();
for (const property of description.properties) {
const show = JSON.stringify(property.displayOptions?.show ?? {});
const key = `${property.name}|${show}`;
assert.ok(!seen.has(key), `${label}duplicate parameter "${property.name}" with identical display options`);
seen.set(key, true);
}
assert.equal(names.length > 0, true, `${label}no parameters`);
const loadOptionsMethods = description.properties
.filter((property) => property.typeOptions?.loadOptionsMethod)
.map((property) => property.typeOptions.loadOptionsMethod);
for (const method of loadOptionsMethods) {
assert.equal(
typeof instance.methods?.loadOptions?.[method],
'function',
`${label}parameter uses loadOptionsMethod "${method}" but the node does not implement it`,
);
}
assert.ok(loadOptionsMethods.length > 0, `${label}no dynamic model list`);
for (const credential of description.credentials ?? []) {
assert.ok(credentialNames.has(credential.name), `${label}references unknown credential ${credential.name}`);
}
for (const property of description.properties) {
if (property.typeOptions?.loadOptionsMethod !== undefined) continue; // dynamic list, filled at runtime
if (property.type === 'options') {
assert.ok(Array.isArray(property.options) && property.options.length > 0, `${label}"${property.name}" has no options`);
for (const option of property.options) {
assert.equal(typeof option.value, 'string', `${label}option of "${property.name}" without a value`);
assert.ok(option.value !== '', `${label}option of "${property.name}" with an empty value`);
}
assert.ok(
property.options.some((option) => option.value === property.default),
`${label}default "${property.default}" of "${property.name}" is not one of its options`,
);
}
if (property.type === 'collection') {
for (const option of property.options ?? []) {
assert.equal(typeof option.name, 'string', `${label}collection "${property.name}" has an option without a name`);
assert.equal(typeof option.type, 'string', `${label}collection "${property.name}" has an option without a type`);
}
}
}
}
const chatModel = loaded.nodes.find(({ instance }) => instance.description.name === 'lmChatTeamRouter').instance;
assert.deepEqual(chatModel.description.inputs, [], 'chat model sub-node must not have a main input');
assert.ok(
chatModel.description.outputs.includes('ai_languageModel'),
'chat model sub-node must output the ai_languageModel connection type',
);
assert.equal(typeof chatModel.supplyData, 'function', 'chat model sub-node must implement supplyData');
console.error(
`OK validation: ${loaded.nodes.length} nodes, ${loaded.credentials.length} credential(s) [${[...credentialNames].join(', ')}]`,
);

24
tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"strict": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2019",
"lib": ["es2019", "es2020", "es2022.error"],
"removeComments": true,
"useUnknownInCatchVariables": false,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"strictNullChecks": true,
"preserveConstEnums": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"declaration": false,
"sourceMap": false,
"outDir": "./dist/"
},
"include": ["credentials/**/*.ts", "nodes/**/*.ts"]
}