Add TeamoRouter nodes for n8n: chat model sub-node, chat/image/model action node, credentials, tests
This commit is contained in:
commit
775ba0a6d9
18 changed files with 3294 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tgz
|
||||||
|
npm-debug.log*
|
||||||
|
.DS_Store
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
173
README.md
Normal 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
|
||||||
51
credentials/TeamorouterApi.credentials.ts
Normal file
51
credentials/TeamorouterApi.credentials.ts
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
200
nodes/LmChatTeamRouter/LmChatTeamRouter.node.ts
Normal file
200
nodes/LmChatTeamRouter/LmChatTeamRouter.node.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
1
nodes/LmChatTeamRouter/teamorouter.svg
Normal file
1
nodes/LmChatTeamRouter/teamorouter.svg
Normal 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 |
435
nodes/Teamorouter/Teamorouter.node.ts
Normal file
435
nodes/Teamorouter/Teamorouter.node.ts
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
1
nodes/Teamorouter/teamorouter.svg
Normal file
1
nodes/Teamorouter/teamorouter.svg
Normal 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 |
76
nodes/shared/langchainModules.ts
Normal file
76
nodes/shared/langchainModules.ts
Normal 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;
|
||||||
|
}
|
||||||
19
nodes/shared/loadModels.ts
Normal file
19
nodes/shared/loadModels.ts
Normal 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
104
nodes/shared/teamorouter.ts
Normal 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
1508
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
52
package.json
Normal file
52
package.json
Normal 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
19
scripts/copy-assets.mjs
Normal 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
139
test/mock-server.mjs
Normal 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
348
test/run-node-tests.mjs
Normal 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
118
test/validate-nodes.mjs
Normal 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
24
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue