mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
Compare commits
12 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d655f24d9f | ||
|
|
ce7d1b7762 | ||
|
|
5d320521fb | ||
|
|
c15d6d0f6d | ||
|
|
0be830210f | ||
|
|
2ec433263b | ||
|
|
2d50843216 | ||
|
|
b4fd5fa71e | ||
|
|
afcb69523d | ||
|
|
509dbb173c | ||
|
|
884b7ab3ec | ||
|
|
ea8bcf8a76 |
71 changed files with 1090 additions and 342 deletions
108
.github/scripts/bump-versions.mjs
vendored
108
.github/scripts/bump-versions.mjs
vendored
|
|
@ -1,4 +1,5 @@
|
|||
import semver from 'semver';
|
||||
import { parse } from 'yaml';
|
||||
import { writeFile, readFile } from 'fs/promises';
|
||||
import { resolve } from 'path';
|
||||
import child_process from 'child_process';
|
||||
|
|
@ -7,14 +8,19 @@ import assert from 'assert';
|
|||
|
||||
const exec = promisify(child_process.exec);
|
||||
|
||||
/**
|
||||
* @param {string | semver.SemVer} currentVersion
|
||||
*/
|
||||
function generateExperimentalVersion(currentVersion) {
|
||||
const parsed = semver.parse(currentVersion);
|
||||
if (!parsed) throw new Error(`Invalid version: ${currentVersion}`);
|
||||
|
||||
// Check if it's already an experimental version
|
||||
if (parsed.prerelease.length > 0 && parsed.prerelease[0] === 'exp') {
|
||||
const minor = parsed.prerelease[1] || 0;
|
||||
const minorInt = typeof minor === 'string' ? parseInt(minor) : minor;
|
||||
// Increment the experimental minor version
|
||||
const expMinor = (parsed.prerelease[1] || 0) + 1;
|
||||
const expMinor = minorInt + 1;
|
||||
return `${parsed.major}.${parsed.minor}.${parsed.patch}-exp.${expMinor}`;
|
||||
}
|
||||
|
||||
|
|
@ -23,7 +29,10 @@ function generateExperimentalVersion(currentVersion) {
|
|||
}
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const releaseType = process.env.RELEASE_TYPE;
|
||||
|
||||
const releaseType = /** @type { import('semver').ReleaseType | "experimental" } */ (
|
||||
process.env.RELEASE_TYPE
|
||||
);
|
||||
assert.match(releaseType, /^(patch|minor|major|experimental|premajor)$/, 'Invalid RELEASE_TYPE');
|
||||
|
||||
// TODO: if releaseType is `auto` determine release type based on the changelog
|
||||
|
|
@ -39,8 +48,12 @@ const packages = JSON.parse(
|
|||
|
||||
const packageMap = {};
|
||||
for (let { name, path, version, private: isPrivate } of packages) {
|
||||
if (isPrivate && path !== rootDir) continue;
|
||||
if (path === rootDir) name = 'monorepo-root';
|
||||
if (isPrivate && path !== rootDir) {
|
||||
continue;
|
||||
}
|
||||
if (path === rootDir) {
|
||||
name = 'monorepo-root';
|
||||
}
|
||||
|
||||
const isDirty = await exec(`git diff --quiet HEAD ${lastTag} -- ${path}`)
|
||||
.then(() => false)
|
||||
|
|
@ -57,11 +70,94 @@ assert.ok(
|
|||
// Propagate isDirty transitively: if a package's dependency will be bumped,
|
||||
// that package also needs a bump (e.g. design-system → editor-ui → cli).
|
||||
|
||||
// Detect root-level changes that affect resolved dep versions without touching individual
|
||||
// package.json files: pnpm.overrides (applies to all specifiers)
|
||||
// and pnpm-workspace.yaml catalog entries (applies only to deps using a "catalog:…" specifier).
|
||||
|
||||
const rootPkgJson = JSON.parse(await readFile(resolve(rootDir, 'package.json'), 'utf-8'));
|
||||
const rootPkgJsonAtTag = await exec(`git show ${lastTag}:package.json`)
|
||||
.then(({ stdout }) => JSON.parse(stdout))
|
||||
.catch(() => ({}));
|
||||
|
||||
const getOverrides = (pkg) => ({ ...pkg.pnpm?.overrides, ...pkg.overrides });
|
||||
|
||||
const currentOverrides = getOverrides(rootPkgJson);
|
||||
const previousOverrides = getOverrides(rootPkgJsonAtTag);
|
||||
|
||||
const changedOverrides = new Set(
|
||||
Object.keys({ ...currentOverrides, ...previousOverrides }).filter(
|
||||
(k) => currentOverrides[k] !== previousOverrides[k],
|
||||
),
|
||||
);
|
||||
|
||||
const parseWorkspaceYaml = (content) => {
|
||||
try {
|
||||
return /** @type {Record<string, unknown>} */ (parse(content) ?? {});
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
const workspaceYaml = parseWorkspaceYaml(
|
||||
await readFile(resolve(rootDir, 'pnpm-workspace.yaml'), 'utf-8').catch(() => ''),
|
||||
);
|
||||
const workspaceYamlAtTag = parseWorkspaceYaml(
|
||||
await exec(`git show ${lastTag}:pnpm-workspace.yaml`)
|
||||
.then(({ stdout }) => stdout)
|
||||
.catch(() => ''),
|
||||
);
|
||||
const getCatalogs = (ws) => {
|
||||
const result = new Map();
|
||||
if (ws.catalog) {
|
||||
result.set('default', /** @type {Record<string,string>} */ (ws.catalog));
|
||||
}
|
||||
|
||||
for (const [name, entries] of Object.entries(ws.catalogs ?? {})) {
|
||||
result.set(name, entries);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
// changedCatalogEntries: Map<catalogName, Set<depName>>
|
||||
const currentCatalogs = getCatalogs(workspaceYaml);
|
||||
const previousCatalogs = getCatalogs(workspaceYamlAtTag);
|
||||
const changedCatalogEntries = new Map();
|
||||
for (const catalogName of new Set([...currentCatalogs.keys(), ...previousCatalogs.keys()])) {
|
||||
const current = currentCatalogs.get(catalogName) ?? {};
|
||||
const previous = previousCatalogs.get(catalogName) ?? {};
|
||||
const changedDeps = new Set(
|
||||
Object.keys({ ...current, ...previous }).filter((dep) => current[dep] !== previous[dep]),
|
||||
);
|
||||
if (changedDeps.size > 0) {
|
||||
changedCatalogEntries.set(catalogName, changedDeps);
|
||||
}
|
||||
}
|
||||
|
||||
// Store full dep objects (with specifiers) so we can inspect "catalog:…" values below.
|
||||
const depsByPackage = {};
|
||||
for (const packageName in packageMap) {
|
||||
const packageFile = resolve(packageMap[packageName].path, 'package.json');
|
||||
const packageJson = JSON.parse(await readFile(packageFile, 'utf-8'));
|
||||
depsByPackage[packageName] = Object.keys(packageJson.dependencies || {});
|
||||
depsByPackage[packageName] = /** @type {Record<string,string>} */ (
|
||||
packageJson.dependencies ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
// Mark packages dirty if any dep had a root-level override or catalog version change.
|
||||
for (const [packageName, deps] of Object.entries(depsByPackage)) {
|
||||
if (packageMap[packageName].isDirty) continue;
|
||||
for (const [dep, specifier] of Object.entries(deps)) {
|
||||
if (changedOverrides.has(dep)) {
|
||||
packageMap[packageName].isDirty = true;
|
||||
break;
|
||||
}
|
||||
if (typeof specifier === 'string' && specifier.startsWith('catalog:')) {
|
||||
const catalogName = specifier === 'catalog:' ? 'default' : specifier.slice(8);
|
||||
if (changedCatalogEntries.get(catalogName)?.has(dep)) {
|
||||
packageMap[packageName].isDirty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let changed = true;
|
||||
|
|
@ -69,7 +165,7 @@ while (changed) {
|
|||
changed = false;
|
||||
for (const packageName in packageMap) {
|
||||
if (packageMap[packageName].isDirty) continue;
|
||||
if (depsByPackage[packageName].some((dep) => packageMap[dep]?.isDirty)) {
|
||||
if (Object.keys(depsByPackage[packageName]).some((dep) => packageMap[dep]?.isDirty)) {
|
||||
packageMap[packageName].isDirty = true;
|
||||
changed = true;
|
||||
}
|
||||
|
|
|
|||
3
.github/scripts/package.json
vendored
3
.github/scripts/package.json
vendored
|
|
@ -11,7 +11,8 @@
|
|||
"glob": "13.0.6",
|
||||
"minimatch": "10.2.4",
|
||||
"semver": "7.7.4",
|
||||
"tempfile": "6.0.1"
|
||||
"tempfile": "6.0.1",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"conventional-changelog-angular": "8.3.0"
|
||||
|
|
|
|||
10
.github/scripts/pnpm-lock.yaml
vendored
10
.github/scripts/pnpm-lock.yaml
vendored
|
|
@ -32,6 +32,9 @@ importers:
|
|||
tempfile:
|
||||
specifier: 6.0.1
|
||||
version: 6.0.1
|
||||
yaml:
|
||||
specifier: ^2.8.3
|
||||
version: 2.8.3
|
||||
devDependencies:
|
||||
conventional-changelog-angular:
|
||||
specifier: 8.3.0
|
||||
|
|
@ -292,6 +295,11 @@ packages:
|
|||
wordwrap@1.0.0:
|
||||
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
|
||||
|
||||
yaml@2.8.3:
|
||||
resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@actions/github@9.0.0':
|
||||
|
|
@ -540,3 +548,5 @@ snapshots:
|
|||
walk-up-path@4.0.0: {}
|
||||
|
||||
wordwrap@1.0.0: {}
|
||||
|
||||
yaml@2.8.3: {}
|
||||
|
|
|
|||
31
CHANGELOG.md
31
CHANGELOG.md
|
|
@ -1,3 +1,34 @@
|
|||
## [2.17.3](https://github.com/n8n-io/n8n/compare/n8n@2.17.2...n8n@2.17.3) (2026-04-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Guard against undefined config properties in credential overwrites ([#28629](https://github.com/n8n-io/n8n/issues/28629)) ([5d32052](https://github.com/n8n-io/n8n/commit/5d320521fbac7fe2502d10ff21568598dc9d84f6))
|
||||
* **editor:** Restore WASM file paths for cURL import in HTTP Request node ([#28619](https://github.com/n8n-io/n8n/issues/28619)) ([c15d6d0](https://github.com/n8n-io/n8n/commit/c15d6d0f6d576d98475d192a541aacf79479900f))
|
||||
* **LinkedIn Node:** Update LinkedIn API version in request headers ([#28668](https://github.com/n8n-io/n8n/issues/28668)) ([ce7d1b7](https://github.com/n8n-io/n8n/commit/ce7d1b7762b69b1ec12ce40dfcf1224d9f07e5eb))
|
||||
|
||||
|
||||
## [2.17.2](https://github.com/n8n-io/n8n/compare/n8n@2.17.1...n8n@2.17.2) (2026-04-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **editor:** Center sub-node icons and refresh triggers panel icons ([#28521](https://github.com/n8n-io/n8n/issues/28521)) ([2d50843](https://github.com/n8n-io/n8n/commit/2d50843216fbbb2a9e86a4464b0747181262ca3d))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* AI Gateway credentials endpoint instance url ([#28527](https://github.com/n8n-io/n8n/issues/28527)) ([2ec4332](https://github.com/n8n-io/n8n/commit/2ec433263b18547df184fa8aa9481bbdf17492e1))
|
||||
|
||||
|
||||
## [2.17.1](https://github.com/n8n-io/n8n/compare/n8n@2.17.0...n8n@2.17.1) (2026-04-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **MiniMax Chat Model Node:** Add MiniMax Chat Model sub-node ([#28485](https://github.com/n8n-io/n8n/issues/28485)) ([884b7ab](https://github.com/n8n-io/n8n/commit/884b7ab3eca3d032e0abbbf8bd2c0aabae5e2ed9))
|
||||
|
||||
|
||||
# [2.17.0](https://github.com/n8n-io/n8n/compare/n8n@2.16.0...n8n@2.17.0) (2026-04-13)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "2.17.0",
|
||||
"version": "2.17.3",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.16",
|
||||
|
|
@ -102,7 +102,7 @@
|
|||
"@mistralai/mistralai": "^1.10.0",
|
||||
"@n8n/typeorm>@sentry/node": "catalog:sentry",
|
||||
"@types/node": "^20.17.50",
|
||||
"axios": "1.13.5",
|
||||
"axios": "1.15.0",
|
||||
"chokidar": "4.0.3",
|
||||
"esbuild": "^0.25.0",
|
||||
"expr-eval@2.0.2": "npm:expr-eval-fork@3.0.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-workflow-builder",
|
||||
"version": "1.17.0",
|
||||
"version": "1.17.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "1.17.0",
|
||||
"version": "1.17.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export interface AiGatewayUsageEntry {
|
|||
provider: string;
|
||||
model: string;
|
||||
timestamp: number;
|
||||
creditsDeducted: number;
|
||||
cost: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ export interface FrontendSettings {
|
|||
};
|
||||
aiGateway?: {
|
||||
enabled: boolean;
|
||||
creditsQuota: number;
|
||||
budget: number;
|
||||
};
|
||||
ai: {
|
||||
allowSendingParameterValues: boolean;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-common",
|
||||
"version": "1.17.0",
|
||||
"version": "1.17.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-test-utils",
|
||||
"version": "1.17.0",
|
||||
"version": "1.17.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-benchmark",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.1",
|
||||
"description": "Cli for running benchmark tests for n8n",
|
||||
"main": "dist/index",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/chat-hub",
|
||||
"version": "1.10.0",
|
||||
"version": "1.10.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/client-oauth2",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/constants",
|
||||
"version": "0.21.0",
|
||||
"version": "0.21.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export const LICENSE_QUOTAS = {
|
|||
WORKFLOW_HISTORY_PRUNE_LIMIT: 'quota:workflowHistoryPrune',
|
||||
TEAM_PROJECT_LIMIT: 'quota:maxTeamProjects',
|
||||
AI_CREDITS: 'quota:aiCredits',
|
||||
AI_GATEWAY_CREDITS: 'quota:aiGatewayCredits',
|
||||
AI_GATEWAY_BUDGET: 'quota:aiGatewayBudget',
|
||||
INSIGHTS_MAX_HISTORY_DAYS: 'quota:insights:maxHistoryDays',
|
||||
INSIGHTS_RETENTION_MAX_AGE_DAYS: 'quota:insights:retention:maxAgeDays',
|
||||
INSIGHTS_RETENTION_PRUNE_INTERVAL_DAYS: 'quota:insights:retention:pruneIntervalDays',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/db",
|
||||
"version": "1.17.0",
|
||||
"version": "1.17.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/decorators",
|
||||
"version": "1.17.0",
|
||||
"version": "1.17.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/instance-ai",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class MinimaxApi implements ICredentialType {
|
||||
name = 'minimaxApi';
|
||||
|
||||
displayName = 'MiniMax';
|
||||
|
||||
documentationUrl = 'minimax';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Region',
|
||||
name: 'region',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'International',
|
||||
value: 'international',
|
||||
description: 'platform.minimax.io - international endpoint',
|
||||
},
|
||||
{
|
||||
name: 'China',
|
||||
value: 'china',
|
||||
description: 'platform.minimaxi.com - mainland China endpoint',
|
||||
},
|
||||
],
|
||||
default: 'international',
|
||||
},
|
||||
{
|
||||
displayName: 'Base URL',
|
||||
name: 'url',
|
||||
type: 'hidden',
|
||||
default:
|
||||
'={{ $self.region === "china" ? "https://api.minimaxi.com/v1" : "https://api.minimax.io/v1" }}',
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
headers: {
|
||||
Authorization: '=Bearer {{$credentials.apiKey}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: '={{ $credentials.url }}',
|
||||
url: '/files/list',
|
||||
qs: { purpose: 'voice_clone' },
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
type: 'responseSuccessBody',
|
||||
properties: {
|
||||
key: 'base_resp.status_code',
|
||||
value: 1004,
|
||||
message: 'Authentication failed. Please check your API key.',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'responseSuccessBody',
|
||||
properties: {
|
||||
key: 'base_resp.status_code',
|
||||
value: 2049,
|
||||
message: 'Invalid API key. Please verify your key matches the selected region.',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -103,6 +103,7 @@ function getInputs(
|
|||
'@n8n/n8n-nodes-langchain.lmChatGoogleGemini',
|
||||
'@n8n/n8n-nodes-langchain.lmChatGoogleVertex',
|
||||
'@n8n/n8n-nodes-langchain.lmChatMistralCloud',
|
||||
'@n8n/n8n-nodes-langchain.lmChatMinimax',
|
||||
'@n8n/n8n-nodes-langchain.lmChatMoonshot',
|
||||
'@n8n/n8n-nodes-langchain.lmChatAzureOpenAi',
|
||||
'@n8n/n8n-nodes-langchain.lmChatDeepSeek',
|
||||
|
|
@ -134,6 +135,7 @@ function getInputs(
|
|||
'@n8n/n8n-nodes-langchain.lmChatAwsBedrock',
|
||||
'@n8n/n8n-nodes-langchain.lmChatLemonade',
|
||||
'@n8n/n8n-nodes-langchain.lmChatMistralCloud',
|
||||
'@n8n/n8n-nodes-langchain.lmChatMinimax',
|
||||
'@n8n/n8n-nodes-langchain.lmChatMoonshot',
|
||||
'@n8n/n8n-nodes-langchain.lmChatOllama',
|
||||
'@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
import { ChatOpenAI, type ClientOptions } from '@langchain/openai';
|
||||
import {
|
||||
getProxyAgent,
|
||||
makeN8nLlmFailedAttemptHandler,
|
||||
N8nLlmTracing,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { OpenAICompatibleCredential } from '../../../types/types';
|
||||
import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling';
|
||||
|
||||
export class LmChatMinimax implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'MiniMax Chat Model',
|
||||
|
||||
name: 'lmChatMinimax',
|
||||
icon: 'file:minimax.svg',
|
||||
group: ['transform'],
|
||||
version: [1],
|
||||
description: 'For advanced usage with an AI chain',
|
||||
defaults: {
|
||||
name: 'MiniMax Chat Model',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Language Models', 'Root Nodes'],
|
||||
'Language Models': ['Chat Models (Recommended)'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatminimax/',
|
||||
},
|
||||
],
|
||||
},
|
||||
alias: ['minimax'],
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'minimaxApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
requestDefaults: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
baseURL: '={{ $credentials?.url }}',
|
||||
},
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]),
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
description:
|
||||
'The model which will generate the completion. <a href="https://platform.minimax.io/docs/api-reference/text-openai-api">Learn more</a>.',
|
||||
options: [
|
||||
{ name: 'MiniMax-M2', value: 'MiniMax-M2' },
|
||||
{ name: 'MiniMax-M2.1', value: 'MiniMax-M2.1' },
|
||||
{ name: 'MiniMax-M2.1-Highspeed', value: 'MiniMax-M2.1-highspeed' },
|
||||
{ name: 'MiniMax-M2.5', value: 'MiniMax-M2.5' },
|
||||
{ name: 'MiniMax-M2.5-Highspeed', value: 'MiniMax-M2.5-highspeed' },
|
||||
{ name: 'MiniMax-M2.7', value: 'MiniMax-M2.7' },
|
||||
{ name: 'MiniMax-M2.7-Highspeed', value: 'MiniMax-M2.7-highspeed' },
|
||||
],
|
||||
default: 'MiniMax-M2.7',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
description: 'Additional options to add',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Hide Thinking',
|
||||
name: 'hideThinking',
|
||||
default: true,
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether to strip chain-of-thought reasoning from the response, returning only the final answer',
|
||||
},
|
||||
{
|
||||
displayName: 'Maximum Number of Tokens',
|
||||
name: 'maxTokens',
|
||||
default: -1,
|
||||
description:
|
||||
'The maximum number of tokens to generate in the completion. The limit depends on the selected model.',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Sampling Temperature',
|
||||
name: 'temperature',
|
||||
default: 0.7,
|
||||
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Timeout',
|
||||
name: 'timeout',
|
||||
default: 360000,
|
||||
description: 'Maximum amount of time a request is allowed to take in milliseconds',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Max Retries',
|
||||
name: 'maxRetries',
|
||||
default: 2,
|
||||
description: 'Maximum number of retries to attempt',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Top P',
|
||||
name: 'topP',
|
||||
default: 1,
|
||||
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'Controls diversity via nucleus sampling: 0.5 means half of all likelihood-weighted options are considered. We generally recommend altering this or temperature but not both.',
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials<OpenAICompatibleCredential>('minimaxApi');
|
||||
|
||||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
hideThinking?: boolean;
|
||||
maxTokens?: number;
|
||||
maxRetries: number;
|
||||
timeout: number;
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
};
|
||||
|
||||
const hideThinking = options.hideThinking ?? true;
|
||||
|
||||
const timeout = options.timeout;
|
||||
const configuration: ClientOptions = {
|
||||
baseURL: credentials.url,
|
||||
fetchOptions: {
|
||||
dispatcher: getProxyAgent(credentials.url, {
|
||||
headersTimeout: timeout,
|
||||
bodyTimeout: timeout,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const model = new ChatOpenAI({
|
||||
apiKey: credentials.apiKey,
|
||||
model: modelName,
|
||||
...options,
|
||||
timeout,
|
||||
maxRetries: options.maxRetries ?? 2,
|
||||
configuration,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
modelKwargs: hideThinking ? { reasoning_split: true } : undefined,
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, openAiFailedAttemptHandler),
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<svg width="40" height="40" viewBox="0 0 490.16 411.7" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="minimax-grad" y1="205.85" x2="490.16" y2="205.85" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#e4177f"/>
|
||||
<stop offset="0.5" stop-color="#e73562"/>
|
||||
<stop offset="1" stop-color="#e94e4a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#minimax-grad)" d="M233.45,40.81a17.55,17.55,0,1,0-35.1,0V331.56a40.82,40.82,0,0,1-81.63,0V145a17.55,17.55,0,1,0-35.09,0v79.06a40.82,40.82,0,0,1-81.63,0V195.42a11.63,11.63,0,0,1,23.26,0v28.66a17.55,17.55,0,0,0,35.1,0V145A40.82,40.82,0,0,1,140,145V331.56a17.55,17.55,0,0,0,35.1,0V217.5h0V40.81a40.81,40.81,0,1,1,81.62,0V281.56a11.63,11.63,0,1,1-23.26,0Zm215.9,63.4A40.86,40.86,0,0,0,408.53,145V300.85a17.55,17.55,0,0,1-35.09,0v-260a40.82,40.82,0,0,0-81.63,0V370.89a17.55,17.55,0,0,1-35.1,0V330a11.63,11.63,0,1,0-23.26,0v40.86a40.81,40.81,0,0,0,81.62,0V40.81a17.55,17.55,0,0,1,35.1,0v260a40.82,40.82,0,0,0,81.63,0V145a17.55,17.55,0,1,1,35.1,0V281.56a11.63,11.63,0,0,0,23.26,0V145A40.85,40.85,0,0,0,449.35,104.21Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -0,0 +1,193 @@
|
|||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing, getProxyAgent } from '@n8n/ai-utilities';
|
||||
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
|
||||
import { LmChatMinimax } from '../LmChatMinimax.node';
|
||||
|
||||
jest.mock('@langchain/openai');
|
||||
jest.mock('@n8n/ai-utilities');
|
||||
|
||||
const MockedChatOpenAI = jest.mocked(ChatOpenAI);
|
||||
const MockedN8nLlmTracing = jest.mocked(N8nLlmTracing);
|
||||
const mockedMakeN8nLlmFailedAttemptHandler = jest.mocked(makeN8nLlmFailedAttemptHandler);
|
||||
const mockedGetProxyAgent = jest.mocked(getProxyAgent);
|
||||
|
||||
describe('LmChatMinimax', () => {
|
||||
let node: LmChatMinimax;
|
||||
|
||||
const mockNodeDef: INode = {
|
||||
id: '1',
|
||||
name: 'MiniMax Chat Model',
|
||||
typeVersion: 1,
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatMinimax',
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
const setupMockContext = (nodeOverrides: Partial<INode> = {}) => {
|
||||
const nodeDef = { ...mockNodeDef, ...nodeOverrides };
|
||||
const ctx = createMockExecuteFunction<ISupplyDataFunctions>(
|
||||
{},
|
||||
nodeDef,
|
||||
) as jest.Mocked<ISupplyDataFunctions>;
|
||||
|
||||
ctx.getCredentials = jest.fn().mockResolvedValue({
|
||||
apiKey: 'test-minimax-key',
|
||||
url: 'https://api.minimax.io/v1',
|
||||
});
|
||||
ctx.getNode = jest.fn().mockReturnValue(nodeDef);
|
||||
ctx.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model') return 'MiniMax-M2.7';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
MockedN8nLlmTracing.mockImplementation(() => ({}) as unknown as N8nLlmTracing);
|
||||
mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(jest.fn());
|
||||
mockedGetProxyAgent.mockReturnValue({} as any);
|
||||
return ctx;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
node = new LmChatMinimax();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('node description', () => {
|
||||
it('should have correct node properties', () => {
|
||||
expect(node.description).toMatchObject({
|
||||
displayName: 'MiniMax Chat Model',
|
||||
name: 'lmChatMinimax',
|
||||
group: ['transform'],
|
||||
version: [1],
|
||||
});
|
||||
});
|
||||
|
||||
it('should require minimaxApi credentials', () => {
|
||||
expect(node.description.credentials).toEqual([{ name: 'minimaxApi', required: true }]);
|
||||
});
|
||||
|
||||
it('should output ai_languageModel', () => {
|
||||
expect(node.description.outputs).toEqual(['ai_languageModel']);
|
||||
expect(node.description.outputNames).toEqual(['Model']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supplyData', () => {
|
||||
it('should create ChatOpenAI with Minimax base URL', async () => {
|
||||
const ctx = setupMockContext();
|
||||
|
||||
const result = await node.supplyData.call(ctx, 0);
|
||||
|
||||
expect(ctx.getCredentials).toHaveBeenCalledWith('minimaxApi');
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-minimax-key',
|
||||
model: 'MiniMax-M2.7',
|
||||
maxRetries: 2,
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
onFailedAttempt: expect.any(Function),
|
||||
configuration: expect.objectContaining({
|
||||
baseURL: 'https://api.minimax.io/v1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ response: expect.any(Object) });
|
||||
});
|
||||
|
||||
it('should pass options to ChatOpenAI', async () => {
|
||||
const ctx = setupMockContext();
|
||||
ctx.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model') return 'MiniMax-M2.5';
|
||||
if (paramName === 'options')
|
||||
return {
|
||||
temperature: 0.5,
|
||||
maxTokens: 2000,
|
||||
topP: 0.9,
|
||||
timeout: 60000,
|
||||
maxRetries: 5,
|
||||
};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await node.supplyData.call(ctx, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'MiniMax-M2.5',
|
||||
temperature: 0.5,
|
||||
maxTokens: 2000,
|
||||
topP: 0.9,
|
||||
timeout: 60000,
|
||||
maxRetries: 5,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set reasoning_split by default (hideThinking defaults to true)', async () => {
|
||||
const ctx = setupMockContext();
|
||||
|
||||
await node.supplyData.call(ctx, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelKwargs: { reasoning_split: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not set reasoning_split when hideThinking is false', async () => {
|
||||
const ctx = setupMockContext();
|
||||
ctx.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model') return 'MiniMax-M2.7';
|
||||
if (paramName === 'options') return { hideThinking: false };
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await node.supplyData.call(ctx, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelKwargs: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should configure proxy agent with credentials URL', async () => {
|
||||
const ctx = setupMockContext();
|
||||
|
||||
await node.supplyData.call(ctx, 0);
|
||||
|
||||
expect(mockedGetProxyAgent).toHaveBeenCalledWith(
|
||||
'https://api.minimax.io/v1',
|
||||
expect.objectContaining({
|
||||
headersTimeout: undefined,
|
||||
bodyTimeout: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should configure proxy agent with custom timeout', async () => {
|
||||
const ctx = setupMockContext();
|
||||
ctx.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model') return 'MiniMax-M2.7';
|
||||
if (paramName === 'options') return { timeout: 120000 };
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await node.supplyData.call(ctx, 0);
|
||||
|
||||
expect(mockedGetProxyAgent).toHaveBeenCalledWith(
|
||||
'https://api.minimax.io/v1',
|
||||
expect.objectContaining({
|
||||
headersTimeout: 120000,
|
||||
bodyTimeout: 120000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-nodes-langchain",
|
||||
"version": "2.17.0",
|
||||
"version": "2.17.2",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"exports": {
|
||||
|
|
@ -61,6 +61,7 @@
|
|||
"dist/credentials/MotorheadApi.credentials.js",
|
||||
"dist/credentials/MilvusApi.credentials.js",
|
||||
"dist/credentials/MistralCloudApi.credentials.js",
|
||||
"dist/credentials/MinimaxApi.credentials.js",
|
||||
"dist/credentials/MoonshotApi.credentials.js",
|
||||
"dist/credentials/LemonadeApi.credentials.js",
|
||||
"dist/credentials/OllamaApi.credentials.js",
|
||||
|
|
@ -121,6 +122,7 @@
|
|||
"dist/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.js",
|
||||
"dist/nodes/llms/LmChatGroq/LmChatGroq.node.js",
|
||||
"dist/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.js",
|
||||
"dist/nodes/llms/LmChatMinimax/LmChatMinimax.node.js",
|
||||
"dist/nodes/llms/LmChatMoonshot/LmChatMoonshot.node.js",
|
||||
"dist/nodes/llms/LMChatLemonade/LmChatLemonade.node.js",
|
||||
"dist/nodes/llms/LMChatOllama/LmChatOllama.node.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/scan-community-package",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.1",
|
||||
"description": "Static code analyser for n8n community packages",
|
||||
"license": "none",
|
||||
"bin": "scanner/cli.mjs",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/task-runner",
|
||||
"version": "2.17.0",
|
||||
"version": "2.17.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"start": "node dist/start.js",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@n8n/utils",
|
||||
"type": "module",
|
||||
"version": "1.28.0",
|
||||
"version": "1.28.1",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n",
|
||||
"version": "2.17.0",
|
||||
"version": "2.17.3",
|
||||
"description": "n8n Workflow Automation Tool",
|
||||
"main": "dist/index",
|
||||
"types": "dist/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -113,6 +113,36 @@ describe('CredentialsOverwrites', () => {
|
|||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('should not crash when skipTypes is undefined', () => {
|
||||
// Simulate version mismatch where skipTypes is not present on the config object
|
||||
globalConfig.credentials.overwrite.skipTypes =
|
||||
undefined as unknown as CommaSeparatedStringArray<string>;
|
||||
|
||||
const result = credentialsOverwrites.applyOverwrite('test', {
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ username: 'user', password: 'pass' });
|
||||
});
|
||||
|
||||
it('should not crash when overwrite config object is undefined', () => {
|
||||
// Simulate a DI/version mismatch where the nested overwrite config is undefined
|
||||
const savedOverwrite = globalConfig.credentials.overwrite;
|
||||
globalConfig.credentials.overwrite = undefined as never;
|
||||
|
||||
try {
|
||||
const result = credentialsOverwrites.applyOverwrite('test', {
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ username: 'user', password: 'pass' });
|
||||
} finally {
|
||||
globalConfig.credentials.overwrite = savedOverwrite;
|
||||
}
|
||||
});
|
||||
|
||||
describe('N8N_SKIP_CREDENTIAL_OVERWRITE', () => {
|
||||
beforeEach(() => {
|
||||
globalConfig.credentials.overwrite.skipTypes = [
|
||||
|
|
|
|||
|
|
@ -598,21 +598,21 @@ describe('AiController', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getGatewayCredits', () => {
|
||||
it('should return credits from aiGatewayService', async () => {
|
||||
const credits = { creditsQuota: 10, creditsRemaining: 7 };
|
||||
aiGatewayService.getCreditsRemaining.mockResolvedValue(credits);
|
||||
describe('getGatewayWallet', () => {
|
||||
it('should return wallet from aiGatewayService', async () => {
|
||||
const walletData = { budget: 10, balance: 7 };
|
||||
aiGatewayService.getWallet.mockResolvedValue(walletData);
|
||||
|
||||
const result = await controller.getGatewayCredits(request);
|
||||
const result = await controller.getGatewayWallet(request);
|
||||
|
||||
expect(aiGatewayService.getCreditsRemaining).toHaveBeenCalledWith(request.user.id);
|
||||
expect(result).toEqual(credits);
|
||||
expect(aiGatewayService.getWallet).toHaveBeenCalledWith(request.user.id);
|
||||
expect(result).toEqual(walletData);
|
||||
});
|
||||
|
||||
it('should throw InternalServerError when aiGatewayService throws', async () => {
|
||||
aiGatewayService.getCreditsRemaining.mockRejectedValue(new Error('Gateway unreachable'));
|
||||
aiGatewayService.getWallet.mockRejectedValue(new Error('Gateway unreachable'));
|
||||
|
||||
await expect(controller.getGatewayCredits(request)).rejects.toThrow(InternalServerError);
|
||||
await expect(controller.getGatewayWallet(request)).rejects.toThrow(InternalServerError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -271,12 +271,10 @@ export class AiController {
|
|||
}
|
||||
|
||||
@Licensed('feat:aiGateway')
|
||||
@Get('/gateway/credits')
|
||||
async getGatewayCredits(
|
||||
req: AuthenticatedRequest,
|
||||
): Promise<{ creditsQuota: number; creditsRemaining: number }> {
|
||||
@Get('/gateway/wallet')
|
||||
async getGatewayWallet(req: AuthenticatedRequest): Promise<{ budget: number; balance: number }> {
|
||||
try {
|
||||
return await this.aiGatewayService.getCreditsRemaining(req.user.id);
|
||||
return await this.aiGatewayService.getWallet(req.user.id);
|
||||
} catch (e) {
|
||||
assert(e instanceof Error);
|
||||
throw new InternalServerError(e.message, e);
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ export class E2EController {
|
|||
[LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT]: -1,
|
||||
[LICENSE_QUOTAS.TEAM_PROJECT_LIMIT]: 0,
|
||||
[LICENSE_QUOTAS.AI_CREDITS]: 0,
|
||||
[LICENSE_QUOTAS.AI_GATEWAY_CREDITS]: 0,
|
||||
[LICENSE_QUOTAS.AI_GATEWAY_BUDGET]: 0,
|
||||
[LICENSE_QUOTAS.INSIGHTS_MAX_HISTORY_DAYS]: 7,
|
||||
[LICENSE_QUOTAS.INSIGHTS_RETENTION_MAX_AGE_DAYS]: 30,
|
||||
[LICENSE_QUOTAS.INSIGHTS_RETENTION_PRUNE_INTERVAL_DAYS]: 180,
|
||||
|
|
@ -153,8 +153,8 @@ export class E2EController {
|
|||
[LICENSE_QUOTAS.TEAM_PROJECT_LIMIT]:
|
||||
E2EController.numericFeaturesDefaults[LICENSE_QUOTAS.TEAM_PROJECT_LIMIT],
|
||||
[LICENSE_QUOTAS.AI_CREDITS]: E2EController.numericFeaturesDefaults[LICENSE_QUOTAS.AI_CREDITS],
|
||||
[LICENSE_QUOTAS.AI_GATEWAY_CREDITS]:
|
||||
E2EController.numericFeaturesDefaults[LICENSE_QUOTAS.AI_GATEWAY_CREDITS],
|
||||
[LICENSE_QUOTAS.AI_GATEWAY_BUDGET]:
|
||||
E2EController.numericFeaturesDefaults[LICENSE_QUOTAS.AI_GATEWAY_BUDGET],
|
||||
|
||||
[LICENSE_QUOTAS.INSIGHTS_MAX_HISTORY_DAYS]:
|
||||
E2EController.numericFeaturesDefaults[LICENSE_QUOTAS.INSIGHTS_MAX_HISTORY_DAYS],
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ export class CredentialsOverwrites {
|
|||
// customized (any overwrite field has a non-empty value that differs from
|
||||
// the overwrite value). Since overwrites are never persisted to the DB,
|
||||
// any non-empty stored value that differs from the overwrite is user-set.
|
||||
if (this.globalConfig.credentials.overwrite.skipTypes.includes(type)) {
|
||||
if (this.globalConfig.credentials.overwrite?.skipTypes?.includes(type)) {
|
||||
const isFieldCustomized = (key: string) => {
|
||||
const storedValue = data[key];
|
||||
return (
|
||||
|
|
@ -208,11 +208,17 @@ export class CredentialsOverwrites {
|
|||
|
||||
private get(name: string): ICredentialDataDecryptedObject | undefined {
|
||||
const parentTypes = this.credentialTypes.getParentTypes(name);
|
||||
return [name, ...parentTypes]
|
||||
const entries = [name, ...parentTypes]
|
||||
.reverse()
|
||||
.map((type) => this.overwriteData[type])
|
||||
.filter((type) => !!type)
|
||||
.reduce((acc, current) => Object.assign(acc, current), {});
|
||||
.filter((type): type is ICredentialDataDecryptedObject => !!type);
|
||||
|
||||
if (entries.length === 0) return undefined;
|
||||
|
||||
return entries.reduce(
|
||||
(acc, current) => Object.assign(acc, current),
|
||||
{} as ICredentialDataDecryptedObject,
|
||||
);
|
||||
}
|
||||
|
||||
getAll(): ICredentialsOverwrite {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import type { License } from '@/license';
|
|||
import { AiGatewayService } from '@/services/ai-gateway.service';
|
||||
import type { Project, User, UserRepository } from '@n8n/db';
|
||||
import type { OwnershipService } from '@/services/ownership.service';
|
||||
import type { UrlService } from '@/services/url.service';
|
||||
|
||||
const INSTANCE_BASE_URL = 'https://my-n8n.example.com';
|
||||
|
||||
const BASE_URL = 'http://gateway.test';
|
||||
const INSTANCE_ID = 'test-instance-id';
|
||||
|
|
@ -34,6 +37,9 @@ function makeService({
|
|||
isAiGatewayLicensed = true,
|
||||
ownershipService = mock<OwnershipService>(),
|
||||
userRepository = mock<UserRepository>({ findOneBy: jest.fn().mockResolvedValue(null) }),
|
||||
urlService = mock<UrlService>({
|
||||
getInstanceBaseUrl: jest.fn().mockReturnValue(INSTANCE_BASE_URL),
|
||||
}),
|
||||
} = {}) {
|
||||
const globalConfig = {
|
||||
aiAssistant: { baseUrl: baseUrl ?? undefined },
|
||||
|
|
@ -53,6 +59,7 @@ function makeService({
|
|||
instanceSettings,
|
||||
ownershipService,
|
||||
userRepository,
|
||||
urlService,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -189,11 +196,21 @@ describe('AiGatewayService', () => {
|
|||
'x-n8n-version': N8N_VERSION,
|
||||
'x-instance-id': INSTANCE_ID,
|
||||
},
|
||||
body: JSON.stringify({ licenseCert: LICENSE_CERT }),
|
||||
body: JSON.stringify({ licenseCert: LICENSE_CERT, instanceUrl: INSTANCE_BASE_URL }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('includes instanceUrl in token body', async () => {
|
||||
mockConfigThenToken(fetchMock);
|
||||
const service = makeService();
|
||||
|
||||
await service.getSyntheticCredential({ credentialType: 'googlePalmApi', userId: USER_ID });
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[1][1].body as string);
|
||||
expect(body.instanceUrl).toBe(INSTANCE_BASE_URL);
|
||||
});
|
||||
|
||||
it('includes userEmail and userName in token body when user exists', async () => {
|
||||
const userRepository = mock<UserRepository>({
|
||||
findOneBy: jest
|
||||
|
|
@ -215,6 +232,7 @@ describe('AiGatewayService', () => {
|
|||
licenseCert: LICENSE_CERT,
|
||||
userEmail: 'alice@example.com',
|
||||
userName: 'Alice Smith',
|
||||
instanceUrl: INSTANCE_BASE_URL,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
@ -245,7 +263,7 @@ describe('AiGatewayService', () => {
|
|||
await service.getSyntheticCredential({ credentialType: 'googlePalmApi', userId: USER_ID });
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[1][1].body as string);
|
||||
expect(body).toEqual({ licenseCert: LICENSE_CERT });
|
||||
expect(body).toEqual({ licenseCert: LICENSE_CERT, instanceUrl: INSTANCE_BASE_URL });
|
||||
});
|
||||
|
||||
it('caches config and token — second call makes no additional fetches', async () => {
|
||||
|
|
@ -374,13 +392,13 @@ describe('AiGatewayService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getCreditsRemaining()', () => {
|
||||
describe('getWallet()', () => {
|
||||
it('throws UserError when baseUrl is not configured', async () => {
|
||||
const service = makeService({ baseUrl: null });
|
||||
await expect(service.getCreditsRemaining(USER_ID)).rejects.toThrow(UserError);
|
||||
await expect(service.getWallet(USER_ID)).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('returns creditsQuota and creditsRemaining from gateway', async () => {
|
||||
it('returns budget and balance from gateway wallet', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
|
@ -388,13 +406,13 @@ describe('AiGatewayService', () => {
|
|||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({ creditsQuota: 10, creditsRemaining: 7 }),
|
||||
json: jest.fn().mockResolvedValue({ budget: 10, balance: 7 }),
|
||||
});
|
||||
const service = makeService();
|
||||
|
||||
const result = await service.getCreditsRemaining(USER_ID);
|
||||
const result = await service.getWallet(USER_ID);
|
||||
|
||||
expect(result).toEqual({ creditsQuota: 10, creditsRemaining: 7 });
|
||||
expect(result).toEqual({ budget: 10, balance: 7 });
|
||||
});
|
||||
|
||||
it('sends JWT Bearer token in Authorization header to credits endpoint', async () => {
|
||||
|
|
@ -405,27 +423,27 @@ describe('AiGatewayService', () => {
|
|||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({ creditsQuota: 10, creditsRemaining: 7 }),
|
||||
json: jest.fn().mockResolvedValue({ budget: 10, balance: 7 }),
|
||||
});
|
||||
const service = makeService();
|
||||
|
||||
await service.getCreditsRemaining(USER_ID);
|
||||
await service.getWallet(USER_ID);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
`${BASE_URL}/v1/gateway/credentials`,
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ licenseCert: LICENSE_CERT }),
|
||||
body: JSON.stringify({ licenseCert: LICENSE_CERT, instanceUrl: INSTANCE_BASE_URL }),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, `${BASE_URL}/v1/gateway/credits`, {
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, `${BASE_URL}/v1/gateway/wallet`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer mock-jwt' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws UserError when credits gateway returns non-ok status', async () => {
|
||||
it('throws UserError when wallet gateway returns non-ok status', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
|
@ -433,7 +451,7 @@ describe('AiGatewayService', () => {
|
|||
})
|
||||
.mockResolvedValueOnce({ ok: false, status: 429 });
|
||||
const service = makeService();
|
||||
await expect(service.getCreditsRemaining(USER_ID)).rejects.toThrow(UserError);
|
||||
await expect(service.getWallet(USER_ID)).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when gateway returns invalid response shape', async () => {
|
||||
|
|
@ -444,18 +462,16 @@ describe('AiGatewayService', () => {
|
|||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({ creditsQuota: 'not-a-number' }),
|
||||
json: jest.fn().mockResolvedValue({ budget: 'not-a-number' }),
|
||||
});
|
||||
const service = makeService();
|
||||
await expect(service.getCreditsRemaining(USER_ID)).rejects.toThrow(UserError);
|
||||
await expect(service.getWallet(USER_ID)).rejects.toThrow(UserError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUsage()', () => {
|
||||
const MOCK_USAGE_RESPONSE = {
|
||||
entries: [
|
||||
{ provider: 'google', model: 'gemini-pro', timestamp: 1700000000, creditsDeducted: 2 },
|
||||
],
|
||||
entries: [{ provider: 'google', model: 'gemini-pro', timestamp: 1700000000, cost: 2 }],
|
||||
total: 1,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -12,15 +12,16 @@ import { N8N_VERSION, AI_ASSISTANT_SDK_VERSION } from '@/constants';
|
|||
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
|
||||
import { License } from '@/license';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
|
||||
interface GatewayTokenResponse {
|
||||
token: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
interface GatewayCreditsResponse {
|
||||
creditsQuota: number;
|
||||
creditsRemaining: number;
|
||||
interface GatewayWalletResponse {
|
||||
budget: number;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
@Service()
|
||||
|
|
@ -44,6 +45,7 @@ export class AiGatewayService {
|
|||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly ownershipService: OwnershipService,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly urlService: UrlService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -152,31 +154,31 @@ export class AiGatewayService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns the current credits quota and remaining credits for the given user.
|
||||
* Returns the current wallet (budget and remaining balance) for the given user.
|
||||
*/
|
||||
async getCreditsRemaining(userId: string): Promise<GatewayCreditsResponse> {
|
||||
async getWallet(userId: string): Promise<GatewayWalletResponse> {
|
||||
const baseUrl = this.requireBaseUrl();
|
||||
|
||||
const jwt = await this.getOrFetchToken(userId);
|
||||
if (!jwt) {
|
||||
throw new UserError('Failed to obtain a valid AI Gateway token.');
|
||||
}
|
||||
const response = await fetch(`${baseUrl}/v1/gateway/credits`, {
|
||||
const response = await fetch(`${baseUrl}/v1/gateway/wallet`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new UserError(`Failed to fetch AI Gateway credits: HTTP ${response.status}`);
|
||||
throw new UserError(`Failed to fetch AI Gateway wallet: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return this.parseCreditsResponse(await response.json());
|
||||
return this.parseWalletResponse(await response.json());
|
||||
}
|
||||
|
||||
private parseCreditsResponse(data: unknown): GatewayCreditsResponse {
|
||||
const d = data as GatewayCreditsResponse;
|
||||
if (typeof d.creditsQuota !== 'number' || typeof d.creditsRemaining !== 'number') {
|
||||
throw new UserError('AI Gateway returned an invalid credits response.');
|
||||
private parseWalletResponse(data: unknown): GatewayWalletResponse {
|
||||
const d = data as GatewayWalletResponse;
|
||||
if (typeof d.budget !== 'number' || typeof d.balance !== 'number') {
|
||||
throw new UserError('AI Gateway returned an invalid wallet response.');
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
|
@ -273,6 +275,7 @@ export class AiGatewayService {
|
|||
...(user && {
|
||||
userName: [user.firstName, user.lastName].filter(Boolean).join(' ') || undefined,
|
||||
}),
|
||||
instanceUrl: this.urlService.getInstanceBaseUrl(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -528,7 +528,7 @@ export class FrontendService {
|
|||
if (isAiGatewayEnabled) {
|
||||
this.settings.aiGateway = {
|
||||
enabled: true,
|
||||
creditsQuota: this.license.getValue(LICENSE_QUOTAS.AI_GATEWAY_CREDITS) ?? 0,
|
||||
budget: this.license.getValue(LICENSE_QUOTAS.AI_GATEWAY_BUDGET) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-core",
|
||||
"version": "2.17.0",
|
||||
"version": "2.17.1",
|
||||
"description": "Core functionality of n8n",
|
||||
"main": "dist/index",
|
||||
"types": "dist/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/chat",
|
||||
"version": "1.17.0",
|
||||
"version": "1.17.1",
|
||||
"scripts": {
|
||||
"dev": "pnpm run --dir=../storybook dev --initial-path=/docs/chat-chat--docs",
|
||||
"build": "pnpm build:vite && pnpm build:bundle",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"type": "module",
|
||||
"name": "@n8n/design-system",
|
||||
"version": "2.17.0",
|
||||
"version": "2.17.1",
|
||||
"main": "src/index.ts",
|
||||
"import": "src/index.ts",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@n8n/i18n",
|
||||
"type": "module",
|
||||
"version": "2.17.0",
|
||||
"version": "2.17.2",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1198,7 +1198,7 @@
|
|||
"experiments.resourceCenter.seeMore": "See more",
|
||||
"experiments.resourceCenter.sidebar": "Resources",
|
||||
"experiments.resourceCenter.sidebar.inspiration": "Inspiration",
|
||||
"experiments.resourceCenter.templateCard.useNow": "▶︎ Run workflow",
|
||||
"experiments.resourceCenter.templateCard.useNow": "▶ Run workflow",
|
||||
"experiments.resourceCenter.templatePreviews.title": "Popular Templates",
|
||||
"experiments.resourceCenter.title": "Resources",
|
||||
"experiments.resourceCenter.viewAllTemplates": "View all templates",
|
||||
|
|
@ -2007,8 +2007,8 @@
|
|||
"aiGateway.toggle.label": "Connect via n8n Connect",
|
||||
"aiGateway.toggle.description": "n8n Connect is the easy way to manage AI model usage",
|
||||
"aiGateway.toggle.topUp": "Top up",
|
||||
"aiGateway.wallet.balanceRemaining": "{balance} remaining",
|
||||
"aiGateway.credentialMode.sectionLabel": "Credential",
|
||||
"aiGateway.credentialMode.creditsShort": "{count} credits",
|
||||
"aiGateway.credentialMode.n8nConnect.title": "n8n Connect",
|
||||
"aiGateway.credentialMode.n8nConnect.subtitle": "No API key required",
|
||||
"aiGateway.credentialMode.own.title": "My own credential",
|
||||
|
|
@ -3465,7 +3465,7 @@
|
|||
"templates.collection": "Collection",
|
||||
"templates.collections": "Collections",
|
||||
"templates.collectionsNotFound": "Collection could not be found",
|
||||
"templates.connectionWarning": "⚠️ There was a problem fetching workflow templates. Check your internet connection.",
|
||||
"templates.connectionWarning": "! There was a problem fetching workflow templates. Check your internet connection.",
|
||||
"templates.heading": "Workflow templates",
|
||||
"templates.shareWorkflow": "Share template",
|
||||
"templates.noSearchResults": "Nothing found. Try adjusting your search to see more.",
|
||||
|
|
@ -3995,6 +3995,8 @@
|
|||
"ImportCurlModal.notice.content": "This will overwrite any changes you have already made to the current node",
|
||||
"importCurlModal.button.label": "Import",
|
||||
"importCurlParameter.label": "Import cURL",
|
||||
"importCurlParameter.showError.failedToLoad.title": "Failed to load dependencies",
|
||||
"importCurlParameter.showError.failedToLoad.message": "There was an error loading required dependencies.",
|
||||
"importCurlParameter.showError.invalidCurlCommand.title": "Couldn’t import cURL command",
|
||||
"importCurlParameter.showError.invalidCurlCommand.message": "This command is in an unsupported format",
|
||||
"importCurlParameter.showError.invalidProtocol1.title": "Use the {node} node",
|
||||
|
|
@ -4214,13 +4216,13 @@
|
|||
"settings.n8nConnect.usage.col.model": "Model",
|
||||
"settings.n8nConnect.usage.col.inputTokens": "Input Tokens",
|
||||
"settings.n8nConnect.usage.col.outputTokens": "Output Tokens",
|
||||
"settings.n8nConnect.usage.col.credits": "Credits",
|
||||
"settings.n8nConnect.usage.col.cost": "Cost (USD)",
|
||||
"settings.n8nConnect.usage.empty": "No usage records found.",
|
||||
"settings.n8nConnect.usage.loadMore": "Load more",
|
||||
"settings.n8nConnect.credits.title": "Credits",
|
||||
"settings.n8nConnect.credits.remaining": "{remaining} / {quota} credits remaining",
|
||||
"settings.n8nConnect.credits.quota": "credits total",
|
||||
"settings.n8nConnect.credits.topUp": "Top up credits",
|
||||
"settings.n8nConnect.wallet.title": "Balance",
|
||||
"settings.n8nConnect.wallet.remaining": "${remaining} / ${budget} remaining",
|
||||
"settings.n8nConnect.wallet.quota": "budget",
|
||||
"settings.n8nConnect.wallet.topUp": "Top up balance",
|
||||
"settings.n8nConnect.usage.refresh.tooltip": "Refresh usage records",
|
||||
"settings.instanceAi": "Instance AI",
|
||||
"settings.instanceAi.description": "Configure the Instance AI agent, model, memory, and permissions.",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@n8n/rest-api-client",
|
||||
"type": "module",
|
||||
"version": "2.17.0",
|
||||
"version": "2.17.1",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-editor-ui",
|
||||
"version": "2.17.0",
|
||||
"version": "2.17.3",
|
||||
"description": "Workflow Editor UI for n8n",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -11,13 +11,13 @@ import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
|||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { AI_GATEWAY_TOP_UP_MODAL_KEY } from '@/app/constants';
|
||||
|
||||
const mockFetchCredits = vi.fn().mockResolvedValue(undefined);
|
||||
const mockCreditsRemaining = ref<number | undefined>(undefined);
|
||||
const mockFetchBalance = vi.fn().mockResolvedValue(undefined);
|
||||
const mockBalance = ref<number | undefined>(undefined);
|
||||
|
||||
vi.mock('@/app/composables/useAiGateway', () => ({
|
||||
useAiGateway: vi.fn(() => ({
|
||||
creditsRemaining: computed(() => mockCreditsRemaining.value),
|
||||
fetchCredits: mockFetchCredits,
|
||||
balance: computed(() => mockBalance.value),
|
||||
fetchWallet: mockFetchBalance,
|
||||
})),
|
||||
}));
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ describe('AiGatewaySelector', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreditsRemaining.value = undefined;
|
||||
mockBalance.value = undefined;
|
||||
const pinia = createTestingPinia({ stubActions: false });
|
||||
setActivePinia(pinia);
|
||||
workflowsStore = mockedStore(useWorkflowsStore);
|
||||
|
|
@ -55,25 +55,25 @@ describe('AiGatewaySelector', () => {
|
|||
expect(screen.getByText('My own credential')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show credits badge when aiGatewayEnabled and creditsRemaining is defined', () => {
|
||||
mockCreditsRemaining.value = 5;
|
||||
it('should show balance badge when aiGatewayEnabled and balance is defined', () => {
|
||||
mockBalance.value = 5;
|
||||
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
|
||||
|
||||
expect(screen.getByText('5 credits')).toBeInTheDocument();
|
||||
expect(screen.getByText('$5.00 remaining')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show credits badge when creditsRemaining is undefined', () => {
|
||||
mockCreditsRemaining.value = undefined;
|
||||
it('should not show balance badge when balance is undefined', () => {
|
||||
mockBalance.value = undefined;
|
||||
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
|
||||
|
||||
expect(screen.queryByText(/\d+ credits$/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/\$[\d.]+/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show credits badge when aiGatewayEnabled is false', () => {
|
||||
mockCreditsRemaining.value = 5;
|
||||
it('should not show balance badge when aiGatewayEnabled is false', () => {
|
||||
mockBalance.value = 5;
|
||||
renderComponent({ props: { aiGatewayEnabled: false, readonly: false } });
|
||||
|
||||
expect(screen.queryByText(/\d+ credits$/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/\$[\d.]+/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable both cards in readonly mode', () => {
|
||||
|
|
@ -128,79 +128,79 @@ describe('AiGatewaySelector', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('fetchCredits — mount watch (immediate)', () => {
|
||||
it('should call fetchCredits immediately when enabled on mount', () => {
|
||||
describe('fetchWallet — mount watch (immediate)', () => {
|
||||
it('should call fetchWallet immediately when enabled on mount', () => {
|
||||
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
|
||||
|
||||
expect(mockFetchCredits).toHaveBeenCalledOnce();
|
||||
expect(mockFetchBalance).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should not call fetchCredits on mount when disabled', () => {
|
||||
it('should not call fetchWallet on mount when disabled', () => {
|
||||
renderComponent({ props: { aiGatewayEnabled: false, readonly: false } });
|
||||
|
||||
expect(mockFetchCredits).not.toHaveBeenCalled();
|
||||
expect(mockFetchBalance).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchCredits — execution finish watch', () => {
|
||||
it('should call fetchCredits when execution data has finished:true (saved run)', async () => {
|
||||
describe('fetchWallet — execution finish watch', () => {
|
||||
it('should call fetchWallet when execution data has finished:true (saved run)', async () => {
|
||||
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
|
||||
mockFetchCredits.mockClear();
|
||||
mockFetchBalance.mockClear();
|
||||
|
||||
workflowsStore.workflowExecutionData = { finished: true } as never;
|
||||
|
||||
await vi.waitFor(() => expect(mockFetchCredits).toHaveBeenCalledOnce());
|
||||
await vi.waitFor(() => expect(mockFetchBalance).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it('should call fetchCredits when execution data has stoppedAt set (step/test run)', async () => {
|
||||
it('should call fetchWallet when execution data has stoppedAt set (step/test run)', async () => {
|
||||
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
|
||||
mockFetchCredits.mockClear();
|
||||
mockFetchBalance.mockClear();
|
||||
|
||||
workflowsStore.workflowExecutionData = { finished: false, stoppedAt: new Date() } as never;
|
||||
|
||||
await vi.waitFor(() => expect(mockFetchCredits).toHaveBeenCalledOnce());
|
||||
await vi.waitFor(() => expect(mockFetchBalance).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it('should not call fetchCredits when execution is still in progress', async () => {
|
||||
it('should not call fetchWallet when execution is still in progress', async () => {
|
||||
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
|
||||
mockFetchCredits.mockClear();
|
||||
mockFetchBalance.mockClear();
|
||||
|
||||
workflowsStore.workflowExecutionData = { finished: false, stoppedAt: undefined } as never;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(mockFetchCredits).not.toHaveBeenCalled();
|
||||
expect(mockFetchBalance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call fetchCredits again on consecutive executions', async () => {
|
||||
it('should call fetchWallet again on consecutive executions', async () => {
|
||||
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
|
||||
mockFetchCredits.mockClear();
|
||||
mockFetchBalance.mockClear();
|
||||
|
||||
workflowsStore.workflowExecutionData = { finished: true } as never;
|
||||
await vi.waitFor(() => expect(mockFetchCredits).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(mockFetchBalance).toHaveBeenCalledTimes(1));
|
||||
|
||||
workflowsStore.workflowExecutionData = { finished: true } as never;
|
||||
await vi.waitFor(() => expect(mockFetchCredits).toHaveBeenCalledTimes(2));
|
||||
await vi.waitFor(() => expect(mockFetchBalance).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('should not call fetchCredits when execution finishes but gateway is disabled', async () => {
|
||||
it('should not call fetchWallet when execution finishes but gateway is disabled', async () => {
|
||||
renderComponent({ props: { aiGatewayEnabled: false, readonly: false } });
|
||||
|
||||
workflowsStore.workflowExecutionData = { finished: true } as never;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(mockFetchCredits).not.toHaveBeenCalled();
|
||||
expect(mockFetchBalance).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('top-up badge', () => {
|
||||
it('opens top-up modal when badge is clicked', async () => {
|
||||
mockCreditsRemaining.value = 5;
|
||||
mockBalance.value = 5;
|
||||
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
|
||||
|
||||
const uiStore = useUIStore();
|
||||
vi.spyOn(uiStore, 'openModalWithData');
|
||||
|
||||
await userEvent.click(screen.getByText('5 credits'));
|
||||
await userEvent.click(screen.getByText('$5.00 remaining'));
|
||||
|
||||
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
|
||||
name: AI_GATEWAY_TOP_UP_MODAL_KEY,
|
||||
|
|
@ -208,8 +208,8 @@ describe('AiGatewaySelector', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('renders "Top up" label alongside the credits label in the badge', () => {
|
||||
mockCreditsRemaining.value = 5;
|
||||
it('renders "Top up" label alongside the balance label in the badge', () => {
|
||||
mockBalance.value = 5;
|
||||
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
|
||||
|
||||
expect(screen.getByText('Top up')).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -23,18 +23,18 @@ const uiStore = useUIStore();
|
|||
const workflowsStore = useWorkflowsStore();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const { creditsRemaining, fetchCredits } = useAiGateway();
|
||||
const { balance, fetchWallet } = useAiGateway();
|
||||
|
||||
// Fetch when enabled (on mount if already enabled, or when toggled on)
|
||||
watch(
|
||||
() => props.aiGatewayEnabled,
|
||||
(enabled) => {
|
||||
if (enabled) void fetchCredits();
|
||||
if (enabled) void fetchWallet();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Refresh after each execution completes so the badge reflects consumed credits.
|
||||
// Refresh after each execution completes so the badge reflects consumed balance.
|
||||
watch(
|
||||
() => workflowsStore.workflowExecutionData,
|
||||
(executionData) => {
|
||||
|
|
@ -42,7 +42,7 @@ watch(
|
|||
(executionData?.finished || executionData?.stoppedAt !== undefined) &&
|
||||
props.aiGatewayEnabled
|
||||
) {
|
||||
void fetchCredits();
|
||||
void fetchWallet();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -101,12 +101,12 @@ function onBadgeClick(event: MouseEvent): void {
|
|||
</span>
|
||||
</span>
|
||||
<N8nActionPill
|
||||
v-if="aiGatewayEnabled && creditsRemaining !== undefined"
|
||||
v-if="aiGatewayEnabled && balance !== undefined"
|
||||
:clickable="!readonly"
|
||||
size="small"
|
||||
:text="
|
||||
i18n.baseText('aiGateway.credentialMode.creditsShort', {
|
||||
interpolate: { count: String(creditsRemaining) },
|
||||
i18n.baseText('aiGateway.wallet.balanceRemaining', {
|
||||
interpolate: { balance: `$${Number(balance).toFixed(2)}` },
|
||||
})
|
||||
"
|
||||
:hover-text="!readonly ? i18n.baseText('aiGateway.toggle.topUp') : undefined"
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const { isCollapsed, sidebarWidth, onResizeStart, onResize, onResizeEnd, toggleC
|
|||
useSidebarLayout();
|
||||
|
||||
const { settingsItems } = useSettingsItems();
|
||||
const { fetchCredits, isEnabled: isAiGatewayEnabled } = useAiGateway();
|
||||
const { fetchWallet, isEnabled: isAiGatewayEnabled } = useAiGateway();
|
||||
|
||||
// Component data
|
||||
const basePath = ref('');
|
||||
|
|
@ -226,7 +226,7 @@ watch(isCollapsed, () => {
|
|||
|
||||
onMounted(() => {
|
||||
basePath.value = rootStore.baseUrl;
|
||||
if (isAiGatewayEnabled.value) void fetchCredits();
|
||||
if (isAiGatewayEnabled.value) void fetchWallet();
|
||||
|
||||
void nextTick(() => {
|
||||
checkOverflow();
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ const rootStore = useRootStore();
|
|||
const uiStore = useUIStore();
|
||||
|
||||
const { settingsItems } = useSettingsItems();
|
||||
const { fetchCredits, isEnabled } = useAiGateway();
|
||||
const { fetchWallet, isEnabled } = useAiGateway();
|
||||
|
||||
onMounted(() => {
|
||||
if (isEnabled.value) void fetchCredits();
|
||||
if (isEnabled.value) void fetchWallet();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import { ref } from 'vue';
|
|||
import { useAiGateway } from './useAiGateway';
|
||||
import { useAiGatewayStore } from '@/app/stores/aiGateway.store';
|
||||
|
||||
const mockGetGatewayCredits = vi.fn();
|
||||
const mockGetGatewayWallet = vi.fn();
|
||||
const mockGetGatewayConfig = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ nodes: [], credentialTypes: [], providerConfig: {} });
|
||||
|
||||
vi.mock('@/features/ai/assistant/assistant.api', () => ({
|
||||
getGatewayCredits: (...args: unknown[]) => mockGetGatewayCredits(...args),
|
||||
getGatewayWallet: (...args: unknown[]) => mockGetGatewayWallet(...args),
|
||||
getGatewayConfig: (...args: unknown[]) => mockGetGatewayConfig(...args),
|
||||
}));
|
||||
|
||||
|
|
@ -42,60 +42,60 @@ describe('useAiGateway', () => {
|
|||
mockGetGatewayConfig.mockResolvedValue({ nodes: [], credentialTypes: [], providerConfig: {} });
|
||||
});
|
||||
|
||||
describe('fetchCredits()', () => {
|
||||
describe('fetchWallet()', () => {
|
||||
it('should not call API when AI Gateway is not enabled', async () => {
|
||||
// isEnabled = false (default mock values)
|
||||
const { fetchCredits, creditsRemaining } = useAiGateway();
|
||||
const { fetchWallet, balance } = useAiGateway();
|
||||
|
||||
await fetchCredits();
|
||||
await fetchWallet();
|
||||
|
||||
expect(mockGetGatewayCredits).not.toHaveBeenCalled();
|
||||
expect(creditsRemaining.value).toBeUndefined();
|
||||
expect(mockGetGatewayWallet).not.toHaveBeenCalled();
|
||||
expect(balance.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fetch and update creditsRemaining and creditsQuota when enabled', async () => {
|
||||
it('should fetch and update balance and budget when enabled', async () => {
|
||||
mockIsAiGatewayEnabled.value = true;
|
||||
mockGetGatewayCredits.mockResolvedValue({ creditsRemaining: 7, creditsQuota: 10 });
|
||||
mockGetGatewayWallet.mockResolvedValue({ balance: 7, budget: 10 });
|
||||
|
||||
const { fetchCredits, creditsRemaining, creditsQuota } = useAiGateway();
|
||||
const { fetchWallet, balance, budget } = useAiGateway();
|
||||
|
||||
await fetchCredits();
|
||||
await fetchWallet();
|
||||
|
||||
expect(mockGetGatewayCredits).toHaveBeenCalledOnce();
|
||||
expect(creditsRemaining.value).toBe(7);
|
||||
expect(creditsQuota.value).toBe(10);
|
||||
expect(mockGetGatewayWallet).toHaveBeenCalledOnce();
|
||||
expect(balance.value).toBe(7);
|
||||
expect(budget.value).toBe(10);
|
||||
});
|
||||
|
||||
it('should keep previous values on API error', async () => {
|
||||
mockIsAiGatewayEnabled.value = true;
|
||||
|
||||
// First successful call
|
||||
mockGetGatewayCredits.mockResolvedValueOnce({ creditsRemaining: 5, creditsQuota: 10 });
|
||||
const { fetchCredits, creditsRemaining, creditsQuota } = useAiGateway();
|
||||
await fetchCredits();
|
||||
expect(creditsRemaining.value).toBe(5);
|
||||
mockGetGatewayWallet.mockResolvedValueOnce({ balance: 5, budget: 10 });
|
||||
const { fetchWallet, balance, budget } = useAiGateway();
|
||||
await fetchWallet();
|
||||
expect(balance.value).toBe(5);
|
||||
|
||||
// Second call fails
|
||||
mockGetGatewayCredits.mockRejectedValueOnce(new Error('Network error'));
|
||||
await fetchCredits();
|
||||
mockGetGatewayWallet.mockRejectedValueOnce(new Error('Network error'));
|
||||
await fetchWallet();
|
||||
|
||||
// Values should remain from first successful call
|
||||
expect(creditsRemaining.value).toBe(5);
|
||||
expect(creditsQuota.value).toBe(10);
|
||||
expect(balance.value).toBe(5);
|
||||
expect(budget.value).toBe(10);
|
||||
});
|
||||
|
||||
it('should share credits state across multiple composable instances', async () => {
|
||||
it('should share balance state across multiple composable instances', async () => {
|
||||
mockIsAiGatewayEnabled.value = true;
|
||||
mockGetGatewayCredits.mockResolvedValue({ creditsRemaining: 3, creditsQuota: 5 });
|
||||
mockGetGatewayWallet.mockResolvedValue({ balance: 3, budget: 5 });
|
||||
|
||||
const instance1 = useAiGateway();
|
||||
const instance2 = useAiGateway();
|
||||
|
||||
await instance1.fetchCredits();
|
||||
await instance1.fetchWallet();
|
||||
|
||||
// Both instances read from the same store
|
||||
expect(instance1.creditsRemaining.value).toBe(3);
|
||||
expect(instance2.creditsRemaining.value).toBe(3);
|
||||
expect(instance1.balance.value).toBe(3);
|
||||
expect(instance2.balance.value).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,15 +10,15 @@ export function useAiGateway() {
|
|||
const { saveCurrentWorkflow } = useWorkflowSaving({ router });
|
||||
const aiGatewayStore = useAiGatewayStore();
|
||||
|
||||
const creditsRemaining = computed(() => aiGatewayStore.creditsRemaining);
|
||||
const creditsQuota = computed(() => aiGatewayStore.creditsQuota);
|
||||
const balance = computed(() => aiGatewayStore.balance);
|
||||
const budget = computed(() => aiGatewayStore.budget);
|
||||
const fetchError = computed(() => aiGatewayStore.fetchError);
|
||||
|
||||
const isEnabled = computed(() => settingsStore.isAiGatewayEnabled);
|
||||
|
||||
async function fetchCredits(): Promise<void> {
|
||||
async function fetchWallet(): Promise<void> {
|
||||
if (!isEnabled.value) return;
|
||||
await aiGatewayStore.fetchCredits();
|
||||
await aiGatewayStore.fetchWallet();
|
||||
}
|
||||
|
||||
const isCredentialTypeSupported = (credentialType: string): boolean =>
|
||||
|
|
@ -35,11 +35,11 @@ export function useAiGateway() {
|
|||
|
||||
return {
|
||||
isEnabled,
|
||||
creditsRemaining,
|
||||
creditsQuota,
|
||||
balance,
|
||||
budget,
|
||||
fetchError,
|
||||
fetchConfig,
|
||||
fetchCredits,
|
||||
fetchWallet,
|
||||
isCredentialTypeSupported,
|
||||
saveAfterToggle,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export function useSettingsItems() {
|
|||
const uiStore = useUIStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { canUserAccessRouteByName } = useUserHelpers(router);
|
||||
const { creditsRemaining } = useAiGateway();
|
||||
const { balance } = useAiGateway();
|
||||
|
||||
const settingsItems = computed<IMenuItem[]>(() => {
|
||||
const menuItems: IMenuItem[] = [
|
||||
|
|
@ -62,9 +62,9 @@ export function useSettingsItems() {
|
|||
settingsStore.isAiGatewayEnabled && canUserAccessRouteByName(VIEWS.AI_GATEWAY_SETTINGS),
|
||||
route: { to: { name: VIEWS.AI_GATEWAY_SETTINGS } },
|
||||
creditsTag:
|
||||
creditsRemaining.value !== undefined
|
||||
? i18n.baseText('aiGateway.credentialMode.creditsShort', {
|
||||
interpolate: { count: String(creditsRemaining.value) },
|
||||
balance.value !== undefined
|
||||
? i18n.baseText('aiGateway.wallet.balanceRemaining', {
|
||||
interpolate: { balance: `$${Number(balance.value).toFixed(2)}` },
|
||||
})
|
||||
: undefined,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import { describe, it, vi, beforeEach, expect } from 'vitest';
|
|||
import { useAiGatewayStore } from './aiGateway.store';
|
||||
|
||||
const mockGetGatewayConfig = vi.fn();
|
||||
const mockGetGatewayCredits = vi.fn();
|
||||
const mockGetGatewayWallet = vi.fn();
|
||||
const mockGetGatewayUsage = vi.fn();
|
||||
|
||||
vi.mock('@/features/ai/assistant/assistant.api', () => ({
|
||||
getGatewayConfig: (...args: unknown[]) => mockGetGatewayConfig(...args),
|
||||
getGatewayCredits: (...args: unknown[]) => mockGetGatewayCredits(...args),
|
||||
getGatewayWallet: (...args: unknown[]) => mockGetGatewayWallet(...args),
|
||||
getGatewayUsage: (...args: unknown[]) => mockGetGatewayUsage(...args),
|
||||
}));
|
||||
|
||||
|
|
@ -27,12 +27,12 @@ const MOCK_CONFIG = {
|
|||
};
|
||||
|
||||
const MOCK_USAGE_PAGE_1 = [
|
||||
{ provider: 'google', model: 'gemini-pro', timestamp: 1700000001, creditsDeducted: 1 },
|
||||
{ provider: 'google', model: 'gemini-pro', timestamp: 1700000002, creditsDeducted: 2 },
|
||||
{ provider: 'google', model: 'gemini-pro', timestamp: 1700000001, cost: 1 },
|
||||
{ provider: 'google', model: 'gemini-pro', timestamp: 1700000002, cost: 2 },
|
||||
];
|
||||
|
||||
const MOCK_USAGE_PAGE_2 = [
|
||||
{ provider: 'anthropic', model: 'claude-3', timestamp: 1700000003, creditsDeducted: 3 },
|
||||
{ provider: 'anthropic', model: 'claude-3', timestamp: 1700000003, cost: 3 },
|
||||
];
|
||||
|
||||
describe('aiGateway.store', () => {
|
||||
|
|
@ -98,39 +98,39 @@ describe('aiGateway.store', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('fetchCredits()', () => {
|
||||
it('should update creditsRemaining and creditsQuota', async () => {
|
||||
mockGetGatewayCredits.mockResolvedValue({ creditsRemaining: 7, creditsQuota: 10 });
|
||||
describe('fetchWallet()', () => {
|
||||
it('should update balance and budget', async () => {
|
||||
mockGetGatewayWallet.mockResolvedValue({ balance: 7, budget: 10 });
|
||||
const store = useAiGatewayStore();
|
||||
|
||||
await store.fetchCredits();
|
||||
await store.fetchWallet();
|
||||
|
||||
expect(store.creditsRemaining).toBe(7);
|
||||
expect(store.creditsQuota).toBe(10);
|
||||
expect(store.balance).toBe(7);
|
||||
expect(store.budget).toBe(10);
|
||||
expect(store.fetchError).toBeNull();
|
||||
});
|
||||
|
||||
it('should set fetchError when API throws', async () => {
|
||||
mockGetGatewayCredits.mockRejectedValue(new Error('Unauthorized'));
|
||||
mockGetGatewayWallet.mockRejectedValue(new Error('Unauthorized'));
|
||||
const store = useAiGatewayStore();
|
||||
|
||||
await store.fetchCredits();
|
||||
await store.fetchWallet();
|
||||
|
||||
expect(store.fetchError).toBeInstanceOf(Error);
|
||||
expect(store.fetchError?.message).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('should clear fetchError on success after a previous failure', async () => {
|
||||
mockGetGatewayCredits.mockRejectedValueOnce(new Error('fail'));
|
||||
mockGetGatewayWallet.mockRejectedValueOnce(new Error('fail'));
|
||||
const store = useAiGatewayStore();
|
||||
await store.fetchCredits();
|
||||
await store.fetchWallet();
|
||||
expect(store.fetchError).not.toBeNull();
|
||||
|
||||
mockGetGatewayCredits.mockResolvedValue({ creditsRemaining: 3, creditsQuota: 10 });
|
||||
await store.fetchCredits();
|
||||
mockGetGatewayWallet.mockResolvedValue({ balance: 3, budget: 10 });
|
||||
await store.fetchWallet();
|
||||
|
||||
expect(store.fetchError).toBeNull();
|
||||
expect(store.creditsRemaining).toBe(3);
|
||||
expect(store.balance).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useRootStore } from '@n8n/stores/useRootStore';
|
|||
|
||||
import {
|
||||
getGatewayConfig,
|
||||
getGatewayCredits,
|
||||
getGatewayWallet,
|
||||
getGatewayUsage,
|
||||
} from '@/features/ai/assistant/assistant.api';
|
||||
|
||||
|
|
@ -18,8 +18,8 @@ export const useAiGatewayStore = defineStore(STORES.AI_GATEWAY, () => {
|
|||
const rootStore = useRootStore();
|
||||
|
||||
const config = ref<AiGatewayConfigDto | null>(null);
|
||||
const creditsRemaining = ref<number | undefined>(undefined);
|
||||
const creditsQuota = ref<number | undefined>(undefined);
|
||||
const balance = ref<number | undefined>(undefined);
|
||||
const budget = ref<number | undefined>(undefined);
|
||||
const usageEntries = ref<AiGatewayUsageEntry[]>([]);
|
||||
const usageTotal = ref<number>(0);
|
||||
const fetchError = ref<Error | null>(null);
|
||||
|
|
@ -34,11 +34,11 @@ export const useAiGatewayStore = defineStore(STORES.AI_GATEWAY, () => {
|
|||
}
|
||||
}
|
||||
|
||||
async function fetchCredits(): Promise<void> {
|
||||
async function fetchWallet(): Promise<void> {
|
||||
try {
|
||||
const data = await getGatewayCredits(rootStore.restApiContext);
|
||||
creditsRemaining.value = data.creditsRemaining;
|
||||
creditsQuota.value = data.creditsQuota;
|
||||
const data = await getGatewayWallet(rootStore.restApiContext);
|
||||
balance.value = data.balance;
|
||||
budget.value = data.budget;
|
||||
fetchError.value = null;
|
||||
} catch (error) {
|
||||
fetchError.value = toError(error);
|
||||
|
|
@ -77,13 +77,13 @@ export const useAiGatewayStore = defineStore(STORES.AI_GATEWAY, () => {
|
|||
|
||||
return {
|
||||
config,
|
||||
creditsRemaining,
|
||||
creditsQuota,
|
||||
balance,
|
||||
budget,
|
||||
usageEntries,
|
||||
usageTotal,
|
||||
fetchError,
|
||||
fetchConfig,
|
||||
fetchCredits,
|
||||
fetchWallet,
|
||||
fetchUsage,
|
||||
fetchMoreUsage,
|
||||
isNodeSupported,
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
|||
|
||||
const isAiGatewayEnabled = computed(() => settings.value.aiGateway?.enabled ?? false);
|
||||
|
||||
const aiGatewayCreditsQuota = computed(() => settings.value.aiGateway?.creditsQuota ?? 0);
|
||||
const aiGatewayBudget = computed(() => settings.value.aiGateway?.budget ?? 0);
|
||||
|
||||
const isSmtpSetup = computed(() => userManagement.value.smtpSetup);
|
||||
|
||||
|
|
@ -417,7 +417,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
|||
aiCreditsQuota,
|
||||
isAiDataSharingEnabled,
|
||||
isAiGatewayEnabled,
|
||||
aiGatewayCreditsQuota,
|
||||
aiGatewayBudget,
|
||||
reset,
|
||||
getTimezones,
|
||||
testTemplatesEndpoint,
|
||||
|
|
|
|||
|
|
@ -133,11 +133,11 @@ export async function getGatewayConfig(ctx: IRestApiContext): Promise<AiGatewayC
|
|||
return await makeRestApiRequest(ctx, 'GET', '/ai/gateway/config');
|
||||
}
|
||||
|
||||
export async function getGatewayCredits(ctx: IRestApiContext): Promise<{
|
||||
creditsQuota: number;
|
||||
creditsRemaining: number;
|
||||
export async function getGatewayWallet(ctx: IRestApiContext): Promise<{
|
||||
budget: number;
|
||||
balance: number;
|
||||
}> {
|
||||
return await makeRestApiRequest(ctx, 'GET', '/ai/gateway/credits');
|
||||
return await makeRestApiRequest(ctx, 'GET', '/ai/gateway/wallet');
|
||||
}
|
||||
|
||||
export async function getGatewayUsage(
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ describe('AiGatewayTopUpModal.vue', () => {
|
|||
|
||||
it('shows the coming-soon content', () => {
|
||||
renderModal();
|
||||
expect(screen.getByText('Credit top up is coming soon')).toBeInTheDocument();
|
||||
expect(screen.getByText('Top up is coming soon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render any buy UI or footer buttons', () => {
|
||||
|
|
|
|||
|
|
@ -48,9 +48,7 @@ const credentialDocsLinkText = computed(() => {
|
|||
<div :class="$style.contentWrapper">
|
||||
<div :class="$style.body">
|
||||
<N8nIcon icon="hourglass" size="xlarge" color="text-base" :class="$style.icon" />
|
||||
<N8nText :class="$style.title" bold color="text-dark"
|
||||
>Credit top up is coming soon</N8nText
|
||||
>
|
||||
<N8nText :class="$style.title" bold color="text-dark">Top up is coming soon</N8nText>
|
||||
|
||||
<div :class="$style.paragraphs">
|
||||
<p :class="$style.paragraph">
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import { useAiGatewayStore } from '@/app/stores/aiGateway.store';
|
|||
import { AI_GATEWAY_TOP_UP_MODAL_KEY } from '@/app/constants';
|
||||
|
||||
const mockGetGatewayUsage = vi.fn();
|
||||
const mockGetGatewayCredits = vi.fn();
|
||||
const mockGetGatewayWallet = vi.fn();
|
||||
|
||||
vi.mock('@/features/ai/assistant/assistant.api', () => ({
|
||||
getGatewayConfig: vi.fn(),
|
||||
getGatewayCredits: (...args: unknown[]) => mockGetGatewayCredits(...args),
|
||||
getGatewayWallet: (...args: unknown[]) => mockGetGatewayWallet(...args),
|
||||
getGatewayUsage: (...args: unknown[]) => mockGetGatewayUsage(...args),
|
||||
}));
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ const MOCK_ENTRIES = [
|
|||
timestamp: new Date('2024-01-15T10:30:00Z').getTime(),
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
creditsDeducted: 2,
|
||||
cost: 2,
|
||||
},
|
||||
{
|
||||
provider: 'anthropic',
|
||||
|
|
@ -42,7 +42,7 @@ const MOCK_ENTRIES = [
|
|||
timestamp: new Date('2024-01-16T14:00:00Z').getTime(),
|
||||
inputTokens: undefined,
|
||||
outputTokens: undefined,
|
||||
creditsDeducted: 5,
|
||||
cost: 5,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -53,26 +53,26 @@ describe('SettingsAiGatewayView', () => {
|
|||
vi.clearAllMocks();
|
||||
setActivePinia(createPinia());
|
||||
mockGetGatewayUsage.mockResolvedValue({ entries: [], total: 0 });
|
||||
mockGetGatewayCredits.mockResolvedValue({ creditsRemaining: 42, creditsQuota: 100 });
|
||||
mockGetGatewayWallet.mockResolvedValue({ balance: 42, budget: 100 });
|
||||
});
|
||||
|
||||
describe('credits card', () => {
|
||||
it('should display creditsRemaining after fetching', async () => {
|
||||
describe('balance card', () => {
|
||||
it('should display balance after fetching', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('settings-ai-gateway')).toBeInTheDocument());
|
||||
const store = useAiGatewayStore();
|
||||
await waitFor(() => expect(store.creditsRemaining).toBe(42));
|
||||
expect(screen.getByText('42 credits')).toBeInTheDocument();
|
||||
await waitFor(() => expect(store.balance).toBe(42));
|
||||
expect(screen.getByText('$42.00 remaining')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the credits number before data loads', () => {
|
||||
mockGetGatewayCredits.mockReturnValue(new Promise(() => {})); // never resolves
|
||||
it('should not render the balance before data loads', () => {
|
||||
mockGetGatewayWallet.mockReturnValue(new Promise(() => {})); // never resolves
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByTestId('ai-gateway-topup-button')).not.toBeNull(); // button present
|
||||
// number not yet visible (creditsRemaining undefined)
|
||||
expect(screen.queryByText('42')).not.toBeInTheDocument();
|
||||
// number not yet visible (balance undefined)
|
||||
expect(screen.queryByText('$42.00 remaining')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open top-up modal when "Top up credits" button is clicked', async () => {
|
||||
|
|
|
|||
|
|
@ -30,11 +30,11 @@ const isAppending = ref(false);
|
|||
const offset = ref(0);
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const creditsRemaining = computed(() => aiGatewayStore.creditsRemaining);
|
||||
const creditsBadgeText = computed(() =>
|
||||
creditsRemaining.value !== undefined
|
||||
? i18n.baseText('aiGateway.credentialMode.creditsShort', {
|
||||
interpolate: { count: String(creditsRemaining.value) },
|
||||
const walletBalance = computed(() => aiGatewayStore.balance);
|
||||
const walletBadgeText = computed(() =>
|
||||
walletBalance.value !== undefined
|
||||
? i18n.baseText('aiGateway.wallet.balanceRemaining', {
|
||||
interpolate: { balance: `$${Number(walletBalance.value).toFixed(2)}` },
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
|
|
@ -81,8 +81,8 @@ const tableHeaders = ref<Array<TableHeader<AiGatewayUsageEntry>>>([
|
|||
resize: false,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.n8nConnect.usage.col.credits'),
|
||||
key: 'creditsDeducted',
|
||||
title: i18n.baseText('settings.n8nConnect.usage.col.cost'),
|
||||
key: 'cost',
|
||||
width: 100,
|
||||
disableSort: true,
|
||||
resize: false,
|
||||
|
|
@ -137,7 +137,7 @@ async function loadMore(): Promise<void> {
|
|||
|
||||
onMounted(async () => {
|
||||
documentTitle.set(i18n.baseText('settings.n8nConnect.title'));
|
||||
await Promise.all([aiGatewayStore.fetchCredits(), load()]);
|
||||
await Promise.all([aiGatewayStore.fetchWallet(), load()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -148,9 +148,9 @@ onMounted(async () => {
|
|||
<div :class="$style.headingRow">
|
||||
<N8nHeading size="2xlarge">{{ i18n.baseText('settings.n8nConnect.title') }}</N8nHeading>
|
||||
<N8nActionPill
|
||||
v-if="creditsBadgeText"
|
||||
v-if="walletBadgeText"
|
||||
size="medium"
|
||||
:text="creditsBadgeText"
|
||||
:text="walletBadgeText"
|
||||
data-test-id="ai-gateway-header-credits-badge"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -159,7 +159,7 @@ onMounted(async () => {
|
|||
</N8nText>
|
||||
</div>
|
||||
<N8nButton
|
||||
:label="i18n.baseText('settings.n8nConnect.credits.topUp')"
|
||||
:label="i18n.baseText('settings.n8nConnect.wallet.topUp')"
|
||||
icon="hand-coins"
|
||||
variant="solid"
|
||||
data-test-id="ai-gateway-topup-button"
|
||||
|
|
@ -224,8 +224,8 @@ onMounted(async () => {
|
|||
<template #[`item.outputTokens`]="{ item }">
|
||||
{{ formatTokens(item.outputTokens) }}
|
||||
</template>
|
||||
<template #[`item.creditsDeducted`]="{ item }">
|
||||
{{ item.creditsDeducted }}
|
||||
<template #[`item.cost`]="{ item }">
|
||||
{{ `$${Number(item.cost).toFixed(4)}` }}
|
||||
</template>
|
||||
</N8nDataTableServer>
|
||||
|
||||
|
|
|
|||
|
|
@ -31,10 +31,10 @@ vi.mock('@/app/composables/useAiGateway', () => ({
|
|||
useAiGateway: vi.fn(() => ({
|
||||
isEnabled: ref(false),
|
||||
isCredentialTypeSupported: vi.fn(() => false),
|
||||
creditsRemaining: computed(() => undefined),
|
||||
creditsQuota: computed(() => undefined),
|
||||
balance: computed(() => undefined),
|
||||
budget: computed(() => undefined),
|
||||
fetchConfig: vi.fn().mockResolvedValue(undefined),
|
||||
fetchCredits: vi.fn().mockResolvedValue(undefined),
|
||||
fetchWallet: vi.fn().mockResolvedValue(undefined),
|
||||
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
|
@ -902,10 +902,10 @@ describe('NodeCredentials', () => {
|
|||
vi.mocked(useAiGateway).mockReturnValue({
|
||||
isEnabled: computed(() => true),
|
||||
isCredentialTypeSupported: vi.fn((credType: string) => credType === 'googlePalmApi'),
|
||||
creditsRemaining: computed(() => undefined),
|
||||
creditsQuota: computed(() => undefined),
|
||||
balance: computed(() => undefined),
|
||||
budget: computed(() => undefined),
|
||||
fetchConfig: vi.fn().mockResolvedValue(undefined),
|
||||
fetchCredits: vi.fn().mockResolvedValue(undefined),
|
||||
fetchWallet: vi.fn().mockResolvedValue(undefined),
|
||||
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
|
||||
fetchError: computed(() => null),
|
||||
});
|
||||
|
|
@ -974,11 +974,11 @@ describe('NodeCredentials', () => {
|
|||
vi.mocked(useAiGateway).mockReturnValue({
|
||||
isEnabled: computed(() => true),
|
||||
isCredentialTypeSupported: vi.fn(() => false),
|
||||
creditsRemaining: computed(() => undefined),
|
||||
creditsQuota: computed(() => undefined),
|
||||
balance: computed(() => undefined),
|
||||
budget: computed(() => undefined),
|
||||
fetchError: computed(() => null),
|
||||
fetchConfig: vi.fn().mockResolvedValue(undefined),
|
||||
fetchCredits: vi.fn().mockResolvedValue(undefined),
|
||||
fetchWallet: vi.fn().mockResolvedValue(undefined),
|
||||
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
|
|
@ -1001,11 +1001,11 @@ describe('NodeCredentials', () => {
|
|||
vi.mocked(useAiGateway).mockReturnValue({
|
||||
isEnabled: computed(() => false),
|
||||
isCredentialTypeSupported: vi.fn(() => false),
|
||||
creditsRemaining: computed(() => undefined),
|
||||
creditsQuota: computed(() => undefined),
|
||||
balance: computed(() => undefined),
|
||||
budget: computed(() => undefined),
|
||||
fetchError: computed(() => null),
|
||||
fetchConfig: vi.fn().mockResolvedValue(undefined),
|
||||
fetchCredits: vi.fn().mockResolvedValue(undefined),
|
||||
fetchWallet: vi.fn().mockResolvedValue(undefined),
|
||||
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
ndvStore.activeNode = googleAiNode;
|
||||
|
|
|
|||
|
|
@ -15,16 +15,23 @@ vi.mock('@/app/composables/useTelemetry', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
const mockShowToast = vi.fn();
|
||||
vi.mock('@/app/composables/useToast', () => ({
|
||||
useToast: () => ({ showToast: mockShowToast }),
|
||||
}));
|
||||
|
||||
const mockImportCurlCommand = vi.fn();
|
||||
let mockImportOptions: {
|
||||
onImportSuccess: () => void;
|
||||
onImportFailure: (data: { invalidProtocol: boolean; protocol?: string }) => void;
|
||||
onAfterImport: () => void;
|
||||
};
|
||||
|
||||
vi.mock('@/app/composables/useImportCurlCommand', () => ({
|
||||
useImportCurlCommand: (options: {
|
||||
onImportSuccess: () => void;
|
||||
onAfterImport: () => void;
|
||||
}) => ({
|
||||
importCurlCommand: () => {
|
||||
options.onImportSuccess();
|
||||
options.onAfterImport();
|
||||
},
|
||||
}),
|
||||
useImportCurlCommand: (options: typeof mockImportOptions) => {
|
||||
mockImportOptions = options;
|
||||
return { importCurlCommand: mockImportCurlCommand };
|
||||
},
|
||||
}));
|
||||
|
||||
const renderModal = createComponentRenderer(ImportCurlModal, {
|
||||
|
|
@ -43,6 +50,10 @@ const testNode = {
|
|||
describe('ImportCurlModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockImportCurlCommand.mockImplementation(() => {
|
||||
mockImportOptions.onImportSuccess();
|
||||
mockImportOptions.onAfterImport();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show empty input when no curl command exists for active node', async () => {
|
||||
|
|
@ -149,4 +160,35 @@ describe('ImportCurlModal', () => {
|
|||
'node-1': 'curl -X GET https://api.example.com/other',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error toast and track failure telemetry when import throws (e.g. WASM load failure)', async () => {
|
||||
mockImportCurlCommand.mockImplementation(() => {
|
||||
throw new Error('WASM failed to load');
|
||||
});
|
||||
|
||||
const uiStore = mockedStore(useUIStore);
|
||||
uiStore.modalsById = {
|
||||
[IMPORT_CURL_MODAL_KEY]: {
|
||||
open: true,
|
||||
data: { curlCommands: {} },
|
||||
},
|
||||
};
|
||||
uiStore.modalStack = [IMPORT_CURL_MODAL_KEY];
|
||||
const ndvStore = mockedStore(useNDVStore);
|
||||
ndvStore.activeNode = testNode;
|
||||
|
||||
const { getByTestId } = renderModal();
|
||||
await nextTick();
|
||||
|
||||
const input = getByTestId('import-curl-modal-input');
|
||||
await userEvent.type(input, 'curl -X GET https://api.example.com/data');
|
||||
const button = getByTestId('import-curl-modal-button');
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }));
|
||||
expect(mockTelemetryTrack).toHaveBeenCalledWith(
|
||||
'User imported curl command',
|
||||
expect.objectContaining({ success: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ import { onMounted, ref } from 'vue';
|
|||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
|
||||
|
||||
import { N8nButton, N8nInput, N8nInputLabel, N8nNotice } from '@n8n/design-system';
|
||||
const telemetry = useTelemetry();
|
||||
const toast = useToast();
|
||||
const i18n = useI18n();
|
||||
|
||||
const uiStore = useUIStore();
|
||||
|
|
@ -80,13 +82,24 @@ function sendTelemetry(
|
|||
}
|
||||
|
||||
async function onImport() {
|
||||
const { useImportCurlCommand } = await import('@/app/composables/useImportCurlCommand');
|
||||
const { importCurlCommand } = useImportCurlCommand({
|
||||
onImportSuccess,
|
||||
onImportFailure,
|
||||
onAfterImport,
|
||||
});
|
||||
importCurlCommand(curlCommand);
|
||||
try {
|
||||
const { useImportCurlCommand } = await import('@/app/composables/useImportCurlCommand');
|
||||
const { importCurlCommand } = useImportCurlCommand({
|
||||
onImportSuccess,
|
||||
onImportFailure,
|
||||
onAfterImport,
|
||||
});
|
||||
importCurlCommand(curlCommand);
|
||||
} catch {
|
||||
// Handles WASM loading failures (e.g. wrong MIME type for tree-sitter.wasm)
|
||||
toast.showToast({
|
||||
title: i18n.baseText('importCurlParameter.showError.failedToLoad.title'),
|
||||
message: i18n.baseText('importCurlParameter.showError.failedToLoad.message'),
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
});
|
||||
onImportFailure({ invalidProtocol: false });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -460,7 +460,9 @@ const setHttpNodeParameters = (parameters: CurlToJSONResponse) => {
|
|||
name: 'parameters',
|
||||
value: parameters as unknown as INodeParameters,
|
||||
});
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error('Failed to apply cURL parameters to node:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const onSwitchSelectedNode = (node: string) => {
|
||||
|
|
|
|||
|
|
@ -194,13 +194,15 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
|
|||
@dragend="onDragEnd"
|
||||
>
|
||||
<template #icon>
|
||||
<div v-if="isSubNodeType" :class="$style.subNodeBackground"></div>
|
||||
<NodeIcon
|
||||
:class="$style.nodeIcon"
|
||||
:node-type="nodeType"
|
||||
:size="nodeListIconSize"
|
||||
color-default="var(--color--foreground--shade-2)"
|
||||
/>
|
||||
<div :class="$style.iconWrapper">
|
||||
<div v-if="isSubNodeType" :class="$style.subNodeBackground"></div>
|
||||
<NodeIcon
|
||||
:class="$style.nodeIcon"
|
||||
:node-type="nodeType"
|
||||
:size="nodeListIconSize"
|
||||
color-default="var(--color--foreground--shade-2)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="isOfficial" #extraDetails>
|
||||
|
|
@ -264,6 +266,13 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
|
|||
user-select: none;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nodeIcon {
|
||||
z-index: 2;
|
||||
}
|
||||
|
|
@ -272,9 +281,11 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
|
|||
background-color: var(--node-type--supplemental--color--background);
|
||||
border-radius: 50%;
|
||||
height: 40px;
|
||||
position: absolute;
|
||||
transform: translate(-7px, -7px);
|
||||
width: 40px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ vi.mock('@/app/composables/useAiGateway', () => ({
|
|||
useAiGateway: vi.fn(() => ({
|
||||
isEnabled: { value: false },
|
||||
fetchConfig: mockFetchConfig,
|
||||
fetchCredits: vi.fn(),
|
||||
creditsRemaining: { value: undefined },
|
||||
creditsQuota: { value: undefined },
|
||||
fetchWallet: vi.fn(),
|
||||
balance: { value: undefined },
|
||||
budget: { value: undefined },
|
||||
fetchError: { value: undefined },
|
||||
isCredentialTypeSupported: vi.fn(() => false),
|
||||
saveAfterToggle: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -427,7 +427,7 @@ export function TriggerView() {
|
|||
name: WEBHOOK_NODE_TYPE,
|
||||
displayName: i18n.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDisplayName'),
|
||||
description: i18n.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDescription'),
|
||||
iconData: { type: 'icon', icon: 'webhook' },
|
||||
icon: 'node:webhook',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -439,7 +439,7 @@ export function TriggerView() {
|
|||
name: FORM_TRIGGER_NODE_TYPE,
|
||||
displayName: i18n.baseText('nodeCreator.triggerHelperPanel.formTriggerDisplayName'),
|
||||
description: i18n.baseText('nodeCreator.triggerHelperPanel.formTriggerDescription'),
|
||||
iconData: { type: 'icon', icon: 'form' },
|
||||
icon: 'node:form-trigger',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -110,11 +110,13 @@ const plugins: UserConfig['plugins'] = [
|
|||
targets: [
|
||||
{
|
||||
src: 'node_modules/web-tree-sitter/tree-sitter.wasm',
|
||||
dest: 'dist',
|
||||
dest: '.',
|
||||
rename: { stripBase: true },
|
||||
},
|
||||
{
|
||||
src: 'node_modules/curlconverter/dist/tree-sitter-bash.wasm',
|
||||
dest: 'dist',
|
||||
dest: '.',
|
||||
rename: { stripBase: true },
|
||||
},
|
||||
// wa-sqlite WASM files for OPFS database support (no cross-origin isolation needed)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export async function linkedInApiRequest(
|
|||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Restli-Protocol-Version': '2.0.0',
|
||||
'LinkedIn-Version': '202504',
|
||||
'LinkedIn-Version': '202604',
|
||||
},
|
||||
method,
|
||||
body,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-nodes-base",
|
||||
"version": "2.17.0",
|
||||
"version": "2.17.1",
|
||||
"description": "Base nodes of n8n",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
105
pnpm-lock.yaml
105
pnpm-lock.yaml
|
|
@ -349,7 +349,7 @@ overrides:
|
|||
'@mistralai/mistralai': ^1.10.0
|
||||
'@n8n/typeorm>@sentry/node': ^10.36.0
|
||||
'@types/node': ^20.17.50
|
||||
axios: 1.13.5
|
||||
axios: 1.15.0
|
||||
chokidar: 4.0.3
|
||||
esbuild: ^0.25.0
|
||||
expr-eval@2.0.2: npm:expr-eval-fork@3.0.0
|
||||
|
|
@ -679,8 +679,8 @@ importers:
|
|||
specifier: 'catalog:'
|
||||
version: 3.0.1
|
||||
axios:
|
||||
specifier: 1.13.5
|
||||
version: 1.13.5
|
||||
specifier: 1.15.0
|
||||
version: 1.15.0
|
||||
jest-mock-extended:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(jest@29.7.0(@types/node@20.19.21)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@20.19.21)(typescript@6.0.2)))(typescript@6.0.2)
|
||||
|
|
@ -902,8 +902,8 @@ importers:
|
|||
specifier: 4.0.7
|
||||
version: 4.0.7
|
||||
axios:
|
||||
specifier: 1.13.5
|
||||
version: 1.13.5
|
||||
specifier: 1.15.0
|
||||
version: 1.15.0
|
||||
dotenv:
|
||||
specifier: 17.2.3
|
||||
version: 17.2.3
|
||||
|
|
@ -956,8 +956,8 @@ importers:
|
|||
packages/@n8n/client-oauth2:
|
||||
dependencies:
|
||||
axios:
|
||||
specifier: 1.13.5
|
||||
version: 1.13.5
|
||||
specifier: 1.15.0
|
||||
version: 1.15.0
|
||||
devDependencies:
|
||||
'@n8n/typescript-config':
|
||||
specifier: workspace:*
|
||||
|
|
@ -2150,8 +2150,8 @@ importers:
|
|||
specifier: workspace:*
|
||||
version: link:../eslint-plugin-community-nodes
|
||||
axios:
|
||||
specifier: 1.13.5
|
||||
version: 1.13.5
|
||||
specifier: 1.15.0
|
||||
version: 1.15.0
|
||||
eslint:
|
||||
specifier: 'catalog:'
|
||||
version: 9.29.0(jiti@2.6.1)
|
||||
|
|
@ -2470,8 +2470,8 @@ importers:
|
|||
specifier: 1.11.0
|
||||
version: 1.11.0
|
||||
axios:
|
||||
specifier: 1.13.5
|
||||
version: 1.13.5
|
||||
specifier: 1.15.0
|
||||
version: 1.15.0
|
||||
bcryptjs:
|
||||
specifier: 2.4.3
|
||||
version: 2.4.3
|
||||
|
|
@ -2825,8 +2825,8 @@ importers:
|
|||
specifier: catalog:sentry
|
||||
version: 10.36.0
|
||||
axios:
|
||||
specifier: 1.13.5
|
||||
version: 1.13.5
|
||||
specifier: 1.15.0
|
||||
version: 1.15.0
|
||||
callsites:
|
||||
specifier: 'catalog:'
|
||||
version: 3.1.0
|
||||
|
|
@ -3298,8 +3298,8 @@ importers:
|
|||
specifier: workspace:*
|
||||
version: link:../../../@n8n/utils
|
||||
axios:
|
||||
specifier: 1.13.5
|
||||
version: 1.13.5
|
||||
specifier: 1.15.0
|
||||
version: 1.15.0
|
||||
flatted:
|
||||
specifier: 3.4.2
|
||||
version: 3.4.2
|
||||
|
|
@ -3619,8 +3619,8 @@ importers:
|
|||
specifier: 1.1.4
|
||||
version: 1.1.4
|
||||
axios:
|
||||
specifier: 1.13.5
|
||||
version: 1.13.5
|
||||
specifier: 1.15.0
|
||||
version: 1.15.0
|
||||
bowser:
|
||||
specifier: 2.11.0
|
||||
version: 2.11.0
|
||||
|
|
@ -12293,10 +12293,10 @@ packages:
|
|||
axios-retry@4.5.0:
|
||||
resolution: {integrity: sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==}
|
||||
peerDependencies:
|
||||
axios: 1.13.5
|
||||
axios: 1.15.0
|
||||
|
||||
axios@1.13.5:
|
||||
resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==}
|
||||
axios@1.15.0:
|
||||
resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==}
|
||||
|
||||
b4a@1.6.7:
|
||||
resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==}
|
||||
|
|
@ -18884,6 +18884,10 @@ packages:
|
|||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
|
||||
proxy-from-env@2.1.0:
|
||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
ps-tree@1.2.0:
|
||||
resolution: {integrity: sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
|
@ -19325,7 +19329,7 @@ packages:
|
|||
resolution: {integrity: sha512-pOLi+Gdll3JekwuFjXO3fTq+L9lzMQGcSq7M5gIjExcl3Gu1hd4XXuf5o3+LuSBsaULQH7DiNbsqPd1chVpQGQ==}
|
||||
engines: {node: '>=10.7.0'}
|
||||
peerDependencies:
|
||||
axios: 1.13.5
|
||||
axios: 1.15.0
|
||||
|
||||
retry-request@7.0.2:
|
||||
resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==}
|
||||
|
|
@ -21961,7 +21965,7 @@ snapshots:
|
|||
|
||||
'@1password/connect@1.4.2':
|
||||
dependencies:
|
||||
axios: 1.13.5(debug@4.4.3)
|
||||
axios: 1.15.0(debug@4.4.3)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
lodash.clonedeep: 4.5.0
|
||||
slugify: 1.6.6
|
||||
|
|
@ -25447,7 +25451,7 @@ snapshots:
|
|||
|
||||
'@codspeed/core@5.2.0':
|
||||
dependencies:
|
||||
axios: 1.13.5
|
||||
axios: 1.15.0
|
||||
find-up: 6.3.0
|
||||
form-data: 4.0.4
|
||||
node-gyp-build: 4.8.4
|
||||
|
|
@ -25511,8 +25515,8 @@ snapshots:
|
|||
'@commander-js/extra-typings': 12.1.0(commander@12.1.0)
|
||||
'@currents/commit-info': 1.0.1-beta.0
|
||||
async-retry: 1.3.3
|
||||
axios: 1.13.5(debug@4.4.3)
|
||||
axios-retry: 4.5.0(axios@1.13.5)
|
||||
axios: 1.15.0(debug@4.4.3)
|
||||
axios-retry: 4.5.0(axios@1.15.0(debug@4.4.3))
|
||||
c12: 1.11.2(magicast@0.3.5)
|
||||
chalk: 4.1.2
|
||||
commander: 12.1.0
|
||||
|
|
@ -25562,13 +25566,13 @@ snapshots:
|
|||
|
||||
'@daytonaio/api-client@0.143.0':
|
||||
dependencies:
|
||||
axios: 1.13.5
|
||||
axios: 1.15.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
'@daytonaio/api-client@0.149.0':
|
||||
dependencies:
|
||||
axios: 1.13.5
|
||||
axios: 1.15.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
|
|
@ -25587,7 +25591,7 @@ snapshots:
|
|||
'@opentelemetry/sdk-node': 0.207.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/semantic-conventions': 1.40.0
|
||||
axios: 1.13.5
|
||||
axios: 1.15.0
|
||||
busboy: 1.6.0
|
||||
dotenv: 17.2.3
|
||||
expand-tilde: 2.0.2
|
||||
|
|
@ -25618,7 +25622,7 @@ snapshots:
|
|||
'@opentelemetry/sdk-node': 0.207.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/semantic-conventions': 1.40.0
|
||||
axios: 1.13.5
|
||||
axios: 1.15.0
|
||||
busboy: 1.6.0
|
||||
dotenv: 17.2.3
|
||||
expand-tilde: 2.0.2
|
||||
|
|
@ -25636,13 +25640,13 @@ snapshots:
|
|||
|
||||
'@daytonaio/toolbox-api-client@0.143.0':
|
||||
dependencies:
|
||||
axios: 1.13.5
|
||||
axios: 1.15.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
'@daytonaio/toolbox-api-client@0.149.0':
|
||||
dependencies:
|
||||
axios: 1.13.5
|
||||
axios: 1.15.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
|
|
@ -27549,7 +27553,7 @@ snapshots:
|
|||
'@azure/core-auth': 1.10.1
|
||||
'@azure/msal-node': 3.8.4
|
||||
'@microsoft/agents-activity': 1.2.3
|
||||
axios: 1.13.5
|
||||
axios: 1.15.0
|
||||
jsonwebtoken: 9.0.3
|
||||
jwks-rsa: 3.2.2
|
||||
object-path: 0.11.8
|
||||
|
|
@ -29406,8 +29410,8 @@ snapshots:
|
|||
|
||||
'@rudderstack/rudder-sdk-node@3.0.0':
|
||||
dependencies:
|
||||
axios: 1.13.5
|
||||
axios-retry: 4.5.0(axios@1.13.5)
|
||||
axios: 1.15.0
|
||||
axios-retry: 4.5.0(axios@1.15.0)
|
||||
component-type: 2.0.0
|
||||
join-component: 1.1.0
|
||||
lodash.clonedeep: 4.5.0
|
||||
|
|
@ -32916,24 +32920,29 @@ snapshots:
|
|||
|
||||
axe-core@4.7.2: {}
|
||||
|
||||
axios-retry@4.5.0(axios@1.13.5):
|
||||
axios-retry@4.5.0(axios@1.15.0(debug@4.4.3)):
|
||||
dependencies:
|
||||
axios: 1.13.5
|
||||
axios: 1.15.0(debug@4.4.3)
|
||||
is-retry-allowed: 2.2.0
|
||||
|
||||
axios@1.13.5:
|
||||
axios-retry@4.5.0(axios@1.15.0):
|
||||
dependencies:
|
||||
axios: 1.15.0
|
||||
is-retry-allowed: 2.2.0
|
||||
|
||||
axios@1.15.0:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11(debug@4.4.1)
|
||||
form-data: 4.0.4
|
||||
proxy-from-env: 1.1.0
|
||||
proxy-from-env: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
axios@1.13.5(debug@4.4.3):
|
||||
axios@1.15.0(debug@4.4.3):
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11(debug@4.4.3)
|
||||
form-data: 4.0.4
|
||||
proxy-from-env: 1.1.0
|
||||
proxy-from-env: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
|
|
@ -36914,7 +36923,7 @@ snapshots:
|
|||
'@types/debug': 4.1.12
|
||||
'@types/node': 20.19.21
|
||||
'@types/tough-cookie': 4.0.5
|
||||
axios: 1.13.5(debug@4.4.3)
|
||||
axios: 1.15.0(debug@4.4.3)
|
||||
camelcase: 6.3.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
dotenv: 16.6.1
|
||||
|
|
@ -36924,7 +36933,7 @@ snapshots:
|
|||
isstream: 0.1.2
|
||||
jsonwebtoken: 9.0.3
|
||||
mime-types: 2.1.35
|
||||
retry-axios: 2.6.0(axios@1.13.5)
|
||||
retry-axios: 2.6.0(axios@1.15.0)
|
||||
tough-cookie: 4.1.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -37027,7 +37036,7 @@ snapshots:
|
|||
|
||||
infisical-node@1.3.0:
|
||||
dependencies:
|
||||
axios: 1.13.5
|
||||
axios: 1.15.0
|
||||
dotenv: 16.6.1
|
||||
tweetnacl: 1.0.3
|
||||
tweetnacl-util: 0.15.1
|
||||
|
|
@ -40986,7 +40995,7 @@ snapshots:
|
|||
|
||||
posthog-node@3.2.1:
|
||||
dependencies:
|
||||
axios: 1.13.5
|
||||
axios: 1.15.0
|
||||
rusha: 0.8.14
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
|
@ -41188,6 +41197,8 @@ snapshots:
|
|||
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
proxy-from-env@2.1.0: {}
|
||||
|
||||
ps-tree@1.2.0:
|
||||
dependencies:
|
||||
event-stream: 3.3.4
|
||||
|
|
@ -41741,9 +41752,9 @@ snapshots:
|
|||
|
||||
retimer@3.0.0: {}
|
||||
|
||||
retry-axios@2.6.0(axios@1.13.5):
|
||||
retry-axios@2.6.0(axios@1.15.0):
|
||||
dependencies:
|
||||
axios: 1.13.5
|
||||
axios: 1.15.0
|
||||
|
||||
retry-request@7.0.2(encoding@0.1.13):
|
||||
dependencies:
|
||||
|
|
@ -42453,7 +42464,7 @@ snapshots:
|
|||
asn1.js: 5.4.1
|
||||
asn1.js-rfc2560: 5.0.1(asn1.js@5.4.1)
|
||||
asn1.js-rfc5280: 3.0.0
|
||||
axios: 1.13.5
|
||||
axios: 1.15.0
|
||||
big-integer: 1.6.52
|
||||
bignumber.js: 9.1.2
|
||||
binascii: 0.0.2
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ catalog:
|
|||
'@types/uuid': ^10.0.0
|
||||
'@types/xml2js': ^0.4.14
|
||||
'@vitest/coverage-v8': 4.1.1
|
||||
axios: 1.13.5
|
||||
axios: 1.15.0
|
||||
basic-auth: 2.0.1
|
||||
callsites: 3.1.0
|
||||
chokidar: 4.0.3
|
||||
|
|
|
|||
Loading…
Reference in a new issue