mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(core): Add NVIDIA Nemotron Models with cloud and self-hosted NIM support (#29618)
This commit is contained in:
@@ -32,6 +32,7 @@ export const chatHubLLMProviderSchema = z.enum([
|
||||
'deepSeek',
|
||||
'cohere',
|
||||
'mistralCloud',
|
||||
'nvidia',
|
||||
]);
|
||||
|
||||
export type ChatHubLLMProvider = z.infer<typeof chatHubLLMProviderSchema>;
|
||||
@@ -97,6 +98,7 @@ export const PROVIDER_CREDENTIAL_TYPE_MAP: Record<ChatHubLLMProvider, string> =
|
||||
deepSeek: 'deepSeekApi',
|
||||
cohere: 'cohereApi',
|
||||
mistralCloud: 'mistralCloudApi',
|
||||
nvidia: 'nvidiaApi',
|
||||
};
|
||||
|
||||
export const VECTOR_STORE_PROVIDER_CREDENTIAL_TYPE_MAP: Record<ChatHubVectorStoreProvider, string> =
|
||||
@@ -179,6 +181,11 @@ const mistralCloudModelSchema = z.object({
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
const nvidiaModelSchema = z.object({
|
||||
provider: z.literal('nvidia'),
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
const n8nModelSchema = z.object({
|
||||
provider: z.literal('n8n'),
|
||||
workflowId: z.string(),
|
||||
@@ -204,6 +211,7 @@ export const chatHubConversationModelSchema = z.discriminatedUnion('provider', [
|
||||
deepSeekModelSchema,
|
||||
cohereModelSchema,
|
||||
mistralCloudModelSchema,
|
||||
nvidiaModelSchema,
|
||||
n8nModelSchema,
|
||||
chatAgentSchema,
|
||||
]);
|
||||
@@ -222,6 +230,7 @@ export type ChatHubOpenRouterModel = z.infer<typeof openRouterModelSchema>;
|
||||
export type ChatHubDeepSeekModel = z.infer<typeof deepSeekModelSchema>;
|
||||
export type ChatHubCohereModel = z.infer<typeof cohereModelSchema>;
|
||||
export type ChatHubMistralCloudModel = z.infer<typeof mistralCloudModelSchema>;
|
||||
export type ChatHubNvidiaModel = z.infer<typeof nvidiaModelSchema>;
|
||||
export type ChatHubBaseLLMModel =
|
||||
| ChatHubOpenAIModel
|
||||
| ChatHubAnthropicModel
|
||||
@@ -236,7 +245,8 @@ export type ChatHubBaseLLMModel =
|
||||
| ChatHubOpenRouterModel
|
||||
| ChatHubDeepSeekModel
|
||||
| ChatHubCohereModel
|
||||
| ChatHubMistralCloudModel;
|
||||
| ChatHubMistralCloudModel
|
||||
| ChatHubNvidiaModel;
|
||||
|
||||
export type ChatHubN8nModel = z.infer<typeof n8nModelSchema>;
|
||||
export type ChatHubCustomAgentModel = z.infer<typeof chatAgentSchema>;
|
||||
@@ -302,6 +312,7 @@ export const emptyChatModelsResponse: ChatModelsResponse = {
|
||||
deepSeek: { models: [] },
|
||||
cohere: { models: [] },
|
||||
mistralCloud: { models: [] },
|
||||
nvidia: { models: [] },
|
||||
n8n: { models: [] },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'custom-agent': { models: [] },
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
IHttpRequestOptions,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class NvidiaApi implements ICredentialType {
|
||||
name = 'nvidiaApi';
|
||||
|
||||
displayName = 'NVIDIA Nemotron';
|
||||
|
||||
documentationUrl = 'nvidia';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Base URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: 'https://integrate.api.nvidia.com/v1',
|
||||
description:
|
||||
'Use the default for build.nvidia.com cloud, or change it to point at a self-hosted NIM container (e.g. http://localhost:8000/v1)',
|
||||
},
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
required: false,
|
||||
default: '',
|
||||
description:
|
||||
'Required for build.nvidia.com cloud. Leave blank for a self-hosted NIM that does not require authentication',
|
||||
},
|
||||
];
|
||||
|
||||
authenticate = async (
|
||||
credentials: ICredentialDataDecryptedObject,
|
||||
requestOptions: IHttpRequestOptions,
|
||||
): Promise<IHttpRequestOptions> => {
|
||||
if (!credentials.apiKey) {
|
||||
return requestOptions;
|
||||
}
|
||||
return {
|
||||
...requestOptions,
|
||||
headers: {
|
||||
...requestOptions.headers,
|
||||
Authorization: `Bearer ${credentials.apiKey as string}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: '={{ $credentials.url }}',
|
||||
url: '/models',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
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';
|
||||
|
||||
const NEMOTRON_FALLBACK_MODELS = [
|
||||
'nvidia/llama-3.3-nemotron-super-49b-v1',
|
||||
'nvidia/llama-3.1-nemotron-70b-instruct',
|
||||
'nvidia/llama-3.1-nemotron-nano-8b-v1',
|
||||
'nvidia/nemotron-4-340b-instruct',
|
||||
'nvidia/nemotron-mini-4b-instruct',
|
||||
];
|
||||
|
||||
export class LmChatNvidia implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'NVIDIA Nemotron Chat Model',
|
||||
|
||||
name: 'lmChatNvidia',
|
||||
icon: { light: 'file:nvidia.svg', dark: 'file:nvidia.dark.svg' },
|
||||
group: ['transform'],
|
||||
version: [1],
|
||||
description: 'NVIDIA Nemotron models from build.nvidia.com or self-hosted NIM',
|
||||
defaults: {
|
||||
name: 'NVIDIA Nemotron 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.lmchatnvidia/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'nvidiaApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
requestDefaults: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
baseURL: '={{ $credentials?.url }}',
|
||||
},
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]),
|
||||
{
|
||||
displayName:
|
||||
'If using JSON response format, you must include word "json" in the prompt in your chain or agent.',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/options.responseFormat': ['json_object'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
description:
|
||||
'The Nemotron model which will generate the completion. <a href="https://build.nvidia.com/models">Learn more</a>.',
|
||||
typeOptions: {
|
||||
loadOptions: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/models',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'data',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'filter',
|
||||
properties: {
|
||||
pass: '={{ /nemotron/i.test($responseItem.id) }}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{$responseItem.id}}',
|
||||
value: '={{$responseItem.id}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
send: {
|
||||
type: 'body',
|
||||
property: 'model',
|
||||
},
|
||||
},
|
||||
default: 'nvidia/llama-3.3-nemotron-super-49b-v1',
|
||||
options: NEMOTRON_FALLBACK_MODELS.map((id) => ({ name: id, value: id })),
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
description: 'Additional options to add',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Frequency Penalty',
|
||||
name: 'frequencyPenalty',
|
||||
default: 0,
|
||||
typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 },
|
||||
description:
|
||||
"Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim",
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Maximum Number of Tokens',
|
||||
name: 'maxTokens',
|
||||
default: -1,
|
||||
description:
|
||||
'The maximum number of tokens to generate in the completion. Use -1 for the model default.',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Response Format',
|
||||
name: 'responseFormat',
|
||||
default: 'text',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Text',
|
||||
value: 'text',
|
||||
description: 'Regular text response',
|
||||
},
|
||||
{
|
||||
name: 'JSON',
|
||||
value: 'json_object',
|
||||
description:
|
||||
'Enables JSON mode, which should guarantee the message the model generates is valid JSON',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Presence Penalty',
|
||||
name: 'presencePenalty',
|
||||
default: 0,
|
||||
typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 },
|
||||
description:
|
||||
"Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics",
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Sampling Temperature',
|
||||
name: 'temperature',
|
||||
default: 0.7,
|
||||
typeOptions: { maxValue: 2, 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>('nvidiaApi');
|
||||
|
||||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
frequencyPenalty?: number;
|
||||
maxTokens?: number;
|
||||
maxRetries: number;
|
||||
timeout: number;
|
||||
presencePenalty?: number;
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
responseFormat?: 'text' | 'json_object';
|
||||
};
|
||||
|
||||
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 || 'unused',
|
||||
model: modelName,
|
||||
...options,
|
||||
timeout,
|
||||
maxRetries: options.maxRetries ?? 2,
|
||||
configuration,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
modelKwargs: options.responseFormat
|
||||
? {
|
||||
response_format: { type: options.responseFormat },
|
||||
}
|
||||
: undefined,
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, openAiFailedAttemptHandler),
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>NVIDIA</title><path fill="#76B900" d="M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z"/></svg>
|
||||
|
After Width: | Height: | Size: 897 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>NVIDIA</title><path fill="#76B900" d="M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z"/></svg>
|
||||
|
After Width: | Height: | Size: 897 B |
@@ -0,0 +1,199 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { makeN8nLlmFailedAttemptHandler, getProxyAgent } from '@n8n/ai-utilities';
|
||||
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import type { Mocked } from 'vitest';
|
||||
|
||||
import { LmChatNvidia } from '../LmChatNvidia.node';
|
||||
|
||||
vi.mock('@langchain/openai');
|
||||
vi.mock('@n8n/ai-utilities');
|
||||
|
||||
const MockedChatOpenAI = vi.mocked(ChatOpenAI);
|
||||
const mockedMakeN8nLlmFailedAttemptHandler = vi.mocked(makeN8nLlmFailedAttemptHandler);
|
||||
const mockedGetProxyAgent = vi.mocked(getProxyAgent);
|
||||
|
||||
describe('LmChatNvidia', () => {
|
||||
let node: LmChatNvidia;
|
||||
|
||||
const mockNodeDef: INode = {
|
||||
id: '1',
|
||||
name: 'NVIDIA Nemotron Chat Model',
|
||||
typeVersion: 1,
|
||||
type: 'n8n-nodes-langchain.lmChatNvidia',
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
const setupMockContext = (
|
||||
credentialOverrides: Partial<{ apiKey: string; url: string }> = {},
|
||||
nodeOverrides: Partial<INode> = {},
|
||||
) => {
|
||||
const nodeDef = { ...mockNodeDef, ...nodeOverrides };
|
||||
const ctx = createMockExecuteFunction<ISupplyDataFunctions>(
|
||||
{},
|
||||
nodeDef,
|
||||
) as Mocked<ISupplyDataFunctions>;
|
||||
|
||||
ctx.getCredentials = vi.fn().mockResolvedValue({
|
||||
apiKey: 'test-key',
|
||||
url: 'https://integrate.api.nvidia.com/v1',
|
||||
...credentialOverrides,
|
||||
});
|
||||
ctx.getNode = vi.fn().mockReturnValue(nodeDef);
|
||||
ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model') return 'nvidia/llama-3.1-nemotron-70b-instruct';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(vi.fn());
|
||||
mockedGetProxyAgent.mockReturnValue({} as any);
|
||||
return ctx;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
node = new LmChatNvidia();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('node description', () => {
|
||||
it('should have correct node properties', () => {
|
||||
expect(node.description).toMatchObject({
|
||||
displayName: 'NVIDIA Nemotron Chat Model',
|
||||
name: 'lmChatNvidia',
|
||||
group: ['transform'],
|
||||
version: [1],
|
||||
});
|
||||
});
|
||||
|
||||
it('should require a single nvidiaApi credential', () => {
|
||||
expect(node.description.credentials).toEqual([{ name: 'nvidiaApi', required: true }]);
|
||||
});
|
||||
|
||||
it('should output ai_languageModel', () => {
|
||||
expect(node.description.outputs).toEqual(['ai_languageModel']);
|
||||
expect(node.description.outputNames).toEqual(['Model']);
|
||||
});
|
||||
|
||||
it('should filter to Nemotron models in loadOptions', () => {
|
||||
const modelProp = node.description.properties.find((p) => p?.name === 'model');
|
||||
expect(modelProp).toBeDefined();
|
||||
const postReceive = (modelProp?.typeOptions as any)?.loadOptions?.routing?.output
|
||||
?.postReceive as Array<{ type: string; properties: { pass?: string } }>;
|
||||
const filterStep = postReceive.find((step) => step.type === 'filter');
|
||||
expect(filterStep?.properties.pass).toMatch(/nemotron/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supplyData', () => {
|
||||
it('should pass credential url to ChatOpenAI configuration', async () => {
|
||||
const ctx = setupMockContext();
|
||||
|
||||
const result = await node.supplyData.call(ctx, 0);
|
||||
|
||||
expect(ctx.getCredentials).toHaveBeenCalledWith('nvidiaApi');
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-key',
|
||||
model: 'nvidia/llama-3.1-nemotron-70b-instruct',
|
||||
configuration: expect.objectContaining({
|
||||
baseURL: 'https://integrate.api.nvidia.com/v1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ response: expect.any(Object) });
|
||||
});
|
||||
|
||||
it('should accept a self-hosted base URL on the same credential', async () => {
|
||||
const ctx = setupMockContext({ url: 'http://localhost:8000/v1', apiKey: '' });
|
||||
|
||||
await node.supplyData.call(ctx, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
configuration: expect.objectContaining({
|
||||
baseURL: 'http://localhost:8000/v1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to a placeholder apiKey when the credential has none', async () => {
|
||||
const ctx = setupMockContext({ apiKey: '' });
|
||||
|
||||
await node.supplyData.call(ctx, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'unused',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass options through to ChatOpenAI', async () => {
|
||||
const ctx = setupMockContext();
|
||||
ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model') return 'nvidia/llama-3.1-nemotron-70b-instruct';
|
||||
if (paramName === 'options')
|
||||
return {
|
||||
temperature: 0.5,
|
||||
maxTokens: 2000,
|
||||
topP: 0.9,
|
||||
frequencyPenalty: 0.3,
|
||||
presencePenalty: 0.2,
|
||||
timeout: 60000,
|
||||
maxRetries: 5,
|
||||
};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await node.supplyData.call(ctx, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.5,
|
||||
maxTokens: 2000,
|
||||
topP: 0.9,
|
||||
frequencyPenalty: 0.3,
|
||||
presencePenalty: 0.2,
|
||||
timeout: 60000,
|
||||
maxRetries: 5,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set response_format in modelKwargs when responseFormat is provided', async () => {
|
||||
const ctx = setupMockContext();
|
||||
ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model') return 'nvidia/llama-3.1-nemotron-70b-instruct';
|
||||
if (paramName === 'options') return { responseFormat: 'json_object' };
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await node.supplyData.call(ctx, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelKwargs: { response_format: { type: 'json_object' } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not set modelKwargs when no responseFormat', async () => {
|
||||
const ctx = setupMockContext();
|
||||
|
||||
await node.supplyData.call(ctx, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelKwargs: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,7 @@
|
||||
"dist/credentials/MinimaxApi.credentials.js",
|
||||
"dist/credentials/MoonshotApi.credentials.js",
|
||||
"dist/credentials/LemonadeApi.credentials.js",
|
||||
"dist/credentials/NvidiaApi.credentials.js",
|
||||
"dist/credentials/OllamaApi.credentials.js",
|
||||
"dist/credentials/OpenRouterApi.credentials.js",
|
||||
"dist/credentials/PineconeApi.credentials.js",
|
||||
@@ -128,6 +129,7 @@
|
||||
"dist/nodes/llms/LmChatMinimax/LmChatMinimax.node.js",
|
||||
"dist/nodes/llms/LmChatMoonshot/LmChatMoonshot.node.js",
|
||||
"dist/nodes/llms/LMChatLemonade/LmChatLemonade.node.js",
|
||||
"dist/nodes/llms/LmChatNvidia/LmChatNvidia.node.js",
|
||||
"dist/nodes/llms/LMChatOllama/LmChatOllama.node.js",
|
||||
"dist/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.js",
|
||||
"dist/nodes/llms/LmChatVercelAiGateway/LmChatVercelAiGateway.node.js",
|
||||
|
||||
+1
@@ -46,6 +46,7 @@ const emptyCredentialIds = {
|
||||
deepSeek: null,
|
||||
cohere: null,
|
||||
mistralCloud: null,
|
||||
nvidia: null,
|
||||
};
|
||||
|
||||
describe('ChatHubModelsService', () => {
|
||||
|
||||
@@ -895,6 +895,15 @@ ${this.getSystemMessageMetadata(timeZone) + artifactContext}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'nvidia': {
|
||||
return {
|
||||
...common,
|
||||
parameters: {
|
||||
model,
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new OperationalError('Unsupported model provider');
|
||||
}
|
||||
|
||||
@@ -93,6 +93,10 @@ export const PROVIDER_NODE_TYPE_MAP: Record<ChatHubLLMProvider, INodeTypeNameVer
|
||||
name: '@n8n/n8n-nodes-langchain.lmChatMistralCloud',
|
||||
version: 1,
|
||||
},
|
||||
nvidia: {
|
||||
name: '@n8n/n8n-nodes-langchain.lmChatNvidia',
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
|
||||
export const NODE_NAMES = {
|
||||
|
||||
@@ -158,6 +158,10 @@ export class ChatHubModelsService {
|
||||
const rawModels = await this.fetchMistralCloudModels(credentials, additionalData);
|
||||
return { models: this.transformAndFilterModels(rawModels, 'mistralCloud') };
|
||||
}
|
||||
case 'nvidia': {
|
||||
const rawModels = await this.fetchNvidiaModels(credentials, additionalData);
|
||||
return { models: this.transformAndFilterModels(rawModels, 'nvidia') };
|
||||
}
|
||||
case 'n8n':
|
||||
return { models: await this.fetchAgentWorkflowsAsModels(user) };
|
||||
case 'custom-agent':
|
||||
@@ -405,6 +409,55 @@ export class ChatHubModelsService {
|
||||
return foundationModels.concat(inferenceProfileModels);
|
||||
}
|
||||
|
||||
private async fetchNvidiaModels(
|
||||
credentials: INodeCredentials,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
return await this.nodeParametersService.getOptionsViaLoadOptions(
|
||||
{
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/models',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'data',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'filter',
|
||||
properties: {
|
||||
pass: '={{ /nemotron/i.test($responseItem.id) }}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{$responseItem.id}}',
|
||||
value: '={{$responseItem.id}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalData,
|
||||
PROVIDER_NODE_TYPE_MAP.nvidia,
|
||||
{},
|
||||
credentials,
|
||||
);
|
||||
}
|
||||
|
||||
private async fetchMistralCloudModels(
|
||||
credentials: INodeCredentials,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
|
||||
@@ -214,6 +214,7 @@ export const maxContextWindowTokens: Record<ChatHubLLMProvider, Record<string, n
|
||||
'mistral-small-2506': 128000,
|
||||
'mistral-small-latest': 128000,
|
||||
},
|
||||
nvidia: {},
|
||||
};
|
||||
|
||||
const CONTEXT_WINDOW_SAFETY_FACTOR = 0.95;
|
||||
|
||||
@@ -306,6 +306,14 @@ export function createChatHubModuleSettings(
|
||||
updatedAt: null,
|
||||
enabled: true,
|
||||
},
|
||||
nvidia: {
|
||||
provider: 'nvidia',
|
||||
credentialId: null,
|
||||
allowedModels: [],
|
||||
createdAt: '2025-12-18T09:07:29.060Z',
|
||||
updatedAt: null,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ export const providerDisplayNames: Record<ChatHubProvider, string> = {
|
||||
deepSeek: 'DeepSeek',
|
||||
cohere: 'Cohere',
|
||||
mistralCloud: 'Mistral Cloud',
|
||||
nvidia: 'NVIDIA Nemotron',
|
||||
n8n: 'Workflow agent',
|
||||
'custom-agent': 'Personal agent',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user