Compare commits

...

12 commits

Author SHA1 Message Date
n8n-assistant[bot]
d655f24d9f
🚀 Release 2.17.3 (#28674)
Co-authored-by: Matsuuu <16068444+Matsuuu@users.noreply.github.com>
2026-04-20 12:46:09 +03:00
n8n-assistant[bot]
ce7d1b7762
fix(LinkedIn Node): Update LinkedIn API version in request headers (backport to release-candidate/2.17.x) (#28668) 2026-04-20 07:28:03 +00:00
n8n-assistant[bot]
5d320521fb
fix(core): Guard against undefined config properties in credential overwrites (backport to release-candidate/2.17.x) (#28629)
Co-authored-by: Jon <jonathan.bennetts@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 18:43:13 +01:00
n8n-assistant[bot]
c15d6d0f6d
fix(editor): Restore WASM file paths for cURL import in HTTP Request node (backport to release-candidate/2.17.x) (#28619)
Co-authored-by: Jon <jonathan.bennetts@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Matsuuu <huhta.matias@gmail.com>
2026-04-17 14:27:46 +01:00
n8n-assistant[bot]
0be830210f
🚀 Release 2.17.2 (#28566)
Co-authored-by: Matsuuu <16068444+Matsuuu@users.noreply.github.com>
2026-04-16 09:09:23 +00:00
n8n-assistant[bot]
2ec433263b
feat: AI Gateway credentials endpoint instance url (backport to release-candidate/2.17.x) (#28527)
Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com>
2026-04-15 12:46:58 +00:00
n8n-assistant[bot]
2d50843216
fix(editor): Center sub-node icons and refresh triggers panel icons (backport to release-candidate/2.17.x) (#28521)
Co-authored-by: Tuukka Kantola <Tuukkaa@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 14:06:20 +02:00
n8n-assistant[bot]
b4fd5fa71e
🚀 Release 2.17.1 (#28509)
Co-authored-by: Matsuuu <16068444+Matsuuu@users.noreply.github.com>
2026-04-15 07:30:48 +00:00
n8n-assistant[bot]
afcb69523d
ci: Account for pnpm-workspace changes in bump-versions.mjs (backport to release-candidate/2.17.x) (#28504)
Co-authored-by: Matsu <huhta.matias@gmail.com>
2026-04-15 10:07:48 +03:00
n8n-assistant[bot]
509dbb173c
refactor(core): Rename AI Gateway credits to wallet with USD amounts (backport to release-candidate/2.17.x) (#28489)
Co-authored-by: Dawid Myslak <dawid.myslak@gmail.com>
2026-04-14 18:22:18 +02:00
n8n-assistant[bot]
884b7ab3ec
feat(MiniMax Chat Model Node): Add MiniMax Chat Model sub-node (backport to release-candidate/2.17.x) (#28485)
Co-authored-by: Dawid Myslak <dawid.myslak@gmail.com>
2026-04-14 17:27:53 +02:00
n8n-assistant[bot]
ea8bcf8a76
chore: Bump axios to 1.15.0 (backport to release-candidate/2.17.x) (#28464)
Co-authored-by: Matsu <huhta.matias@gmail.com>
2026-04-14 14:47:59 +03:00
71 changed files with 1090 additions and 342 deletions

View file

@ -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;
}

View file

@ -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"

View file

@ -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: {}

View file

@ -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)

View file

@ -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",

View file

@ -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",

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/api-types",
"version": "1.17.0",
"version": "1.17.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -7,7 +7,7 @@ export interface AiGatewayUsageEntry {
provider: string;
model: string;
timestamp: number;
creditsDeducted: number;
cost: number;
inputTokens?: number;
outputTokens?: number;
}

View file

@ -217,7 +217,7 @@ export interface FrontendSettings {
};
aiGateway?: {
enabled: boolean;
creditsQuota: number;
budget: number;
};
ai: {
allowSendingParameterValues: boolean;

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-common",
"version": "1.17.0",
"version": "1.17.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -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",

View file

@ -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": {

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/chat-hub",
"version": "1.10.0",
"version": "1.10.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/client-oauth2",
"version": "1.1.0",
"version": "1.1.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/constants",
"version": "0.21.0",
"version": "0.21.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -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',

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/db",
"version": "1.17.0",
"version": "1.17.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/decorators",
"version": "1.17.0",
"version": "1.17.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/instance-ai",
"version": "1.2.0",
"version": "1.2.1",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",

View file

@ -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.',
},
},
],
};
}

View file

@ -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',

View file

@ -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,
};
}
}

View file

@ -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

View file

@ -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,
}),
);
});
});
});

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -1,7 +1,7 @@
{
"name": "@n8n/utils",
"type": "module",
"version": "1.28.0",
"version": "1.28.1",
"files": [
"dist"
],

View file

@ -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",

View file

@ -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 = [

View file

@ -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);
});
});
});

View file

@ -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);

View file

@ -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],

View file

@ -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 {

View file

@ -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,
};

View file

@ -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(),
}),
});

View file

@ -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,
};
}

View file

@ -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",

View file

@ -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",

View file

@ -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": {

View file

@ -1,7 +1,7 @@
{
"name": "@n8n/i18n",
"type": "module",
"version": "2.17.0",
"version": "2.17.2",
"files": [
"dist"
],

View file

@ -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": "Couldnt 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.",

View file

@ -1,7 +1,7 @@
{
"name": "@n8n/rest-api-client",
"type": "module",
"version": "2.17.0",
"version": "2.17.1",
"files": [
"dist"
],

View file

@ -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",

View file

@ -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();

View file

@ -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"

View file

@ -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();

View file

@ -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>

View file

@ -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);
});
});

View file

@ -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,
};

View file

@ -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,
},

View file

@ -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);
});
});

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -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', () => {

View file

@ -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">

View file

@ -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 () => {

View file

@ -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>

View file

@ -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;

View file

@ -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 }),
);
});
});

View file

@ -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>

View file

@ -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) => {

View file

@ -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;
}

View file

@ -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(),

View file

@ -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',
},
},
{

View file

@ -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)
{

View file

@ -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,

View file

@ -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": {

View file

@ -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

View file

@ -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