From 775ba0a6d9f9fda3106611f5c87434eefa440652 Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 22 Oct 2026 14:02:23 +0300 Subject: [PATCH] Add TeamoRouter nodes for n8n: chat model sub-node, chat/image/model action node, credentials, tests --- .gitignore | 5 + LICENSE | 21 + README.md | 173 ++ credentials/TeamorouterApi.credentials.ts | 51 + .../LmChatTeamRouter/LmChatTeamRouter.node.ts | 200 +++ nodes/LmChatTeamRouter/teamorouter.svg | 1 + nodes/Teamorouter/Teamorouter.node.ts | 435 +++++ nodes/Teamorouter/teamorouter.svg | 1 + nodes/shared/langchainModules.ts | 76 + nodes/shared/loadModels.ts | 19 + nodes/shared/teamorouter.ts | 104 ++ package-lock.json | 1508 +++++++++++++++++ package.json | 52 + scripts/copy-assets.mjs | 19 + test/mock-server.mjs | 139 ++ test/run-node-tests.mjs | 348 ++++ test/validate-nodes.mjs | 118 ++ tsconfig.json | 24 + 18 files changed, 3294 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 credentials/TeamorouterApi.credentials.ts create mode 100644 nodes/LmChatTeamRouter/LmChatTeamRouter.node.ts create mode 100644 nodes/LmChatTeamRouter/teamorouter.svg create mode 100644 nodes/Teamorouter/Teamorouter.node.ts create mode 100644 nodes/Teamorouter/teamorouter.svg create mode 100644 nodes/shared/langchainModules.ts create mode 100644 nodes/shared/loadModels.ts create mode 100644 nodes/shared/teamorouter.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/copy-assets.mjs create mode 100644 test/mock-server.mjs create mode 100644 test/run-node-tests.mjs create mode 100644 test/validate-nodes.mjs create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3542963 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.tgz +npm-debug.log* +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9f0e154 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a1a48f --- /dev/null +++ b/README.md @@ -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 diff --git a/credentials/TeamorouterApi.credentials.ts b/credentials/TeamorouterApi.credentials.ts new file mode 100644 index 0000000..19908f8 --- /dev/null +++ b/credentials/TeamorouterApi.credentials.ts @@ -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', + }, + }; +} diff --git a/nodes/LmChatTeamRouter/LmChatTeamRouter.node.ts b/nodes/LmChatTeamRouter/LmChatTeamRouter.node.ts new file mode 100644 index 0000000..f68124c --- /dev/null +++ b/nodes/LmChatTeamRouter/LmChatTeamRouter.node.ts @@ -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 { + 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 = {}; + 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 = { + 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 }; + } +} diff --git a/nodes/LmChatTeamRouter/teamorouter.svg b/nodes/LmChatTeamRouter/teamorouter.svg new file mode 100644 index 0000000..fea33f0 --- /dev/null +++ b/nodes/LmChatTeamRouter/teamorouter.svg @@ -0,0 +1 @@ + diff --git a/nodes/Teamorouter/Teamorouter.node.ts b/nodes/Teamorouter/Teamorouter.node.ts new file mode 100644 index 0000000..c0be56d --- /dev/null +++ b/nodes/Teamorouter/Teamorouter.node.ts @@ -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(context: IExecuteFunctions, requestOptions: IHttpRequestOptions): Promise { + try { + return (await context.helpers.httpRequestWithAuthentication.call( + context, + 'teamorouterApi', + requestOptions, + )) as T; + } catch (error) { + const hint = hintForError(error); + if (hint === undefined) throw error; + throw new NodeOperationError(context.getNode(), `${hint} ${errorMessage(error)}`); + } +} + +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 { + 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(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(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]; + } +} diff --git a/nodes/Teamorouter/teamorouter.svg b/nodes/Teamorouter/teamorouter.svg new file mode 100644 index 0000000..fea33f0 --- /dev/null +++ b/nodes/Teamorouter/teamorouter.svg @@ -0,0 +1 @@ + diff --git a/nodes/shared/langchainModules.ts b/nodes/shared/langchainModules.ts new file mode 100644 index 0000000..ab2f3b5 --- /dev/null +++ b/nodes/shared/langchainModules.ts @@ -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(); + 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(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) => 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; +} diff --git a/nodes/shared/loadModels.ts b/nodes/shared/loadModels.ts new file mode 100644 index 0000000..6cf8265 --- /dev/null +++ b/nodes/shared/loadModels.ts @@ -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 { + 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); + }; +} diff --git a/nodes/shared/teamorouter.ts b/nodes/shared/teamorouter.ts new file mode 100644 index 0000000..5d955bf --- /dev/null +++ b/nodes/shared/teamorouter.ts @@ -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; +} + +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 { + if (extraBody === undefined || extraBody === null || extraBody === '') return {}; + if (typeof extraBody === 'object' && !Array.isArray(extraBody)) return extraBody as Record; + + 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; + } catch (error) { + throw new NodeOperationError(node, `Extra Body must be a JSON object: ${errorMessage(error)}`); + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b66489d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1508 @@ +{ + "name": "n8n-nodes-teamorouter", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "n8n-nodes-teamorouter", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@langchain/core": "^1.2.0", + "@langchain/openai": "^1.6.0", + "@types/node": "^22.10.0", + "n8n-workflow": "*", + "typescript": "^5.9.0" + }, + "peerDependencies": { + "n8n-workflow": "*" + } + }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@langchain/core": { + "version": "1.2.20", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.20.tgz", + "integrity": "sha512-0Nzh4/2On0MWFWbWRK7tDdMmSmD2Z/gt9+XoEQMPrV74Fpdf8r6grqEZNW6BkeqwY+KwKn+aFhatm4sK/yLldQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cfworker/json-schema": "^4.0.2", + "@standard-schema/spec": "^1.1.0", + "js-tiktoken": "^1.0.12", + "langsmith": ">=0.11.0 <1.0.0", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@langchain/openai": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.6.3.tgz", + "integrity": "sha512-vioA4Si8bIAczcsmrfV8C8KZ3QozAz2yiuTZSgR57WsjuDsuF2dzZ4tx/jTEEtyOrFzfKXnSYi32QsNz8Mah9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tiktoken": "^1.0.12", + "openai": "^7.23.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@langchain/core": "^1.2.20" + } + }, + "node_modules/@n8n_io/riot-tmpl": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@n8n_io/riot-tmpl/-/riot-tmpl-4.0.1.tgz", + "integrity": "sha512-/zdRbEfTFjsm1NqnpPQHgZTkTdbp5v3VUxGeMA9098sps8jRCTraQkc3AQstJgHUm7ylBXJcIVhnVeLUMWAfwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-config-riot": "^1.0.0" + } + }, + "node_modules/@n8n/errors": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@n8n/errors/-/errors-0.6.0.tgz", + "integrity": "sha512-oVJ0lgRYJY6/aPOW2h37ea5T+nX7/wULRn5FymwYeaiYlsLdqwIQEtGwZrajpzxJB0Os74u4lSH3WWQgZCkgxQ==", + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "callsites": "3.1.0" + } + }, + "node_modules/@n8n/expression-runtime": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@n8n/expression-runtime/-/expression-runtime-0.8.0.tgz", + "integrity": "sha512-SnBLoLsrGUCS9cVrF20UuSyKcMv+y6Clc4+EA9zLjX85S0GPwOAdApUVSsi81ejPAqMlm42TW1L6r7NpcK/ztQ==", + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@n8n/tournament": "1.0.6", + "isolated-vm": "^6.0.2", + "js-base64": "3.7.2", + "jssha": "3.3.1", + "lodash": "4.17.23", + "luxon": "3.7.2", + "md5": "2.3.0", + "title-case": "3.0.3", + "transliteration": "2.3.5" + } + }, + "node_modules/@n8n/tournament": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@n8n/tournament/-/tournament-1.0.6.tgz", + "integrity": "sha512-UGSxYXXVuOX0yL6HTLBStKYwLIa0+JmRKiSZSCMcM2s2Wax984KWT6XIA1TR/27i7yYpDk1MY14KsTPnuEp27A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@n8n_io/riot-tmpl": "^4.0.1", + "ast-types": "^0.16.1", + "esprima-next": "^5.8.4", + "recast": "^0.22.0" + }, + "engines": { + "node": ">=20.15", + "pnpm": ">=9.5" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-config-riot": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-riot/-/eslint-config-riot-1.0.0.tgz", + "integrity": "sha512-NB/L/1Y30qyJcG5xZxCJKW/+bqyj+llbcCwo9DEz8bESIP0SLTOQ8T1DWCCFc+wJ61AMEstj4511PSScqMMfCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esprima-next": { + "version": "5.8.4", + "resolved": "https://registry.npmjs.org/esprima-next/-/esprima-next-5.8.4.tgz", + "integrity": "sha512-8nYVZ4ioIH4Msjb/XmhnBdz5WRRBaYqevKa1cv9nGJdCehMbzZCPNEEnqfLCZVetUVrUPEcb5IYyu1GG4hFqgg==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isolated-vm": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/isolated-vm/-/isolated-vm-6.2.0.tgz", + "integrity": "sha512-UuSlxSHWt2QuJ5WvBhzlIJx2VVZN/a44SqBbEZFKNdvuSyhOvhmyDo8SQ+njVbhnh/njoL/aW0bUTiFYlpweGQ==", + "dev": true, + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/jmespath": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", + "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/js-base64": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz", + "integrity": "sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, + "node_modules/jsonrepair": { + "version": "3.13.2", + "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.2.tgz", + "integrity": "sha512-Leuly0nbM4R+S5SVJk3VHfw1oxnlEK9KygdZvfUtEtTawNDyzB4qa1xWTmFt1aeoA7sXZkVTRuIixJ8bAvqVUg==", + "dev": true, + "license": "ISC", + "bin": { + "jsonrepair": "bin/cli.js" + } + }, + "node_modules/jssha": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz", + "integrity": "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/langsmith": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.11.0.tgz", + "integrity": "sha512-NMyaEhfHKAVPdXR8jqP1in4BAr2RhJTmBsH+ZzhvBDTxWUsuaprnJxkRe/3NY5d2+wCaPtzbDK2Myhh6vg2q7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-queue": "6.6.2" + }, + "peerDependencies": { + "@opentelemetry/api": "*", + "@opentelemetry/exporter-trace-otlp-proto": "*", + "@opentelemetry/sdk-trace-base": "*", + "openai": "*", + "ws": ">=7" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-proto": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "openai": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/n8n-workflow": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/n8n-workflow/-/n8n-workflow-2.16.0.tgz", + "integrity": "sha512-JoDvHLvV4QZQBCDVQl5xkrGOmPmsacLO4TkJkErCzMmiEqIej+h14J6eCUY7gbxBj8V+GIBlpqyO63akJbXRQg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@n8n/errors": "0.6.0", + "@n8n/expression-runtime": "0.8.0", + "@n8n/tournament": "1.0.6", + "ast-types": "0.16.1", + "callsites": "3.1.0", + "esprima-next": "5.8.4", + "form-data": "4.0.4", + "jmespath": "0.16.0", + "js-base64": "3.7.2", + "jsonrepair": "3.13.2", + "jssha": "3.3.1", + "lodash": "4.17.23", + "luxon": "3.7.2", + "md5": "2.3.0", + "recast": "0.22.0", + "title-case": "3.0.3", + "transliteration": "2.3.5", + "uuid": "10.0.0", + "xml2js": "0.6.2", + "zod": "3.25.67" + } + }, + "node_modules/n8n-workflow/node_modules/zod": { + "version": "3.25.67", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", + "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/openai": { + "version": "7.42.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.42.0.tgz", + "integrity": "sha512-RziM2bxp1k24mTLtunije+OOCGRC+qtK0YNYPWkrTdKJLJ65WYW+rPYxlLbnBzU4WwINFCFa0OjCgbsX+fif/w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "google-auth-library": ">=9.15.1 <11", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "google-auth-library": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/recast": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.22.0.tgz", + "integrity": "sha512-5AAx+mujtXijsEavc5lWXBPQqrM4+Dl5qNH96N2aNeuJFUzpiiToKPsxQD/zAIJHspz7zz0maX0PCtCTFVlixQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert": "^2.0.0", + "ast-types": "0.15.2", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/ast-types": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.15.2.tgz", + "integrity": "sha512-c27loCv9QkZinsa5ProX751khO9DJl/AcB5c2KNtA6NRvHKS0PgLfcftz72KVq504vB0Gku5s2kUZzDBvQWvHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/title-case": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", + "integrity": "sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/transliteration": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/transliteration/-/transliteration-2.3.5.tgz", + "integrity": "sha512-HAGI4Lq4Q9dZ3Utu2phaWgtm3vB6PkLUFqWAScg/UW+1eZ/Tg6Exo4oC0/3VUol/w4BlefLhUUSVBr/9/ZGQOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "yargs": "^17.5.1" + }, + "bin": { + "slugify": "dist/bin/slugify", + "transliterate": "dist/bin/transliterate" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3644491 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs new file mode 100644 index 0000000..226e502 --- /dev/null +++ b/scripts/copy-assets.mjs @@ -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/`); diff --git a/test/mock-server.mjs b/test/mock-server.mjs new file mode 100644 index 0000000..d010bf8 --- /dev/null +++ b/test/mock-server.mjs @@ -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'})`); +} diff --git a/test/run-node-tests.mjs b/test/run-node-tests.mjs new file mode 100644 index 0000000..7614a97 --- /dev/null +++ b/test/run-node-tests.mjs @@ -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; diff --git a/test/validate-nodes.mjs b/test/validate-nodes.mjs new file mode 100644 index 0000000..cb5cea5 --- /dev/null +++ b/test/validate-nodes.mjs @@ -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(', ')}]`, +); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2d086b7 --- /dev/null +++ b/tsconfig.json @@ -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"] +}