first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
@@ -0,0 +1,399 @@
|
||||
import { ChatAnthropic } from '@langchain/anthropic';
|
||||
import type { LLMResult } from '@langchain/core/outputs';
|
||||
import {
|
||||
getProxyAgent,
|
||||
makeN8nLlmFailedAttemptHandler,
|
||||
N8nLlmTracing,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
NodeOperationError,
|
||||
type INodeProperties,
|
||||
type INodePropertyOptions,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { searchModels } from './methods/searchModels';
|
||||
|
||||
const modelField: INodeProperties = {
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
|
||||
options: [
|
||||
{
|
||||
name: 'Claude 3.5 Sonnet(20241022)',
|
||||
value: 'claude-3-5-sonnet-20241022',
|
||||
},
|
||||
{
|
||||
name: 'Claude 3 Opus(20240229)',
|
||||
value: 'claude-3-opus-20240229',
|
||||
},
|
||||
{
|
||||
name: 'Claude 3.5 Sonnet(20240620)',
|
||||
value: 'claude-3-5-sonnet-20240620',
|
||||
},
|
||||
{
|
||||
name: 'Claude 3 Sonnet(20240229)',
|
||||
value: 'claude-3-sonnet-20240229',
|
||||
},
|
||||
{
|
||||
name: 'Claude 3.5 Haiku(20241022)',
|
||||
value: 'claude-3-5-haiku-20241022',
|
||||
},
|
||||
{
|
||||
name: 'Claude 3 Haiku(20240307)',
|
||||
value: 'claude-3-haiku-20240307',
|
||||
},
|
||||
{
|
||||
name: 'LEGACY: Claude 2',
|
||||
value: 'claude-2',
|
||||
},
|
||||
{
|
||||
name: 'LEGACY: Claude 2.1',
|
||||
value: 'claude-2.1',
|
||||
},
|
||||
{
|
||||
name: 'LEGACY: Claude Instant 1.2',
|
||||
value: 'claude-instant-1.2',
|
||||
},
|
||||
{
|
||||
name: 'LEGACY: Claude Instant 1',
|
||||
value: 'claude-instant-1',
|
||||
},
|
||||
],
|
||||
description:
|
||||
'The model which will generate the completion. <a href="https://docs.anthropic.com/claude/docs/models-overview">Learn more</a>.',
|
||||
default: 'claude-2',
|
||||
};
|
||||
|
||||
const MIN_THINKING_BUDGET = 1024;
|
||||
const DEFAULT_MAX_TOKENS = 4096;
|
||||
export class LmChatAnthropic implements INodeType {
|
||||
methods = {
|
||||
listSearch: {
|
||||
searchModels,
|
||||
},
|
||||
};
|
||||
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Anthropic Chat Model',
|
||||
|
||||
name: 'lmChatAnthropic',
|
||||
icon: 'file:anthropic.svg',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2, 1.3],
|
||||
defaultVersion: 1.3,
|
||||
description: 'Language Model Anthropic',
|
||||
defaults: {
|
||||
name: 'Anthropic 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.lmchatanthropic/',
|
||||
},
|
||||
],
|
||||
},
|
||||
alias: ['claude', 'sonnet', 'opus'],
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'anthropicApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiChain]),
|
||||
{
|
||||
...modelField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...modelField,
|
||||
default: 'claude-3-sonnet-20240229',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1.1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...modelField,
|
||||
default: 'claude-3-5-sonnet-20240620',
|
||||
options: (modelField.options ?? []).filter(
|
||||
(o): o is INodePropertyOptions => 'name' in o && !o.name.toString().startsWith('LEGACY'),
|
||||
),
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { lte: 1.2 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
default: {
|
||||
mode: 'list',
|
||||
value: 'claude-sonnet-4-5-20250929',
|
||||
cachedResultName: 'Claude Sonnet 4.5',
|
||||
},
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
placeholder: 'Select a model...',
|
||||
typeOptions: {
|
||||
searchListMethod: 'searchModels',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
placeholder: 'Claude Sonnet',
|
||||
},
|
||||
],
|
||||
description:
|
||||
'The model. Choose from the list, or specify an ID. <a href="https://docs.anthropic.com/claude/docs/models-overview">Learn more</a>.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
description: 'Additional options to add',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Maximum Number of Tokens',
|
||||
name: 'maxTokensToSample',
|
||||
default: DEFAULT_MAX_TOKENS,
|
||||
description: 'The maximum number of tokens to generate in the completion',
|
||||
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',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
thinking: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Top K',
|
||||
name: 'topK',
|
||||
default: -1,
|
||||
typeOptions: { maxValue: 1, minValue: -1, numberPrecision: 1 },
|
||||
description:
|
||||
'Used to remove "long tail" low probability responses. Defaults to -1, which disables it.',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
thinking: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
thinking: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Enable Thinking',
|
||||
name: 'thinking',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to enable thinking mode for the model',
|
||||
},
|
||||
{
|
||||
displayName: 'Thinking Budget (Tokens)',
|
||||
name: 'thinkingBudget',
|
||||
type: 'number',
|
||||
default: MIN_THINKING_BUDGET,
|
||||
description: 'The maximum number of tokens to use for thinking',
|
||||
displayOptions: {
|
||||
show: {
|
||||
thinking: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials<{
|
||||
url?: string;
|
||||
apiKey?: string;
|
||||
header?: boolean;
|
||||
headerName?: string;
|
||||
headerValue?: string;
|
||||
}>('anthropicApi');
|
||||
const baseURL = credentials.url ?? 'https://api.anthropic.com';
|
||||
const version = this.getNode().typeVersion;
|
||||
const modelName =
|
||||
version >= 1.3
|
||||
? (this.getNodeParameter('model.value', itemIndex) as string)
|
||||
: (this.getNodeParameter('model', itemIndex) as string);
|
||||
|
||||
if (!modelName) {
|
||||
throw new NodeOperationError(this.getNode(), 'No model selected. Please choose a model.', {
|
||||
itemIndex,
|
||||
});
|
||||
}
|
||||
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
maxTokensToSample?: number;
|
||||
temperature: number;
|
||||
topK?: number;
|
||||
topP?: number;
|
||||
thinking?: boolean;
|
||||
thinkingBudget?: number;
|
||||
};
|
||||
let invocationKwargs = {};
|
||||
|
||||
const tokensUsageParser = (result: LLMResult) => {
|
||||
const usage = (result?.llmOutput?.usage as {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
}) ?? {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
};
|
||||
return {
|
||||
completionTokens: usage.output_tokens,
|
||||
promptTokens: usage.input_tokens,
|
||||
totalTokens: usage.input_tokens + usage.output_tokens,
|
||||
};
|
||||
};
|
||||
|
||||
if (options.thinking) {
|
||||
invocationKwargs = {
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
// If thinking is enabled, we need to set a budget.
|
||||
// We fallback to 1024 as that is the minimum
|
||||
budget_tokens: options.thinkingBudget ?? MIN_THINKING_BUDGET,
|
||||
},
|
||||
// The default Langchain max_tokens is -1 (no limit) but Anthropic requires a number
|
||||
// higher than budget_tokens
|
||||
max_tokens: options.maxTokensToSample ?? DEFAULT_MAX_TOKENS,
|
||||
// These need to be unset when thinking is enabled.
|
||||
// Because the invocationKwargs will override the model options
|
||||
// we can pass options to the model and then override them here
|
||||
top_k: undefined,
|
||||
top_p: undefined,
|
||||
temperature: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const clientOptions: {
|
||||
fetchOptions?: { dispatcher: ReturnType<typeof getProxyAgent> };
|
||||
defaultHeaders?: Record<string, string>;
|
||||
} = {
|
||||
fetchOptions: {
|
||||
dispatcher: getProxyAgent(baseURL),
|
||||
},
|
||||
};
|
||||
|
||||
if (
|
||||
credentials.header &&
|
||||
typeof credentials.headerName === 'string' &&
|
||||
credentials.headerName &&
|
||||
typeof credentials.headerValue === 'string'
|
||||
) {
|
||||
clientOptions.defaultHeaders = {
|
||||
[credentials.headerName]: credentials.headerValue,
|
||||
};
|
||||
}
|
||||
|
||||
const isUsingGateway = baseURL !== 'https://api.anthropic.com';
|
||||
const gatewayErrorHandler = isUsingGateway
|
||||
? (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const isModelError =
|
||||
/model.*not found|not found.*model|invalid model|does not exist/i.test(message);
|
||||
if (isModelError) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The model "${modelName}" was not found at ${baseURL}. If you're using an AI gateway, select a model that your gateway supports.`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const model = new ChatAnthropic({
|
||||
anthropicApiKey: credentials.apiKey,
|
||||
model: modelName,
|
||||
anthropicApiUrl: baseURL,
|
||||
maxTokens: options.maxTokensToSample,
|
||||
temperature: options.temperature,
|
||||
topK: options.topK,
|
||||
topP: options.topP,
|
||||
callbacks: [new N8nLlmTracing(this, { tokensUsageParser })],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, gatewayErrorHandler),
|
||||
invocationKwargs,
|
||||
clientOptions,
|
||||
});
|
||||
|
||||
// Some Anthropic models do not support Langchain default of -1 for topP so we need to unset it
|
||||
if (options.topP === undefined) {
|
||||
delete model.topP;
|
||||
}
|
||||
|
||||
// If topP is set to a value and temperature is not, unset default Langchain temperature
|
||||
if (options.topP !== undefined && options.temperature === undefined) {
|
||||
delete model.temperature;
|
||||
}
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="46" height="32" fill="none"><path fill="#7D7D87" d="M32.73 0h-6.945L38.45 32h6.945zM12.665 0 0 32h7.082l2.59-6.72h13.25l2.59 6.72h7.082L19.929 0zm-.702 19.337 4.334-11.246 4.334 11.246z"/></svg>
|
||||
|
After Width: | Height: | Size: 241 B |
@@ -0,0 +1,142 @@
|
||||
import type { ILoadOptionsFunctions } from 'n8n-workflow';
|
||||
|
||||
import { searchModels, type AnthropicModel } from '../searchModels';
|
||||
|
||||
describe('searchModels', () => {
|
||||
let mockContext: jest.Mocked<ILoadOptionsFunctions>;
|
||||
|
||||
const mockModels: AnthropicModel[] = [
|
||||
{
|
||||
id: 'claude-3-opus-20240229',
|
||||
display_name: 'Claude 3 Opus',
|
||||
type: 'model',
|
||||
created_at: '2024-02-29T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'claude-3-sonnet-20240229',
|
||||
display_name: 'Claude 3 Sonnet',
|
||||
type: 'model',
|
||||
created_at: '2024-02-29T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'claude-3-haiku-20240307',
|
||||
display_name: 'Claude 3 Haiku',
|
||||
type: 'model',
|
||||
created_at: '2024-03-07T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'claude-2.1',
|
||||
display_name: 'Claude 2.1',
|
||||
type: 'model',
|
||||
created_at: '2023-11-21T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'claude-2.0',
|
||||
display_name: 'Claude 2.0',
|
||||
type: 'model',
|
||||
created_at: '2023-07-11T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = {
|
||||
getCredentials: jest.fn().mockResolvedValue({}),
|
||||
helpers: {
|
||||
httpRequestWithAuthentication: jest.fn().mockResolvedValue({
|
||||
data: mockModels,
|
||||
}),
|
||||
},
|
||||
} as unknown as jest.Mocked<ILoadOptionsFunctions>;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset the getCredentials mock to its default value
|
||||
mockContext.getCredentials = jest.fn().mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('should fetch models from default Anthropic API URL when no custom URL is provided', async () => {
|
||||
const result = await searchModels.call(mockContext);
|
||||
|
||||
expect(mockContext.getCredentials).toHaveBeenCalledWith('anthropicApi');
|
||||
expect(mockContext.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith('anthropicApi', {
|
||||
url: 'https://api.anthropic.com/v1/models',
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
});
|
||||
expect(result.results).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should fetch models from custom Anthropic API URL when provided in credentials', async () => {
|
||||
const customUrl = 'https://custom-anthropic-api.example.com';
|
||||
// Override the default mock to return credentials with a custom URL
|
||||
mockContext.getCredentials = jest.fn().mockResolvedValue({ url: customUrl });
|
||||
|
||||
const result = await searchModels.call(mockContext);
|
||||
|
||||
expect(mockContext.getCredentials).toHaveBeenCalledWith('anthropicApi');
|
||||
expect(mockContext.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith('anthropicApi', {
|
||||
url: `${customUrl}/v1/models`,
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
});
|
||||
expect(result.results).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should use default URL when empty URL is provided in credentials', async () => {
|
||||
// Override the default mock to return credentials with an empty URL
|
||||
mockContext.getCredentials = jest.fn().mockResolvedValue({ url: null });
|
||||
|
||||
const result = await searchModels.call(mockContext);
|
||||
|
||||
expect(mockContext.getCredentials).toHaveBeenCalledWith('anthropicApi');
|
||||
expect(mockContext.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith('anthropicApi', {
|
||||
url: 'https://api.anthropic.com/v1/models',
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
});
|
||||
expect(result.results).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should sort models by created_at date, most recent first', async () => {
|
||||
const result = await searchModels.call(mockContext);
|
||||
const sortedResults = result.results;
|
||||
|
||||
expect(sortedResults[0].value).toBe('claude-3-haiku-20240307');
|
||||
expect(sortedResults[1].value).toBe('claude-3-opus-20240229');
|
||||
expect(sortedResults[2].value).toBe('claude-3-sonnet-20240229');
|
||||
expect(sortedResults[3].value).toBe('claude-2.1');
|
||||
expect(sortedResults[4].value).toBe('claude-2.0');
|
||||
});
|
||||
|
||||
it('should filter models based on search term', async () => {
|
||||
const result = await searchModels.call(mockContext, 'claude-3');
|
||||
|
||||
expect(result.results).toHaveLength(3);
|
||||
expect(result.results).toEqual([
|
||||
{ name: 'Claude 3 Haiku', value: 'claude-3-haiku-20240307' },
|
||||
{ name: 'Claude 3 Opus', value: 'claude-3-opus-20240229' },
|
||||
{ name: 'Claude 3 Sonnet', value: 'claude-3-sonnet-20240229' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive search', async () => {
|
||||
const result = await searchModels.call(mockContext, 'CLAUDE-3');
|
||||
|
||||
expect(result.results).toHaveLength(3);
|
||||
expect(result.results).toEqual([
|
||||
{ name: 'Claude 3 Haiku', value: 'claude-3-haiku-20240307' },
|
||||
{ name: 'Claude 3 Opus', value: 'claude-3-opus-20240229' },
|
||||
{ name: 'Claude 3 Sonnet', value: 'claude-3-sonnet-20240229' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle when no models match the filter', async () => {
|
||||
const result = await searchModels.call(mockContext, 'nonexistent-model');
|
||||
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type {
|
||||
ILoadOptionsFunctions,
|
||||
INodeListSearchItems,
|
||||
INodeListSearchResult,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export interface AnthropicModel {
|
||||
id: string;
|
||||
display_name: string;
|
||||
type: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function searchModels(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const credentials = await this.getCredentials<{ url?: string }>('anthropicApi');
|
||||
|
||||
const baseURL = credentials.url ?? 'https://api.anthropic.com';
|
||||
const response = (await this.helpers.httpRequestWithAuthentication.call(this, 'anthropicApi', {
|
||||
url: `${baseURL}/v1/models`,
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
})) as { data: AnthropicModel[] };
|
||||
|
||||
const models = response.data || [];
|
||||
let results: INodeListSearchItems[] = [];
|
||||
|
||||
if (filter) {
|
||||
for (const model of models) {
|
||||
if (model.id.toLowerCase().includes(filter.toLowerCase())) {
|
||||
results.push({
|
||||
name: model.display_name,
|
||||
value: model.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
results = models.map((model) => ({
|
||||
name: model.display_name,
|
||||
value: model.id,
|
||||
}));
|
||||
}
|
||||
|
||||
// Sort models with more recent ones first (claude-3 before claude-2)
|
||||
results = results.sort((a, b) => {
|
||||
const modelA = models.find((m) => m.id === a.value);
|
||||
const modelB = models.find((m) => m.id === b.value);
|
||||
|
||||
if (!modelA || !modelB) return 0;
|
||||
|
||||
// Sort by created_at date, most recent first
|
||||
const dateA = new Date(modelA.created_at);
|
||||
const dateB = new Date(modelB.created_at);
|
||||
return dateB.getTime() - dateA.getTime();
|
||||
});
|
||||
|
||||
return {
|
||||
results,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { ChatAnthropic } from '@langchain/anthropic';
|
||||
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 { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { LmChatAnthropic } from '../LmChatAnthropic.node';
|
||||
|
||||
jest.mock('@langchain/anthropic');
|
||||
jest.mock('@n8n/ai-utilities', () => ({
|
||||
getConnectionHintNoticeField: jest
|
||||
.fn()
|
||||
.mockReturnValue({ displayName: '', name: 'notice', type: 'notice', default: '' }),
|
||||
makeN8nLlmFailedAttemptHandler: jest.fn(),
|
||||
N8nLlmTracing: jest.fn(),
|
||||
getProxyAgent: jest.fn(),
|
||||
}));
|
||||
|
||||
const MockedChatAnthropic = jest.mocked(ChatAnthropic);
|
||||
const MockedN8nLlmTracing = jest.mocked(N8nLlmTracing);
|
||||
const mockedMakeN8nLlmFailedAttemptHandler = jest.mocked(makeN8nLlmFailedAttemptHandler);
|
||||
const mockedGetProxyAgent = jest.mocked(getProxyAgent);
|
||||
|
||||
describe('LmChatAnthropic', () => {
|
||||
let lmChatAnthropic: LmChatAnthropic;
|
||||
let mockContext: jest.Mocked<ISupplyDataFunctions>;
|
||||
|
||||
const mockNode: INode = {
|
||||
id: '1',
|
||||
name: 'Anthropic Chat Model',
|
||||
typeVersion: 1.3,
|
||||
type: 'n8n-nodes-langchain.lmChatAnthropic',
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
const setupMockContext = (nodeOverrides: Partial<INode> = {}) => {
|
||||
const node = { ...mockNode, ...nodeOverrides };
|
||||
mockContext = createMockExecuteFunction<ISupplyDataFunctions>(
|
||||
{},
|
||||
node,
|
||||
) as jest.Mocked<ISupplyDataFunctions>;
|
||||
|
||||
// Setup default mocks
|
||||
mockContext.getCredentials = jest.fn().mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
});
|
||||
mockContext.getNode = jest.fn().mockReturnValue(node);
|
||||
mockContext.getNodeParameter = jest.fn();
|
||||
|
||||
// Mock the constructors/functions properly
|
||||
MockedN8nLlmTracing.mockImplementation(() => ({}) as N8nLlmTracing);
|
||||
mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(jest.fn());
|
||||
mockedGetProxyAgent.mockReturnValue({} as any);
|
||||
return mockContext;
|
||||
};
|
||||
|
||||
const createMockModel = (properties: Partial<ChatAnthropic>): ChatAnthropic => {
|
||||
const mockModel = properties as ChatAnthropic;
|
||||
MockedChatAnthropic.mockImplementation(() => mockModel);
|
||||
return mockModel;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
lmChatAnthropic = new LmChatAnthropic();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('node description', () => {
|
||||
it('should have correct node properties', () => {
|
||||
expect(lmChatAnthropic.description).toMatchObject({
|
||||
displayName: 'Anthropic Chat Model',
|
||||
name: 'lmChatAnthropic',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2, 1.3],
|
||||
description: 'Language Model Anthropic',
|
||||
});
|
||||
});
|
||||
|
||||
it('should have correct credentials configuration', () => {
|
||||
expect(lmChatAnthropic.description.credentials).toEqual([
|
||||
{
|
||||
name: 'anthropicApi',
|
||||
required: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should have correct output configuration', () => {
|
||||
expect(lmChatAnthropic.description.outputs).toEqual(['ai_languageModel']);
|
||||
expect(lmChatAnthropic.description.outputNames).toEqual(['Model']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supplyData', () => {
|
||||
it('should create ChatAnthropic instance with basic configuration (version >= 1.3)', async () => {
|
||||
const mockContext = setupMockContext({ typeVersion: 1.3 });
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(mockContext.getCredentials).toHaveBeenCalledWith('anthropicApi');
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('model.value', 0);
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('options', 0, {});
|
||||
expect(MockedChatAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
anthropicApiKey: 'test-api-key',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
anthropicApiUrl: 'https://api.anthropic.com',
|
||||
maxTokens: undefined,
|
||||
temperature: undefined,
|
||||
topK: undefined,
|
||||
topP: undefined,
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
onFailedAttempt: expect.any(Function),
|
||||
invocationKwargs: {},
|
||||
clientOptions: {
|
||||
fetchOptions: {
|
||||
dispatcher: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
response: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('should create ChatAnthropic instance with basic configuration (version < 1.3)', async () => {
|
||||
const mockContext = setupMockContext({ typeVersion: 1.2 });
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model') return 'claude-3-5-sonnet-20241022';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('model', 0);
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('options', 0, {});
|
||||
expect(MockedChatAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
anthropicApiKey: 'test-api-key',
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
anthropicApiUrl: 'https://api.anthropic.com',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle custom baseURL from credentials', async () => {
|
||||
const customURL = 'https://custom-anthropic.example.com';
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
url: customURL,
|
||||
});
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
anthropicApiKey: 'test-api-key',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
anthropicApiUrl: customURL,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle all available options', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
const options = {
|
||||
maxTokensToSample: 2048,
|
||||
temperature: 0.8,
|
||||
topK: 40,
|
||||
topP: 0.9,
|
||||
};
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return options;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
anthropicApiKey: 'test-api-key',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
anthropicApiUrl: 'https://api.anthropic.com',
|
||||
maxTokens: 2048,
|
||||
temperature: 0.8,
|
||||
topK: 40,
|
||||
topP: 0.9,
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
onFailedAttempt: expect.any(Function),
|
||||
invocationKwargs: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove topP from model when not explicitly set', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return { temperature: 0.7 };
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const mockModel = createMockModel({
|
||||
topP: -1,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
// Verify topP was deleted from the model instance
|
||||
expect(mockModel).not.toHaveProperty('topP');
|
||||
});
|
||||
|
||||
it('should keep topP on model when explicitly set', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return { topP: 0.9 };
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const mockModel = createMockModel({
|
||||
topP: 0.9,
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
// Verify topP was not deleted from the model instance
|
||||
expect(mockModel).toHaveProperty('topP', 0.9);
|
||||
});
|
||||
|
||||
it('should remove temperature when topP is set but temperature is not', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return { topP: 0.9 };
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const mockModel = createMockModel({
|
||||
topP: 0.9,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
// Verify temperature was deleted from the model instance
|
||||
expect(mockModel).not.toHaveProperty('temperature');
|
||||
expect(mockModel).toHaveProperty('topP', 0.9);
|
||||
});
|
||||
|
||||
it('should keep temperature when both topP and temperature are set', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return { topP: 0.9, temperature: 0.8 };
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const mockModel = createMockModel({
|
||||
topP: 0.9,
|
||||
temperature: 0.8,
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
// Verify both properties remain
|
||||
expect(mockModel).toHaveProperty('topP', 0.9);
|
||||
expect(mockModel).toHaveProperty('temperature', 0.8);
|
||||
});
|
||||
|
||||
it('should configure thinking mode correctly when enabled', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
const options = {
|
||||
thinking: true,
|
||||
thinkingBudget: 2048,
|
||||
maxTokensToSample: 4096,
|
||||
};
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return options;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
anthropicApiKey: 'test-api-key',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
maxTokens: 4096,
|
||||
invocationKwargs: {
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
budget_tokens: 2048,
|
||||
},
|
||||
max_tokens: 4096,
|
||||
top_k: undefined,
|
||||
top_p: undefined,
|
||||
temperature: undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default thinking budget when not specified', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
const options = {
|
||||
thinking: true,
|
||||
};
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return options;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
invocationKwargs: {
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
budget_tokens: 1024, // MIN_THINKING_BUDGET
|
||||
},
|
||||
max_tokens: 4096, // DEFAULT_MAX_TOKENS
|
||||
top_k: undefined,
|
||||
top_p: undefined,
|
||||
temperature: undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should unset sampling parameters when thinking mode is enabled', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
const options = {
|
||||
thinking: true,
|
||||
thinkingBudget: 2048,
|
||||
maxTokensToSample: 4096,
|
||||
temperature: 0.8,
|
||||
topK: 40,
|
||||
topP: 0.9,
|
||||
};
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return options;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
// These are set initially but will be overridden by invocationKwargs
|
||||
temperature: 0.8,
|
||||
topK: 40,
|
||||
topP: 0.9,
|
||||
invocationKwargs: {
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
budget_tokens: 2048,
|
||||
},
|
||||
max_tokens: 4096,
|
||||
// These override the model options
|
||||
top_k: undefined,
|
||||
top_p: undefined,
|
||||
temperature: undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create N8nLlmTracing callback with tokens usage parser', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedN8nLlmTracing).toHaveBeenCalledWith(
|
||||
mockContext,
|
||||
expect.objectContaining({
|
||||
tokensUsageParser: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create failed attempt handler without gateway handler for direct API', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(mockedMakeN8nLlmFailedAttemptHandler).toHaveBeenCalledWith(mockContext, undefined);
|
||||
});
|
||||
|
||||
it('should create failed attempt handler with gateway handler for custom URL', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
url: 'https://ai-gateway.example.com',
|
||||
});
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(mockedMakeN8nLlmFailedAttemptHandler).toHaveBeenCalledWith(
|
||||
mockContext,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should enrich model-not-found errors with gateway hint when using custom URL', async () => {
|
||||
const gatewayURL = 'https://ai-gateway.example.com';
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
url: gatewayURL,
|
||||
});
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Capture the gateway handler passed to makeN8nLlmFailedAttemptHandler
|
||||
let capturedHandler: ((error: unknown) => void) | undefined;
|
||||
mockedMakeN8nLlmFailedAttemptHandler.mockImplementation((_ctx, handler) => {
|
||||
capturedHandler = handler as (error: unknown) => void;
|
||||
return jest.fn();
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(capturedHandler).toBeDefined();
|
||||
|
||||
// Model-not-found error should be enriched
|
||||
expect(() => capturedHandler!(new Error('model not found'))).toThrow(NodeOperationError);
|
||||
expect(() => capturedHandler!(new Error('model not found'))).toThrow(
|
||||
/ai-gateway\.example\.com/,
|
||||
);
|
||||
|
||||
// Non-model errors should pass through without throwing
|
||||
expect(() => capturedHandler!(new Error('rate limit exceeded'))).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when model is empty (v1.3)', async () => {
|
||||
const mockContext = setupMockContext({ typeVersion: 1.3 });
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return '';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(lmChatAnthropic.supplyData.call(mockContext, 0)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
expect(MockedChatAnthropic).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when model is empty (v1.2)', async () => {
|
||||
const mockContext = setupMockContext({ typeVersion: 1.2 });
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model') return '';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(lmChatAnthropic.supplyData.call(mockContext, 0)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
expect(MockedChatAnthropic).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use gateway-provided model name via custom base URL without hardcoded defaults', async () => {
|
||||
const gatewayURL = 'https://ai-gateway.example.com';
|
||||
const gatewayModel = 'my-org/claude-3-sonnet';
|
||||
const mockContext = setupMockContext({ typeVersion: 1.3 });
|
||||
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
apiKey: 'gateway-api-key',
|
||||
url: gatewayURL,
|
||||
});
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return gatewayModel;
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
anthropicApiKey: 'gateway-api-key',
|
||||
model: gatewayModel,
|
||||
anthropicApiUrl: gatewayURL,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should have a default model in v1.3 resource locator', () => {
|
||||
const v13ModelField = lmChatAnthropic.description.properties.find(
|
||||
(p) =>
|
||||
p.name === 'model' &&
|
||||
p.type === 'resourceLocator' &&
|
||||
p.displayOptions?.show?.['@version']?.[0] !== undefined,
|
||||
);
|
||||
|
||||
expect(v13ModelField).toBeDefined();
|
||||
expect(v13ModelField!.default).toEqual({
|
||||
mode: 'list',
|
||||
value: 'claude-sonnet-4-5-20250929',
|
||||
cachedResultName: 'Claude Sonnet 4.5',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('methods', () => {
|
||||
it('should have searchModels method', () => {
|
||||
expect(lmChatAnthropic.methods).toEqual({
|
||||
listSearch: {
|
||||
searchModels: expect.any(Function),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
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 { LemonadeApiCredentialsType } from '../../../credentials/LemonadeApi.credentials';
|
||||
|
||||
import { lemonadeModel, lemonadeOptions, lemonadeDescription } from '../LMLemonade/description';
|
||||
|
||||
export class LmChatLemonade implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Lemonade Chat Model',
|
||||
|
||||
name: 'lmChatLemonade',
|
||||
icon: 'file:lemonade.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Language Model Lemonade Chat',
|
||||
defaults: {
|
||||
name: 'Lemonade 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.lmchatlemonade/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
...lemonadeDescription,
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]),
|
||||
lemonadeModel,
|
||||
lemonadeOptions,
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = (await this.getCredentials('lemonadeApi')) as LemonadeApiCredentialsType;
|
||||
|
||||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
frequencyPenalty?: number;
|
||||
presencePenalty?: number;
|
||||
maxTokens?: number;
|
||||
stop?: string;
|
||||
};
|
||||
|
||||
// Process stop sequences and maxTokens
|
||||
const processedOptions: {
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
frequencyPenalty?: number;
|
||||
presencePenalty?: number;
|
||||
maxTokens?: number;
|
||||
stop?: string[] | undefined;
|
||||
} = {
|
||||
...options,
|
||||
maxTokens: options.maxTokens && options.maxTokens > 0 ? options.maxTokens : undefined,
|
||||
stop: undefined, // Will be set below if options.stop exists
|
||||
};
|
||||
|
||||
if (options.stop) {
|
||||
const stopSequences = options.stop
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
processedOptions.stop = stopSequences.length > 0 ? stopSequences : undefined;
|
||||
}
|
||||
|
||||
// Build configuration object like official OpenAI node
|
||||
const configuration: ClientOptions = {
|
||||
baseURL: credentials.baseUrl,
|
||||
};
|
||||
|
||||
// Add custom headers if API key is provided
|
||||
if (credentials.apiKey) {
|
||||
configuration.defaultHeaders = {
|
||||
Authorization: `Bearer ${credentials.apiKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
configuration.fetchOptions = {
|
||||
dispatcher: getProxyAgent(configuration.baseURL ?? '', {}),
|
||||
};
|
||||
|
||||
const model = new ChatOpenAI({
|
||||
apiKey: credentials.apiKey || 'lemonade-placeholder-key',
|
||||
model: modelName,
|
||||
...processedOptions,
|
||||
configuration,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.03604 2.49169L2 5.00962C2.82591 7.34634 5.52523 8.53484 8.04325 7.52763L14.0865 5.00967C13.2606 2.66287 9.85018 1.17686 7.03604 2.49169Z" fill="url(#paint0_linear_18_30102)"/>
|
||||
<g filter="url(#filter0_f_18_30102)">
|
||||
<path d="M2.64575 5.26035L7.22767 2.96947C8.5803 2.39118 10.05 2.55851 11.2129 2.97773C12.2111 3.33758 12.9907 3.9608 13.418 4.75328L7.85198 7.0724C5.7348 7.91746 3.52324 7.0363 2.64575 5.26035Z" fill="url(#paint1_linear_18_30102)"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_f_18_30102)">
|
||||
<path d="M5.00464 4.17246L7.43032 2.76879C8.78294 2.1905 10.2527 2.35783 11.4155 2.77705C12.4137 3.13689 13.1933 3.76012 13.6206 4.5526C10.4511 2.99575 9.9351 2.43024 5.00464 4.17246Z" fill="url(#paint2_linear_18_30102)"/>
|
||||
</g>
|
||||
<path d="M14.9236 4.5997C9.51985 6.50701 6.6904 12.4499 8.59216 17.8694L9.84454 21.4282C11.2477 25.4172 14.8309 28.0107 18.7736 28.3363C19.5157 28.3945 20.2115 28.7201 20.7681 29.2202C21.5682 29.9413 22.7162 30.2087 23.7947 29.8249C24.8731 29.4412 25.6037 28.5108 25.7776 27.4525C25.8936 26.7082 26.2299 26.0336 26.7749 25.5103C29.6391 22.7656 30.8103 18.4974 29.4072 14.5084L28.1548 10.9496C26.2531 5.51849 20.3274 2.68077 14.9236 4.5997Z" fill="url(#paint3_radial_18_30102)"/>
|
||||
<path d="M14.9236 4.5997C9.51985 6.50701 6.6904 12.4499 8.59216 17.8694L9.84454 21.4282C11.2477 25.4172 14.8309 28.0107 18.7736 28.3363C19.5157 28.3945 20.2115 28.7201 20.7681 29.2202C21.5682 29.9413 22.7162 30.2087 23.7947 29.8249C24.8731 29.4412 25.6037 28.5108 25.7776 27.4525C25.8936 26.7082 26.2299 26.0336 26.7749 25.5103C29.6391 22.7656 30.8103 18.4974 29.4072 14.5084L28.1548 10.9496C26.2531 5.51849 20.3274 2.68077 14.9236 4.5997Z" fill="url(#paint4_radial_18_30102)"/>
|
||||
<path d="M14.9236 4.5997C9.51985 6.50701 6.6904 12.4499 8.59216 17.8694L9.84454 21.4282C11.2477 25.4172 14.8309 28.0107 18.7736 28.3363C19.5157 28.3945 20.2115 28.7201 20.7681 29.2202C21.5682 29.9413 22.7162 30.2087 23.7947 29.8249C24.8731 29.4412 25.6037 28.5108 25.7776 27.4525C25.8936 26.7082 26.2299 26.0336 26.7749 25.5103C29.6391 22.7656 30.8103 18.4974 29.4072 14.5084L28.1548 10.9496C26.2531 5.51849 20.3274 2.68077 14.9236 4.5997Z" fill="url(#paint5_radial_18_30102)"/>
|
||||
<defs>
|
||||
<filter id="filter0_f_18_30102" x="1.89035" y="1.84127" width="12.283" height="6.31025" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.377703" result="effect1_foregroundBlur_18_30102"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_18_30102" x="4.24923" y="1.64059" width="10.1268" height="3.66743" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.377703" result="effect1_foregroundBlur_18_30102"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_18_30102" x1="2" y1="5.00899" x2="14.0865" y2="5.00899" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#80A338"/>
|
||||
<stop offset="1" stop-color="#B3D745"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_18_30102" x1="1.99902" y1="5.02002" x2="14.0855" y2="5.02002" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#95BD27"/>
|
||||
<stop offset="1" stop-color="#BAE038"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_18_30102" x1="13.6206" y1="4.2397" x2="6.38307" y2="3.29833" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#D1F56E" stop-opacity="0"/>
|
||||
<stop offset="0.286062" stop-color="#D1F56E"/>
|
||||
<stop offset="1" stop-color="#D1F56E" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="paint3_radial_18_30102" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(21.2025 10.5724) rotate(115.148) scale(17.6305 14.9181)">
|
||||
<stop stop-color="#FFFB98"/>
|
||||
<stop offset="0.505208" stop-color="#FFD84C"/>
|
||||
<stop offset="1" stop-color="#E6B534"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint4_radial_18_30102" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(14.6238 4.96834) rotate(69.3343) scale(26.7531 22.6372)">
|
||||
<stop offset="0.521583" stop-color="#FFDE67" stop-opacity="0"/>
|
||||
<stop offset="0.736095" stop-color="#FFA457" stop-opacity="0.2"/>
|
||||
<stop offset="0.886173" stop-color="#D5676D" stop-opacity="0.75"/>
|
||||
<stop offset="0.917885" stop-color="#E88257"/>
|
||||
<stop offset="1" stop-color="#F49754"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint5_radial_18_30102" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(-10.5942 -28.473) rotate(56.1215) scale(51.3589 43.4576)">
|
||||
<stop offset="0.707976" stop-color="#D5B638"/>
|
||||
<stop offset="0.873737" stop-color="#D5B638" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,87 @@
|
||||
import type { ChatOllamaInput } from '@langchain/ollama';
|
||||
import { ChatOllama } from '@langchain/ollama';
|
||||
import {
|
||||
makeN8nLlmFailedAttemptHandler,
|
||||
N8nLlmTracing,
|
||||
proxyFetch,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ollamaModel, ollamaOptions, ollamaDescription } from '../LMOllama/description';
|
||||
|
||||
export class LmChatOllama implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Ollama Chat Model',
|
||||
|
||||
name: 'lmChatOllama',
|
||||
icon: 'file:ollama.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Language Model Ollama',
|
||||
defaults: {
|
||||
name: 'Ollama 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.lmchatollama/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
...ollamaDescription,
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]),
|
||||
ollamaModel,
|
||||
ollamaOptions,
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials('ollamaApi');
|
||||
|
||||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as ChatOllamaInput;
|
||||
const headers = credentials.apiKey
|
||||
? {
|
||||
Authorization: `Bearer ${credentials.apiKey as string}`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const fetchWithTimeout = async (input: RequestInfo | URL, init?: RequestInit) =>
|
||||
await proxyFetch(input, init, {});
|
||||
|
||||
const model = new ChatOllama({
|
||||
...options,
|
||||
baseUrl: credentials.baseUrl as string,
|
||||
model: modelName,
|
||||
format: options.format === 'default' ? undefined : options.format,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
headers,
|
||||
fetch: fetchWithTimeout,
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="241.333" height="341.333" version="1.0" viewBox="0 0 181 256"><g fill="#7D7D87"><path d="M37.7 19.5c-5.2 1.8-8.3 4.9-11.7 11.6-4.5 8.9-6.2 19.2-5.8 35.5l.3 14.2-5.8 6.1c-14.8 15.5-18.5 38.7-9.2 57.4l3.4 6.9-2 4.4c-3.4 8.2-5 16.4-5 26.3 0 10.8 1.8 19 5.8 26.2l2.6 4.8-2.1 4.9c-1.2 2.7-2.6 7.1-3.2 9.8-1.4 6.2-1.5 22.1-.1 25.7 1 2.6 1.4 2.7 7.6 2.7 7.3 0 7 .4 5.3-8.6-1.5-8.2.2-18.8 4.2-26.6 3.7-7 3.8-10.4.5-14.8-4.7-6.4-6.8-13.6-6.9-24-.1-10.3 1.4-16 6.6-26.1 3.1-6.1 2.9-8.7-1-12.2-1.1-1-3.1-4.2-4.3-7-1.9-4.2-2.4-6.9-2.3-14.2 0-11.4 2.5-18.3 9.5-26 7-7.6 14.2-11 23.9-11.2 4.1 0 7.8-.2 8.2-.2.4-.1 1.7-2.2 2.9-4.7 3-5.9 9.6-11.9 16.7-15.2 4.9-2.3 7-2.7 14.7-2.7 7.9 0 9.7.4 14.9 2.9 6.8 3.3 13.3 9.4 15.9 14.8 1 2 2.3 4.1 3 4.5.6.4 4.6.8 8.7.8 6.7.1 8.3.5 14 3.6 12.3 6.8 19.3 18.7 19.3 33.4.1 6.7-.4 9-2.7 14.2-1.6 3.5-3.5 6.8-4.3 7.5-3.4 2.8-3.5 5.8-.5 11.7 5.2 10.1 6.7 15.8 6.6 26.1-.1 10.4-2.2 17.6-6.9 24-3.3 4.4-3.2 7.8.5 14.8 4 7.8 5.7 18.4 4.2 26.6-1.7 9-2 8.6 5.3 8.6 6.2 0 6.6-.1 7.6-2.7 1.4-3.6 1.3-19.5-.1-25.7-.6-2.7-2-7.1-3.2-9.8l-2.1-4.9 2.6-4.8c7.6-13.9 7.9-35.9.6-52.8l-2-4.7 2.5-4.6c9.9-18.3 6.4-43.9-8.1-59.1l-5.8-6.1.3-14.2c.4-16.4-1.3-26.6-5.8-35.7-6.4-12.6-17.2-15.9-26.3-7.9-5.4 4.7-9.2 13.8-12.3 29.8-.3 1.4-1 2.2-1.7 1.8-18.2-8-29.7-8.5-44.3-2.1L65 54.9l-.4-2.2C61 34.2 56.1 24.2 49 20.5c-4.3-2.1-7.4-2.4-11.3-1m7.7 16.8c4.2 7.1 8.1 30.1 5.7 33.6-.5.8-3.1 1.6-5.8 1.8-2.6.2-6.2.8-8 1.3l-3.1.8-.7-4.9c-.8-5.9.2-17.2 2.2-24.8C37.1 38.4 40.5 32 42 32c.5 0 2 1.9 3.4 4.3m96.5-1c4 6.5 6.9 23.9 5.6 33.6l-.7 4.9-3.1-.8c-1.8-.5-5.4-1.1-8-1.3-2.7-.2-5.3-1-5.8-1.8-1.2-1.7-.3-14.1 1.7-22.9 1.5-6.4 5.7-15 7.4-15 .4 0 1.8 1.5 2.9 3.3"/><path d="M77.8 119.9c-7.3 2.4-11.6 5.1-16.5 10.4-5.5 6-7.6 12-7.1 20.1.5 7.6 3.5 12.9 10.6 18.3 6.2 4.7 12.7 6.3 25.7 6.3 17.2 0 25.8-3.6 32.9-13.8 4.2-5.9 4.8-15.5 1.6-23-2.9-6.8-11.1-14.3-18.8-17.3-8-3.1-20.7-3.6-28.4-1m25.7 10c16.1 7.1 19.4 23.2 6.6 31.8-4.9 3.3-9.4 4.3-19.6 4.3s-14.7-1-19.6-4.3c-17.8-12-3.2-35.6 21.1-34.3 3.9.2 8.6 1.2 11.5 2.5"/><path d="M83.8 140.1c-2.5 1.4-2.2 4.4.7 6.7 2 1.6 2.4 2.6 1.9 4.9-.7 3.6 1.5 5.8 5.1 4.9 2.1-.5 2.5-1.2 2.5-4.6 0-2.9.5-4.2 2-5 2.7-1.5 2.7-6.6 0-7.5-1-.3-2.8-.1-4 .5-1.4.7-2.6.8-3.9 0-2.3-1.2-2.2-1.2-4.3.1m-44.1-18.9c-.9.7-2.3 3-3.2 5-2.1 5.3-.1 10.3 4.7 11.6 4.3 1.1 6 .6 9.2-2.7 4-4.1 4.3-8.1 1.1-11.9-2.1-2.5-3.4-3.2-6.4-3.2-2 0-4.5.6-5.4 1.2m89.8 2c-3.2 3.8-2.9 7.8 1.1 11.9 3.2 3.3 4.9 3.8 9.2 2.7 4.9-1.3 6.8-6.2 4.6-11.8-1.9-4.7-3.8-6-8.7-6-2.7 0-4.1.7-6.2 3.2"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,835 @@
|
||||
import { ChatOpenAI, type ChatOpenAIFields, type ClientOptions } from '@langchain/openai';
|
||||
import pick from 'lodash/pick';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type INodeProperties,
|
||||
type IDataObject,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { checkDomainRestrictions } from '@utils/checkDomainRestrictions';
|
||||
import { mergeCustomHeaders } from '@utils/helpers';
|
||||
|
||||
import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling';
|
||||
import {
|
||||
makeN8nLlmFailedAttemptHandler,
|
||||
N8nLlmTracing,
|
||||
getProxyAgent,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
import { formatBuiltInTools, prepareAdditionalResponsesParams } from './common';
|
||||
import { searchModels } from './methods/loadModels';
|
||||
import type { ModelOptions } from './types';
|
||||
import { Container } from '@n8n/di';
|
||||
import { AiConfig } from '@n8n/config';
|
||||
|
||||
const INCLUDE_JSON_WARNING: INodeProperties = {
|
||||
displayName:
|
||||
'If using JSON response format, you must include word "json" in the prompt in your chain or agent. Also, make sure to select latest models released post November 2023.',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
};
|
||||
|
||||
const completionsResponseFormat: INodeProperties = {
|
||||
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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const jsonSchemaExample = `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["message"]
|
||||
}`;
|
||||
|
||||
export class LmChatOpenAi implements INodeType {
|
||||
methods = {
|
||||
listSearch: {
|
||||
searchModels,
|
||||
},
|
||||
};
|
||||
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'OpenAI Chat Model',
|
||||
|
||||
name: 'lmChatOpenAi',
|
||||
icon: { light: 'file:openAiLight.svg', dark: 'file:openAiLight.dark.svg' },
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2, 1.3],
|
||||
description: 'For advanced usage with an AI chain',
|
||||
defaults: {
|
||||
name: 'OpenAI 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.lmchatopenai/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'openAiApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
requestDefaults: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
baseURL:
|
||||
'={{ $parameter.options?.baseURL?.split("/").slice(0,-1).join("/") || $credentials?.url?.split("/").slice(0,-1).join("/") || "https://api.openai.com" }}',
|
||||
},
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]),
|
||||
{
|
||||
...INCLUDE_JSON_WARNING,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/options.responseFormat': ['json_object'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...INCLUDE_JSON_WARNING,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/options.textFormat.textOptions.type': ['json_object'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
description:
|
||||
'The model which will generate the completion. <a href="https://beta.openai.com/docs/models/overview">Learn more</a>.',
|
||||
typeOptions: {
|
||||
loadOptions: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '={{ $parameter.options?.baseURL?.split("/").slice(-1).pop() || $credentials?.url?.split("/").slice(-1).pop() || "v1" }}/models',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'data',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'filter',
|
||||
properties: {
|
||||
// If the baseURL is not set or is set to api.openai.com, include only chat models
|
||||
pass: `={{
|
||||
($parameter.options?.baseURL && !$parameter.options?.baseURL?.startsWith('https://api.openai.com/')) ||
|
||||
($credentials?.url && !$credentials.url.startsWith('https://api.openai.com/')) ||
|
||||
$responseItem.id.startsWith('ft:') ||
|
||||
$responseItem.id.startsWith('o1') ||
|
||||
$responseItem.id.startsWith('o3') ||
|
||||
($responseItem.id.startsWith('gpt-') && !$responseItem.id.includes('instruct'))
|
||||
}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{$responseItem.id}}',
|
||||
value: '={{$responseItem.id}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
send: {
|
||||
type: 'body',
|
||||
property: 'model',
|
||||
},
|
||||
},
|
||||
default: 'gpt-5-mini',
|
||||
builderHint: { message: 'Always default to latest mini model gpt-5-mini' },
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [{ _cnd: { gte: 1.2 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: 'gpt-5-mini' },
|
||||
builderHint: { message: 'Always default to latest mini model gpt-5-mini' },
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
placeholder: 'Select a model...',
|
||||
typeOptions: {
|
||||
searchListMethod: 'searchModels',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
placeholder: 'gpt-5-mini',
|
||||
},
|
||||
],
|
||||
description: 'The model. Choose from the list, or specify an ID.',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [{ _cnd: { lte: 1.1 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'When using non-OpenAI models via "Base URL" override, not all models might be chat-compatible or support other features, like tools calling or JSON response format',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/options.baseURL': [{ _cnd: { exists: true } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Use Responses API',
|
||||
name: 'responsesApiEnabled',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to use the Responses API to generate the response. <a href="https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatopenai/#use-responses-api">Learn more</a>.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Built-in Tools',
|
||||
name: 'builtInTools',
|
||||
placeholder: 'Add Built-in Tool',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Web Search',
|
||||
name: 'webSearch',
|
||||
type: 'collection',
|
||||
default: { searchContextSize: 'medium' },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Search Context Size',
|
||||
name: 'searchContextSize',
|
||||
type: 'options',
|
||||
default: 'medium',
|
||||
description:
|
||||
'High level guidance for the amount of context window space to use for the search',
|
||||
options: [
|
||||
{ name: 'Low', value: 'low' },
|
||||
{ name: 'Medium', value: 'medium' },
|
||||
{ name: 'High', value: 'high' },
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Web Search Allowed Domains',
|
||||
name: 'allowedDomains',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Comma-separated list of domains to search. Only domains in this list will be searched.',
|
||||
placeholder: 'e.g. google.com, wikipedia.org',
|
||||
},
|
||||
{
|
||||
displayName: 'Country',
|
||||
name: 'country',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. US, GB',
|
||||
},
|
||||
{
|
||||
displayName: 'City',
|
||||
name: 'city',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. New York, London',
|
||||
},
|
||||
{
|
||||
displayName: 'Region',
|
||||
name: 'region',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. New York, London',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'File Search',
|
||||
name: 'fileSearch',
|
||||
type: 'collection',
|
||||
default: { vectorStoreIds: '[]' },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Vector Store IDs',
|
||||
name: 'vectorStoreIds',
|
||||
description:
|
||||
'The vector store IDs to use for the file search. Vector stores are managed via OpenAI Dashboard. <a href="https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatopenai/#built-in-tools">Learn more</a>.',
|
||||
type: 'json',
|
||||
default: '[]',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Filters',
|
||||
name: 'filters',
|
||||
type: 'json',
|
||||
default: '{}',
|
||||
},
|
||||
{
|
||||
displayName: 'Max Results',
|
||||
name: 'maxResults',
|
||||
type: 'number',
|
||||
default: 1,
|
||||
typeOptions: { minValue: 1, maxValue: 50 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Code Interpreter',
|
||||
name: 'codeInterpreter',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to allow the model to execute code in a sandboxed environment',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
'/responsesApiEnabled': [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
description: 'Additional options to add',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Base URL',
|
||||
name: 'baseURL',
|
||||
default: 'https://api.openai.com/v1',
|
||||
description: 'Override the default base URL for the API',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [{ _cnd: { gte: 1.1 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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. Most models have a context length of 2048 tokens (except for the newest models, which support 32,768).',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
maxValue: 32768,
|
||||
},
|
||||
},
|
||||
{
|
||||
...completionsResponseFormat,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { lt: 1.3 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...completionsResponseFormat,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
'/responsesApiEnabled': [false],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Response Format',
|
||||
name: 'textFormat',
|
||||
type: 'fixedCollection',
|
||||
default: { textOptions: [{ type: 'text' }] },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Text',
|
||||
name: 'textOptions',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Type',
|
||||
name: 'type',
|
||||
type: 'options',
|
||||
default: '',
|
||||
options: [
|
||||
{ name: 'Text', value: 'text' },
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
{ name: 'JSON Schema (recommended)', value: 'json_schema' },
|
||||
{ name: 'JSON Object', value: 'json_object' },
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Verbosity',
|
||||
name: 'verbosity',
|
||||
type: 'options',
|
||||
default: 'medium',
|
||||
options: [
|
||||
{ name: 'Low', value: 'low' },
|
||||
{ name: 'Medium', value: 'medium' },
|
||||
{ name: 'High', value: 'high' },
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: 'my_schema',
|
||||
description:
|
||||
'The name of the response format. Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['json_schema'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'All properties in the schema must be set to "required", when using "strict" mode.',
|
||||
name: 'requiredNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
strict: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Schema',
|
||||
name: 'schema',
|
||||
type: 'json',
|
||||
default: jsonSchemaExample,
|
||||
description: 'The schema of the response format',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['json_schema'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Description',
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'The description of the response format',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['json_schema'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Strict',
|
||||
name: 'strict',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to require that the AI will always generate responses that match the provided JSON Schema',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['json_schema'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
'/responsesApiEnabled': [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'Reasoning Effort',
|
||||
name: 'reasoningEffort',
|
||||
default: 'medium',
|
||||
description:
|
||||
'Controls the amount of reasoning tokens to use. A value of "low" will favor speed and economical token usage, "high" will favor more complete reasoning at the cost of more tokens generated and slower responses.',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Low',
|
||||
value: 'low',
|
||||
description: 'Favors speed and economical token usage',
|
||||
},
|
||||
{
|
||||
name: 'Medium',
|
||||
value: 'medium',
|
||||
description: 'Balance between speed and reasoning accuracy',
|
||||
},
|
||||
{
|
||||
name: 'High',
|
||||
value: 'high',
|
||||
description:
|
||||
'Favors more complete reasoning at the cost of more tokens generated and slower responses',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
// reasoning_effort is only available on o1, o1-versioned, or on o3-mini and beyond, and gpt-5 models. Not on o1-mini or other GPT-models.
|
||||
'/model': [{ _cnd: { regex: '(^o1([-\\d]+)?$)|(^o[3-9].*)|(^gpt-5.*)' } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Timeout',
|
||||
name: 'timeout',
|
||||
default: 60000,
|
||||
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',
|
||||
},
|
||||
{
|
||||
displayName: 'Conversation ID',
|
||||
name: 'conversationId',
|
||||
default: '',
|
||||
description:
|
||||
'The conversation that this response belongs to. Input items and output items from this response are automatically added to this conversation after this response completes.',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
'/responsesApiEnabled': [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt Cache Key',
|
||||
name: 'promptCacheKey',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Used by OpenAI to cache responses for similar requests to optimize your cache hit rates',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
'/responsesApiEnabled': [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Safety Identifier',
|
||||
name: 'safetyIdentifier',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
"A stable identifier used to help detect users of your application that may be violating OpenAI's usage policies. The IDs should be a string that uniquely identifies each user.",
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
'/responsesApiEnabled': [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Service Tier',
|
||||
name: 'serviceTier',
|
||||
type: 'options',
|
||||
default: 'auto',
|
||||
description: 'The service tier to use for the request',
|
||||
options: [
|
||||
{ name: 'Auto', value: 'auto' },
|
||||
{ name: 'Flex', value: 'flex' },
|
||||
{ name: 'Default', value: 'default' },
|
||||
{ name: 'Priority', value: 'priority' },
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
'/responsesApiEnabled': [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Metadata',
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
description:
|
||||
'Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object in a structured format, and querying for objects via API or the dashboard. Keys are strings with a maximum length of 64 characters. Values are strings with a maximum length of 512 characters.',
|
||||
default: '{}',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
'/responsesApiEnabled': [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Top Logprobs',
|
||||
name: 'topLogprobs',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description:
|
||||
'An integer between 0 and 20 specifying the number of most likely tokens to return at each token position, each with an associated log probability',
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
maxValue: 20,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
'/responsesApiEnabled': [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'promptConfig',
|
||||
type: 'fixedCollection',
|
||||
description:
|
||||
'Configure the reusable prompt template configured via OpenAI Dashboard. <a href="https://platform.openai.com/docs/guides/prompt-engineering#reusable-prompts">Learn more</a>.',
|
||||
default: { promptOptions: [{ promptId: '' }] },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'promptOptions',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Prompt ID',
|
||||
name: 'promptId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'The unique identifier of the prompt template to use',
|
||||
},
|
||||
{
|
||||
displayName: 'Version',
|
||||
name: 'version',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Optional version of the prompt template',
|
||||
},
|
||||
{
|
||||
displayName: 'Variables',
|
||||
name: 'variables',
|
||||
type: 'json',
|
||||
default: '{}',
|
||||
description: 'Variables to be substituted into the prompt template',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
'/responsesApiEnabled': [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials('openAiApi');
|
||||
|
||||
const version = this.getNode().typeVersion;
|
||||
const modelName =
|
||||
version >= 1.2
|
||||
? (this.getNodeParameter('model.value', itemIndex) as string)
|
||||
: (this.getNodeParameter('model', itemIndex) as string);
|
||||
|
||||
const responsesApiEnabled = this.getNodeParameter('responsesApiEnabled', itemIndex, false);
|
||||
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as ModelOptions;
|
||||
|
||||
const { openAiDefaultHeaders: defaultHeaders } = Container.get(AiConfig);
|
||||
|
||||
const configuration: ClientOptions = {
|
||||
defaultHeaders,
|
||||
};
|
||||
|
||||
if (options.baseURL) {
|
||||
checkDomainRestrictions(this, credentials, options.baseURL);
|
||||
configuration.baseURL = options.baseURL;
|
||||
} else if (credentials.url) {
|
||||
configuration.baseURL = credentials.url as string;
|
||||
}
|
||||
|
||||
const timeout = options.timeout;
|
||||
configuration.fetchOptions = {
|
||||
dispatcher: getProxyAgent(configuration.baseURL ?? 'https://api.openai.com/v1', {
|
||||
headersTimeout: timeout,
|
||||
bodyTimeout: timeout,
|
||||
}),
|
||||
};
|
||||
configuration.defaultHeaders = mergeCustomHeaders(
|
||||
credentials,
|
||||
(configuration.defaultHeaders ?? {}) as Record<string, string>,
|
||||
);
|
||||
|
||||
// Extra options to send to OpenAI, that are not directly supported by LangChain
|
||||
const modelKwargs: Record<string, unknown> = {};
|
||||
if (responsesApiEnabled) {
|
||||
const kwargs = prepareAdditionalResponsesParams(options);
|
||||
Object.assign(modelKwargs, kwargs);
|
||||
} else {
|
||||
if (options.responseFormat) modelKwargs.response_format = { type: options.responseFormat };
|
||||
if (options.reasoningEffort && ['low', 'medium', 'high'].includes(options.reasoningEffort)) {
|
||||
modelKwargs.reasoning_effort = options.reasoningEffort;
|
||||
}
|
||||
}
|
||||
|
||||
const includedOptions = pick(options, [
|
||||
'frequencyPenalty',
|
||||
'maxTokens',
|
||||
'presencePenalty',
|
||||
'temperature',
|
||||
'topP',
|
||||
'baseURL',
|
||||
]);
|
||||
|
||||
const fields: ChatOpenAIFields = {
|
||||
apiKey: credentials.apiKey as string,
|
||||
model: modelName,
|
||||
...includedOptions,
|
||||
timeout,
|
||||
maxRetries: options.maxRetries ?? 2,
|
||||
configuration,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
modelKwargs,
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, openAiFailedAttemptHandler),
|
||||
// Set to false to ensure compatibility with OpenAI-compatible backends (LM Studio, vLLM, etc.)
|
||||
// that reject strict: null in tool definitions
|
||||
supportsStrictToolCalling: false,
|
||||
};
|
||||
|
||||
// by default ChatOpenAI can switch to responses API automatically, so force it only on 1.3 and above to keep backwards compatibility
|
||||
if (responsesApiEnabled) {
|
||||
fields.useResponsesApi = true;
|
||||
}
|
||||
|
||||
const model = new ChatOpenAI(fields);
|
||||
|
||||
if (responsesApiEnabled) {
|
||||
const tools = formatBuiltInTools(
|
||||
this.getNodeParameter('builtInTools', itemIndex, {}) as IDataObject,
|
||||
);
|
||||
// pass tools to the model metadata, ToolAgent will use it to create agent configuration
|
||||
if (tools.length) {
|
||||
model.metadata = {
|
||||
...model.metadata,
|
||||
tools,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { OpenAIClient } from '@langchain/openai';
|
||||
import type { ChatOpenAIToolType } from '@langchain/openai/dist/utils/tools';
|
||||
import get from 'lodash/get';
|
||||
import isObject from 'lodash/isObject';
|
||||
import { isObjectEmpty, jsonParse } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
BuiltInTools,
|
||||
ChatResponseRequest,
|
||||
ModelOptions,
|
||||
PromptOptions,
|
||||
TextOptions,
|
||||
} from './types';
|
||||
|
||||
const removeEmptyProperties = <T>(rest: { [key: string]: any }): T => {
|
||||
return Object.keys(rest)
|
||||
.filter(
|
||||
(k) =>
|
||||
rest[k] !== '' && rest[k] !== undefined && !(isObject(rest[k]) && isObjectEmpty(rest[k])),
|
||||
)
|
||||
.reduce((a, k) => ({ ...a, [k]: rest[k] }), {}) as unknown as T;
|
||||
};
|
||||
|
||||
const toArray = (str: string) =>
|
||||
str
|
||||
.split(',')
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export const formatBuiltInTools = (builtInTools: BuiltInTools) => {
|
||||
const tools: ChatOpenAIToolType[] = [];
|
||||
if (builtInTools) {
|
||||
const webSearchOptions = get(builtInTools, 'webSearch');
|
||||
if (webSearchOptions) {
|
||||
let allowedDomains: string[] | undefined;
|
||||
const allowedDomainsRaw = get(webSearchOptions, 'allowedDomains', '');
|
||||
if (allowedDomainsRaw) {
|
||||
allowedDomains = toArray(allowedDomainsRaw);
|
||||
}
|
||||
|
||||
let userLocation: OpenAIClient.Responses.WebSearchTool.UserLocation | undefined;
|
||||
if (webSearchOptions.country || webSearchOptions.city || webSearchOptions.region) {
|
||||
userLocation = {
|
||||
type: 'approximate',
|
||||
country: webSearchOptions.country as string,
|
||||
city: webSearchOptions.city as string,
|
||||
region: webSearchOptions.region as string,
|
||||
};
|
||||
}
|
||||
|
||||
tools.push({
|
||||
type: 'web_search',
|
||||
search_context_size: get(webSearchOptions, 'searchContextSize', 'medium'),
|
||||
user_location: userLocation,
|
||||
...(allowedDomains && { filters: { allowed_domains: allowedDomains } }),
|
||||
});
|
||||
}
|
||||
|
||||
if (builtInTools.codeInterpreter) {
|
||||
tools.push({
|
||||
type: 'code_interpreter',
|
||||
container: {
|
||||
type: 'auto',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (builtInTools.fileSearch) {
|
||||
const vectorStoreIds = get(builtInTools.fileSearch, 'vectorStoreIds', '[]');
|
||||
const filters = get(builtInTools.fileSearch, 'filters', '{}');
|
||||
tools.push({
|
||||
type: 'file_search',
|
||||
vector_store_ids: jsonParse(vectorStoreIds, {
|
||||
errorMessage: 'Failed to parse vector store IDs',
|
||||
}),
|
||||
filters: filters
|
||||
? jsonParse(filters, { errorMessage: 'Failed to parse filters' })
|
||||
: undefined,
|
||||
max_num_results: get(builtInTools.fileSearch, 'maxResults') as number,
|
||||
});
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
};
|
||||
|
||||
export const prepareAdditionalResponsesParams = (options: ModelOptions) => {
|
||||
const body: Partial<ChatResponseRequest> = {
|
||||
prompt_cache_key: options.promptCacheKey,
|
||||
safety_identifier: options.safetyIdentifier,
|
||||
service_tier: options.serviceTier,
|
||||
top_logprobs: options.topLogprobs,
|
||||
};
|
||||
|
||||
if (options.conversationId) {
|
||||
body.conversation = options.conversationId;
|
||||
}
|
||||
|
||||
if (options.metadata) {
|
||||
body.metadata = jsonParse(options.metadata, {
|
||||
errorMessage: 'Failed to parse metadata',
|
||||
});
|
||||
}
|
||||
|
||||
if (options.promptConfig) {
|
||||
const prompt = get(options, 'promptConfig.promptOptions', {} as PromptOptions);
|
||||
body.prompt = removeEmptyProperties({
|
||||
id: prompt.promptId,
|
||||
version: prompt.version,
|
||||
...(prompt.variables && {
|
||||
variables: jsonParse(prompt.variables, {
|
||||
errorMessage: 'Failed to parse prompt variables',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (options.textFormat) {
|
||||
const textOptions = get(options, 'textFormat.textOptions', {} as TextOptions);
|
||||
const textConfig: OpenAIClient.Responses.ResponseTextConfig = {
|
||||
verbosity: textOptions.verbosity as OpenAIClient.Responses.ResponseTextConfig['verbosity'],
|
||||
};
|
||||
if (textOptions.type === 'json_schema') {
|
||||
textConfig.format = {
|
||||
type: textOptions.type,
|
||||
name: textOptions.name as string,
|
||||
schema: jsonParse(textOptions.schema as string, {
|
||||
errorMessage: 'Failed to parse schema',
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
textConfig.format = {
|
||||
type: textOptions.type as 'json_object' | 'text',
|
||||
};
|
||||
}
|
||||
|
||||
if (textConfig.format) {
|
||||
textConfig.format = removeEmptyProperties(textConfig.format);
|
||||
}
|
||||
|
||||
body.text = textConfig;
|
||||
}
|
||||
|
||||
if (options.reasoningEffort) {
|
||||
body.reasoning = {
|
||||
effort: options.reasoningEffort,
|
||||
};
|
||||
}
|
||||
|
||||
return body;
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { ILoadOptionsFunctions } from 'n8n-workflow';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
import { searchModels } from '../loadModels';
|
||||
|
||||
jest.mock('openai');
|
||||
|
||||
describe('searchModels', () => {
|
||||
let mockContext: jest.Mocked<ILoadOptionsFunctions>;
|
||||
let mockOpenAI: jest.Mocked<typeof OpenAI>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = {
|
||||
getCredentials: jest.fn().mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
}),
|
||||
getNodeParameter: jest.fn().mockReturnValue(''),
|
||||
} as unknown as jest.Mocked<ILoadOptionsFunctions>;
|
||||
|
||||
// Setup OpenAI mock with required properties
|
||||
const mockOpenAIInstance = {
|
||||
apiKey: 'test-api-key',
|
||||
organization: null,
|
||||
project: null,
|
||||
_options: {},
|
||||
models: {
|
||||
list: jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'gpt-4' },
|
||||
{ id: 'gpt-3.5-turbo' },
|
||||
{ id: 'gpt-3.5-turbo-instruct' },
|
||||
{ id: 'ft:gpt-3.5-turbo' },
|
||||
{ id: 'o1-model' },
|
||||
{ id: 'whisper-1' },
|
||||
{ id: 'davinci-instruct-beta' },
|
||||
{ id: 'computer-use-preview' },
|
||||
{ id: 'whisper-1-preview' },
|
||||
{ id: 'tts-model' },
|
||||
{ id: 'other-model' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
} as unknown as OpenAI;
|
||||
|
||||
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => mockOpenAIInstance);
|
||||
|
||||
mockOpenAI = OpenAI as jest.Mocked<typeof OpenAI>;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return filtered models if custom API endpoint is not provided', async () => {
|
||||
const result = await searchModels.call(mockContext);
|
||||
|
||||
expect(mockOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'test-api-key',
|
||||
}),
|
||||
);
|
||||
expect(result.results).toEqual([
|
||||
{ name: 'ft:gpt-3.5-turbo', value: 'ft:gpt-3.5-turbo' },
|
||||
{ name: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo' },
|
||||
{ name: 'gpt-4', value: 'gpt-4' },
|
||||
{ name: 'o1-model', value: 'o1-model' },
|
||||
{ name: 'other-model', value: 'other-model' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should initialize OpenAI with correct credentials', async () => {
|
||||
mockContext.getCredentials.mockResolvedValueOnce({
|
||||
apiKey: 'test-api-key',
|
||||
url: 'https://test-url.com',
|
||||
});
|
||||
await searchModels.call(mockContext);
|
||||
|
||||
expect(mockOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: 'https://test-url.com',
|
||||
apiKey: 'test-api-key',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default OpenAI URL if no custom URL provided', async () => {
|
||||
mockContext.getCredentials = jest.fn().mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
});
|
||||
|
||||
await searchModels.call(mockContext);
|
||||
|
||||
expect(mockOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'test-api-key',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include all models for custom API endpoints', async () => {
|
||||
mockContext.getNodeParameter = jest.fn().mockReturnValue('https://custom-api.com');
|
||||
|
||||
const result = await searchModels.call(mockContext);
|
||||
expect(result.results).toEqual([
|
||||
{ name: 'computer-use-preview', value: 'computer-use-preview' },
|
||||
{ name: 'davinci-instruct-beta', value: 'davinci-instruct-beta' },
|
||||
{ name: 'ft:gpt-3.5-turbo', value: 'ft:gpt-3.5-turbo' },
|
||||
{ name: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo' },
|
||||
{ name: 'gpt-3.5-turbo-instruct', value: 'gpt-3.5-turbo-instruct' },
|
||||
{ name: 'gpt-4', value: 'gpt-4' },
|
||||
{ name: 'o1-model', value: 'o1-model' },
|
||||
{ name: 'other-model', value: 'other-model' },
|
||||
{ name: 'tts-model', value: 'tts-model' },
|
||||
{ name: 'whisper-1', value: 'whisper-1' },
|
||||
{ name: 'whisper-1-preview', value: 'whisper-1-preview' },
|
||||
]);
|
||||
expect(result.results).toHaveLength(11);
|
||||
});
|
||||
|
||||
it('should treat ai-assistant.n8n.io as official API', async () => {
|
||||
mockContext.getCredentials.mockResolvedValueOnce({
|
||||
apiKey: 'test-api-key',
|
||||
url: 'https://ai-assistant.n8n.io/v1',
|
||||
});
|
||||
|
||||
const result = await searchModels.call(mockContext);
|
||||
|
||||
expect(result.results).toEqual([
|
||||
{ name: 'ft:gpt-3.5-turbo', value: 'ft:gpt-3.5-turbo' },
|
||||
{ name: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo' },
|
||||
{ name: 'gpt-4', value: 'gpt-4' },
|
||||
{ name: 'o1-model', value: 'o1-model' },
|
||||
{ name: 'other-model', value: 'other-model' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter models based on search term', async () => {
|
||||
const result = await searchModels.call(mockContext, 'gpt');
|
||||
|
||||
expect(result.results).toEqual([
|
||||
{ name: 'ft:gpt-3.5-turbo', value: 'ft:gpt-3.5-turbo' },
|
||||
{ name: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo' },
|
||||
{ name: 'gpt-4', value: 'gpt-4' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive search', async () => {
|
||||
const result = await searchModels.call(mockContext, 'GPT');
|
||||
|
||||
expect(result.results).toEqual([
|
||||
{ name: 'ft:gpt-3.5-turbo', value: 'ft:gpt-3.5-turbo' },
|
||||
{ name: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo' },
|
||||
{ name: 'gpt-4', value: 'gpt-4' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return models sorted alphabetically by id', async () => {
|
||||
// Setup a mock with scrambled order
|
||||
const mockUnsortedInstance = {
|
||||
apiKey: 'test-api-key',
|
||||
models: {
|
||||
list: jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'gpt-4' },
|
||||
{ id: 'a-model' },
|
||||
{ id: 'o1-model' },
|
||||
{ id: 'gpt-3.5-turbo' },
|
||||
{ id: 'z-model' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
} as unknown as OpenAI;
|
||||
|
||||
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => mockUnsortedInstance);
|
||||
|
||||
// Custom API endpoint to include all models
|
||||
mockContext.getNodeParameter = jest.fn().mockReturnValue('https://custom-api.com');
|
||||
|
||||
const result = await searchModels.call(mockContext);
|
||||
|
||||
// Verify the results are sorted alphabetically
|
||||
expect(result.results).toEqual([
|
||||
{ name: 'a-model', value: 'a-model' },
|
||||
{ name: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo' },
|
||||
{ name: 'gpt-4', value: 'gpt-4' },
|
||||
{ name: 'o1-model', value: 'o1-model' },
|
||||
{ name: 'z-model', value: 'z-model' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
import { shouldIncludeModel } from '../../../vendors/OpenAi/helpers/modelFiltering';
|
||||
import { getProxyAgent } from '@n8n/ai-utilities';
|
||||
import { Container } from '@n8n/di';
|
||||
import { AiConfig } from '@n8n/config';
|
||||
|
||||
export async function searchModels(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const credentials = await this.getCredentials('openAiApi');
|
||||
const baseURL =
|
||||
(this.getNodeParameter('options.baseURL', '') as string) ||
|
||||
(credentials.url as string) ||
|
||||
'https://api.openai.com/v1';
|
||||
const { openAiDefaultHeaders: defaultHeaders } = Container.get(AiConfig);
|
||||
|
||||
const openai = new OpenAI({
|
||||
baseURL,
|
||||
apiKey: credentials.apiKey as string,
|
||||
fetchOptions: {
|
||||
dispatcher: getProxyAgent(baseURL),
|
||||
},
|
||||
defaultHeaders,
|
||||
});
|
||||
const { data: models = [] } = await openai.models.list();
|
||||
|
||||
const url = baseURL && new URL(baseURL);
|
||||
const isCustomAPI = !!(url && !['api.openai.com', 'ai-assistant.n8n.io'].includes(url.hostname));
|
||||
|
||||
const filteredModels = models.filter((model: { id: string }) => {
|
||||
const includeModel = shouldIncludeModel(model.id, isCustomAPI);
|
||||
|
||||
if (!filter) return includeModel;
|
||||
|
||||
return includeModel && model.id.toLowerCase().includes(filter.toLowerCase());
|
||||
});
|
||||
|
||||
filteredModels.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
return {
|
||||
results: filteredModels.map((model: { id: string }) => ({
|
||||
name: model.id,
|
||||
value: model.id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M36.8671 16.3718C37.7746 13.648 37.4621 10.6642 36.0108 8.18661C33.8282 4.38653 29.4407 2.43149 25.1556 3.35151C23.2493 1.20396 20.5105 -0.0173148 17.6392 0.000185533C13.2591 -0.00981468 9.37273 2.81025 8.0252 6.97783C5.21139 7.5541 2.78258 9.31538 1.3613 11.8117C-0.837493 15.6018 -0.336232 20.3794 2.60133 23.6294C1.69381 26.3532 2.00632 29.3371 3.4576 31.8146C5.64015 35.6147 10.0277 37.5697 14.3128 36.6497C16.2179 38.7973 18.9579 40.0185 21.8292 39.9998C26.2118 40.011 30.0994 37.1885 31.4469 33.0171C34.2608 32.4409 36.6896 30.6796 38.1108 28.1833C40.3071 24.3932 39.8046 19.6194 36.8683 16.3693L36.8671 16.3718ZM21.8317 37.386C20.078 37.3885 18.3792 36.7747 17.0329 35.6509C17.0941 35.6184 17.2004 35.5597 17.2691 35.5172L25.2343 30.9171C25.6418 30.6858 25.8918 30.2521 25.8893 29.7833V18.5543L29.2557 20.4981C29.2919 20.5156 29.3157 20.5506 29.3207 20.5906V29.8896C29.3157 34.0247 25.9668 37.3772 21.8317 37.386ZM5.7264 30.5071C4.84763 28.9896 4.53137 27.2108 4.83263 25.4845C4.89138 25.5195 4.99513 25.5832 5.06888 25.6257L13.0341 30.2258C13.4378 30.4621 13.9378 30.4621 14.3428 30.2258L24.0668 24.6107V28.4983C24.0693 28.5383 24.0505 28.577 24.0193 28.602L15.9679 33.2509C12.3815 35.3159 7.80144 34.0884 5.72765 30.5071H5.7264ZM3.6301 13.1205C4.50512 11.6004 5.8864 10.4379 7.53144 9.83415C7.53144 9.9029 7.52769 10.0242 7.52769 10.1092V19.3106C7.52519 19.7781 7.77519 20.2119 8.18145 20.4431L17.9054 26.057L14.5391 28.0008C14.5053 28.0233 14.4628 28.027 14.4253 28.0108L6.37266 23.3582C2.79383 21.2856 1.56631 16.7068 3.62885 13.1217L3.6301 13.1205ZM31.2882 19.5569L21.5642 13.9417L24.9306 11.9992C24.9643 11.9767 25.0068 11.9729 25.0443 11.9892L33.097 16.638C36.6821 18.7093 37.9108 23.2957 35.8395 26.8808C34.9633 28.3983 33.5832 29.5608 31.9395 30.1658V20.6894C31.9432 20.2219 31.6945 19.7894 31.2894 19.5569H31.2882ZM34.6383 14.5142C34.5795 14.478 34.4758 14.4155 34.402 14.373L26.4368 9.77289C26.0331 9.53664 25.5331 9.53664 25.1281 9.77289L15.4041 15.388V11.5004C15.4016 11.4604 15.4204 11.4217 15.4516 11.3967L23.503 6.75158C27.0894 4.68279 31.6745 5.91406 33.742 9.50164C34.6158 11.0167 34.932 12.7905 34.6358 14.5142H34.6383ZM13.5741 21.4431L10.2065 19.4994C10.1702 19.4819 10.1465 19.4468 10.1415 19.4068V10.1079C10.144 5.96781 13.5028 2.61274 17.6429 2.61524C19.3942 2.61524 21.0892 3.23025 22.4355 4.35028C22.3743 4.38278 22.2693 4.44153 22.1992 4.48403L14.2341 9.08413C13.8266 9.31538 13.5766 9.74789 13.5791 10.2167L13.5741 21.4406V21.4431ZM15.4029 17.5006L19.7342 14.9993L24.0655 17.4993V22.5007L19.7342 25.0007L15.4029 22.5007V17.5006Z" fill="#C3C9D5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M36.8671 16.3718C37.7746 13.648 37.4621 10.6642 36.0108 8.18661C33.8282 4.38653 29.4407 2.43149 25.1556 3.35151C23.2493 1.20396 20.5105 -0.0173148 17.6392 0.000185533C13.2591 -0.00981468 9.37273 2.81025 8.0252 6.97783C5.21139 7.5541 2.78258 9.31538 1.3613 11.8117C-0.837493 15.6018 -0.336232 20.3794 2.60133 23.6294C1.69381 26.3532 2.00632 29.3371 3.4576 31.8146C5.64015 35.6147 10.0277 37.5697 14.3128 36.6497C16.2179 38.7973 18.9579 40.0185 21.8292 39.9998C26.2118 40.011 30.0994 37.1885 31.4469 33.0171C34.2608 32.4409 36.6896 30.6796 38.1108 28.1833C40.3071 24.3932 39.8046 19.6194 36.8683 16.3693L36.8671 16.3718ZM21.8317 37.386C20.078 37.3885 18.3792 36.7747 17.0329 35.6509C17.0941 35.6184 17.2004 35.5597 17.2691 35.5172L25.2343 30.9171C25.6418 30.6858 25.8918 30.2521 25.8893 29.7833V18.5543L29.2557 20.4981C29.2919 20.5156 29.3157 20.5506 29.3207 20.5906V29.8896C29.3157 34.0247 25.9668 37.3772 21.8317 37.386ZM5.7264 30.5071C4.84763 28.9896 4.53137 27.2108 4.83263 25.4845C4.89138 25.5195 4.99513 25.5832 5.06888 25.6257L13.0341 30.2258C13.4378 30.4621 13.9378 30.4621 14.3428 30.2258L24.0668 24.6107V28.4983C24.0693 28.5383 24.0505 28.577 24.0193 28.602L15.9679 33.2509C12.3815 35.3159 7.80144 34.0884 5.72765 30.5071H5.7264ZM3.6301 13.1205C4.50512 11.6004 5.8864 10.4379 7.53144 9.83415C7.53144 9.9029 7.52769 10.0242 7.52769 10.1092V19.3106C7.52519 19.7781 7.77519 20.2119 8.18145 20.4431L17.9054 26.057L14.5391 28.0008C14.5053 28.0233 14.4628 28.027 14.4253 28.0108L6.37266 23.3582C2.79383 21.2856 1.56631 16.7068 3.62885 13.1217L3.6301 13.1205ZM31.2882 19.5569L21.5642 13.9417L24.9306 11.9992C24.9643 11.9767 25.0068 11.9729 25.0443 11.9892L33.097 16.638C36.6821 18.7093 37.9108 23.2957 35.8395 26.8808C34.9633 28.3983 33.5832 29.5608 31.9395 30.1658V20.6894C31.9432 20.2219 31.6945 19.7894 31.2894 19.5569H31.2882ZM34.6383 14.5142C34.5795 14.478 34.4758 14.4155 34.402 14.373L26.4368 9.77289C26.0331 9.53664 25.5331 9.53664 25.1281 9.77289L15.4041 15.388V11.5004C15.4016 11.4604 15.4204 11.4217 15.4516 11.3967L23.503 6.75158C27.0894 4.68279 31.6745 5.91406 33.742 9.50164C34.6158 11.0167 34.932 12.7905 34.6358 14.5142H34.6383ZM13.5741 21.4431L10.2065 19.4994C10.1702 19.4819 10.1465 19.4468 10.1415 19.4068V10.1079C10.144 5.96781 13.5028 2.61274 17.6429 2.61524C19.3942 2.61524 21.0892 3.23025 22.4355 4.35028C22.3743 4.38278 22.2693 4.44153 22.1992 4.48403L14.2341 9.08413C13.8266 9.31538 13.5766 9.74789 13.5791 10.2167L13.5741 21.4406V21.4431ZM15.4029 17.5006L19.7342 14.9993L24.0655 17.4993V22.5007L19.7342 25.0007L15.4029 22.5007V17.5006Z" fill="#7D7D87"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,217 @@
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
import { formatBuiltInTools, prepareAdditionalResponsesParams } from '../common';
|
||||
|
||||
describe('formatBuiltInTools', () => {
|
||||
it('returns empty array when no built-in tools provided', () => {
|
||||
expect(formatBuiltInTools(undefined as unknown as IDataObject)).toEqual([]);
|
||||
expect(formatBuiltInTools({} as unknown as IDataObject)).toEqual([]);
|
||||
});
|
||||
|
||||
it('formats web_search with allowed domains and user location', () => {
|
||||
const tools = formatBuiltInTools({
|
||||
webSearch: {
|
||||
searchContextSize: 'high',
|
||||
allowedDomains: 'example.com, sub.domain.org , , another.net',
|
||||
country: 'US',
|
||||
city: 'NYC',
|
||||
region: 'NY',
|
||||
},
|
||||
} as unknown as IDataObject);
|
||||
|
||||
expect(tools).toEqual([
|
||||
{
|
||||
type: 'web_search',
|
||||
search_context_size: 'high',
|
||||
user_location: {
|
||||
type: 'approximate',
|
||||
country: 'US',
|
||||
city: 'NYC',
|
||||
region: 'NY',
|
||||
},
|
||||
filters: { allowed_domains: ['example.com', 'sub.domain.org', 'another.net'] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{}, false],
|
||||
[
|
||||
{
|
||||
country: 'US',
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
city: 'NYC',
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
region: 'NY',
|
||||
},
|
||||
true,
|
||||
],
|
||||
])(
|
||||
'formats web_search with allowed domains and %s user location',
|
||||
(userLocation, hasUserLocation) => {
|
||||
const tools = formatBuiltInTools({
|
||||
webSearch: {
|
||||
searchContextSize: 'high',
|
||||
allowedDomains: 'example.com, sub.domain.org , , another.net',
|
||||
...userLocation,
|
||||
},
|
||||
} as unknown as IDataObject);
|
||||
|
||||
const commonData = {
|
||||
type: 'web_search',
|
||||
search_context_size: 'high',
|
||||
filters: { allowed_domains: ['example.com', 'sub.domain.org', 'another.net'] },
|
||||
};
|
||||
if (hasUserLocation) {
|
||||
expect(tools).toEqual([
|
||||
expect.objectContaining({ ...commonData, user_location: expect.anything() }),
|
||||
]);
|
||||
} else {
|
||||
expect(tools).toEqual([
|
||||
expect.objectContaining({ ...commonData, user_location: undefined }),
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('adds code_interpreter tool when enabled', () => {
|
||||
const tools = formatBuiltInTools({ codeInterpreter: true } as unknown as IDataObject);
|
||||
expect(tools).toEqual([
|
||||
{
|
||||
type: 'code_interpreter',
|
||||
container: { type: 'auto' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('formats file_search tool with parsed vector_store_ids and filters', () => {
|
||||
const tools = formatBuiltInTools({
|
||||
fileSearch: {
|
||||
vectorStoreIds: '["vs1","vs2"]',
|
||||
filters: '{"file_types":["pdf"],"tags":["t1"]}',
|
||||
maxResults: 50,
|
||||
},
|
||||
} as unknown as IDataObject);
|
||||
|
||||
expect(tools).toEqual([
|
||||
{
|
||||
type: 'file_search',
|
||||
vector_store_ids: ['vs1', 'vs2'],
|
||||
filters: { file_types: ['pdf'], tags: ['t1'] },
|
||||
max_num_results: 50,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits filters when empty string provided in file_search', () => {
|
||||
const tools = formatBuiltInTools({
|
||||
fileSearch: { vectorStoreIds: '["only"]', filters: '', maxResults: 3 },
|
||||
} as unknown as IDataObject);
|
||||
expect(tools).toEqual([
|
||||
{
|
||||
type: 'file_search',
|
||||
vector_store_ids: ['only'],
|
||||
filters: undefined,
|
||||
max_num_results: 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareAdditionalResponsesParams', () => {
|
||||
it('maps simple scalar fields and conversation id', () => {
|
||||
const body = prepareAdditionalResponsesParams({
|
||||
promptCacheKey: 'cache-1',
|
||||
safetyIdentifier: 'safe-1',
|
||||
serviceTier: 'default',
|
||||
topLogprobs: 5,
|
||||
conversationId: 'conv-123',
|
||||
} as unknown as IDataObject);
|
||||
|
||||
expect(body).toEqual({
|
||||
prompt_cache_key: 'cache-1',
|
||||
safety_identifier: 'safe-1',
|
||||
service_tier: 'default',
|
||||
top_logprobs: 5,
|
||||
conversation: 'conv-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses metadata JSON', () => {
|
||||
const body = prepareAdditionalResponsesParams({
|
||||
metadata: '{"a":1}',
|
||||
} as unknown as IDataObject);
|
||||
expect(body).toEqual({ metadata: { a: 1 } });
|
||||
});
|
||||
|
||||
it('builds prompt config with variables JSON', () => {
|
||||
const body = prepareAdditionalResponsesParams({
|
||||
promptConfig: {
|
||||
promptOptions: {
|
||||
promptId: 'p1',
|
||||
version: 'v2',
|
||||
variables: '{"x":true,"y":2}',
|
||||
},
|
||||
},
|
||||
} as unknown as IDataObject);
|
||||
|
||||
expect(body).toEqual({
|
||||
prompt: {
|
||||
id: 'p1',
|
||||
version: 'v2',
|
||||
variables: { x: true, y: 2 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('sets text format for json_schema with parsed schema and verbosity', () => {
|
||||
const body = prepareAdditionalResponsesParams({
|
||||
textFormat: {
|
||||
textOptions: {
|
||||
type: 'json_schema',
|
||||
name: 'MySchema',
|
||||
schema: '{"type":"object","properties":{"a":{"type":"number"}}}',
|
||||
verbosity: 'low',
|
||||
},
|
||||
},
|
||||
} as unknown as IDataObject);
|
||||
|
||||
expect(body).toEqual({
|
||||
text: {
|
||||
verbosity: 'low',
|
||||
format: {
|
||||
type: 'json_schema',
|
||||
name: 'MySchema',
|
||||
schema: { type: 'object', properties: { a: { type: 'number' } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('sets text format for json_object and text', () => {
|
||||
const jsonObj = prepareAdditionalResponsesParams({
|
||||
textFormat: { textOptions: { type: 'json_object', verbosity: 'medium' } },
|
||||
} as unknown as IDataObject);
|
||||
expect(jsonObj).toEqual({ text: { verbosity: 'medium', format: { type: 'json_object' } } });
|
||||
|
||||
const text = prepareAdditionalResponsesParams({
|
||||
textFormat: { textOptions: { type: 'text', verbosity: 'high' } },
|
||||
} as unknown as IDataObject);
|
||||
expect(text).toEqual({ text: { verbosity: 'high', format: { type: 'text' } } });
|
||||
});
|
||||
|
||||
it('sets reasoning effort', () => {
|
||||
const body = prepareAdditionalResponsesParams({
|
||||
reasoningEffort: 'low',
|
||||
} as unknown as IDataObject);
|
||||
expect(body).toEqual({ reasoning: { effort: 'low' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { OpenAIClient } from '@langchain/openai';
|
||||
|
||||
export type BuiltInTools = {
|
||||
webSearch?: {
|
||||
searchContextSize?: 'low' | 'medium' | 'high';
|
||||
allowedDomains?: string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
};
|
||||
fileSearch?: {
|
||||
vectorStoreIds?: string;
|
||||
filters?: string;
|
||||
maxResults?: number;
|
||||
};
|
||||
codeInterpreter?: boolean;
|
||||
};
|
||||
|
||||
export type ModelOptions = {
|
||||
baseURL?: string;
|
||||
frequencyPenalty?: number;
|
||||
maxTokens?: number;
|
||||
responseFormat?: 'text' | 'json_object';
|
||||
presencePenalty?: number;
|
||||
temperature?: number;
|
||||
reasoningEffort?: 'low' | 'medium' | 'high';
|
||||
timeout?: number;
|
||||
maxRetries?: number;
|
||||
topP?: number;
|
||||
conversationId?: string;
|
||||
metadata?: string;
|
||||
promptCacheKey?: string;
|
||||
safetyIdentifier?: string;
|
||||
serviceTier?: 'auto' | 'flex' | 'default' | 'priority';
|
||||
topLogprobs?: number;
|
||||
textFormat?: {
|
||||
textOptions?: TextOptions;
|
||||
};
|
||||
promptConfig?: {
|
||||
promptOptions?: PromptOptions;
|
||||
};
|
||||
};
|
||||
|
||||
export type PromptOptions = {
|
||||
promptId?: string;
|
||||
version?: string;
|
||||
variables?: string;
|
||||
};
|
||||
|
||||
export type TextOptions = {
|
||||
type?: 'text' | 'json_schema' | 'json_object';
|
||||
verbosity?: 'low' | 'medium' | 'high';
|
||||
name?: string;
|
||||
schema?: string;
|
||||
description?: string;
|
||||
strict?: boolean;
|
||||
};
|
||||
export type ChatResponseRequest = OpenAIClient.Responses.ResponseCreateParamsNonStreaming & {
|
||||
conversation?: { id: string } | string;
|
||||
top_logprobs?: number;
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Cohere } from '@langchain/cohere';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
makeN8nLlmFailedAttemptHandler,
|
||||
N8nLlmTracing,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
|
||||
export class LmCohere implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Cohere Model',
|
||||
|
||||
name: 'lmCohere',
|
||||
icon: { light: 'file:cohere.svg', dark: 'file:cohere.dark.svg' },
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Language Model Cohere',
|
||||
defaults: {
|
||||
name: 'Cohere Model',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Language Models', 'Root Nodes'],
|
||||
'Language Models': ['Text Completion Models'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmcohere/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'cohereApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]),
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
description: 'Additional options to add',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Maximum Number of Tokens',
|
||||
name: 'maxTokens',
|
||||
default: 250,
|
||||
description:
|
||||
'The maximum number of tokens to generate in the completion. Most models have a context length of 2048 tokens (except for the newest models, which support 32,768).',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
maxValue: 32768,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'string',
|
||||
description: 'The name of the model to use',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Sampling Temperature',
|
||||
name: 'temperature',
|
||||
default: 0,
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials('cohereApi');
|
||||
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as object;
|
||||
|
||||
const model = new Cohere({
|
||||
apiKey: credentials.apiKey as string,
|
||||
...options,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.96 23.84C14.0267 23.84 16.16 23.7867 19.1467 22.56C22.6133 21.12 29.44 18.56 34.4 15.8933C37.8667 14.0267 39.36 11.5733 39.36 8.26667C39.36 3.73333 35.68 0 31.0933 0H11.8933C5.33333 0 0 5.33333 0 11.8933C0 18.4533 5.01333 23.84 12.96 23.84Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.2134 31.9999C16.2134 28.7999 18.1334 25.8666 21.12 24.6399L27.1467 22.1333C33.28 19.6266 40 24.1066 40 30.7199C40 35.8399 35.84 39.9999 30.72 39.9999H24.16C19.7867 39.9999 16.2134 36.4266 16.2134 31.9999Z" fill="white"/>
|
||||
<path d="M6.88 25.3867C3.09333 25.3867 0 28.4801 0 32.2667V33.1734C0 36.9067 3.09333 40.0001 6.88 40.0001C10.6667 40.0001 13.76 36.9067 13.76 33.1201V32.2134C13.7067 28.4801 10.6667 25.3867 6.88 25.3867Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 907 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.96 23.84C14.0267 23.84 16.16 23.7867 19.1467 22.56C22.6133 21.12 29.44 18.56 34.4 15.8933C37.8667 14.0267 39.36 11.5733 39.36 8.26667C39.36 3.73333 35.68 0 31.0933 0H11.8933C5.33333 0 0 5.33333 0 11.8933C0 18.4533 5.01333 23.84 12.96 23.84Z" fill="#39594D"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.2134 31.9999C16.2134 28.7999 18.1334 25.8666 21.12 24.6399L27.1467 22.1333C33.28 19.6266 40 24.1066 40 30.7199C40 35.8399 35.84 39.9999 30.72 39.9999H24.16C19.7867 39.9999 16.2134 36.4266 16.2134 31.9999Z" fill="#D18EE2"/>
|
||||
<path d="M6.88 25.3867C3.09333 25.3867 0 28.4801 0 32.2667V33.1734C0 36.9067 3.09333 40.0001 6.88 40.0001C10.6667 40.0001 13.76 36.9067 13.76 33.1201V32.2134C13.7067 28.4801 10.6667 25.3867 6.88 25.3867Z" fill="#FF7759"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 913 B |
@@ -0,0 +1,114 @@
|
||||
import { OpenAI } from '@langchain/openai';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { LemonadeApiCredentialsType } from '../../../credentials/LemonadeApi.credentials';
|
||||
|
||||
import { lemonadeDescription, lemonadeModel, lemonadeOptions } from './description';
|
||||
import {
|
||||
makeN8nLlmFailedAttemptHandler,
|
||||
N8nLlmTracing,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
|
||||
export class LmLemonade implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Lemonade Model',
|
||||
|
||||
name: 'lmLemonade',
|
||||
icon: 'file:lemonade.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Language Model Lemonade',
|
||||
defaults: {
|
||||
name: 'Lemonade Model',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Language Models', 'Root Nodes'],
|
||||
'Language Models': ['Text Completion Models'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmlemonade/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
...lemonadeDescription,
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]),
|
||||
lemonadeModel,
|
||||
lemonadeOptions,
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = (await this.getCredentials('lemonadeApi')) as LemonadeApiCredentialsType;
|
||||
|
||||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
frequencyPenalty?: number;
|
||||
presencePenalty?: number;
|
||||
maxTokens?: number;
|
||||
stop?: string;
|
||||
};
|
||||
|
||||
// Process stop sequences
|
||||
let stop: string[] | undefined;
|
||||
if (options.stop) {
|
||||
const stopSequences = options.stop
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
stop = stopSequences.length > 0 ? stopSequences : undefined;
|
||||
}
|
||||
|
||||
// Ensure we have an API key for OpenAI client validation
|
||||
const apiKey = credentials.apiKey || 'lemonade-placeholder-key';
|
||||
|
||||
// Build configuration object separately like official OpenAI node
|
||||
const configuration: any = {
|
||||
baseURL: credentials.baseUrl,
|
||||
};
|
||||
|
||||
// Add custom headers if API key is provided
|
||||
if (credentials.apiKey) {
|
||||
configuration.defaultHeaders = {
|
||||
Authorization: `Bearer ${credentials.apiKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
const model = new OpenAI({
|
||||
apiKey,
|
||||
model: modelName,
|
||||
temperature: options.temperature,
|
||||
topP: options.topP,
|
||||
frequencyPenalty: options.frequencyPenalty,
|
||||
presencePenalty: options.presencePenalty,
|
||||
maxTokens: options.maxTokens && options.maxTokens > 0 ? options.maxTokens : undefined,
|
||||
stop,
|
||||
configuration,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { INodeProperties, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export const lemonadeDescription: Partial<INodeTypeDescription> = {
|
||||
credentials: [
|
||||
{
|
||||
name: 'lemonadeApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
requestDefaults: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
baseURL: '={{ $credentials.baseUrl.replace(new RegExp("/$"), "") }}',
|
||||
},
|
||||
};
|
||||
|
||||
export const lemonadeModel: INodeProperties = {
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
default: '',
|
||||
description:
|
||||
'The model which will generate the completion. Models are loaded and managed through the Lemonade server.',
|
||||
typeOptions: {
|
||||
loadOptions: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/models',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'data',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{$responseItem.id}}',
|
||||
value: '={{$responseItem.id}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
send: {
|
||||
type: 'body',
|
||||
property: 'model',
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
};
|
||||
|
||||
export const lemonadeOptions: INodeProperties = {
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
description: 'Additional options to add',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Sampling Temperature',
|
||||
name: 'temperature',
|
||||
default: 0.7,
|
||||
typeOptions: { maxValue: 2, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'Controls the randomness of the generated text. Lower values make the output more focused and deterministic, while higher values make it more diverse and random.',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Top P',
|
||||
name: 'topP',
|
||||
default: 1,
|
||||
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'Chooses from the smallest possible set of tokens whose cumulative probability exceeds the probability top_p. Helps generate more human-like text by reducing repetitions.',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Frequency Penalty',
|
||||
name: 'frequencyPenalty',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
typeOptions: { minValue: -2, maxValue: 2, numberPrecision: 1 },
|
||||
description:
|
||||
'Adjusts the penalty for tokens that have already appeared in the generated text. Positive values discourage repetition, negative values encourage it.',
|
||||
},
|
||||
{
|
||||
displayName: 'Presence Penalty',
|
||||
name: 'presencePenalty',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
typeOptions: { minValue: -2, maxValue: 2, numberPrecision: 1 },
|
||||
description:
|
||||
'Adjusts the penalty for tokens based on their presence in the generated text so far. Positive values penalize tokens that have already appeared, encouraging diversity.',
|
||||
},
|
||||
{
|
||||
displayName: 'Max Tokens to Generate',
|
||||
name: 'maxTokens',
|
||||
type: 'number',
|
||||
default: -1,
|
||||
description:
|
||||
'The maximum number of tokens to generate. Set to -1 for no limit. Be cautious when setting this to a large value, as it can lead to very long outputs.',
|
||||
},
|
||||
{
|
||||
displayName: 'Stop Sequences',
|
||||
name: 'stop',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Comma-separated list of sequences where the model will stop generating text',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.03604 2.49169L2 5.00962C2.82591 7.34634 5.52523 8.53484 8.04325 7.52763L14.0865 5.00967C13.2606 2.66287 9.85018 1.17686 7.03604 2.49169Z" fill="url(#paint0_linear_18_30102)"/>
|
||||
<g filter="url(#filter0_f_18_30102)">
|
||||
<path d="M2.64575 5.26035L7.22767 2.96947C8.5803 2.39118 10.05 2.55851 11.2129 2.97773C12.2111 3.33758 12.9907 3.9608 13.418 4.75328L7.85198 7.0724C5.7348 7.91746 3.52324 7.0363 2.64575 5.26035Z" fill="url(#paint1_linear_18_30102)"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_f_18_30102)">
|
||||
<path d="M5.00464 4.17246L7.43032 2.76879C8.78294 2.1905 10.2527 2.35783 11.4155 2.77705C12.4137 3.13689 13.1933 3.76012 13.6206 4.5526C10.4511 2.99575 9.9351 2.43024 5.00464 4.17246Z" fill="url(#paint2_linear_18_30102)"/>
|
||||
</g>
|
||||
<path d="M14.9236 4.5997C9.51985 6.50701 6.6904 12.4499 8.59216 17.8694L9.84454 21.4282C11.2477 25.4172 14.8309 28.0107 18.7736 28.3363C19.5157 28.3945 20.2115 28.7201 20.7681 29.2202C21.5682 29.9413 22.7162 30.2087 23.7947 29.8249C24.8731 29.4412 25.6037 28.5108 25.7776 27.4525C25.8936 26.7082 26.2299 26.0336 26.7749 25.5103C29.6391 22.7656 30.8103 18.4974 29.4072 14.5084L28.1548 10.9496C26.2531 5.51849 20.3274 2.68077 14.9236 4.5997Z" fill="url(#paint3_radial_18_30102)"/>
|
||||
<path d="M14.9236 4.5997C9.51985 6.50701 6.6904 12.4499 8.59216 17.8694L9.84454 21.4282C11.2477 25.4172 14.8309 28.0107 18.7736 28.3363C19.5157 28.3945 20.2115 28.7201 20.7681 29.2202C21.5682 29.9413 22.7162 30.2087 23.7947 29.8249C24.8731 29.4412 25.6037 28.5108 25.7776 27.4525C25.8936 26.7082 26.2299 26.0336 26.7749 25.5103C29.6391 22.7656 30.8103 18.4974 29.4072 14.5084L28.1548 10.9496C26.2531 5.51849 20.3274 2.68077 14.9236 4.5997Z" fill="url(#paint4_radial_18_30102)"/>
|
||||
<path d="M14.9236 4.5997C9.51985 6.50701 6.6904 12.4499 8.59216 17.8694L9.84454 21.4282C11.2477 25.4172 14.8309 28.0107 18.7736 28.3363C19.5157 28.3945 20.2115 28.7201 20.7681 29.2202C21.5682 29.9413 22.7162 30.2087 23.7947 29.8249C24.8731 29.4412 25.6037 28.5108 25.7776 27.4525C25.8936 26.7082 26.2299 26.0336 26.7749 25.5103C29.6391 22.7656 30.8103 18.4974 29.4072 14.5084L28.1548 10.9496C26.2531 5.51849 20.3274 2.68077 14.9236 4.5997Z" fill="url(#paint5_radial_18_30102)"/>
|
||||
<defs>
|
||||
<filter id="filter0_f_18_30102" x="1.89035" y="1.84127" width="12.283" height="6.31025" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.377703" result="effect1_foregroundBlur_18_30102"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_18_30102" x="4.24923" y="1.64059" width="10.1268" height="3.66743" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.377703" result="effect1_foregroundBlur_18_30102"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_18_30102" x1="2" y1="5.00899" x2="14.0865" y2="5.00899" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#80A338"/>
|
||||
<stop offset="1" stop-color="#B3D745"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_18_30102" x1="1.99902" y1="5.02002" x2="14.0855" y2="5.02002" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#95BD27"/>
|
||||
<stop offset="1" stop-color="#BAE038"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_18_30102" x1="13.6206" y1="4.2397" x2="6.38307" y2="3.29833" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#D1F56E" stop-opacity="0"/>
|
||||
<stop offset="0.286062" stop-color="#D1F56E"/>
|
||||
<stop offset="1" stop-color="#D1F56E" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="paint3_radial_18_30102" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(21.2025 10.5724) rotate(115.148) scale(17.6305 14.9181)">
|
||||
<stop stop-color="#FFFB98"/>
|
||||
<stop offset="0.505208" stop-color="#FFD84C"/>
|
||||
<stop offset="1" stop-color="#E6B534"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint4_radial_18_30102" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(14.6238 4.96834) rotate(69.3343) scale(26.7531 22.6372)">
|
||||
<stop offset="0.521583" stop-color="#FFDE67" stop-opacity="0"/>
|
||||
<stop offset="0.736095" stop-color="#FFA457" stop-opacity="0.2"/>
|
||||
<stop offset="0.886173" stop-color="#D5676D" stop-opacity="0.75"/>
|
||||
<stop offset="0.917885" stop-color="#E88257"/>
|
||||
<stop offset="1" stop-color="#F49754"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint5_radial_18_30102" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(-10.5942 -28.473) rotate(56.1215) scale(51.3589 43.4576)">
|
||||
<stop offset="0.707976" stop-color="#D5B638"/>
|
||||
<stop offset="0.873737" stop-color="#D5B638" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,80 @@
|
||||
import { Ollama } from '@langchain/ollama';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ollamaDescription, ollamaModel, ollamaOptions } from './description';
|
||||
import {
|
||||
makeN8nLlmFailedAttemptHandler,
|
||||
N8nLlmTracing,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
|
||||
export class LmOllama implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Ollama Model',
|
||||
|
||||
name: 'lmOllama',
|
||||
icon: 'file:ollama.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Language Model Ollama',
|
||||
defaults: {
|
||||
name: 'Ollama Model',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Language Models', 'Root Nodes'],
|
||||
'Language Models': ['Text Completion Models'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmollama/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
...ollamaDescription,
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]),
|
||||
ollamaModel,
|
||||
ollamaOptions,
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials('ollamaApi');
|
||||
|
||||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as object;
|
||||
const headers = credentials.apiKey
|
||||
? {
|
||||
Authorization: `Bearer ${credentials.apiKey as string}`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const model = new Ollama({
|
||||
baseUrl: credentials.baseUrl as string,
|
||||
model: modelName,
|
||||
...options,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
headers,
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { INodeProperties, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export const ollamaDescription: Partial<INodeTypeDescription> = {
|
||||
credentials: [
|
||||
{
|
||||
name: 'ollamaApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
requestDefaults: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
baseURL: '={{ $credentials.baseUrl.replace(new RegExp("/$"), "") }}',
|
||||
},
|
||||
};
|
||||
|
||||
export const ollamaModel: INodeProperties = {
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
default: 'llama3.2',
|
||||
description:
|
||||
'The model which will generate the completion. To download models, visit <a href="https://ollama.ai/library">Ollama Models Library</a>.',
|
||||
typeOptions: {
|
||||
loadOptions: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/api/tags',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'models',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{$responseItem.name}}',
|
||||
value: '={{$responseItem.name}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
send: {
|
||||
type: 'body',
|
||||
property: 'model',
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
};
|
||||
|
||||
export const ollamaOptions: INodeProperties = {
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
description: 'Additional options to add',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Sampling Temperature',
|
||||
name: 'temperature',
|
||||
default: 0.7,
|
||||
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'Controls the randomness of the generated text. Lower values make the output more focused and deterministic, while higher values make it more diverse and random.',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Top K',
|
||||
name: 'topK',
|
||||
default: -1,
|
||||
typeOptions: { maxValue: 100, minValue: -1, numberPrecision: 1 },
|
||||
description:
|
||||
'Limits the number of highest probability vocabulary tokens to consider at each step. A higher value increases diversity but may reduce coherence. Set to -1 to disable.',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Top P',
|
||||
name: 'topP',
|
||||
default: 1,
|
||||
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'Chooses from the smallest possible set of tokens whose cumulative probability exceeds the probability top_p. Helps generate more human-like text by reducing repetitions.',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Frequency Penalty',
|
||||
name: 'frequencyPenalty',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
typeOptions: { minValue: 0 },
|
||||
description:
|
||||
'Adjusts the penalty for tokens that have already appeared in the generated text. Higher values discourage repetition.',
|
||||
},
|
||||
{
|
||||
displayName: 'Keep Alive',
|
||||
name: 'keepAlive',
|
||||
type: 'string',
|
||||
default: '5m',
|
||||
description:
|
||||
'Specifies the duration to keep the loaded model in memory after use. Useful for frequently used models. Format: 1h30m (1 hour 30 minutes).',
|
||||
},
|
||||
{
|
||||
displayName: 'Low VRAM Mode',
|
||||
name: 'lowVram',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to Activate low VRAM mode, which reduces memory usage at the cost of slower generation speed. Useful for GPUs with limited memory.',
|
||||
},
|
||||
{
|
||||
displayName: 'Main GPU ID',
|
||||
name: 'mainGpu',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description:
|
||||
'Specifies the ID of the GPU to use for the main computation. Only change this if you have multiple GPUs.',
|
||||
},
|
||||
{
|
||||
displayName: 'Context Batch Size',
|
||||
name: 'numBatch',
|
||||
type: 'number',
|
||||
default: 512,
|
||||
description:
|
||||
'Sets the batch size for prompt processing. Larger batch sizes may improve generation speed but increase memory usage.',
|
||||
},
|
||||
{
|
||||
displayName: 'Context Length',
|
||||
name: 'numCtx',
|
||||
type: 'number',
|
||||
default: 2048,
|
||||
description:
|
||||
'The maximum number of tokens to use as context for generating the next token. Smaller values reduce memory usage, while larger values provide more context to the model.',
|
||||
},
|
||||
{
|
||||
displayName: 'Number of GPUs',
|
||||
name: 'numGpu',
|
||||
type: 'number',
|
||||
default: -1,
|
||||
description:
|
||||
'Specifies the number of GPUs to use for parallel processing. Set to -1 for auto-detection.',
|
||||
},
|
||||
{
|
||||
displayName: 'Max Tokens to Generate',
|
||||
name: 'numPredict',
|
||||
type: 'number',
|
||||
default: -1,
|
||||
description:
|
||||
'The maximum number of tokens to generate. Set to -1 for no limit. Be cautious when setting this to a large value, as it can lead to very long outputs.',
|
||||
},
|
||||
{
|
||||
displayName: 'Number of CPU Threads',
|
||||
name: 'numThread',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description:
|
||||
'Specifies the number of CPU threads to use for processing. Set to 0 for auto-detection.',
|
||||
},
|
||||
{
|
||||
displayName: 'Penalize Newlines',
|
||||
name: 'penalizeNewline',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether the model will be less likely to generate newline characters, encouraging longer continuous sequences of text',
|
||||
},
|
||||
{
|
||||
displayName: 'Presence Penalty',
|
||||
name: 'presencePenalty',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
description:
|
||||
'Adjusts the penalty for tokens based on their presence in the generated text so far. Positive values penalize tokens that have already appeared, encouraging diversity.',
|
||||
},
|
||||
{
|
||||
displayName: 'Repetition Penalty',
|
||||
name: 'repeatPenalty',
|
||||
type: 'number',
|
||||
default: 1.0,
|
||||
description:
|
||||
'Adjusts the penalty factor for repeated tokens. Higher values more strongly discourage repetition. Set to 1.0 to disable repetition penalty.',
|
||||
},
|
||||
{
|
||||
displayName: 'Use Memory Locking',
|
||||
name: 'useMLock',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to lock the model in memory to prevent swapping. This can improve performance but requires sufficient available memory.',
|
||||
},
|
||||
{
|
||||
displayName: 'Use Memory Mapping',
|
||||
name: 'useMMap',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to use memory mapping for loading the model. This can reduce memory usage but may impact performance. Recommended to keep enabled.',
|
||||
},
|
||||
{
|
||||
displayName: 'Load Vocabulary Only',
|
||||
name: 'vocabOnly',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to only load the model vocabulary without the weights. Useful for quickly testing tokenization.',
|
||||
},
|
||||
{
|
||||
displayName: 'Output Format',
|
||||
name: 'format',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Default', value: 'default' },
|
||||
{ name: 'JSON', value: 'json' },
|
||||
],
|
||||
default: 'default',
|
||||
description: 'Specifies the format of the API response',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="241.333" height="341.333" version="1.0" viewBox="0 0 181 256"><g fill="#7D7D87"><path d="M37.7 19.5c-5.2 1.8-8.3 4.9-11.7 11.6-4.5 8.9-6.2 19.2-5.8 35.5l.3 14.2-5.8 6.1c-14.8 15.5-18.5 38.7-9.2 57.4l3.4 6.9-2 4.4c-3.4 8.2-5 16.4-5 26.3 0 10.8 1.8 19 5.8 26.2l2.6 4.8-2.1 4.9c-1.2 2.7-2.6 7.1-3.2 9.8-1.4 6.2-1.5 22.1-.1 25.7 1 2.6 1.4 2.7 7.6 2.7 7.3 0 7 .4 5.3-8.6-1.5-8.2.2-18.8 4.2-26.6 3.7-7 3.8-10.4.5-14.8-4.7-6.4-6.8-13.6-6.9-24-.1-10.3 1.4-16 6.6-26.1 3.1-6.1 2.9-8.7-1-12.2-1.1-1-3.1-4.2-4.3-7-1.9-4.2-2.4-6.9-2.3-14.2 0-11.4 2.5-18.3 9.5-26 7-7.6 14.2-11 23.9-11.2 4.1 0 7.8-.2 8.2-.2.4-.1 1.7-2.2 2.9-4.7 3-5.9 9.6-11.9 16.7-15.2 4.9-2.3 7-2.7 14.7-2.7 7.9 0 9.7.4 14.9 2.9 6.8 3.3 13.3 9.4 15.9 14.8 1 2 2.3 4.1 3 4.5.6.4 4.6.8 8.7.8 6.7.1 8.3.5 14 3.6 12.3 6.8 19.3 18.7 19.3 33.4.1 6.7-.4 9-2.7 14.2-1.6 3.5-3.5 6.8-4.3 7.5-3.4 2.8-3.5 5.8-.5 11.7 5.2 10.1 6.7 15.8 6.6 26.1-.1 10.4-2.2 17.6-6.9 24-3.3 4.4-3.2 7.8.5 14.8 4 7.8 5.7 18.4 4.2 26.6-1.7 9-2 8.6 5.3 8.6 6.2 0 6.6-.1 7.6-2.7 1.4-3.6 1.3-19.5-.1-25.7-.6-2.7-2-7.1-3.2-9.8l-2.1-4.9 2.6-4.8c7.6-13.9 7.9-35.9.6-52.8l-2-4.7 2.5-4.6c9.9-18.3 6.4-43.9-8.1-59.1l-5.8-6.1.3-14.2c.4-16.4-1.3-26.6-5.8-35.7-6.4-12.6-17.2-15.9-26.3-7.9-5.4 4.7-9.2 13.8-12.3 29.8-.3 1.4-1 2.2-1.7 1.8-18.2-8-29.7-8.5-44.3-2.1L65 54.9l-.4-2.2C61 34.2 56.1 24.2 49 20.5c-4.3-2.1-7.4-2.4-11.3-1m7.7 16.8c4.2 7.1 8.1 30.1 5.7 33.6-.5.8-3.1 1.6-5.8 1.8-2.6.2-6.2.8-8 1.3l-3.1.8-.7-4.9c-.8-5.9.2-17.2 2.2-24.8C37.1 38.4 40.5 32 42 32c.5 0 2 1.9 3.4 4.3m96.5-1c4 6.5 6.9 23.9 5.6 33.6l-.7 4.9-3.1-.8c-1.8-.5-5.4-1.1-8-1.3-2.7-.2-5.3-1-5.8-1.8-1.2-1.7-.3-14.1 1.7-22.9 1.5-6.4 5.7-15 7.4-15 .4 0 1.8 1.5 2.9 3.3"/><path d="M77.8 119.9c-7.3 2.4-11.6 5.1-16.5 10.4-5.5 6-7.6 12-7.1 20.1.5 7.6 3.5 12.9 10.6 18.3 6.2 4.7 12.7 6.3 25.7 6.3 17.2 0 25.8-3.6 32.9-13.8 4.2-5.9 4.8-15.5 1.6-23-2.9-6.8-11.1-14.3-18.8-17.3-8-3.1-20.7-3.6-28.4-1m25.7 10c16.1 7.1 19.4 23.2 6.6 31.8-4.9 3.3-9.4 4.3-19.6 4.3s-14.7-1-19.6-4.3c-17.8-12-3.2-35.6 21.1-34.3 3.9.2 8.6 1.2 11.5 2.5"/><path d="M83.8 140.1c-2.5 1.4-2.2 4.4.7 6.7 2 1.6 2.4 2.6 1.9 4.9-.7 3.6 1.5 5.8 5.1 4.9 2.1-.5 2.5-1.2 2.5-4.6 0-2.9.5-4.2 2-5 2.7-1.5 2.7-6.6 0-7.5-1-.3-2.8-.1-4 .5-1.4.7-2.6.8-3.9 0-2.3-1.2-2.2-1.2-4.3.1m-44.1-18.9c-.9.7-2.3 3-3.2 5-2.1 5.3-.1 10.3 4.7 11.6 4.3 1.1 6 .6 9.2-2.7 4-4.1 4.3-8.1 1.1-11.9-2.1-2.5-3.4-3.2-6.4-3.2-2 0-4.5.6-5.4 1.2m89.8 2c-3.2 3.8-2.9 7.8 1.1 11.9 3.2 3.3 4.9 3.8 9.2 2.7 4.9-1.3 6.8-6.2 4.6-11.8-1.9-4.7-3.8-6-8.7-6-2.7 0-4.1.7-6.2 3.2"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,284 @@
|
||||
import { OpenAI, type ClientOptions } from '@langchain/openai';
|
||||
import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import type {
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
ISupplyDataFunctions,
|
||||
SupplyData,
|
||||
ILoadOptionsFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { Container } from '@n8n/di';
|
||||
import { AiConfig } from '@n8n/config';
|
||||
import { mergeCustomHeaders } from '@utils/helpers';
|
||||
|
||||
type LmOpenAiOptions = {
|
||||
baseURL?: string;
|
||||
frequencyPenalty?: number;
|
||||
maxTokens?: number;
|
||||
presencePenalty?: number;
|
||||
temperature?: number;
|
||||
timeout?: number;
|
||||
maxRetries?: number;
|
||||
topP?: number;
|
||||
};
|
||||
|
||||
export class LmOpenAi implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'OpenAI Model',
|
||||
|
||||
name: 'lmOpenAi',
|
||||
hidden: true,
|
||||
icon: { light: 'file:openAiLight.svg', dark: 'file:openAiLight.dark.svg' },
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'For advanced usage with an AI chain',
|
||||
defaults: {
|
||||
name: 'OpenAI Model',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Language Models', 'Root Nodes'],
|
||||
'Language Models': ['Text Completion Models'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmopenai/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'openAiApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
requestDefaults: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
baseURL:
|
||||
'={{ $parameter.options?.baseURL?.split("/").slice(0,-1).join("/") || "https://api.openai.com" }}',
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
displayName:
|
||||
'This node is using OpenAI completions which are now deprecated. Please use the OpenAI Chat Model node instead.',
|
||||
name: 'deprecated',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: 'gpt-3.5-turbo-instruct' },
|
||||
required: true,
|
||||
description:
|
||||
'The model which will generate the completion. <a href="https://beta.openai.com/docs/models/overview">Learn more</a>.',
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'openAiModelSearch',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
routing: {
|
||||
send: {
|
||||
type: 'body',
|
||||
property: 'model',
|
||||
value: '={{$parameter.model.value}}',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'When using non OpenAI models via Base URL override, not all models might be chat-compatible or support other features, like tools calling or JSON response format.',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/options.baseURL': [{ _cnd: { exists: true } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
description: 'Additional options to add',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Base URL',
|
||||
name: 'baseURL',
|
||||
default: 'https://api.openai.com/v1',
|
||||
description: 'Override the default base URL for the API',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
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. Most models have a context length of 2048 tokens (except for the newest models, which support 32,768).',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
maxValue: 32768,
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 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: 60000,
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
methods = {
|
||||
listSearch: {
|
||||
async openAiModelSearch(this: ILoadOptionsFunctions) {
|
||||
const results = [];
|
||||
|
||||
const options = this.getNodeParameter('options', {}) as LmOpenAiOptions;
|
||||
|
||||
let uri = 'https://api.openai.com/v1/models';
|
||||
|
||||
if (options.baseURL) {
|
||||
uri = `${options.baseURL}/models`;
|
||||
}
|
||||
|
||||
const { data } = (await this.helpers.requestWithAuthentication.call(this, 'openAiApi', {
|
||||
method: 'GET',
|
||||
uri,
|
||||
json: true,
|
||||
})) as { data: Array<{ owned_by: string; id: string }> };
|
||||
|
||||
for (const model of data) {
|
||||
if (!options.baseURL && !model.owned_by?.startsWith('system')) continue;
|
||||
results.push({
|
||||
name: model.id,
|
||||
value: model.id,
|
||||
});
|
||||
}
|
||||
|
||||
return { results };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials('openAiApi');
|
||||
|
||||
const modelName = this.getNodeParameter('model', itemIndex, '', {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
baseURL?: string;
|
||||
frequencyPenalty?: number;
|
||||
maxTokens?: number;
|
||||
presencePenalty?: number;
|
||||
temperature?: number;
|
||||
timeout?: number;
|
||||
maxRetries?: number;
|
||||
topP?: number;
|
||||
};
|
||||
|
||||
const { openAiDefaultHeaders } = Container.get(AiConfig);
|
||||
const defaultHeaders = mergeCustomHeaders(credentials, openAiDefaultHeaders ?? {});
|
||||
const timeout = options.timeout;
|
||||
const configuration: ClientOptions = {
|
||||
fetchOptions: {
|
||||
dispatcher: getProxyAgent(options.baseURL ?? 'https://api.openai.com/v1', {
|
||||
headersTimeout: timeout,
|
||||
bodyTimeout: timeout,
|
||||
}),
|
||||
},
|
||||
defaultHeaders,
|
||||
};
|
||||
|
||||
if (options.baseURL) {
|
||||
configuration.baseURL = options.baseURL;
|
||||
}
|
||||
|
||||
const model = new OpenAI({
|
||||
apiKey: credentials.apiKey as string,
|
||||
model: modelName,
|
||||
...options,
|
||||
configuration,
|
||||
timeout,
|
||||
maxRetries: options.maxRetries ?? 2,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M36.8671 16.3718C37.7746 13.648 37.4621 10.6642 36.0108 8.18661C33.8282 4.38653 29.4407 2.43149 25.1556 3.35151C23.2493 1.20396 20.5105 -0.0173148 17.6392 0.000185533C13.2591 -0.00981468 9.37273 2.81025 8.0252 6.97783C5.21139 7.5541 2.78258 9.31538 1.3613 11.8117C-0.837493 15.6018 -0.336232 20.3794 2.60133 23.6294C1.69381 26.3532 2.00632 29.3371 3.4576 31.8146C5.64015 35.6147 10.0277 37.5697 14.3128 36.6497C16.2179 38.7973 18.9579 40.0185 21.8292 39.9998C26.2118 40.011 30.0994 37.1885 31.4469 33.0171C34.2608 32.4409 36.6896 30.6796 38.1108 28.1833C40.3071 24.3932 39.8046 19.6194 36.8683 16.3693L36.8671 16.3718ZM21.8317 37.386C20.078 37.3885 18.3792 36.7747 17.0329 35.6509C17.0941 35.6184 17.2004 35.5597 17.2691 35.5172L25.2343 30.9171C25.6418 30.6858 25.8918 30.2521 25.8893 29.7833V18.5543L29.2557 20.4981C29.2919 20.5156 29.3157 20.5506 29.3207 20.5906V29.8896C29.3157 34.0247 25.9668 37.3772 21.8317 37.386ZM5.7264 30.5071C4.84763 28.9896 4.53137 27.2108 4.83263 25.4845C4.89138 25.5195 4.99513 25.5832 5.06888 25.6257L13.0341 30.2258C13.4378 30.4621 13.9378 30.4621 14.3428 30.2258L24.0668 24.6107V28.4983C24.0693 28.5383 24.0505 28.577 24.0193 28.602L15.9679 33.2509C12.3815 35.3159 7.80144 34.0884 5.72765 30.5071H5.7264ZM3.6301 13.1205C4.50512 11.6004 5.8864 10.4379 7.53144 9.83415C7.53144 9.9029 7.52769 10.0242 7.52769 10.1092V19.3106C7.52519 19.7781 7.77519 20.2119 8.18145 20.4431L17.9054 26.057L14.5391 28.0008C14.5053 28.0233 14.4628 28.027 14.4253 28.0108L6.37266 23.3582C2.79383 21.2856 1.56631 16.7068 3.62885 13.1217L3.6301 13.1205ZM31.2882 19.5569L21.5642 13.9417L24.9306 11.9992C24.9643 11.9767 25.0068 11.9729 25.0443 11.9892L33.097 16.638C36.6821 18.7093 37.9108 23.2957 35.8395 26.8808C34.9633 28.3983 33.5832 29.5608 31.9395 30.1658V20.6894C31.9432 20.2219 31.6945 19.7894 31.2894 19.5569H31.2882ZM34.6383 14.5142C34.5795 14.478 34.4758 14.4155 34.402 14.373L26.4368 9.77289C26.0331 9.53664 25.5331 9.53664 25.1281 9.77289L15.4041 15.388V11.5004C15.4016 11.4604 15.4204 11.4217 15.4516 11.3967L23.503 6.75158C27.0894 4.68279 31.6745 5.91406 33.742 9.50164C34.6158 11.0167 34.932 12.7905 34.6358 14.5142H34.6383ZM13.5741 21.4431L10.2065 19.4994C10.1702 19.4819 10.1465 19.4468 10.1415 19.4068V10.1079C10.144 5.96781 13.5028 2.61274 17.6429 2.61524C19.3942 2.61524 21.0892 3.23025 22.4355 4.35028C22.3743 4.38278 22.2693 4.44153 22.1992 4.48403L14.2341 9.08413C13.8266 9.31538 13.5766 9.74789 13.5791 10.2167L13.5741 21.4406V21.4431ZM15.4029 17.5006L19.7342 14.9993L24.0655 17.4993V22.5007L19.7342 25.0007L15.4029 22.5007V17.5006Z" fill="#C3C9D5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M36.8671 16.3718C37.7746 13.648 37.4621 10.6642 36.0108 8.18661C33.8282 4.38653 29.4407 2.43149 25.1556 3.35151C23.2493 1.20396 20.5105 -0.0173148 17.6392 0.000185533C13.2591 -0.00981468 9.37273 2.81025 8.0252 6.97783C5.21139 7.5541 2.78258 9.31538 1.3613 11.8117C-0.837493 15.6018 -0.336232 20.3794 2.60133 23.6294C1.69381 26.3532 2.00632 29.3371 3.4576 31.8146C5.64015 35.6147 10.0277 37.5697 14.3128 36.6497C16.2179 38.7973 18.9579 40.0185 21.8292 39.9998C26.2118 40.011 30.0994 37.1885 31.4469 33.0171C34.2608 32.4409 36.6896 30.6796 38.1108 28.1833C40.3071 24.3932 39.8046 19.6194 36.8683 16.3693L36.8671 16.3718ZM21.8317 37.386C20.078 37.3885 18.3792 36.7747 17.0329 35.6509C17.0941 35.6184 17.2004 35.5597 17.2691 35.5172L25.2343 30.9171C25.6418 30.6858 25.8918 30.2521 25.8893 29.7833V18.5543L29.2557 20.4981C29.2919 20.5156 29.3157 20.5506 29.3207 20.5906V29.8896C29.3157 34.0247 25.9668 37.3772 21.8317 37.386ZM5.7264 30.5071C4.84763 28.9896 4.53137 27.2108 4.83263 25.4845C4.89138 25.5195 4.99513 25.5832 5.06888 25.6257L13.0341 30.2258C13.4378 30.4621 13.9378 30.4621 14.3428 30.2258L24.0668 24.6107V28.4983C24.0693 28.5383 24.0505 28.577 24.0193 28.602L15.9679 33.2509C12.3815 35.3159 7.80144 34.0884 5.72765 30.5071H5.7264ZM3.6301 13.1205C4.50512 11.6004 5.8864 10.4379 7.53144 9.83415C7.53144 9.9029 7.52769 10.0242 7.52769 10.1092V19.3106C7.52519 19.7781 7.77519 20.2119 8.18145 20.4431L17.9054 26.057L14.5391 28.0008C14.5053 28.0233 14.4628 28.027 14.4253 28.0108L6.37266 23.3582C2.79383 21.2856 1.56631 16.7068 3.62885 13.1217L3.6301 13.1205ZM31.2882 19.5569L21.5642 13.9417L24.9306 11.9992C24.9643 11.9767 25.0068 11.9729 25.0443 11.9892L33.097 16.638C36.6821 18.7093 37.9108 23.2957 35.8395 26.8808C34.9633 28.3983 33.5832 29.5608 31.9395 30.1658V20.6894C31.9432 20.2219 31.6945 19.7894 31.2894 19.5569H31.2882ZM34.6383 14.5142C34.5795 14.478 34.4758 14.4155 34.402 14.373L26.4368 9.77289C26.0331 9.53664 25.5331 9.53664 25.1281 9.77289L15.4041 15.388V11.5004C15.4016 11.4604 15.4204 11.4217 15.4516 11.3967L23.503 6.75158C27.0894 4.68279 31.6745 5.91406 33.742 9.50164C34.6158 11.0167 34.932 12.7905 34.6358 14.5142H34.6383ZM13.5741 21.4431L10.2065 19.4994C10.1702 19.4819 10.1465 19.4468 10.1415 19.4068V10.1079C10.144 5.96781 13.5028 2.61274 17.6429 2.61524C19.3942 2.61524 21.0892 3.23025 22.4355 4.35028C22.3743 4.38278 22.2693 4.44153 22.1992 4.48403L14.2341 9.08413C13.8266 9.31538 13.5766 9.74789 13.5791 10.2167L13.5741 21.4406V21.4431ZM15.4029 17.5006L19.7342 14.9993L24.0655 17.4993V22.5007L19.7342 25.0007L15.4029 22.5007V17.5006Z" fill="#7D7D87"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,160 @@
|
||||
import { HuggingFaceInference } from '@langchain/community/llms/hf';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
makeN8nLlmFailedAttemptHandler,
|
||||
N8nLlmTracing,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
|
||||
export class LmOpenHuggingFaceInference implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Hugging Face Inference Model',
|
||||
|
||||
name: 'lmOpenHuggingFaceInference',
|
||||
icon: 'file:huggingface.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Language Model HuggingFaceInference',
|
||||
defaults: {
|
||||
name: 'Hugging Face Inference Model',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Language Models', 'Root Nodes'],
|
||||
'Language Models': ['Text Completion Models'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmopenhuggingfaceinference/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'huggingFaceApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]),
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'string',
|
||||
default: 'mistralai/Mistral-Nemo-Base-2407',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
description: 'Additional options to add',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Custom Inference Endpoint',
|
||||
name: 'endpointUrl',
|
||||
default: '',
|
||||
description: 'Custom endpoint URL',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
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: 128,
|
||||
description:
|
||||
'The maximum number of tokens to generate in the completion. Most models have a context length of 2048 tokens (except for the newest models, which support 32,768).',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
maxValue: 32768,
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 1,
|
||||
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: 'Top K',
|
||||
name: 'topK',
|
||||
default: 1,
|
||||
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'Controls the top tokens to consider within the sample operation to create new text',
|
||||
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('huggingFaceApi');
|
||||
|
||||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as object;
|
||||
|
||||
// LangChain does not yet support specifying Provider
|
||||
// That's why mistral's model is the default value
|
||||
// It is one of the few models that seem to work out of the box
|
||||
// Other models are returning "Model x/y is not supported for task text-generation and provider z. Supported task: conversational."
|
||||
// https://github.com/langchain-ai/langchainjs/discussions/8434#discussioncomment-13603787
|
||||
const model = new HuggingFaceInference({
|
||||
model: modelName,
|
||||
apiKey: credentials.apiKey as string,
|
||||
...options,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,276 @@
|
||||
import type { BedrockRuntimeClientConfig } from '@aws-sdk/client-bedrock-runtime';
|
||||
import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime';
|
||||
import { ChatBedrockConverse } from '@langchain/aws';
|
||||
import {
|
||||
getNodeProxyAgent,
|
||||
makeN8nLlmFailedAttemptHandler,
|
||||
N8nLlmTracing,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
||||
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class LmChatAwsBedrock implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'AWS Bedrock Chat Model',
|
||||
|
||||
name: 'lmChatAwsBedrock',
|
||||
icon: 'file:bedrock.svg',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1],
|
||||
description: 'Language Model AWS Bedrock',
|
||||
defaults: {
|
||||
name: 'AWS Bedrock 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.lmchatawsbedrock/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'aws',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
requestDefaults: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
baseURL: '=https://bedrock.{{$credentials?.region ?? "eu-central-1"}}.amazonaws.com',
|
||||
},
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiChain]),
|
||||
{
|
||||
displayName: 'Model Source',
|
||||
name: 'modelSource',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.1 } }],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'On-Demand Models',
|
||||
value: 'onDemand',
|
||||
description: 'Standard foundation models with on-demand pricing',
|
||||
},
|
||||
{
|
||||
name: 'Inference Profiles',
|
||||
value: 'inferenceProfile',
|
||||
description:
|
||||
'Cross-region inference profiles (required for models like Claude Sonnet 4 and others)',
|
||||
},
|
||||
],
|
||||
default: 'onDemand',
|
||||
description: 'Choose between on-demand foundation models or inference profiles',
|
||||
},
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
allowArbitraryValues: true, // Hide issues when model name is specified in the expression and does not match any of the options
|
||||
description:
|
||||
'The model which will generate the completion. <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/foundation-models.html">Learn more</a>.',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
modelSource: ['inferenceProfile'],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['modelSource'],
|
||||
loadOptions: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/foundation-models?&byOutputModality=TEXT&byInferenceType=ON_DEMAND',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'modelSummaries',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{$responseItem.modelName}}',
|
||||
description: '={{$responseItem.modelArn}}',
|
||||
value: '={{$responseItem.modelId}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
send: {
|
||||
type: 'body',
|
||||
property: 'model',
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
allowArbitraryValues: true,
|
||||
description:
|
||||
'The inference profile which will generate the completion. <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-use.html">Learn more</a>.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
modelSource: ['inferenceProfile'],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['modelSource'],
|
||||
loadOptions: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/inference-profiles?maxResults=1000',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'inferenceProfileSummaries',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{$responseItem.inferenceProfileName}}',
|
||||
description:
|
||||
'={{$responseItem.description || $responseItem.inferenceProfileArn}}',
|
||||
value: '={{$responseItem.inferenceProfileId}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
send: {
|
||||
type: 'body',
|
||||
property: 'model',
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
description: 'Additional options to add',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Maximum Number of Tokens',
|
||||
name: 'maxTokensToSample',
|
||||
default: 2000,
|
||||
description: 'The maximum number of tokens to generate in the completion',
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials<{
|
||||
region: string;
|
||||
secretAccessKey: string;
|
||||
accessKeyId: string;
|
||||
sessionToken: string;
|
||||
}>('aws');
|
||||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
temperature: number;
|
||||
maxTokensToSample: number;
|
||||
};
|
||||
|
||||
// We set-up client manually to pass httpAgent and httpsAgent
|
||||
const proxyAgent = getNodeProxyAgent();
|
||||
const clientConfig: BedrockRuntimeClientConfig = {
|
||||
region: credentials.region,
|
||||
credentials: {
|
||||
secretAccessKey: credentials.secretAccessKey,
|
||||
accessKeyId: credentials.accessKeyId,
|
||||
...(credentials.sessionToken && { sessionToken: credentials.sessionToken }),
|
||||
},
|
||||
};
|
||||
|
||||
if (proxyAgent) {
|
||||
clientConfig.requestHandler = new NodeHttpHandler({
|
||||
httpAgent: proxyAgent,
|
||||
httpsAgent: proxyAgent,
|
||||
});
|
||||
}
|
||||
|
||||
// Pass the pre-configured client to avoid credential resolution proxy issues
|
||||
const client = new BedrockRuntimeClient(clientConfig);
|
||||
|
||||
const model = new ChatBedrockConverse({
|
||||
client,
|
||||
model: modelName,
|
||||
region: credentials.region,
|
||||
temperature: options.temperature,
|
||||
maxTokens: options.maxTokensToSample,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><defs><linearGradient id="a" x1="0%" x2="100%" y1="100%" y2="0%"><stop offset="0%" stop-color="#055F4E"/><stop offset="100%" stop-color="#56C0A7"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><path fill="url(#a)" d="M0 0h24v24H0z"/><path fill="#FFF" d="m12 18.14-2.426.809-.946-.631 1.03-.344-.316-.948-1.768.589L7 17.233V14.5a.5.5 0 0 0-.276-.447L5 13.19v-2.382l1.5-.75 1.5.75v1.69c0 .19.107.364.276.449l2 1 .448-.895L9 12.19v-1.382l1.724-.861A.5.5 0 0 0 11 9.5V8h-1v1.19l-1.5.75L7 9.19V6.769L8 6.1V8h1V5.435l.574-.383L12 5.86zM17.5 17a.5.5 0 1 1-.002 1 .5.5 0 0 1 .002-1m-1-11a.5.5 0 1 1-.002 1 .5.5 0 0 1 .002-1m2 6a.5.5 0 1 1-.002 1 .5.5 0 0 1 .002-1m-1.408 1c.207.58.757 1 1.408 1a1.501 1.501 0 0 0 0-3c-.651 0-1.201.42-1.408 1H13v-2h3.5a.5.5 0 0 0 .5-.5V7.908c.581-.207 1-.757 1-1.408 0-.827-.673-1.5-1.5-1.5S15 5.673 15 6.5c0 .65.419 1.2 1 1.408V9h-3V5.5a.5.5 0 0 0-.342-.474l-3-1a.5.5 0 0 0-.435.058l-3 2A.5.5 0 0 0 6 6.5v2.69l-1.724.863A.5.5 0 0 0 4 10.5v3c0 .19.107.363.276.448l1.724.86V17.5a.5.5 0 0 0 .223.416l3 2a.5.5 0 0 0 .435.058l3-1A.5.5 0 0 0 13 18.5V16h2.293l.853.854.013-.013c-.098.2-.159.422-.159.659 0 .827.673 1.5 1.5 1.5s1.5-.673 1.5-1.5-.673-1.5-1.5-1.5c-.238 0-.46.06-.659.16l.013-.013-1-1A.5.5 0 0 0 15.5 15H13v-2z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,154 @@
|
||||
import { AzureChatOpenAI } from '@langchain/openai';
|
||||
import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities';
|
||||
import {
|
||||
NodeOperationError,
|
||||
NodeConnectionTypes,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { setupApiKeyAuthentication } from './credentials/api-key';
|
||||
import { setupOAuth2Authentication } from './credentials/oauth2';
|
||||
import { properties } from './properties';
|
||||
import { AuthenticationType } from './types';
|
||||
import type {
|
||||
AzureOpenAIApiKeyModelConfig,
|
||||
AzureOpenAIOAuth2ModelConfig,
|
||||
AzureOpenAIOptions,
|
||||
} from './types';
|
||||
|
||||
export class LmChatAzureOpenAi implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Azure OpenAI Chat Model',
|
||||
|
||||
name: 'lmChatAzureOpenAi',
|
||||
icon: 'file:azure.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'For advanced usage with an AI chain',
|
||||
defaults: {
|
||||
name: 'Azure OpenAI 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.lmchatazureopenai/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'azureOpenAiApi',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: [AuthenticationType.ApiKey],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'azureEntraCognitiveServicesOAuth2Api',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: [AuthenticationType.EntraOAuth2],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
properties,
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
try {
|
||||
const authenticationMethod = this.getNodeParameter(
|
||||
'authentication',
|
||||
itemIndex,
|
||||
) as AuthenticationType;
|
||||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as AzureOpenAIOptions;
|
||||
|
||||
// Set up Authentication based on selection and get configuration
|
||||
let modelConfig: AzureOpenAIApiKeyModelConfig | AzureOpenAIOAuth2ModelConfig;
|
||||
switch (authenticationMethod) {
|
||||
case AuthenticationType.ApiKey:
|
||||
modelConfig = await setupApiKeyAuthentication.call(this, 'azureOpenAiApi');
|
||||
break;
|
||||
case AuthenticationType.EntraOAuth2:
|
||||
modelConfig = await setupOAuth2Authentication.call(
|
||||
this,
|
||||
'azureEntraCognitiveServicesOAuth2Api',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new NodeOperationError(this.getNode(), 'Invalid authentication method');
|
||||
}
|
||||
|
||||
this.logger.info(`Instantiating AzureChatOpenAI model with deployment: ${modelName}`);
|
||||
|
||||
const timeout = options.timeout;
|
||||
const model = new AzureChatOpenAI({
|
||||
// Force completions API — Azure's SDK doesn't rewrite the /responses path,
|
||||
// so the Responses API hits an invalid endpoint and causes a connection error.
|
||||
// See: https://github.com/langchain-ai/langchainjs/issues/9038
|
||||
useResponsesApi: false,
|
||||
// Model name is required so logs are correct
|
||||
// Also ensures internal logic (like mapping "maxTokens" to "maxCompletionTokens") is correct
|
||||
model: modelName,
|
||||
azureOpenAIApiDeploymentName: modelName,
|
||||
...modelConfig,
|
||||
...options,
|
||||
timeout,
|
||||
maxRetries: options.maxRetries ?? 2,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
configuration: {
|
||||
fetchOptions: {
|
||||
dispatcher: getProxyAgent(undefined, {
|
||||
headersTimeout: timeout,
|
||||
bodyTimeout: timeout,
|
||||
}),
|
||||
},
|
||||
},
|
||||
modelKwargs: options.responseFormat
|
||||
? {
|
||||
response_format: { type: options.responseFormat },
|
||||
}
|
||||
: undefined,
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
this.logger.info(`Azure OpenAI client initialized for deployment: ${modelName}`);
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Error in LmChatAzureOpenAi.supplyData: ${error.message}`, error);
|
||||
|
||||
// Re-throw NodeOperationError directly, wrap others
|
||||
if (error instanceof NodeOperationError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Failed to initialize Azure OpenAI client: ${error.message}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { N8nOAuth2TokenCredential } from '../credentials/N8nOAuth2TokenCredential';
|
||||
import type { AzureEntraCognitiveServicesOAuth2ApiCredential } from '../types';
|
||||
|
||||
// Mock ClientOAuth2
|
||||
jest.mock('@n8n/client-oauth2', () => {
|
||||
return {
|
||||
ClientOAuth2: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
credentials: {
|
||||
getToken: jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
access_token: 'fresh-test-token',
|
||||
expires_on: 1234567890,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mockNode: INode = {
|
||||
id: '1',
|
||||
name: 'Mock node',
|
||||
typeVersion: 2,
|
||||
type: 'n8n-nodes-base.mock',
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
describe('N8nOAuth2TokenCredential', () => {
|
||||
let mockCredential: AzureEntraCognitiveServicesOAuth2ApiCredential;
|
||||
let credential: N8nOAuth2TokenCredential;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a mock credential with all required properties
|
||||
mockCredential = {
|
||||
authQueryParameters: '',
|
||||
authentication: 'body',
|
||||
authUrl: '',
|
||||
accessTokenUrl: '',
|
||||
grantType: 'clientCredentials',
|
||||
clientId: '',
|
||||
clientSecret: 'secret',
|
||||
customScopes: false,
|
||||
apiVersion: '2023-05-15',
|
||||
endpoint: 'https://test.openai.azure.com',
|
||||
resourceName: 'test-resource',
|
||||
oauthTokenData: {
|
||||
access_token: 'test-token',
|
||||
expires_on: 1234567890,
|
||||
ext_expires_on: 0,
|
||||
},
|
||||
scope: '',
|
||||
tenantId: '',
|
||||
};
|
||||
|
||||
credential = new N8nOAuth2TokenCredential(mockNode, mockCredential);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getToken', () => {
|
||||
it('should return a token when credentials are valid', async () => {
|
||||
// Act
|
||||
const result = await credential.getToken();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
token: 'fresh-test-token',
|
||||
expiresOnTimestamp: 1234567890,
|
||||
});
|
||||
expect(ClientOAuth2).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clientId: mockCredential.clientId,
|
||||
clientSecret: mockCredential.clientSecret,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when credentials do not contain token', async () => {
|
||||
// Arrange - remove the token
|
||||
mockCredential.oauthTokenData.access_token = '';
|
||||
credential = new N8nOAuth2TokenCredential(mockNode, mockCredential);
|
||||
|
||||
// Act & Assert
|
||||
await expect(credential.getToken()).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when oauthTokenData is missing', async () => {
|
||||
// Arrange - remove oauthTokenData
|
||||
const incompleteCredential = { ...mockCredential };
|
||||
// @ts-expect-error: purposely making it invalid for test
|
||||
delete incompleteCredential.oauthTokenData;
|
||||
|
||||
credential = new N8nOAuth2TokenCredential(
|
||||
mockNode,
|
||||
incompleteCredential as AzureEntraCognitiveServicesOAuth2ApiCredential,
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
await expect(credential.getToken()).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDeploymentDetails', () => {
|
||||
it('should return deployment details from credentials', async () => {
|
||||
// Act
|
||||
const result = await credential.getDeploymentDetails();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
apiVersion: '2023-05-15',
|
||||
endpoint: 'https://test.openai.azure.com',
|
||||
resourceName: 'test-resource',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { setupApiKeyAuthentication } from '../credentials/api-key';
|
||||
|
||||
describe('setupApiKeyAuthentication', () => {
|
||||
let ctx: ISupplyDataFunctions;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockNode: INode = {
|
||||
id: '1',
|
||||
name: 'Mock node',
|
||||
typeVersion: 2,
|
||||
type: 'n8n-nodes-base.mock',
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
|
||||
ctx.logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return valid configuration when API key is provided', async () => {
|
||||
// Arrange
|
||||
const mockCredentials = {
|
||||
apiKey: 'test-api-key',
|
||||
resourceName: 'test-resource',
|
||||
apiVersion: '2023-05-15',
|
||||
endpoint: 'https://test.openai.azure.com',
|
||||
};
|
||||
|
||||
ctx.getCredentials = jest.fn().mockResolvedValue(mockCredentials);
|
||||
// Act
|
||||
const result = await setupApiKeyAuthentication.call(ctx, 'testCredential');
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
azureOpenAIApiKey: 'test-api-key',
|
||||
azureOpenAIApiInstanceName: 'test-resource',
|
||||
azureOpenAIApiVersion: '2023-05-15',
|
||||
azureOpenAIEndpoint: 'https://test.openai.azure.com',
|
||||
});
|
||||
expect(ctx.getCredentials).toHaveBeenCalledWith('testCredential');
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when API key is missing', async () => {
|
||||
// Arrange
|
||||
const mockCredentials = {
|
||||
// No apiKey
|
||||
resourceName: 'test-resource',
|
||||
apiVersion: '2023-05-15',
|
||||
};
|
||||
|
||||
ctx.getCredentials = jest.fn().mockResolvedValue(mockCredentials);
|
||||
|
||||
// Act & Assert
|
||||
await expect(setupApiKeyAuthentication.call(ctx, 'testCredential')).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when credential retrieval fails', async () => {
|
||||
// Arrange
|
||||
const testError = new Error('Credential fetch failed');
|
||||
ctx.getCredentials = jest.fn().mockRejectedValue(testError);
|
||||
|
||||
// Act & Assert
|
||||
await expect(setupApiKeyAuthentication.call(ctx, 'testCredential')).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { setupOAuth2Authentication } from '../credentials/oauth2';
|
||||
import type { AzureEntraCognitiveServicesOAuth2ApiCredential } from '../types';
|
||||
|
||||
// Mock the N8nOAuth2TokenCredential
|
||||
jest.mock('../credentials/N8nOAuth2TokenCredential', () => ({
|
||||
N8nOAuth2TokenCredential: jest.fn().mockImplementation(() => ({
|
||||
getToken: jest.fn().mockResolvedValue({
|
||||
token: 'test-token',
|
||||
expiresOnTimestamp: 1234567890,
|
||||
}),
|
||||
getDeploymentDetails: jest.fn().mockResolvedValue({
|
||||
apiVersion: '2023-05-15',
|
||||
endpoint: 'https://test.openai.azure.com',
|
||||
resourceName: 'test-resource',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockNode: INode = {
|
||||
id: '1',
|
||||
name: 'Mock node',
|
||||
typeVersion: 2,
|
||||
type: 'n8n-nodes-base.mock',
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
describe('setupOAuth2Authentication', () => {
|
||||
let mockCredential: AzureEntraCognitiveServicesOAuth2ApiCredential;
|
||||
let ctx: ISupplyDataFunctions;
|
||||
beforeEach(() => {
|
||||
// Set up a mock credential
|
||||
mockCredential = {
|
||||
authQueryParameters: '',
|
||||
authentication: 'body', // Set valid authentication type
|
||||
authUrl: '',
|
||||
accessTokenUrl: '', // Added missing property
|
||||
grantType: 'clientCredentials', // Corrected grant type value
|
||||
clientId: '',
|
||||
customScopes: false,
|
||||
apiVersion: '2023-05-15',
|
||||
endpoint: 'https://test.openai.azure.com',
|
||||
resourceName: 'test-resource',
|
||||
oauthTokenData: {
|
||||
access_token: 'test-token',
|
||||
expires_on: 1234567890,
|
||||
ext_expires_on: 0,
|
||||
},
|
||||
scope: '',
|
||||
tenantId: '',
|
||||
};
|
||||
ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
|
||||
ctx.getCredentials = jest.fn().mockResolvedValue(mockCredential);
|
||||
ctx.logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return token provider and deployment details when successful', async () => {
|
||||
// Act
|
||||
const result = await setupOAuth2Authentication.call(ctx, 'testCredential');
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('azureADTokenProvider');
|
||||
expect(typeof result.azureADTokenProvider).toBe('function');
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
azureOpenAIApiInstanceName: 'test-resource',
|
||||
azureOpenAIApiVersion: '2023-05-15',
|
||||
azureOpenAIEndpoint: 'https://test.openai.azure.com',
|
||||
}),
|
||||
);
|
||||
expect(ctx.getCredentials).toHaveBeenCalledWith('testCredential');
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when credential retrieval fails', async () => {
|
||||
// Arrange
|
||||
const testError = new Error('Credential fetch failed');
|
||||
ctx.getCredentials = jest.fn().mockRejectedValue(testError);
|
||||
|
||||
// Act & Assert
|
||||
await expect(setupOAuth2Authentication.call(ctx, 'testCredential')).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="242" preserveAspectRatio="xMidYMid"><defs><linearGradient id="a" x1="58.972%" x2="37.191%" y1="7.411%" y2="103.762%"><stop offset="0%" stop-color="#114A8B"/><stop offset="100%" stop-color="#0669BC"/></linearGradient><linearGradient id="b" x1="59.719%" x2="52.691%" y1="52.313%" y2="54.864%"><stop offset="0%" stop-opacity=".3"/><stop offset="7.1%" stop-opacity=".2"/><stop offset="32.1%" stop-opacity=".1"/><stop offset="62.3%" stop-opacity=".05"/><stop offset="100%" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="37.279%" x2="62.473%" y1="4.6%" y2="99.979%"><stop offset="0%" stop-color="#3CCBF4"/><stop offset="100%" stop-color="#2892DF"/></linearGradient></defs><path fill="url(#a)" d="M85.343.003h75.753L82.457 233a12.08 12.08 0 0 1-11.442 8.216H12.06A12.06 12.06 0 0 1 .633 225.303L73.898 8.219A12.08 12.08 0 0 1 85.343 0z"/><path fill="#0078D4" d="M195.423 156.282H75.297a5.56 5.56 0 0 0-3.796 9.627l77.19 72.047a12.14 12.14 0 0 0 8.28 3.26h68.02z"/><path fill="url(#b)" d="M85.343.003a11.98 11.98 0 0 0-11.471 8.376L.723 225.105a12.045 12.045 0 0 0 11.37 16.112h60.475a12.93 12.93 0 0 0 9.921-8.437l14.588-42.991 52.105 48.6a12.33 12.33 0 0 0 7.757 2.828h67.766l-29.721-84.935-86.643.02L161.37.003z"/><path fill="url(#c)" d="M182.098 8.207A12.06 12.06 0 0 0 170.67.003H86.245c5.175 0 9.773 3.301 11.428 8.204L170.94 225.3a12.062 12.062 0 0 1-11.428 15.92h84.429a12.062 12.062 0 0 0 11.425-15.92z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,61 @@
|
||||
import type { TokenCredential, AccessToken } from '@azure/identity';
|
||||
import type { ClientOAuth2TokenData } from '@n8n/client-oauth2';
|
||||
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type { AzureEntraCognitiveServicesOAuth2ApiCredential } from '../types';
|
||||
/**
|
||||
* Adapts n8n's credential retrieval into the TokenCredential interface expected by @azure/identity
|
||||
*/
|
||||
export class N8nOAuth2TokenCredential implements TokenCredential {
|
||||
constructor(
|
||||
private node: INode,
|
||||
private credential: AzureEntraCognitiveServicesOAuth2ApiCredential,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Gets an access token from OAuth credential
|
||||
*/
|
||||
async getToken(): Promise<AccessToken | null> {
|
||||
try {
|
||||
if (!this.credential?.oauthTokenData?.access_token) {
|
||||
throw new NodeOperationError(this.node, 'Failed to retrieve access token');
|
||||
}
|
||||
const oAuthClient = new ClientOAuth2({
|
||||
clientId: this.credential.clientId,
|
||||
clientSecret: this.credential.clientSecret,
|
||||
accessTokenUri: this.credential.accessTokenUrl,
|
||||
scopes: this.credential.scope?.split(' '),
|
||||
authentication: this.credential.authentication,
|
||||
authorizationUri: this.credential.authUrl,
|
||||
additionalBodyProperties: {
|
||||
resource: 'https://cognitiveservices.azure.com/',
|
||||
},
|
||||
});
|
||||
|
||||
const token = await oAuthClient.credentials.getToken();
|
||||
const data = token.data as ClientOAuth2TokenData & {
|
||||
expires_on: number;
|
||||
};
|
||||
return {
|
||||
token: data.access_token,
|
||||
expiresOnTimestamp: data.expires_on,
|
||||
};
|
||||
} catch (error) {
|
||||
// Re-throw with better error message
|
||||
throw new NodeOperationError(this.node, 'Failed to retrieve OAuth2 access token', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the deployment details from the credential
|
||||
*/
|
||||
async getDeploymentDetails() {
|
||||
return {
|
||||
apiVersion: this.credential.apiVersion,
|
||||
endpoint: this.credential.endpoint,
|
||||
resourceName: this.credential.resourceName,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NodeOperationError, OperationalError, type ISupplyDataFunctions } from 'n8n-workflow';
|
||||
|
||||
import type { AzureOpenAIApiKeyModelConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Handles API Key authentication setup for Azure OpenAI
|
||||
*/
|
||||
export async function setupApiKeyAuthentication(
|
||||
this: ISupplyDataFunctions,
|
||||
credentialName: string,
|
||||
): Promise<AzureOpenAIApiKeyModelConfig> {
|
||||
try {
|
||||
// Get Azure OpenAI Config (Endpoint, Version, etc.)
|
||||
const configCredentials = await this.getCredentials<{
|
||||
apiKey?: string;
|
||||
resourceName: string;
|
||||
apiVersion: string;
|
||||
endpoint?: string;
|
||||
}>(credentialName);
|
||||
|
||||
if (!configCredentials.apiKey) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'API Key is missing in the selected Azure OpenAI API credential. Please configure the API Key or choose Entra ID authentication.',
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.info('Using API Key authentication for Azure OpenAI.');
|
||||
|
||||
return {
|
||||
azureOpenAIApiKey: configCredentials.apiKey,
|
||||
azureOpenAIApiInstanceName: configCredentials.resourceName,
|
||||
azureOpenAIApiVersion: configCredentials.apiVersion,
|
||||
azureOpenAIEndpoint: configCredentials.endpoint,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof OperationalError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error(`Error setting up API Key authentication: ${error.message}`, error);
|
||||
|
||||
throw new NodeOperationError(this.getNode(), 'Failed to retrieve API Key', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getBearerTokenProvider } from '@azure/identity';
|
||||
import { NodeOperationError, type ISupplyDataFunctions } from 'n8n-workflow';
|
||||
|
||||
import { N8nOAuth2TokenCredential } from './N8nOAuth2TokenCredential';
|
||||
import type {
|
||||
AzureEntraCognitiveServicesOAuth2ApiCredential,
|
||||
AzureOpenAIOAuth2ModelConfig,
|
||||
} from '../types';
|
||||
|
||||
const AZURE_OPENAI_SCOPE = 'https://cognitiveservices.azure.com/.default';
|
||||
/**
|
||||
* Creates Entra ID (OAuth2) authentication for Azure OpenAI
|
||||
*/
|
||||
export async function setupOAuth2Authentication(
|
||||
this: ISupplyDataFunctions,
|
||||
credentialName: string,
|
||||
): Promise<AzureOpenAIOAuth2ModelConfig> {
|
||||
try {
|
||||
const credential =
|
||||
await this.getCredentials<AzureEntraCognitiveServicesOAuth2ApiCredential>(credentialName);
|
||||
// Create a TokenCredential
|
||||
const entraTokenCredential = new N8nOAuth2TokenCredential(this.getNode(), credential);
|
||||
const deploymentDetails = await entraTokenCredential.getDeploymentDetails();
|
||||
|
||||
// Use getBearerTokenProvider to create the function LangChain expects
|
||||
// Pass the required scope for Azure Cognitive Services
|
||||
const azureADTokenProvider = getBearerTokenProvider(entraTokenCredential, AZURE_OPENAI_SCOPE);
|
||||
|
||||
this.logger.debug('Successfully created Azure AD Token Provider.');
|
||||
|
||||
return {
|
||||
azureADTokenProvider,
|
||||
azureOpenAIApiInstanceName: deploymentDetails.resourceName,
|
||||
azureOpenAIApiVersion: deploymentDetails.apiVersion,
|
||||
azureOpenAIEndpoint: deploymentDetails.endpoint,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Error setting up Entra ID authentication: ${error.message}`, error);
|
||||
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Error setting up Entra ID authentication: ${error.message}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { getConnectionHintNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
import { AuthenticationType } from './types';
|
||||
|
||||
export const properties: INodeProperties[] = [
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-default-missing
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'options',
|
||||
default: AuthenticationType.ApiKey,
|
||||
options: [
|
||||
{
|
||||
name: 'API Key',
|
||||
value: AuthenticationType.ApiKey,
|
||||
},
|
||||
{
|
||||
name: 'Azure Entra ID (OAuth2)',
|
||||
value: AuthenticationType.EntraOAuth2,
|
||||
},
|
||||
],
|
||||
},
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]),
|
||||
{
|
||||
displayName:
|
||||
'If using JSON response format, you must include word "json" in the prompt in your chain or agent. Also, make sure to select latest models released post November 2023.',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/options.responseFormat': ['json_object'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Model (Deployment) Name',
|
||||
name: 'model',
|
||||
type: 'string',
|
||||
description: 'The name of the model(deployment) to use (e.g., gpt-4, gpt-35-turbo)',
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
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. Most models have a context length of 2048 tokens (except for the newest models, which support 32,768). Use -1 for default.',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
maxValue: 128000,
|
||||
},
|
||||
},
|
||||
{
|
||||
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 }, // Max temp can be 2
|
||||
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 (Ms)',
|
||||
name: 'timeout',
|
||||
default: 60000,
|
||||
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 on failure',
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { OAuth2CredentialData } from '@n8n/client-oauth2';
|
||||
/**
|
||||
* Common interfaces for Azure OpenAI configuration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Basic Azure OpenAI API configuration options
|
||||
*/
|
||||
export interface AzureOpenAIConfig {
|
||||
apiVersion: string;
|
||||
resourceName: string;
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for API Key authentication
|
||||
*/
|
||||
export interface AzureOpenAIApiKeyConfig extends AzureOpenAIConfig {
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Azure OpenAI node options
|
||||
*/
|
||||
export interface AzureOpenAIOptions {
|
||||
frequencyPenalty?: number;
|
||||
maxTokens?: number;
|
||||
maxRetries?: number;
|
||||
timeout?: number;
|
||||
presencePenalty?: number;
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
responseFormat?: 'text' | 'json_object';
|
||||
}
|
||||
|
||||
/**
|
||||
* Base model configuration that can be passed to AzureChatOpenAI constructor
|
||||
*/
|
||||
export interface AzureOpenAIBaseModelConfig {
|
||||
azureOpenAIApiInstanceName: string;
|
||||
azureOpenAIApiVersion: string;
|
||||
azureOpenAIEndpoint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key model configuration that can be passed to AzureChatOpenAI constructor
|
||||
*/
|
||||
export interface AzureOpenAIApiKeyModelConfig extends AzureOpenAIBaseModelConfig {
|
||||
azureOpenAIApiKey: string;
|
||||
azureADTokenProvider?: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth2 model configuration that can be passed to AzureChatOpenAI constructor
|
||||
*/
|
||||
export interface AzureOpenAIOAuth2ModelConfig extends AzureOpenAIBaseModelConfig {
|
||||
azureOpenAIApiKey?: undefined;
|
||||
azureADTokenProvider: () => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication types supported by Azure OpenAI node
|
||||
*/
|
||||
export const enum AuthenticationType {
|
||||
ApiKey = 'azureOpenAiApi',
|
||||
EntraOAuth2 = 'azureEntraCognitiveServicesOAuth2Api',
|
||||
}
|
||||
|
||||
/**
|
||||
* Error types for Azure OpenAI node
|
||||
*/
|
||||
export const enum AzureOpenAIErrorType {
|
||||
AuthenticationError = 'AuthenticationError',
|
||||
ConfigurationError = 'ConfigurationError',
|
||||
APIError = 'APIError',
|
||||
UnknownError = 'UnknownError',
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth2 credential type used by Azure OpenAI node
|
||||
*/
|
||||
type TokenData = OAuth2CredentialData['oauthTokenData'] & {
|
||||
expires_on: number;
|
||||
ext_expires_on: number;
|
||||
};
|
||||
export type AzureEntraCognitiveServicesOAuth2ApiCredential = OAuth2CredentialData & {
|
||||
customScopes: boolean;
|
||||
authentication: string;
|
||||
apiVersion: string;
|
||||
endpoint: string;
|
||||
resourceName: string;
|
||||
tenantId: string;
|
||||
oauthTokenData: TokenData;
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
import { ChatCohere } from '@langchain/cohere';
|
||||
import type { LLMResult } from '@langchain/core/outputs';
|
||||
import type {
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
ISupplyDataFunctions,
|
||||
SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
makeN8nLlmFailedAttemptHandler,
|
||||
N8nLlmTracing,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
|
||||
export function tokensUsageParser(result: LLMResult): {
|
||||
completionTokens: number;
|
||||
promptTokens: number;
|
||||
totalTokens: number;
|
||||
} {
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
|
||||
result.generations?.forEach((generationArray) => {
|
||||
generationArray.forEach((gen) => {
|
||||
const inputTokens = gen.generationInfo?.meta?.tokens?.inputTokens ?? 0;
|
||||
const outputTokens = gen.generationInfo?.meta?.tokens?.outputTokens ?? 0;
|
||||
|
||||
totalInputTokens += inputTokens;
|
||||
totalOutputTokens += outputTokens;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
completionTokens: totalOutputTokens,
|
||||
promptTokens: totalInputTokens,
|
||||
totalTokens: totalInputTokens + totalOutputTokens,
|
||||
};
|
||||
}
|
||||
|
||||
export class LmChatCohere implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Cohere Chat Model',
|
||||
name: 'lmChatCohere',
|
||||
icon: { light: 'file:cohere.svg', dark: 'file:cohere.dark.svg' },
|
||||
group: ['transform'],
|
||||
version: [1],
|
||||
description: 'For advanced usage with an AI chain',
|
||||
defaults: {
|
||||
name: 'Cohere 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.lmchatcohere/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
inputs: [],
|
||||
outputs: ['ai_languageModel'],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'cohereApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
requestDefaults: {
|
||||
baseURL: '={{$credentials?.url}}',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
authorization: '=Bearer {{$credentials?.apiKey}}',
|
||||
},
|
||||
},
|
||||
properties: [
|
||||
getConnectionHintNoticeField(['ai_chain', 'ai_agent']),
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
description:
|
||||
'The model which will generate the completion. <a href="https://docs.cohere.com/docs/models">Learn more</a>.',
|
||||
typeOptions: {
|
||||
loadOptions: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/v1/models?page_size=100&endpoint=chat',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'models',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{$responseItem.name}}',
|
||||
value: '={{$responseItem.name}}',
|
||||
description: '={{$responseItem.description}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
default: 'command-a-03-2025',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
description: 'Additional options to add',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
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: 'Max Retries',
|
||||
name: 'maxRetries',
|
||||
default: 2,
|
||||
description: 'Maximum number of retries to attempt',
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials<{ url?: string; apiKey?: string }>('cohereApi');
|
||||
|
||||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
maxRetries: number;
|
||||
temperature?: number;
|
||||
};
|
||||
|
||||
const model = new ChatCohere({
|
||||
apiKey: credentials.apiKey,
|
||||
model: modelName,
|
||||
temperature: options.temperature,
|
||||
maxRetries: options.maxRetries ?? 2,
|
||||
callbacks: [new N8nLlmTracing(this, { tokensUsageParser })],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.96 23.84C14.0267 23.84 16.16 23.7867 19.1467 22.56C22.6133 21.12 29.44 18.56 34.4 15.8933C37.8667 14.0267 39.36 11.5733 39.36 8.26667C39.36 3.73333 35.68 0 31.0933 0H11.8933C5.33333 0 0 5.33333 0 11.8933C0 18.4533 5.01333 23.84 12.96 23.84Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.2134 31.9999C16.2134 28.7999 18.1334 25.8666 21.12 24.6399L27.1467 22.1333C33.28 19.6266 40 24.1066 40 30.7199C40 35.8399 35.84 39.9999 30.72 39.9999H24.16C19.7867 39.9999 16.2134 36.4266 16.2134 31.9999Z" fill="white"/>
|
||||
<path d="M6.88 25.3867C3.09333 25.3867 0 28.4801 0 32.2667V33.1734C0 36.9067 3.09333 40.0001 6.88 40.0001C10.6667 40.0001 13.76 36.9067 13.76 33.1201V32.2134C13.7067 28.4801 10.6667 25.3867 6.88 25.3867Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 907 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.96 23.84C14.0267 23.84 16.16 23.7867 19.1467 22.56C22.6133 21.12 29.44 18.56 34.4 15.8933C37.8667 14.0267 39.36 11.5733 39.36 8.26667C39.36 3.73333 35.68 0 31.0933 0H11.8933C5.33333 0 0 5.33333 0 11.8933C0 18.4533 5.01333 23.84 12.96 23.84Z" fill="#39594D"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.2134 31.9999C16.2134 28.7999 18.1334 25.8666 21.12 24.6399L27.1467 22.1333C33.28 19.6266 40 24.1066 40 30.7199C40 35.8399 35.84 39.9999 30.72 39.9999H24.16C19.7867 39.9999 16.2134 36.4266 16.2134 31.9999Z" fill="#D18EE2"/>
|
||||
<path d="M6.88 25.3867C3.09333 25.3867 0 28.4801 0 32.2667V33.1734C0 36.9067 3.09333 40.0001 6.88 40.0001C10.6667 40.0001 13.76 36.9067 13.76 33.1201V32.2134C13.7067 28.4801 10.6667 25.3867 6.88 25.3867Z" fill="#FF7759"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 913 B |
@@ -0,0 +1,261 @@
|
||||
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 LmChatDeepSeek implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'DeepSeek Chat Model',
|
||||
|
||||
name: 'lmChatDeepSeek',
|
||||
icon: 'file:deepseek.svg',
|
||||
group: ['transform'],
|
||||
version: [1],
|
||||
description: 'For advanced usage with an AI chain',
|
||||
defaults: {
|
||||
name: 'DeepSeek 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.lmchatdeepseek/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'deepSeekApi',
|
||||
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. Also, make sure to select latest models released post November 2023.',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/options.responseFormat': ['json_object'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
description:
|
||||
'The model which will generate the completion. <a href="https://api-docs.deepseek.com/quick_start/pricing">Learn more</a>.',
|
||||
typeOptions: {
|
||||
loadOptions: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/models',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'data',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{$responseItem.id}}',
|
||||
value: '={{$responseItem.id}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
send: {
|
||||
type: 'body',
|
||||
property: 'model',
|
||||
},
|
||||
},
|
||||
default: 'deepseek-chat',
|
||||
},
|
||||
{
|
||||
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. Most models have a context length of 2048 tokens (except for the newest models, which support 32,768).',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
maxValue: 32768,
|
||||
},
|
||||
},
|
||||
{
|
||||
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>('deepSeekApi');
|
||||
|
||||
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,
|
||||
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 width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,168 @@
|
||||
import type { SafetySetting } from '@google/generative-ai';
|
||||
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import type {
|
||||
NodeError,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
ISupplyDataFunctions,
|
||||
SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { getAdditionalOptions } from '../gemini-common/additional-options';
|
||||
import {
|
||||
makeN8nLlmFailedAttemptHandler,
|
||||
N8nLlmTracing,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
|
||||
function errorDescriptionMapper(error: NodeError) {
|
||||
if (error.description?.includes('properties: should be non-empty for OBJECT type')) {
|
||||
return 'Google Gemini requires at least one <a href="https://docs.n8n.io/advanced-ai/examples/using-the-fromai-function/" target="_blank">dynamic parameter</a> when using tools';
|
||||
}
|
||||
|
||||
return error.description ?? 'Unknown error';
|
||||
}
|
||||
export class LmChatGoogleGemini implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Google Gemini Chat Model',
|
||||
|
||||
name: 'lmChatGoogleGemini',
|
||||
icon: 'file:google.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Chat Model Google Gemini',
|
||||
defaults: {
|
||||
name: 'Google Gemini 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.lmchatgooglegemini/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'googlePalmApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
requestDefaults: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
baseURL: '={{ $credentials.host }}',
|
||||
},
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]),
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'modelName',
|
||||
type: 'options',
|
||||
description:
|
||||
'The model which will generate the completion. <a href="https://developers.generativeai.google/api/rest/generativelanguage/models/list">Learn more</a>.',
|
||||
typeOptions: {
|
||||
loadOptions: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/v1beta/models',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'models',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'filter',
|
||||
properties: {
|
||||
pass: "={{ !$responseItem.name.includes('embedding') }}",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{$responseItem.name}}',
|
||||
value: '={{$responseItem.name}}',
|
||||
description: '={{$responseItem.description}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
send: {
|
||||
type: 'body',
|
||||
property: 'model',
|
||||
},
|
||||
},
|
||||
default: 'models/gemini-2.5-flash',
|
||||
},
|
||||
// thinking budget not supported in @langchain/google-genai
|
||||
// as it utilises the old google generative ai SDK
|
||||
getAdditionalOptions({ supportsThinkingBudget: false }),
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials('googlePalmApi');
|
||||
|
||||
const modelName = this.getNodeParameter('modelName', itemIndex) as string;
|
||||
const options = this.getNodeParameter('options', itemIndex, {
|
||||
maxOutputTokens: 1024,
|
||||
temperature: 0.7,
|
||||
topK: 40,
|
||||
topP: 0.9,
|
||||
}) as {
|
||||
maxOutputTokens: number;
|
||||
temperature: number;
|
||||
topK: number;
|
||||
topP: number;
|
||||
};
|
||||
|
||||
const safetySettings = this.getNodeParameter(
|
||||
'options.safetySettings.values',
|
||||
itemIndex,
|
||||
null,
|
||||
) as SafetySetting[];
|
||||
|
||||
const model = new ChatGoogleGenerativeAI({
|
||||
apiKey: credentials.apiKey as string,
|
||||
baseUrl: credentials.host as string,
|
||||
model: modelName,
|
||||
topK: options.topK,
|
||||
topP: options.topP,
|
||||
temperature: options.temperature,
|
||||
maxOutputTokens: options.maxOutputTokens,
|
||||
safetySettings,
|
||||
callbacks: [new N8nLlmTracing(this, { errorDescriptionMapper })],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48"><defs><path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4"/></defs><clipPath id="b"><use xlink:href="#a" overflow="visible"/></clipPath><path fill="#FBBC05" d="M0 37V11l17 13z" clip-path="url(#b)"/><path fill="#EA4335" d="m0 11 17 13 7-6.1L48 14V0H0z" clip-path="url(#b)"/><path fill="#34A853" d="m0 37 30-23 7.9 1L48 0v48H0z" clip-path="url(#b)"/><path fill="#4285F4" d="M48 48 17 24l-4-3 35-10z" clip-path="url(#b)"/></svg>
|
||||
|
After Width: | Height: | Size: 687 B |
@@ -0,0 +1,228 @@
|
||||
import { ProjectsClient } from '@google-cloud/resource-manager';
|
||||
import type { GoogleAISafetySetting } from '@langchain/google-common';
|
||||
import { ChatVertexAI, type ChatVertexAIInput } from '@langchain/google-vertexai';
|
||||
import { formatPrivateKey } from 'n8n-nodes-base/dist/utils/utilities';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
type ILoadOptionsFunctions,
|
||||
type JsonObject,
|
||||
NodeOperationError,
|
||||
validateNodeParameters,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { makeErrorFromStatus } from './error-handling';
|
||||
import { getAdditionalOptions } from '../gemini-common/additional-options';
|
||||
import {
|
||||
makeN8nLlmFailedAttemptHandler,
|
||||
N8nLlmTracing,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
|
||||
export class LmChatGoogleVertex implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Google Vertex Chat Model',
|
||||
|
||||
name: 'lmChatGoogleVertex',
|
||||
icon: 'file:google.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Chat Model Google Vertex',
|
||||
defaults: {
|
||||
name: 'Google Vertex 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.lmchatgooglevertex/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'googleApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]),
|
||||
{
|
||||
displayName: 'Project ID',
|
||||
name: 'projectId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
description: 'Select or enter your Google Cloud project ID',
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'gcpProjectsList',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Model Name',
|
||||
name: 'modelName',
|
||||
type: 'string',
|
||||
description:
|
||||
'The model which will generate the completion. <a href="https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models">Learn more</a>.',
|
||||
default: 'gemini-2.5-flash',
|
||||
},
|
||||
getAdditionalOptions({ supportsThinkingBudget: true }),
|
||||
],
|
||||
};
|
||||
|
||||
methods = {
|
||||
listSearch: {
|
||||
async gcpProjectsList(this: ILoadOptionsFunctions) {
|
||||
const results: Array<{ name: string; value: string }> = [];
|
||||
|
||||
const credentials = await this.getCredentials('googleApi');
|
||||
const privateKey = formatPrivateKey(credentials.privateKey as string);
|
||||
const email = (credentials.email as string).trim();
|
||||
|
||||
const client = new ProjectsClient({
|
||||
credentials: {
|
||||
client_email: email,
|
||||
private_key: privateKey,
|
||||
},
|
||||
});
|
||||
|
||||
const [projects] = await client.searchProjects();
|
||||
|
||||
for (const project of projects) {
|
||||
if (project.projectId) {
|
||||
results.push({
|
||||
name: project.displayName ?? project.projectId,
|
||||
value: project.projectId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { results };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials('googleApi');
|
||||
const privateKey = formatPrivateKey(credentials.privateKey as string);
|
||||
const email = (credentials.email as string).trim();
|
||||
const region = credentials.region as string;
|
||||
|
||||
const modelName = this.getNodeParameter('modelName', itemIndex) as string;
|
||||
|
||||
const projectId = this.getNodeParameter('projectId', itemIndex, '', {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const options = this.getNodeParameter('options', itemIndex, {
|
||||
maxOutputTokens: 2048,
|
||||
temperature: 0.4,
|
||||
topK: 40,
|
||||
topP: 0.9,
|
||||
});
|
||||
|
||||
// Validate options parameter
|
||||
validateNodeParameters(
|
||||
options,
|
||||
{
|
||||
maxOutputTokens: { type: 'number', required: false },
|
||||
temperature: { type: 'number', required: false },
|
||||
topK: { type: 'number', required: false },
|
||||
topP: { type: 'number', required: false },
|
||||
thinkingBudget: { type: 'number', required: false },
|
||||
},
|
||||
this.getNode(),
|
||||
);
|
||||
|
||||
const safetySettings = this.getNodeParameter(
|
||||
'options.safetySettings.values',
|
||||
itemIndex,
|
||||
null,
|
||||
) as GoogleAISafetySetting[];
|
||||
|
||||
try {
|
||||
const modelConfig: ChatVertexAIInput = {
|
||||
authOptions: {
|
||||
projectId,
|
||||
credentials: {
|
||||
client_email: email,
|
||||
private_key: privateKey,
|
||||
},
|
||||
},
|
||||
location: region,
|
||||
model: modelName,
|
||||
topK: options.topK,
|
||||
topP: options.topP,
|
||||
temperature: options.temperature,
|
||||
maxOutputTokens: options.maxOutputTokens,
|
||||
safetySettings,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
// Handle ChatVertexAI invocation errors to provide better error messages
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, (error: any) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const customError = makeErrorFromStatus(Number(error?.response?.status), {
|
||||
modelName,
|
||||
});
|
||||
|
||||
if (customError) {
|
||||
throw new NodeOperationError(this.getNode(), error as JsonObject, customError);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}),
|
||||
};
|
||||
|
||||
// Add thinkingBudget if specified
|
||||
if (options.thinkingBudget !== undefined) {
|
||||
modelConfig.thinkingBudget = options.thinkingBudget;
|
||||
}
|
||||
|
||||
const model = new ChatVertexAI(modelConfig);
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
} catch (e) {
|
||||
// Catch model name validation error from LangChain (https://github.com/langchain-ai/langchainjs/blob/ef201d0ee85ee4049078270a0cfd7a1767e624f8/libs/langchain-google-common/src/utils/common.ts#L124)
|
||||
// to show more helpful error message
|
||||
if (e?.message?.startsWith('Unable to verify model params')) {
|
||||
throw new NodeOperationError(this.getNode(), e as JsonObject, {
|
||||
message: 'Unsupported model',
|
||||
description: "Only models starting with 'gemini' are supported.",
|
||||
});
|
||||
}
|
||||
|
||||
// Assume all other exceptions while creating a new ChatVertexAI instance are parameter validation errors
|
||||
throw new NodeOperationError(this.getNode(), e as JsonObject, {
|
||||
message: 'Invalid options',
|
||||
description: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface ErrorLike {
|
||||
message?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ErrorContext {
|
||||
modelName?: string;
|
||||
}
|
||||
|
||||
export function makeErrorFromStatus(statusCode: number, context?: ErrorContext): ErrorLike {
|
||||
const errorMessages: Record<number, ErrorLike> = {
|
||||
403: {
|
||||
message: 'Unauthorized for this project',
|
||||
description:
|
||||
'Check your Google Cloud project ID, that your credential has access to that project and that billing is enabled',
|
||||
},
|
||||
404: {
|
||||
message: context?.modelName
|
||||
? `No model found called '${context.modelName}'`
|
||||
: 'No model found',
|
||||
},
|
||||
};
|
||||
|
||||
return errorMessages[statusCode];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48"><defs><path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4"/></defs><clipPath id="b"><use xlink:href="#a" overflow="visible"/></clipPath><path fill="#FBBC05" d="M0 37V11l17 13z" clip-path="url(#b)"/><path fill="#EA4335" d="m0 11 17 13 7-6.1L48 14V0H0z" clip-path="url(#b)"/><path fill="#34A853" d="m0 37 30-23 7.9 1L48 0v48H0z" clip-path="url(#b)"/><path fill="#4285F4" d="M48 48 17 24l-4-3 35-10z" clip-path="url(#b)"/></svg>
|
||||
|
After Width: | Height: | Size: 687 B |
@@ -0,0 +1,147 @@
|
||||
import { ChatVertexAI } from '@langchain/google-vertexai';
|
||||
import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities';
|
||||
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
|
||||
import { LmChatGoogleVertex } from '../LmChatGoogleVertex.node';
|
||||
|
||||
jest.mock('@langchain/google-vertexai');
|
||||
jest.mock('@n8n/ai-utilities');
|
||||
jest.mock('n8n-nodes-base/dist/utils/utilities', () => ({
|
||||
formatPrivateKey: jest.fn().mockImplementation((key: string) => key),
|
||||
}));
|
||||
|
||||
const MockedChatVertexAI = jest.mocked(ChatVertexAI);
|
||||
const MockedN8nLlmTracing = jest.mocked(N8nLlmTracing);
|
||||
const mockedMakeN8nLlmFailedAttemptHandler = jest.mocked(makeN8nLlmFailedAttemptHandler);
|
||||
|
||||
describe('LmChatGoogleVertex - Thinking Budget', () => {
|
||||
let lmChatGoogleVertex: LmChatGoogleVertex;
|
||||
let mockContext: jest.Mocked<ISupplyDataFunctions>;
|
||||
|
||||
const mockNode: INode = {
|
||||
id: '1',
|
||||
name: 'Google Vertex Chat Model',
|
||||
typeVersion: 1,
|
||||
type: 'n8n-nodes-langchain.lmChatGoogleVertex',
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
const setupMockContext = () => {
|
||||
mockContext = createMockExecuteFunction<ISupplyDataFunctions>(
|
||||
{},
|
||||
mockNode,
|
||||
) as jest.Mocked<ISupplyDataFunctions>;
|
||||
|
||||
mockContext.getCredentials = jest.fn().mockResolvedValue({
|
||||
privateKey: 'test-private-key',
|
||||
email: 'test@n8n.io',
|
||||
region: 'us-central1',
|
||||
});
|
||||
mockContext.getNode = jest.fn().mockReturnValue(mockNode);
|
||||
mockContext.getNodeParameter = jest.fn();
|
||||
|
||||
MockedN8nLlmTracing.mockImplementation(() => ({}) as unknown as N8nLlmTracing);
|
||||
mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(jest.fn());
|
||||
|
||||
return mockContext;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
lmChatGoogleVertex = new LmChatGoogleVertex();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('supplyData - thinking budget parameter passing', () => {
|
||||
it('should not include thinkingBudget in model config when not specified', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'modelName') return 'gemini-2.5-flash';
|
||||
if (paramName === 'projectId') return 'test-project';
|
||||
if (paramName === 'options') {
|
||||
// Return options without thinkingBudget
|
||||
return {
|
||||
maxOutputTokens: 2048,
|
||||
temperature: 0.4,
|
||||
topK: 40,
|
||||
topP: 0.9,
|
||||
};
|
||||
}
|
||||
if (paramName === 'options.safetySettings.values') return null;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatGoogleVertex.supplyData.call(mockContext, 0);
|
||||
expect(MockedChatVertexAI).toHaveBeenCalledTimes(1);
|
||||
const callArgs = MockedChatVertexAI.mock.calls[0][0];
|
||||
expect(callArgs).not.toHaveProperty('thinkingBudget');
|
||||
expect(callArgs).toMatchObject({
|
||||
authOptions: {
|
||||
projectId: 'test-project',
|
||||
credentials: {
|
||||
client_email: 'test@n8n.io',
|
||||
private_key: 'test-private-key',
|
||||
},
|
||||
},
|
||||
location: 'us-central1',
|
||||
model: 'gemini-2.5-flash',
|
||||
topK: 40,
|
||||
topP: 0.9,
|
||||
temperature: 0.4,
|
||||
maxOutputTokens: 2048,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include thinkingBudget in model config when specified', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
const expectedThinkingBudget = 1024;
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'modelName') return 'gemini-2.5-flash';
|
||||
if (paramName === 'projectId') return 'test-project';
|
||||
if (paramName === 'options') {
|
||||
// Return options with thinkingBudget
|
||||
return {
|
||||
maxOutputTokens: 2048,
|
||||
temperature: 0.4,
|
||||
topK: 40,
|
||||
topP: 0.9,
|
||||
thinkingBudget: expectedThinkingBudget,
|
||||
};
|
||||
}
|
||||
if (paramName === 'options.safetySettings.values') return null;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatGoogleVertex.supplyData.call(mockContext, 0);
|
||||
expect(MockedChatVertexAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authOptions: {
|
||||
projectId: 'test-project',
|
||||
credentials: {
|
||||
client_email: 'test@n8n.io',
|
||||
private_key: 'test-private-key',
|
||||
},
|
||||
},
|
||||
location: 'us-central1',
|
||||
model: 'gemini-2.5-flash',
|
||||
topK: 40,
|
||||
topP: 0.9,
|
||||
temperature: 0.4,
|
||||
maxOutputTokens: 2048,
|
||||
thinkingBudget: expectedThinkingBudget,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
onFailedAttempt: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { ChatGroq } from '@langchain/groq';
|
||||
import {
|
||||
getProxyAgent,
|
||||
makeN8nLlmFailedAttemptHandler,
|
||||
N8nLlmTracing,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class LmChatGroq implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Groq Chat Model',
|
||||
|
||||
name: 'lmChatGroq',
|
||||
icon: 'file:groq.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Language Model Groq',
|
||||
defaults: {
|
||||
name: 'Groq 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.lmchatgroq/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'groqApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
requestDefaults: {
|
||||
baseURL: 'https://api.groq.com/openai/v1',
|
||||
},
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiChain]),
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
typeOptions: {
|
||||
loadOptions: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/models',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'data',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'filter',
|
||||
properties: {
|
||||
pass: '={{ $responseItem.active === true && $responseItem.object === "model" }}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{$responseItem.id}}',
|
||||
value: '={{$responseItem.id}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
send: {
|
||||
type: 'body',
|
||||
property: 'model',
|
||||
},
|
||||
},
|
||||
description:
|
||||
'The model which will generate the completion. <a href="https://console.groq.com/docs/models">Learn more</a>.',
|
||||
default: 'llama3-8b-8192',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
description: 'Additional options to add',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Maximum Number of Tokens',
|
||||
name: 'maxTokensToSample',
|
||||
default: 4096,
|
||||
description: 'The maximum number of tokens to generate in the completion',
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials('groqApi');
|
||||
|
||||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
maxTokensToSample?: number;
|
||||
temperature: number;
|
||||
};
|
||||
|
||||
const model = new ChatGroq({
|
||||
apiKey: credentials.apiKey as string,
|
||||
model: modelName,
|
||||
maxTokens: options.maxTokensToSample,
|
||||
temperature: options.temperature,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
httpAgent: getProxyAgent('https://api.groq.com/openai/v1'),
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
id="Layer_2"
|
||||
viewBox="0 0 499.99999 499.99999"
|
||||
version="1.1"
|
||||
width="500"
|
||||
height="500"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs4" /><g
|
||||
id="PAGES"><circle
|
||||
style="fill:#f54f35;fill-opacity:1;stroke-width:1.13622"
|
||||
id="path4"
|
||||
cx="250"
|
||||
cy="250"
|
||||
r="250" /><path
|
||||
d="M 250.53664,97.122994 C 192.71931,96.588638 145.48222,142.97075 144.94786,200.78808 c -0.53434,57.81733 45.84777,105.05442 103.6651,105.58877 h 36.33621 v -39.22174 h -34.41253 c -36.12248,0.4275 -65.7258,-28.53462 -66.15329,-64.65708 -0.42749,-36.12248 28.53463,-65.72581 64.65708,-66.1533 h 1.49621 c 36.12248,0 65.4052,29.28272 65.51207,65.4052 v 0 96.39783 0 c 0,35.80187 -29.17585,64.97773 -64.87083,65.40521 -17.09941,-0.10688 -33.45071,-7.05351 -45.52717,-19.12995 l -27.7865,27.78651 c 19.23681,19.3437 45.31339,30.35143 72.56556,30.67205 h 1.38933 c 57.06924,-0.85497 102.917,-47.13022 103.2376,-104.19945 V 199.29189 C 353.66739,142.43639 307.28527,97.122994 250.53664,97.122994 Z"
|
||||
style="fill:#ffffff;stroke-width:0px"
|
||||
id="path1-3" /></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,230 @@
|
||||
import type { ChatMistralAIInput } from '@langchain/mistralai';
|
||||
import { ChatMistralAI } from '@langchain/mistralai';
|
||||
import { HTTPClient } from '@mistralai/mistralai/lib/http.js';
|
||||
import {
|
||||
makeN8nLlmFailedAttemptHandler,
|
||||
N8nLlmTracing,
|
||||
proxyFetch,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
const deprecatedMagistralModelsWithTextOutput = ['magistral-small-2506', 'magistral-medium-2506'];
|
||||
|
||||
export class LmChatMistralCloud implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Mistral Cloud Chat Model',
|
||||
|
||||
name: 'lmChatMistralCloud',
|
||||
icon: 'file:mistral.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'For advanced usage with an AI chain',
|
||||
defaults: {
|
||||
name: 'Mistral Cloud 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.lmchatmistralcloud/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'mistralCloudApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
requestDefaults: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
baseURL: 'https://api.mistral.ai/v1',
|
||||
},
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]),
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
description:
|
||||
'The model which will generate the completion. <a href="https://docs.mistral.ai/platform/endpoints/">Learn more</a>.',
|
||||
typeOptions: {
|
||||
loadOptions: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/models',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'data',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'filter',
|
||||
properties: {
|
||||
pass: "={{ !$responseItem.id.includes('embed') }}",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{ $responseItem.id }}',
|
||||
value: '={{ $responseItem.id }}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
send: {
|
||||
type: 'body',
|
||||
property: 'model',
|
||||
},
|
||||
},
|
||||
default: 'mistral-small',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
description: 'Additional options to add',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Maximum Number of Tokens',
|
||||
name: 'maxTokens',
|
||||
default: -1,
|
||||
description:
|
||||
'The maximum number of tokens to generate in the completion. Most models have a context length of 2048 tokens (except for the newest models, which support 32,768).',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
maxValue: 32768,
|
||||
},
|
||||
},
|
||||
{
|
||||
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: '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',
|
||||
},
|
||||
{
|
||||
displayName: 'Enable Safe Mode',
|
||||
name: 'safeMode',
|
||||
default: false,
|
||||
type: 'boolean',
|
||||
description: 'Whether to inject a safety prompt before all conversations',
|
||||
},
|
||||
{
|
||||
displayName: 'Random Seed',
|
||||
name: 'randomSeed',
|
||||
default: undefined,
|
||||
type: 'number',
|
||||
description:
|
||||
'The seed to use for random sampling. If set, different calls will generate deterministic results.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials('mistralCloudApi');
|
||||
|
||||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||
const options = this.getNodeParameter('options', itemIndex, {
|
||||
maxRetries: 2,
|
||||
topP: 1,
|
||||
temperature: 0.7,
|
||||
maxTokens: -1,
|
||||
safeMode: false,
|
||||
randomSeed: undefined,
|
||||
}) as Partial<ChatMistralAIInput>;
|
||||
|
||||
const fetchWithTimeout = async (input: RequestInfo | URL, init?: RequestInit) =>
|
||||
await proxyFetch(input, init, {});
|
||||
const httpClient = new HTTPClient({ fetcher: fetchWithTimeout });
|
||||
|
||||
const model = new ChatMistralAI({
|
||||
apiKey: credentials.apiKey as string,
|
||||
model: modelName,
|
||||
...options,
|
||||
httpClient,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
metadata: {
|
||||
output_format: isModelWithJSONOutput(modelName) ? 'json' : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isModelWithJSONOutput(modelName: string): boolean {
|
||||
if (!modelName.includes('magistral')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (deprecatedMagistralModelsWithTextOutput.includes(modelName)) {
|
||||
// Deprecated Magistral models return text output
|
||||
// Includes <think></think> chunks as part of text content
|
||||
return false;
|
||||
}
|
||||
|
||||
// All future Magistral models will return JSON output
|
||||
// Which include "thinking" json types
|
||||
// https://docs.mistral.ai/capabilities/reasoning/
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="216"
|
||||
height="216"
|
||||
version="1.1"
|
||||
id="svg41"
|
||||
sodipodi:docname="mistral.svg"
|
||||
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview41"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="1.936488"
|
||||
inkscape:cx="197.78072"
|
||||
inkscape:cy="79.00901"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1017"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg41" />
|
||||
<style
|
||||
id="style1"><![CDATA[.I{fill:#ff7000}.J{fill:#ff4900}.K{fill:#ffa300}.L{fill:#1c1c1b icc-color(adobe-rgb-1998, 0.13299561, 0.13299561, 0.1289978)}]]></style>
|
||||
<defs
|
||||
id="defs10">
|
||||
<clipPath
|
||||
id="A">
|
||||
<path
|
||||
d="M 0,184.252 H 481.89 V 0 H 0 Z"
|
||||
transform="translate(-206.251,-140.139)"
|
||||
id="path1" />
|
||||
</clipPath>
|
||||
<clipPath
|
||||
id="B">
|
||||
<path
|
||||
d="M 0,184.252 H 481.89 V 0 H 0 Z"
|
||||
transform="translate(-247.436,-104.865)"
|
||||
id="path2" />
|
||||
</clipPath>
|
||||
<clipPath
|
||||
id="C">
|
||||
<path
|
||||
d="M 0,184.252 H 481.89 V 0 H 0 Z"
|
||||
transform="translate(-285.938,-102.089)"
|
||||
id="path3" />
|
||||
</clipPath>
|
||||
<clipPath
|
||||
id="D">
|
||||
<path
|
||||
d="M 0,184.252 H 481.89 V 0 H 0 Z"
|
||||
transform="translate(-337.769,-131.877)"
|
||||
id="path4" />
|
||||
</clipPath>
|
||||
<clipPath
|
||||
id="E">
|
||||
<path
|
||||
d="M 0,184.252 H 481.89 V 0 H 0 Z"
|
||||
transform="translate(-377.247,-132.319)"
|
||||
id="path5" />
|
||||
</clipPath>
|
||||
<clipPath
|
||||
id="F">
|
||||
<path
|
||||
d="M 0,184.252 H 481.89 V 0 H 0 Z"
|
||||
transform="translate(-418.107,-114.634)"
|
||||
id="path6" />
|
||||
</clipPath>
|
||||
<clipPath
|
||||
id="G">
|
||||
<path
|
||||
d="M 0,184.252 H 481.89 V 0 H 0 Z"
|
||||
transform="translate(-450.023,-140.139)"
|
||||
id="path7" />
|
||||
</clipPath>
|
||||
<clipPath
|
||||
id="H">
|
||||
<path
|
||||
d="M 0,184.252 H 481.89 V 0 H 0 Z"
|
||||
transform="translate(-217.694,-44.794)"
|
||||
id="path8" />
|
||||
</clipPath>
|
||||
<clipPath
|
||||
id="I">
|
||||
<path
|
||||
d="M 0,184.252 H 481.89 V 0 H 0 Z"
|
||||
transform="translate(-247.436,-35.025)"
|
||||
id="path9" />
|
||||
</clipPath>
|
||||
<clipPath
|
||||
id="J">
|
||||
<path
|
||||
d="M 0,184.252 H 481.89 V 0 H 0 Z"
|
||||
id="path10" />
|
||||
</clipPath>
|
||||
<path
|
||||
id="K"
|
||||
d="m 173.987,134.362 h -37.795 l 9.633,-37.776 h 37.796 z" />
|
||||
</defs>
|
||||
<g
|
||||
transform="matrix(1,0,0.254535,1,-51.362792,-7.4725007)"
|
||||
id="g32">
|
||||
<g
|
||||
class="L"
|
||||
id="g22">
|
||||
<path
|
||||
d="M 98.397,134.362 H 60.602 l 9.633,-37.776 h 37.796 z"
|
||||
id="path11" />
|
||||
<path
|
||||
d="M 126.558,172.138 H 88.763 l 9.633,-37.776 h 37.796 z"
|
||||
id="path12" />
|
||||
<path
|
||||
d="M 136.192,134.362 H 98.397 l 9.633,-37.776 h 37.796 z"
|
||||
id="path13" />
|
||||
<use
|
||||
xlink:href="#K"
|
||||
id="use13" />
|
||||
<path
|
||||
d="M 108.031,96.585 H 70.236 l 9.633,-37.776 h 37.796 z"
|
||||
id="path14" />
|
||||
<use
|
||||
xlink:href="#K"
|
||||
x="9.6339998"
|
||||
y="-37.777"
|
||||
id="use14" />
|
||||
<path
|
||||
d="M 60.602,134.362 H 22.807 L 32.44,96.586 h 37.796 z"
|
||||
id="path15" />
|
||||
<path
|
||||
d="M 70.236,96.585 H 32.441 L 42.074,58.809 H 79.87 Z"
|
||||
id="path16" />
|
||||
<path
|
||||
d="M 79.87,58.809 H 42.075 l 9.633,-37.776 h 37.796 z"
|
||||
id="path17" />
|
||||
<use
|
||||
xlink:href="#K"
|
||||
x="57.063"
|
||||
y="-75.553001"
|
||||
id="use17" />
|
||||
<path
|
||||
d="M 50.968,172.138 H 13.173 l 9.633,-37.776 h 37.796 z"
|
||||
id="path18" />
|
||||
<path
|
||||
d="M 41.334,209.915 H 3.539 l 9.633,-37.776 h 37.796 z"
|
||||
id="path19" />
|
||||
<use
|
||||
xlink:href="#K"
|
||||
x="37.794998"
|
||||
id="use19" />
|
||||
<use
|
||||
xlink:href="#K"
|
||||
x="47.429001"
|
||||
y="-37.777"
|
||||
id="use20" />
|
||||
<use
|
||||
xlink:href="#K"
|
||||
x="28.160999"
|
||||
y="37.776001"
|
||||
id="use21" />
|
||||
<use
|
||||
xlink:href="#K"
|
||||
x="18.527"
|
||||
y="75.553001"
|
||||
id="use22" />
|
||||
</g>
|
||||
<path
|
||||
d="M 114.115,134.359 H 76.321 l 9.633,-37.776 h 37.796 z"
|
||||
class="I"
|
||||
id="path22" />
|
||||
<use
|
||||
xlink:href="#K"
|
||||
x="-31.709999"
|
||||
y="37.772999"
|
||||
class="J"
|
||||
id="use23" />
|
||||
<g
|
||||
class="I"
|
||||
id="g25">
|
||||
<use
|
||||
xlink:href="#K"
|
||||
x="-22.076"
|
||||
y="-0.003"
|
||||
id="use24" />
|
||||
<use
|
||||
xlink:href="#K"
|
||||
x="15.719"
|
||||
y="-0.003"
|
||||
id="use25" />
|
||||
</g>
|
||||
<g
|
||||
class="K"
|
||||
id="g26">
|
||||
<path
|
||||
d="M 123.749,96.582 H 85.955 l 9.633,-37.776 h 37.796 z"
|
||||
id="path25" />
|
||||
<use
|
||||
xlink:href="#K"
|
||||
x="25.353001"
|
||||
y="-37.779999"
|
||||
id="use26" />
|
||||
</g>
|
||||
<path
|
||||
d="M 76.32,134.359 H 38.526 l 9.633,-37.776 h 37.796 z"
|
||||
class="I"
|
||||
id="path26" />
|
||||
<path
|
||||
d="M 85.954,96.582 H 48.16 l 9.633,-37.776 h 37.796 z"
|
||||
class="K"
|
||||
id="path27" />
|
||||
<g
|
||||
fill="#ffce00"
|
||||
id="g28">
|
||||
<path
|
||||
d="M 95.588,58.806 H 57.794 L 67.427,21.03 h 37.796 z"
|
||||
id="path28" />
|
||||
<use
|
||||
xlink:href="#K"
|
||||
x="72.781998"
|
||||
y="-75.556"
|
||||
id="use28" />
|
||||
</g>
|
||||
<path
|
||||
d="M 66.686,172.135 H 28.892 l 9.633,-37.776 h 37.796 z"
|
||||
class="J"
|
||||
id="path29" />
|
||||
<path
|
||||
d="M 57.052,209.912 H 19.258 l 9.633,-37.776 h 37.796 z"
|
||||
fill="#ff0107"
|
||||
id="path30" />
|
||||
<use
|
||||
xlink:href="#K"
|
||||
x="53.514"
|
||||
y="-0.003"
|
||||
class="I"
|
||||
id="use30" />
|
||||
<path
|
||||
d="M 237.135,96.582 H 199.34 l 9.633,-37.776 h 37.796 z"
|
||||
class="K"
|
||||
id="path31" />
|
||||
<use
|
||||
xlink:href="#K"
|
||||
x="43.880001"
|
||||
y="37.772999"
|
||||
class="J"
|
||||
id="use31" />
|
||||
<use
|
||||
xlink:href="#K"
|
||||
x="34.245998"
|
||||
y="75.550003"
|
||||
fill="#ff0107"
|
||||
id="use32" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,327 @@
|
||||
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';
|
||||
|
||||
interface OpenAIToolCall {
|
||||
function?: { arguments?: unknown };
|
||||
}
|
||||
|
||||
interface OpenAIChoice {
|
||||
message?: { tool_calls?: OpenAIToolCall[] };
|
||||
}
|
||||
|
||||
function isOpenAIResponseWithChoices(json: unknown): json is { choices: OpenAIChoice[] } {
|
||||
return (
|
||||
typeof json === 'object' &&
|
||||
json !== null &&
|
||||
'choices' in json &&
|
||||
Array.isArray((json as { choices: unknown }).choices)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps fetch to fix empty tool call arguments in API responses.
|
||||
*
|
||||
* When Anthropic models are accessed through OpenRouter, tool calls for tools
|
||||
* with no parameters return empty string arguments ("") instead of "{}".
|
||||
* LangChain's parseToolCall does JSON.parse("") which throws, breaking the agent.
|
||||
* This wrapper normalizes empty arguments to "{}" before LangChain sees them.
|
||||
*/
|
||||
function createOpenRouterFetch(baseFetch: typeof globalThis.fetch): typeof globalThis.fetch {
|
||||
return async (input, init) => {
|
||||
const response = await baseFetch(input, init);
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (!contentType.includes('json')) return response;
|
||||
|
||||
// Clone before reading, since .json() consumes the body. If no
|
||||
// modification is needed we return the clone with the original body intact.
|
||||
const clone = response.clone();
|
||||
const json: unknown = await response.json();
|
||||
|
||||
if (!isOpenAIResponseWithChoices(json)) return clone;
|
||||
|
||||
const isInvalidArgs = (args: unknown): boolean => typeof args !== 'string' || !args.trim();
|
||||
|
||||
const toolCallsToFix = json.choices
|
||||
.flatMap((choice) => choice.message?.tool_calls ?? [])
|
||||
.filter((tc) => tc.function && isInvalidArgs(tc.function.arguments));
|
||||
|
||||
if (toolCallsToFix.length === 0) return clone;
|
||||
|
||||
for (const tc of toolCallsToFix) {
|
||||
if (!tc.function) continue;
|
||||
const { arguments: args } = tc.function;
|
||||
// Preserve already-parsed plain objects by stringifying them.
|
||||
// Arrays and other non-object types are not valid tool args, so default to '{}'.
|
||||
const isPlainObject = typeof args === 'object' && args !== null && !Array.isArray(args);
|
||||
tc.function.arguments = isPlainObject ? JSON.stringify(args) : '{}';
|
||||
}
|
||||
|
||||
const body = JSON.stringify(json);
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: { 'content-type': contentType },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export class LmChatOpenRouter implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'OpenRouter Chat Model',
|
||||
name: 'lmChatOpenRouter',
|
||||
icon: { light: 'file:openrouter.svg', dark: 'file:openrouter.dark.svg' },
|
||||
group: ['transform'],
|
||||
version: [1],
|
||||
description: 'For advanced usage with an AI chain',
|
||||
defaults: {
|
||||
name: 'OpenRouter 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.lmchatopenrouter/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'openRouterApi',
|
||||
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. Also, make sure to select latest models released post November 2023.',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/options.responseFormat': ['json_object'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
description:
|
||||
'The model which will generate the completion. <a href="https://openrouter.ai/docs/models">Learn more</a>.',
|
||||
typeOptions: {
|
||||
loadOptions: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/models',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'data',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{$responseItem.id}}',
|
||||
value: '={{$responseItem.id}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
send: {
|
||||
type: 'body',
|
||||
property: 'model',
|
||||
},
|
||||
},
|
||||
default: 'openai/gpt-4.1-mini',
|
||||
},
|
||||
{
|
||||
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. Most models have a context length of 2048 tokens (except for the newest models, which support 32,768).',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
maxValue: 32768,
|
||||
},
|
||||
},
|
||||
{
|
||||
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>('openRouterApi');
|
||||
|
||||
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,
|
||||
fetch: createOpenRouterFetch(globalThis.fetch),
|
||||
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: options.responseFormat
|
||||
? {
|
||||
response_format: { type: options.responseFormat },
|
||||
}
|
||||
: undefined,
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, openAiFailedAttemptHandler),
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="white" fill-rule="evenodd" width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>
|
||||
|
After Width: | Height: | Size: 866 B |
@@ -0,0 +1 @@
|
||||
<svg fill="#94A3B8" fill-rule="evenodd" width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>
|
||||
|
After Width: | Height: | Size: 868 B |
@@ -0,0 +1,327 @@
|
||||
/* 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, N8nLlmTracing, getProxyAgent } from '@n8n/ai-utilities';
|
||||
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
|
||||
import { LmChatOpenRouter } from '../LmChatOpenRouter.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('LmChatOpenRouter', () => {
|
||||
let node: LmChatOpenRouter;
|
||||
|
||||
const mockNodeDef: INode = {
|
||||
id: '1',
|
||||
name: 'OpenRouter Chat Model',
|
||||
typeVersion: 1,
|
||||
type: 'n8n-nodes-langchain.lmChatOpenRouter',
|
||||
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-key',
|
||||
url: 'https://openrouter.ai/api/v1',
|
||||
});
|
||||
ctx.getNode = jest.fn().mockReturnValue(nodeDef);
|
||||
ctx.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model') return 'anthropic/claude-sonnet-4-20250514';
|
||||
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 LmChatOpenRouter();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('node description', () => {
|
||||
it('should have correct node properties', () => {
|
||||
expect(node.description).toMatchObject({
|
||||
displayName: 'OpenRouter Chat Model',
|
||||
name: 'lmChatOpenRouter',
|
||||
group: ['transform'],
|
||||
version: [1],
|
||||
});
|
||||
});
|
||||
|
||||
it('should require openRouterApi credentials', () => {
|
||||
expect(node.description.credentials).toEqual([{ name: 'openRouterApi', 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 basic configuration', async () => {
|
||||
const ctx = setupMockContext();
|
||||
|
||||
const result = await node.supplyData.call(ctx, 0);
|
||||
|
||||
expect(ctx.getCredentials).toHaveBeenCalledWith('openRouterApi');
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-key',
|
||||
model: 'anthropic/claude-sonnet-4-20250514',
|
||||
maxRetries: 2,
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
onFailedAttempt: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
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 'anthropic/claude-sonnet-4-20250514';
|
||||
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 = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model') return 'anthropic/claude-sonnet-4-20250514';
|
||||
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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass a custom fetch wrapper in configuration', async () => {
|
||||
const ctx = setupMockContext();
|
||||
|
||||
await node.supplyData.call(ctx, 0);
|
||||
|
||||
const callArgs = MockedChatOpenAI.mock.calls[0][0];
|
||||
expect(callArgs?.configuration?.fetch).toEqual(expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch wrapper (empty tool call arguments fix)', () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = origFetch;
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
const json = JSON.stringify(body);
|
||||
return new Response(json, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a mock fetch, calls supplyData to capture it in the wrapper,
|
||||
* and returns the wrapper function from the ChatOpenAI constructor args.
|
||||
*/
|
||||
async function setupFetchWrapper(mockFetch: jest.Mock): Promise<typeof globalThis.fetch> {
|
||||
globalThis.fetch = mockFetch;
|
||||
const ctx = setupMockContext();
|
||||
await node.supplyData.call(ctx, 0);
|
||||
return MockedChatOpenAI.mock.calls[0][0]?.configuration?.fetch as typeof fetch;
|
||||
}
|
||||
|
||||
it.each<{ input: unknown; expected: string; label: string }>([
|
||||
{ input: '', expected: '{}', label: 'empty string' },
|
||||
{ input: ' ', expected: '{}', label: 'whitespace-only string' },
|
||||
{ input: null, expected: '{}', label: 'null' },
|
||||
{ input: [], expected: '{}', label: 'empty array' },
|
||||
{ input: [1, 2], expected: '{}', label: 'non-empty array' },
|
||||
{ input: {}, expected: '{}', label: 'empty object (stringified)' },
|
||||
{
|
||||
input: { location: 'NYC' },
|
||||
expected: '{"location":"NYC"}',
|
||||
label: 'plain object (stringified)',
|
||||
},
|
||||
{
|
||||
input: '{"location":"NYC"}',
|
||||
expected: '{"location":"NYC"}',
|
||||
label: 'valid JSON string (unchanged)',
|
||||
},
|
||||
{ input: '{}', expected: '{}', label: 'empty JSON object string (unchanged)' },
|
||||
])('should normalize arguments: $label → $expected', async ({ input, expected }) => {
|
||||
const mockFetch = jest.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [{ id: 'call_1', function: { name: 'tool', arguments: input } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const wrappedFetch = await setupFetchWrapper(mockFetch);
|
||||
const response = await wrappedFetch('https://openrouter.ai/api/v1/chat/completions');
|
||||
const result = await response.json();
|
||||
|
||||
expect(result.choices[0].message.tool_calls[0].function.arguments).toBe(expected);
|
||||
});
|
||||
|
||||
it('should pass through non-JSON responses untouched', async () => {
|
||||
const textBody = 'plain text response';
|
||||
const mockFetch = jest.fn().mockResolvedValue(
|
||||
new Response(textBody, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
}),
|
||||
);
|
||||
|
||||
const wrappedFetch = await setupFetchWrapper(mockFetch);
|
||||
const response = await wrappedFetch('https://openrouter.ai/api/v1/chat/completions');
|
||||
|
||||
expect(await response.text()).toBe(textBody);
|
||||
});
|
||||
|
||||
it('should pass through JSON responses without choices', async () => {
|
||||
const body = { models: ['a', 'b'] };
|
||||
const mockFetch = jest.fn().mockResolvedValue(jsonResponse(body));
|
||||
|
||||
const wrappedFetch = await setupFetchWrapper(mockFetch);
|
||||
const response = await wrappedFetch('https://openrouter.ai/api/v1/models');
|
||||
|
||||
expect(await response.json()).toEqual(body);
|
||||
});
|
||||
|
||||
it('should pass through responses without tool_calls', async () => {
|
||||
const body = {
|
||||
choices: [{ message: { role: 'assistant', content: 'Hello!' } }],
|
||||
};
|
||||
const mockFetch = jest.fn().mockResolvedValue(jsonResponse(body));
|
||||
|
||||
const wrappedFetch = await setupFetchWrapper(mockFetch);
|
||||
const response = await wrappedFetch('https://openrouter.ai/api/v1/chat/completions');
|
||||
|
||||
expect(await response.json()).toEqual(body);
|
||||
});
|
||||
|
||||
it('should fix only empty arguments in a mixed set of tool calls', async () => {
|
||||
const mockFetch = jest.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
function: { name: 'get_weather', arguments: '{"city":"NYC"}' },
|
||||
},
|
||||
{ id: 'call_2', function: { name: 'get_time', arguments: '' } },
|
||||
{
|
||||
id: 'call_3',
|
||||
function: { name: 'get_date', arguments: '{"format":"iso"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const wrappedFetch = await setupFetchWrapper(mockFetch);
|
||||
const response = await wrappedFetch('https://openrouter.ai/api/v1/chat/completions');
|
||||
const result = await response.json();
|
||||
|
||||
const toolCalls = result.choices[0].message.tool_calls;
|
||||
expect(toolCalls[0].function.arguments).toBe('{"city":"NYC"}');
|
||||
expect(toolCalls[1].function.arguments).toBe('{}');
|
||||
expect(toolCalls[2].function.arguments).toBe('{"format":"iso"}');
|
||||
});
|
||||
|
||||
it('should only carry content-type header on modified responses', async () => {
|
||||
const mockFetch = jest.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [{ id: 'call_1', function: { name: 'get_time', arguments: '' } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const wrappedFetch = await setupFetchWrapper(mockFetch);
|
||||
const response = await wrappedFetch('https://openrouter.ai/api/v1/chat/completions');
|
||||
|
||||
expect(response.headers.get('content-type')).toBe('application/json');
|
||||
// Stale metadata headers (content-length, etag, etc.) are not carried over
|
||||
expect(response.headers.get('content-length')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
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 LmChatVercelAiGateway implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Vercel AI Gateway Chat Model',
|
||||
name: 'lmChatVercelAiGateway',
|
||||
icon: { light: 'file:vercel.dark.svg', dark: 'file:vercel.svg' },
|
||||
group: ['transform'],
|
||||
version: [1],
|
||||
description: 'For advanced usage with an AI chain via Vercel AI Gateway',
|
||||
defaults: {
|
||||
name: 'Vercel AI Gateway 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.lmchatvercel/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'vercelAiGatewayApi',
|
||||
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. Also, make sure to select latest models released post November 2023.',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/options.responseFormat': ['json_object'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
description: 'The model which will generate the completion',
|
||||
typeOptions: {
|
||||
loadOptions: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/models',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'data',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{$responseItem.id}}',
|
||||
value: '={{$responseItem.id}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
send: {
|
||||
type: 'body',
|
||||
property: 'model',
|
||||
},
|
||||
},
|
||||
default: 'openai/gpt-4o',
|
||||
},
|
||||
{
|
||||
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. Most models have a context length of 2048 tokens (except for the newest models, which support 32,768).',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
maxValue: 32768,
|
||||
},
|
||||
},
|
||||
{
|
||||
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>('vercelAiGatewayApi');
|
||||
|
||||
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,
|
||||
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 width="76" height="65" viewBox="0 0 76 65" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="#000000"/></svg>
|
||||
|
After Width: | Height: | Size: 162 B |
@@ -0,0 +1,6 @@
|
||||
<svg
|
||||
width="76"
|
||||
height="65"
|
||||
viewBox="0 0 76 65"
|
||||
fill="none" xmlns="http://www.w3.org/2000/svg"><path
|
||||
d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="#ffffff"/></svg>
|
||||
|
After Width: | Height: | Size: 331 B |
@@ -0,0 +1,264 @@
|
||||
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 LmChatXAiGrok implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'xAI Grok Chat Model',
|
||||
|
||||
name: 'lmChatXAiGrok',
|
||||
icon: { light: 'file:logo.dark.svg', dark: 'file:logo.svg' },
|
||||
group: ['transform'],
|
||||
version: [1],
|
||||
description: 'For advanced usage with an AI chain',
|
||||
defaults: {
|
||||
name: 'xAI Grok 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.lmchatxaigrok/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'xAiApi',
|
||||
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. Also, make sure to select latest models released post November 2023.',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/options.responseFormat': ['json_object'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
description:
|
||||
'The model which will generate the completion. <a href="https://docs.x.ai/docs/models">Learn more</a>.',
|
||||
typeOptions: {
|
||||
loadOptions: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/models',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'data',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{$responseItem.id}}',
|
||||
value: '={{$responseItem.id}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
send: {
|
||||
type: 'body',
|
||||
property: 'model',
|
||||
},
|
||||
},
|
||||
default: 'grok-2-vision-1212',
|
||||
},
|
||||
{
|
||||
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. Most models have a context length of 2048 tokens (except for the newest models, which support 32,768).',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
maxValue: 32768,
|
||||
},
|
||||
},
|
||||
{
|
||||
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>('xAiApi');
|
||||
|
||||
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,
|
||||
model: modelName,
|
||||
...options,
|
||||
timeout,
|
||||
maxRetries: options.maxRetries ?? 2,
|
||||
configuration,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
modelKwargs: {
|
||||
stream_options: undefined,
|
||||
...(options.responseFormat
|
||||
? {
|
||||
response_format: { type: options.responseFormat },
|
||||
}
|
||||
: undefined),
|
||||
},
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, openAiFailedAttemptHandler),
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true" class="" focusable="false" style="fill: currentcolor; height: 28px; width: 28px;"><path d="m3.005 8.858 8.783 12.544h3.904L6.908 8.858zM6.905 15.825 3 21.402h3.907l1.951-2.788zM16.585 2l-6.75 9.64 1.953 2.79L20.492 2zM17.292 7.965v13.437h3.2V3.395z"></path></svg>
|
||||
|
After Width: | Height: | Size: 363 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="32" height="32"><g><polygon fill="#fff" points="226.83 411.15 501.31 803.15 623.31 803.15 348.82 411.15 226.83 411.15"></polygon><polygon fill="#fff" points="348.72 628.87 226.69 803.15 348.77 803.15 409.76 716.05 348.72 628.87"></polygon><polygon fill="#fff" points="651.23 196.85 440.28 498.12 501.32 585.29 773.31 196.85 651.23 196.85"></polygon><polygon fill="#fff" points="673.31 383.25 673.31 803.15 773.31 803.15 773.31 240.44 673.31 383.25"></polygon></g></svg>
|
||||
|
After Width: | Height: | Size: 541 B |
@@ -0,0 +1,181 @@
|
||||
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
|
||||
import type { SerializedFields } from '@langchain/core/dist/load/map_keys';
|
||||
import type {
|
||||
Serialized,
|
||||
SerializedNotImplemented,
|
||||
SerializedSecret,
|
||||
} from '@langchain/core/load/serializable';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import type { LLMResult } from '@langchain/core/outputs';
|
||||
import pick from 'lodash/pick';
|
||||
import type { IDataObject, ISupplyDataFunctions, JsonObject } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeError, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { logAiEvent } from '@n8n/ai-utilities';
|
||||
|
||||
type RunDetail = {
|
||||
index: number;
|
||||
messages: BaseMessage[] | string[] | string;
|
||||
options: SerializedSecret | SerializedNotImplemented | SerializedFields;
|
||||
};
|
||||
|
||||
export class N8nNonEstimatingTracing extends BaseCallbackHandler {
|
||||
name = 'N8nNonEstimatingTracing';
|
||||
|
||||
// This flag makes sure that LangChain will wait for the handlers to finish before continuing
|
||||
// This is crucial for the handleLLMError handler to work correctly (it should be called before the error is propagated to the root node)
|
||||
awaitHandlers = true;
|
||||
|
||||
connectionType = NodeConnectionTypes.AiLanguageModel;
|
||||
|
||||
#parentRunIndex?: number;
|
||||
|
||||
/**
|
||||
* A map to associate LLM run IDs to run details.
|
||||
* Key: Unique identifier for each LLM run (run ID)
|
||||
* Value: RunDetails object
|
||||
*
|
||||
*/
|
||||
runsMap: Record<string, RunDetail> = {};
|
||||
|
||||
options = {
|
||||
// Default(OpenAI format) parser
|
||||
errorDescriptionMapper: (error: NodeError) => error.description,
|
||||
};
|
||||
|
||||
constructor(
|
||||
private executionFunctions: ISupplyDataFunctions,
|
||||
options?: {
|
||||
errorDescriptionMapper?: (error: NodeError) => string;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
this.options = { ...this.options, ...options };
|
||||
}
|
||||
|
||||
async handleLLMEnd(output: LLMResult, runId: string) {
|
||||
// The fallback should never happen since handleLLMStart should always set the run details
|
||||
// but just in case, we set the index to the length of the runsMap
|
||||
const runDetails = this.runsMap[runId] ?? { index: Object.keys(this.runsMap).length };
|
||||
|
||||
output.generations = output.generations.map((gen) =>
|
||||
gen.map((g) => pick(g, ['text', 'generationInfo'])),
|
||||
);
|
||||
|
||||
const tokenUsageEstimate = {
|
||||
completionTokens: 0,
|
||||
promptTokens: 0,
|
||||
totalTokens: 0,
|
||||
};
|
||||
const response: {
|
||||
response: { generations: LLMResult['generations'] };
|
||||
tokenUsageEstimate?: typeof tokenUsageEstimate;
|
||||
} = {
|
||||
response: { generations: output.generations },
|
||||
};
|
||||
|
||||
response.tokenUsageEstimate = tokenUsageEstimate;
|
||||
|
||||
const parsedMessages =
|
||||
typeof runDetails.messages === 'string'
|
||||
? runDetails.messages
|
||||
: runDetails.messages.map((message) => {
|
||||
if (typeof message === 'string') return message;
|
||||
if (typeof message?.toJSON === 'function') return message.toJSON();
|
||||
|
||||
return message;
|
||||
});
|
||||
|
||||
const sourceNodeRunIndex =
|
||||
this.#parentRunIndex !== undefined ? this.#parentRunIndex + runDetails.index : undefined;
|
||||
|
||||
this.executionFunctions.addOutputData(
|
||||
this.connectionType,
|
||||
runDetails.index,
|
||||
[[{ json: { ...response } }]],
|
||||
undefined,
|
||||
sourceNodeRunIndex,
|
||||
);
|
||||
|
||||
logAiEvent(this.executionFunctions, 'ai-llm-generated-output', {
|
||||
messages: parsedMessages,
|
||||
options: runDetails.options,
|
||||
response,
|
||||
});
|
||||
}
|
||||
|
||||
async handleLLMStart(llm: Serialized, prompts: string[], runId: string) {
|
||||
const estimatedTokens = 0;
|
||||
const sourceNodeRunIndex =
|
||||
this.#parentRunIndex !== undefined
|
||||
? this.#parentRunIndex + this.executionFunctions.getNextRunIndex()
|
||||
: undefined;
|
||||
|
||||
const options = llm.type === 'constructor' ? llm.kwargs : llm;
|
||||
const { index } = this.executionFunctions.addInputData(
|
||||
this.connectionType,
|
||||
[
|
||||
[
|
||||
{
|
||||
json: {
|
||||
messages: prompts,
|
||||
estimatedTokens,
|
||||
options,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
sourceNodeRunIndex,
|
||||
);
|
||||
|
||||
// Save the run details for later use when processing `handleLLMEnd` event
|
||||
this.runsMap[runId] = {
|
||||
index,
|
||||
options,
|
||||
messages: prompts,
|
||||
};
|
||||
}
|
||||
|
||||
async handleLLMError(error: IDataObject | Error, runId: string, parentRunId?: string) {
|
||||
const runDetails = this.runsMap[runId] ?? { index: Object.keys(this.runsMap).length };
|
||||
|
||||
// Filter out non-x- headers to avoid leaking sensitive information in logs
|
||||
if (typeof error === 'object' && error?.hasOwnProperty('headers')) {
|
||||
const errorWithHeaders = error as { headers: Record<string, unknown> };
|
||||
|
||||
Object.keys(errorWithHeaders.headers).forEach((key) => {
|
||||
if (!key.startsWith('x-')) {
|
||||
delete errorWithHeaders.headers[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof NodeError) {
|
||||
if (this.options.errorDescriptionMapper) {
|
||||
error.description = this.options.errorDescriptionMapper(error);
|
||||
}
|
||||
|
||||
this.executionFunctions.addOutputData(this.connectionType, runDetails.index, error);
|
||||
} else {
|
||||
// If the error is not a NodeError, we wrap it in a NodeOperationError
|
||||
this.executionFunctions.addOutputData(
|
||||
this.connectionType,
|
||||
runDetails.index,
|
||||
new NodeOperationError(this.executionFunctions.getNode(), error as JsonObject, {
|
||||
functionality: 'configuration-node',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
logAiEvent(this.executionFunctions, 'ai-llm-errored', {
|
||||
error: Object.keys(error).length === 0 ? error.toString() : error,
|
||||
runId,
|
||||
parentRunId,
|
||||
});
|
||||
}
|
||||
|
||||
// Used to associate subsequent runs with the correct parent run in subnodes of subnodes
|
||||
setParentRunIndex(runIndex: number) {
|
||||
this.#parentRunIndex = runIndex;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { HarmBlockThreshold, HarmCategory } from '@google/genai';
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import { harmCategories, harmThresholds } from './safety-options';
|
||||
|
||||
export function getAdditionalOptions({
|
||||
supportsThinkingBudget,
|
||||
}: { supportsThinkingBudget: boolean }) {
|
||||
const baseOptions: INodeProperties = {
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
description: 'Additional options to add',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Maximum Number of Tokens',
|
||||
name: 'maxOutputTokens',
|
||||
default: 2048,
|
||||
description: 'The maximum number of tokens to generate in the completion',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Sampling Temperature',
|
||||
name: 'temperature',
|
||||
default: 0.4,
|
||||
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: 'Top K',
|
||||
name: 'topK',
|
||||
default: 32,
|
||||
typeOptions: { maxValue: 40, minValue: -1, numberPrecision: 1 },
|
||||
description:
|
||||
'Used to remove "long tail" low probability responses. Defaults to -1, which disables it.',
|
||||
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',
|
||||
},
|
||||
// Safety Settings
|
||||
{
|
||||
displayName: 'Safety Settings',
|
||||
name: 'safetySettings',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: { multipleValues: true },
|
||||
default: {
|
||||
values: {
|
||||
category: harmCategories[0].name as HarmCategory,
|
||||
threshold: harmThresholds[0].name as HarmBlockThreshold,
|
||||
},
|
||||
},
|
||||
placeholder: 'Add Option',
|
||||
options: [
|
||||
{
|
||||
name: 'values',
|
||||
displayName: 'Values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Safety Category',
|
||||
name: 'category',
|
||||
type: 'options',
|
||||
description: 'The category of harmful content to block',
|
||||
default: 'HARM_CATEGORY_UNSPECIFIED',
|
||||
options: harmCategories,
|
||||
},
|
||||
{
|
||||
displayName: 'Safety Threshold',
|
||||
name: 'threshold',
|
||||
type: 'options',
|
||||
description: 'The threshold of harmful content to block',
|
||||
default: 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
|
||||
options: harmThresholds,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
// only supported in the new google genai SDK
|
||||
if (supportsThinkingBudget) {
|
||||
baseOptions.options?.push({
|
||||
displayName: 'Thinking Budget',
|
||||
name: 'thinkingBudget',
|
||||
default: -1,
|
||||
description:
|
||||
'Controls reasoning tokens for thinking models. Set to 0 to disable automatic thinking. Set to -1 for dynamic thinking (default).',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: -1,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
return baseOptions;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { INodePropertyOptions } from 'n8n-workflow';
|
||||
|
||||
export const harmCategories: INodePropertyOptions[] = [
|
||||
{
|
||||
value: 'HARM_CATEGORY_HARASSMENT',
|
||||
name: 'HARM_CATEGORY_HARASSMENT',
|
||||
description: 'Harassment content',
|
||||
},
|
||||
{
|
||||
value: 'HARM_CATEGORY_HATE_SPEECH',
|
||||
name: 'HARM_CATEGORY_HATE_SPEECH',
|
||||
description: 'Hate speech and content',
|
||||
},
|
||||
{
|
||||
value: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
||||
name: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
||||
description: 'Sexually explicit content',
|
||||
},
|
||||
{
|
||||
value: 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
||||
name: 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
||||
description: 'Dangerous content',
|
||||
},
|
||||
];
|
||||
|
||||
export const harmThresholds: INodePropertyOptions[] = [
|
||||
{
|
||||
value: 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
|
||||
name: 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
|
||||
description: 'Threshold is unspecified',
|
||||
},
|
||||
{
|
||||
value: 'BLOCK_LOW_AND_ABOVE',
|
||||
name: 'BLOCK_LOW_AND_ABOVE',
|
||||
description: 'Content with NEGLIGIBLE will be allowed',
|
||||
},
|
||||
{
|
||||
value: 'BLOCK_MEDIUM_AND_ABOVE',
|
||||
name: 'BLOCK_MEDIUM_AND_ABOVE',
|
||||
description: 'Content with NEGLIGIBLE and LOW will be allowed',
|
||||
},
|
||||
{
|
||||
value: 'BLOCK_ONLY_HIGH',
|
||||
name: 'BLOCK_ONLY_HIGH',
|
||||
description: 'Content with NEGLIGIBLE, LOW, and MEDIUM will be allowed',
|
||||
},
|
||||
{
|
||||
value: 'BLOCK_NONE',
|
||||
name: 'BLOCK_NONE',
|
||||
description: 'All content will be allowed',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,563 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
import { ChatAnthropic } from '@langchain/anthropic';
|
||||
import { N8nLlmTracing, makeN8nLlmFailedAttemptHandler, getProxyAgent } from '@n8n/ai-utilities';
|
||||
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||
import type { ILoadOptionsFunctions, INode, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
|
||||
import { LmChatAnthropic } from '../LMChatAnthropic/LmChatAnthropic.node';
|
||||
|
||||
jest.mock('@langchain/anthropic');
|
||||
jest.mock('@n8n/ai-utilities');
|
||||
|
||||
const MockedChatAnthropic = jest.mocked(ChatAnthropic);
|
||||
const MockedN8nLlmTracing = jest.mocked(N8nLlmTracing);
|
||||
const mockedMakeN8nLlmFailedAttemptHandler = jest.mocked(makeN8nLlmFailedAttemptHandler);
|
||||
const mockedGetProxyAgent = jest.mocked(getProxyAgent);
|
||||
|
||||
describe('LmChatAnthropic', () => {
|
||||
let lmChatAnthropic: LmChatAnthropic;
|
||||
let mockContext: jest.Mocked<ISupplyDataFunctions>;
|
||||
|
||||
const mockNode: INode = {
|
||||
id: '1',
|
||||
name: 'Anthropic Chat Model',
|
||||
typeVersion: 1.3,
|
||||
type: 'n8n-nodes-langchain.lmChatAnthropic',
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
const setupMockContext = (nodeOverrides: Partial<INode> = {}) => {
|
||||
const node = { ...mockNode, ...nodeOverrides };
|
||||
mockContext = createMockExecuteFunction<ISupplyDataFunctions>(
|
||||
{},
|
||||
node,
|
||||
) as jest.Mocked<ISupplyDataFunctions>;
|
||||
|
||||
// Setup default mocks
|
||||
mockContext.getCredentials = jest.fn().mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
});
|
||||
mockContext.getNode = jest.fn().mockReturnValue(node);
|
||||
mockContext.getNodeParameter = jest.fn();
|
||||
|
||||
// Mock the constructors/functions properly
|
||||
MockedN8nLlmTracing.mockImplementation(() => ({}) as any);
|
||||
mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(jest.fn());
|
||||
mockedGetProxyAgent.mockReturnValue({} as any);
|
||||
return mockContext;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
lmChatAnthropic = new LmChatAnthropic();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('node description', () => {
|
||||
it('should have correct node properties', () => {
|
||||
expect(lmChatAnthropic.description).toMatchObject({
|
||||
displayName: 'Anthropic Chat Model',
|
||||
name: 'lmChatAnthropic',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2, 1.3],
|
||||
description: 'Language Model Anthropic',
|
||||
});
|
||||
});
|
||||
|
||||
it('should have correct credentials configuration', () => {
|
||||
expect(lmChatAnthropic.description.credentials).toEqual([
|
||||
{
|
||||
name: 'anthropicApi',
|
||||
required: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should have correct output configuration', () => {
|
||||
expect(lmChatAnthropic.description.outputs).toEqual(['ai_languageModel']);
|
||||
expect(lmChatAnthropic.description.outputNames).toEqual(['Model']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supplyData', () => {
|
||||
it('should create ChatAnthropic instance with basic configuration (version >= 1.3)', async () => {
|
||||
const mockContext = setupMockContext({ typeVersion: 1.3 });
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(mockContext.getCredentials).toHaveBeenCalledWith('anthropicApi');
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('model.value', 0);
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('options', 0, {});
|
||||
expect(MockedChatAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
anthropicApiKey: 'test-api-key',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
anthropicApiUrl: 'https://api.anthropic.com',
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
onFailedAttempt: expect.any(Function),
|
||||
invocationKwargs: {},
|
||||
clientOptions: {
|
||||
fetchOptions: {
|
||||
dispatcher: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
response: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('should create ChatAnthropic instance with basic configuration (version < 1.3)', async () => {
|
||||
const mockContext = setupMockContext({ typeVersion: 1.2 });
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model') return 'claude-3-5-sonnet-20240620';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('model', 0);
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('options', 0, {});
|
||||
expect(MockedChatAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
anthropicApiKey: 'test-api-key',
|
||||
model: 'claude-3-5-sonnet-20240620',
|
||||
anthropicApiUrl: 'https://api.anthropic.com',
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
onFailedAttempt: expect.any(Function),
|
||||
invocationKwargs: {},
|
||||
clientOptions: {
|
||||
fetchOptions: {
|
||||
dispatcher: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle custom baseURL from credentials', async () => {
|
||||
const customURL = 'https://custom-anthropic.example.com';
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
url: customURL,
|
||||
});
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
anthropicApiKey: 'test-api-key',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
anthropicApiUrl: customURL,
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
onFailedAttempt: expect.any(Function),
|
||||
invocationKwargs: {},
|
||||
clientOptions: {
|
||||
fetchOptions: {
|
||||
dispatcher: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle custom headers from credentials', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
header: true,
|
||||
headerName: 'X-Custom-Header',
|
||||
headerValue: 'custom-value',
|
||||
});
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
anthropicApiKey: 'test-api-key',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
anthropicApiUrl: 'https://api.anthropic.com',
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
onFailedAttempt: expect.any(Function),
|
||||
invocationKwargs: {},
|
||||
clientOptions: {
|
||||
fetchOptions: {
|
||||
dispatcher: {},
|
||||
},
|
||||
defaultHeaders: {
|
||||
'X-Custom-Header': 'custom-value',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle all available options', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
const options = {
|
||||
maxTokensToSample: 1000,
|
||||
temperature: 0.8,
|
||||
topK: 5,
|
||||
topP: 0.9,
|
||||
};
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return options;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
anthropicApiKey: 'test-api-key',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
maxTokens: 1000,
|
||||
temperature: 0.8,
|
||||
topK: 5,
|
||||
topP: 0.9,
|
||||
anthropicApiUrl: 'https://api.anthropic.com',
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
onFailedAttempt: expect.any(Function),
|
||||
invocationKwargs: {},
|
||||
clientOptions: {
|
||||
fetchOptions: {
|
||||
dispatcher: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle thinking mode', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
const options = {
|
||||
thinking: true,
|
||||
thinkingBudget: 2048,
|
||||
maxTokensToSample: 4096,
|
||||
};
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return options;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
anthropicApiKey: 'test-api-key',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
maxTokens: 4096,
|
||||
anthropicApiUrl: 'https://api.anthropic.com',
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
onFailedAttempt: expect.any(Function),
|
||||
invocationKwargs: {
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
budget_tokens: 2048,
|
||||
},
|
||||
max_tokens: 4096,
|
||||
top_k: undefined,
|
||||
top_p: undefined,
|
||||
temperature: undefined,
|
||||
},
|
||||
clientOptions: {
|
||||
fetchOptions: {
|
||||
dispatcher: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create N8nLlmTracing callback', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedN8nLlmTracing).toHaveBeenCalledWith(mockContext, {
|
||||
tokensUsageParser: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('should create failed attempt handler', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(mockedMakeN8nLlmFailedAttemptHandler).toHaveBeenCalledWith(mockContext, undefined);
|
||||
});
|
||||
|
||||
it('should not add custom headers when header toggle is disabled', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
header: false,
|
||||
headerName: 'X-Custom-Header',
|
||||
headerValue: 'custom-value',
|
||||
});
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clientOptions: {
|
||||
fetchOptions: {
|
||||
dispatcher: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify that defaultHeaders is not set
|
||||
const callArgs = MockedChatAnthropic.mock.calls[0]?.[0];
|
||||
expect(callArgs?.clientOptions?.defaultHeaders).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle custom headers and custom URL together', async () => {
|
||||
const customURL = 'https://custom-anthropic.example.com';
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
url: customURL,
|
||||
header: true,
|
||||
headerName: 'X-Custom-Header',
|
||||
headerValue: 'custom-value',
|
||||
});
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatAnthropic.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatAnthropic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
anthropicApiKey: 'test-api-key',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
anthropicApiUrl: customURL,
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
onFailedAttempt: expect.any(Function),
|
||||
invocationKwargs: {},
|
||||
clientOptions: {
|
||||
fetchOptions: {
|
||||
dispatcher: {},
|
||||
},
|
||||
defaultHeaders: {
|
||||
'X-Custom-Header': 'custom-value',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('methods', () => {
|
||||
describe('searchModels', () => {
|
||||
let mockLoadContext: ILoadOptionsFunctions;
|
||||
let mockGetCredentials: jest.Mock;
|
||||
let mockHttpRequest: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetCredentials = jest.fn();
|
||||
mockHttpRequest = jest.fn();
|
||||
|
||||
mockLoadContext = {
|
||||
getCredentials: mockGetCredentials,
|
||||
helpers: {
|
||||
httpRequestWithAuthentication: mockHttpRequest,
|
||||
},
|
||||
} as unknown as ILoadOptionsFunctions;
|
||||
});
|
||||
|
||||
it('should return all models sorted by creation date', async () => {
|
||||
const mockModels = [
|
||||
{
|
||||
id: 'claude-2',
|
||||
display_name: 'Claude 2',
|
||||
type: 'chat',
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'claude-3-opus-20240229',
|
||||
display_name: 'Claude 3 Opus',
|
||||
type: 'chat',
|
||||
created_at: '2024-02-29T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'claude-3-sonnet-20240229',
|
||||
display_name: 'Claude 3 Sonnet',
|
||||
type: 'chat',
|
||||
created_at: '2024-02-29T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
mockGetCredentials.mockResolvedValue({});
|
||||
mockHttpRequest.mockResolvedValue({
|
||||
data: mockModels,
|
||||
});
|
||||
|
||||
const { searchModels } = lmChatAnthropic.methods.listSearch;
|
||||
const result = await searchModels.call(mockLoadContext);
|
||||
|
||||
expect(mockHttpRequest).toHaveBeenCalledWith('anthropicApi', {
|
||||
url: 'https://api.anthropic.com/v1/models',
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.results).toHaveLength(3);
|
||||
// Verify sorted by creation date (newest first)
|
||||
expect(result.results[0].value).toBe('claude-3-opus-20240229');
|
||||
expect(result.results[0].name).toBe('Claude 3 Opus');
|
||||
expect(result.results[2].value).toBe('claude-2');
|
||||
});
|
||||
|
||||
it('should filter models by search term', async () => {
|
||||
const mockModels = [
|
||||
{
|
||||
id: 'claude-2',
|
||||
display_name: 'Claude 2',
|
||||
type: 'chat',
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'claude-3-opus-20240229',
|
||||
display_name: 'Claude 3 Opus',
|
||||
type: 'chat',
|
||||
created_at: '2024-02-29T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'claude-3-sonnet-20240229',
|
||||
display_name: 'Claude 3 Sonnet',
|
||||
type: 'chat',
|
||||
created_at: '2024-02-29T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
mockGetCredentials.mockResolvedValue({});
|
||||
mockHttpRequest.mockResolvedValue({
|
||||
data: mockModels,
|
||||
});
|
||||
|
||||
const { searchModels } = lmChatAnthropic.methods.listSearch;
|
||||
const result = await searchModels.call(mockLoadContext, 'opus');
|
||||
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(result.results[0].value).toBe('claude-3-opus-20240229');
|
||||
expect(result.results[0].name).toBe('Claude 3 Opus');
|
||||
});
|
||||
|
||||
it('should filter models case-insensitively', async () => {
|
||||
const mockModels = [
|
||||
{
|
||||
id: 'claude-3-sonnet-20240229',
|
||||
display_name: 'Claude 3 Sonnet',
|
||||
type: 'chat',
|
||||
created_at: '2024-02-29T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
mockGetCredentials.mockResolvedValue({});
|
||||
mockHttpRequest.mockResolvedValue({
|
||||
data: mockModels,
|
||||
});
|
||||
|
||||
const { searchModels } = lmChatAnthropic.methods.listSearch;
|
||||
const result = await searchModels.call(mockLoadContext, 'SONNET');
|
||||
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(result.results[0].value).toBe('claude-3-sonnet-20240229');
|
||||
});
|
||||
|
||||
it('should use custom URL from credentials', async () => {
|
||||
const customURL = 'https://custom-anthropic.example.com';
|
||||
|
||||
mockGetCredentials.mockResolvedValue({
|
||||
url: customURL,
|
||||
});
|
||||
mockHttpRequest.mockResolvedValue({
|
||||
data: [],
|
||||
});
|
||||
|
||||
const { searchModels } = lmChatAnthropic.methods.listSearch;
|
||||
await searchModels.call(mockLoadContext);
|
||||
|
||||
expect(mockHttpRequest).toHaveBeenCalledWith('anthropicApi', {
|
||||
url: `${customURL}/v1/models`,
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty model list', async () => {
|
||||
mockGetCredentials.mockResolvedValue({});
|
||||
mockHttpRequest.mockResolvedValue({
|
||||
data: [],
|
||||
});
|
||||
|
||||
const { searchModels } = lmChatAnthropic.methods.listSearch;
|
||||
const result = await searchModels.call(mockLoadContext);
|
||||
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,597 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing, getProxyAgent } from '@n8n/ai-utilities';
|
||||
import { AiConfig } from '@n8n/config';
|
||||
import { Container } from '@n8n/di';
|
||||
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||
import type { IDataObject, INode, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
|
||||
import * as common from '../LMChatOpenAi/common';
|
||||
import { LmChatOpenAi } from '../LMChatOpenAi/LmChatOpenAi.node';
|
||||
|
||||
jest.mock('@langchain/openai');
|
||||
jest.mock('@n8n/ai-utilities');
|
||||
jest.mock('../LMChatOpenAi/common');
|
||||
|
||||
const MockedChatOpenAI = jest.mocked(ChatOpenAI);
|
||||
const MockedN8nLlmTracing = jest.mocked(N8nLlmTracing);
|
||||
const mockedMakeN8nLlmFailedAttemptHandler = jest.mocked(makeN8nLlmFailedAttemptHandler);
|
||||
const mockedCommon = jest.mocked(common);
|
||||
const mockedGetProxyAgent = jest.mocked(getProxyAgent);
|
||||
const { openAiDefaultHeaders: defaultHeaders } = Container.get(AiConfig);
|
||||
|
||||
describe('LmChatOpenAi', () => {
|
||||
let lmChatOpenAi: LmChatOpenAi;
|
||||
let mockContext: jest.Mocked<ISupplyDataFunctions>;
|
||||
|
||||
const mockNode: INode = {
|
||||
id: '1',
|
||||
name: 'OpenAI Chat Model',
|
||||
typeVersion: 1.2,
|
||||
type: 'n8n-nodes-langchain.lmChatOpenAi',
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
const setupMockContext = (nodeOverrides: Partial<INode> = {}) => {
|
||||
const node = { ...mockNode, ...nodeOverrides };
|
||||
mockContext = createMockExecuteFunction<ISupplyDataFunctions>(
|
||||
{},
|
||||
node,
|
||||
) as jest.Mocked<ISupplyDataFunctions>;
|
||||
|
||||
// Setup default mocks
|
||||
mockContext.getCredentials = jest.fn().mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
});
|
||||
mockContext.getNode = jest.fn().mockReturnValue(node);
|
||||
mockContext.getNodeParameter = jest.fn();
|
||||
|
||||
// Mock the constructors/functions properly
|
||||
MockedN8nLlmTracing.mockImplementation(() => ({}) as any);
|
||||
mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(jest.fn());
|
||||
mockedGetProxyAgent.mockReturnValue({} as any);
|
||||
return mockContext;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
lmChatOpenAi = new LmChatOpenAi();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('node description', () => {
|
||||
it('should have correct node properties', () => {
|
||||
expect(lmChatOpenAi.description).toMatchObject({
|
||||
displayName: 'OpenAI Chat Model',
|
||||
name: 'lmChatOpenAi',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2, 1.3],
|
||||
description: 'For advanced usage with an AI chain',
|
||||
});
|
||||
});
|
||||
|
||||
it('should have correct credentials configuration', () => {
|
||||
expect(lmChatOpenAi.description.credentials).toEqual([
|
||||
{
|
||||
name: 'openAiApi',
|
||||
required: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should have correct output configuration', () => {
|
||||
expect(lmChatOpenAi.description.outputs).toEqual(['ai_languageModel']);
|
||||
expect(lmChatOpenAi.description.outputNames).toEqual(['Model']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supplyData', () => {
|
||||
it('should create ChatOpenAI instance with basic configuration (version >= 1.2)', async () => {
|
||||
const mockContext = setupMockContext({ typeVersion: 1.2 });
|
||||
|
||||
// Mock getNodeParameter to handle the proper parameter names for v1.2
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'gpt-4o-mini';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await lmChatOpenAi.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(mockContext.getCredentials).toHaveBeenCalledWith('openAiApi');
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('model.value', 0);
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('options', 0, {});
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
model: 'gpt-4o-mini',
|
||||
maxRetries: 2,
|
||||
configuration: {
|
||||
defaultHeaders,
|
||||
fetchOptions: {
|
||||
dispatcher: {},
|
||||
},
|
||||
},
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
modelKwargs: {},
|
||||
onFailedAttempt: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
response: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('should create ChatOpenAI instance with basic configuration (version < 1.2)', async () => {
|
||||
const mockContext = setupMockContext({ typeVersion: 1.1 });
|
||||
|
||||
// Mock getNodeParameter to handle the proper parameter names for v1.1
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model') return 'gpt-4o-mini';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatOpenAi.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('model', 0);
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('options', 0, {});
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
model: 'gpt-4o-mini',
|
||||
maxRetries: 2,
|
||||
configuration: {
|
||||
defaultHeaders,
|
||||
fetchOptions: {
|
||||
dispatcher: {},
|
||||
},
|
||||
},
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
modelKwargs: {},
|
||||
onFailedAttempt: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle custom baseURL from options', async () => {
|
||||
const customBaseURL = 'https://custom-api.example.com/v1';
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'gpt-4o-mini';
|
||||
if (paramName === 'options')
|
||||
return {
|
||||
baseURL: customBaseURL,
|
||||
timeout: 30000,
|
||||
maxRetries: 5,
|
||||
};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatOpenAi.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
model: 'gpt-4o-mini',
|
||||
baseURL: customBaseURL,
|
||||
timeout: 30000,
|
||||
maxRetries: 5,
|
||||
configuration: {
|
||||
baseURL: customBaseURL,
|
||||
fetchOptions: {
|
||||
dispatcher: {},
|
||||
},
|
||||
defaultHeaders,
|
||||
},
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
modelKwargs: {},
|
||||
onFailedAttempt: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle custom baseURL from credentials', async () => {
|
||||
const customURL = 'https://custom-openai.example.com/v1';
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
url: customURL,
|
||||
});
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'gpt-4o-mini';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatOpenAi.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
model: 'gpt-4o-mini',
|
||||
maxRetries: 2,
|
||||
configuration: {
|
||||
baseURL: customURL,
|
||||
fetchOptions: {
|
||||
dispatcher: {},
|
||||
},
|
||||
defaultHeaders,
|
||||
},
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
modelKwargs: {},
|
||||
onFailedAttempt: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle custom headers from credentials', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
header: true,
|
||||
headerName: 'X-Custom-Header',
|
||||
headerValue: 'custom-value',
|
||||
});
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'gpt-4o-mini';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatOpenAi.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
model: 'gpt-4o-mini',
|
||||
maxRetries: 2,
|
||||
configuration: {
|
||||
defaultHeaders: {
|
||||
...defaultHeaders,
|
||||
'X-Custom-Header': 'custom-value',
|
||||
},
|
||||
fetchOptions: {
|
||||
dispatcher: {},
|
||||
},
|
||||
},
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
modelKwargs: {},
|
||||
onFailedAttempt: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle all available options v1.2', async () => {
|
||||
const mockContext = setupMockContext({ typeVersion: 1.2 });
|
||||
const options = {
|
||||
frequencyPenalty: 0.5,
|
||||
maxTokens: 1000,
|
||||
presencePenalty: 0.3,
|
||||
temperature: 0.8,
|
||||
topP: 0.9,
|
||||
timeout: 45000,
|
||||
maxRetries: 3,
|
||||
responseFormat: 'json_object' as const,
|
||||
reasoningEffort: 'high' as const,
|
||||
};
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'gpt-4o-mini';
|
||||
if (paramName === 'options') return options;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatOpenAi.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
model: 'gpt-4o-mini',
|
||||
frequencyPenalty: 0.5,
|
||||
maxTokens: 1000,
|
||||
presencePenalty: 0.3,
|
||||
temperature: 0.8,
|
||||
topP: 0.9,
|
||||
timeout: 45000,
|
||||
maxRetries: 3,
|
||||
configuration: {
|
||||
defaultHeaders,
|
||||
fetchOptions: {
|
||||
dispatcher: {},
|
||||
},
|
||||
},
|
||||
callbacks: expect.arrayContaining([expect.any(Object)]),
|
||||
modelKwargs: {
|
||||
response_format: { type: 'json_object' },
|
||||
reasoning_effort: 'high',
|
||||
},
|
||||
onFailedAttempt: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should only add valid reasoning effort to modelKwargs', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
const options = {
|
||||
reasoningEffort: 'invalid' as 'low' | 'medium' | 'high',
|
||||
};
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'gpt-4o-mini';
|
||||
if (paramName === 'options') return options;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatOpenAi.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'gpt-4o-mini',
|
||||
modelKwargs: {}, // Should not include invalid reasoning_effort
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create N8nLlmTracing callback', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'gpt-4o-mini';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatOpenAi.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedN8nLlmTracing).toHaveBeenCalledWith(mockContext);
|
||||
});
|
||||
|
||||
it('should create failed attempt handler', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'gpt-4o-mini';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatOpenAi.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(mockedMakeN8nLlmFailedAttemptHandler).toHaveBeenCalledWith(
|
||||
mockContext,
|
||||
expect.any(Function), // openAiFailedAttemptHandler
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default values for maxRetries when not provided', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'gpt-4o-mini';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatOpenAi.supplyData.call(mockContext, 0);
|
||||
|
||||
// timeout is now controlled at the undici level via fetchOptions dispatcher
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
maxRetries: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set supportsStrictToolCalling to false for OpenAI-compatible backends', async () => {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'gpt-4o-mini';
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatOpenAi.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
supportsStrictToolCalling: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize options.baseURL over credentials.url', async () => {
|
||||
const optionsBaseURL = 'https://options-api.example.com/v1';
|
||||
const credentialsURL = 'https://credentials-api.example.com/v1';
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
url: credentialsURL,
|
||||
});
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'gpt-4o-mini';
|
||||
if (paramName === 'options')
|
||||
return {
|
||||
baseURL: optionsBaseURL,
|
||||
};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatOpenAi.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
configuration: {
|
||||
baseURL: optionsBaseURL,
|
||||
fetchOptions: {
|
||||
dispatcher: {},
|
||||
},
|
||||
defaultHeaders,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle text response format correctly v1.2', async () => {
|
||||
const mockContext = setupMockContext({ typeVersion: 1.2 });
|
||||
const options = {
|
||||
responseFormat: 'text' as const,
|
||||
};
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'gpt-4o-mini';
|
||||
if (paramName === 'options') return options;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatOpenAi.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelKwargs: {
|
||||
response_format: { type: 'text' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle all reasoning effort values correctly', async () => {
|
||||
const reasoningEffortValues = ['low', 'medium', 'high'] as const;
|
||||
|
||||
for (const effort of reasoningEffortValues) {
|
||||
const mockContext = setupMockContext();
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model.value') return 'gpt-4o-mini';
|
||||
if (paramName === 'options')
|
||||
return {
|
||||
reasoningEffort: effort,
|
||||
};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await lmChatOpenAi.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelKwargs: {
|
||||
reasoning_effort: effort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
jest.clearAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('methods', () => {
|
||||
beforeEach(() => {
|
||||
setupMockContext();
|
||||
});
|
||||
|
||||
it('should have searchModels method', () => {
|
||||
expect(lmChatOpenAi.methods).toEqual({
|
||||
listSearch: {
|
||||
searchModels: expect.any(Function),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should force Responses API and include additional params for v1.3', async () => {
|
||||
const mockContext = setupMockContext({ typeVersion: 1.3 });
|
||||
|
||||
const options: IDataObject = {
|
||||
conversationId: 'conv_123',
|
||||
promptCacheKey: 'cache_key_1',
|
||||
safetyIdentifier: 'user-42',
|
||||
serviceTier: 'priority' as const,
|
||||
topLogprobs: 10,
|
||||
metadata: '{"team":"ai"}',
|
||||
textFormat: {
|
||||
textOptions: [{ type: 'json_object', verbosity: 'high' }],
|
||||
},
|
||||
promptConfig: {
|
||||
promptOptions: [{ promptId: 'p_1', version: '1', variables: '{"name":"n8n"}' }],
|
||||
},
|
||||
};
|
||||
|
||||
const mockResponsesParams = {
|
||||
custom: true,
|
||||
};
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'responsesApiEnabled') return true;
|
||||
if (paramName === 'model.value') return 'gpt-4o-mini';
|
||||
if (paramName === 'options') return options;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockedCommon.prepareAdditionalResponsesParams = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockResponsesParams);
|
||||
|
||||
mockedCommon.formatBuiltInTools = jest.fn().mockReturnValue([]);
|
||||
|
||||
await lmChatOpenAi.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(mockedCommon.prepareAdditionalResponsesParams).toHaveBeenCalledWith(options);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
useResponsesApi: true,
|
||||
modelKwargs: mockResponsesParams,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should attach built-in tools to model metadata (v1.3)', async () => {
|
||||
const mockContext = setupMockContext({ typeVersion: 1.3 });
|
||||
|
||||
const builtInTools: IDataObject = {
|
||||
webSearch: { searchContextSize: 'high', allowedDomains: 'google.com, wikipedia.org' },
|
||||
fileSearch: { vectorStoreIds: '["vs_1"]', filters: '{}', maxResults: 2 },
|
||||
codeInterpreter: true,
|
||||
};
|
||||
|
||||
const mockTools = [
|
||||
{
|
||||
customTools: true,
|
||||
},
|
||||
];
|
||||
|
||||
mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'responsesApiEnabled') return true;
|
||||
if (paramName === 'model.value') return 'gpt-4o-mini';
|
||||
if (paramName === 'options') return {};
|
||||
if (paramName === 'builtInTools') return builtInTools;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockedCommon.formatBuiltInTools = jest.fn().mockReturnValue(mockTools);
|
||||
|
||||
await lmChatOpenAi.supplyData.call(mockContext, 0);
|
||||
|
||||
expect(mockedCommon.formatBuiltInTools).toHaveBeenCalledWith(builtInTools);
|
||||
|
||||
const instance: unknown = MockedChatOpenAI.mock.instances[0];
|
||||
expect(instance).toBeDefined();
|
||||
expect((instance as { metadata?: { tools?: unknown } }).metadata).toBeDefined();
|
||||
expect((instance as { metadata?: { tools?: unknown } }).metadata?.tools).toEqual(mockTools);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import type { Serialized } from '@langchain/core/load/serializable';
|
||||
import type { LLMResult } from '@langchain/core/outputs';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IDataObject, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError, NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { N8nLlmTracing } from '@n8n/ai-utilities';
|
||||
|
||||
describe('N8nLlmTracing', () => {
|
||||
const executionFunctions = mock<ISupplyDataFunctions>({
|
||||
addInputData: jest.fn().mockReturnValue({ index: 0 }),
|
||||
addOutputData: jest.fn(),
|
||||
getNode: jest.fn().mockReturnValue({ name: 'TestNode' }),
|
||||
getNextRunIndex: jest.fn().mockReturnValue(1),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('tokensUsageParser', () => {
|
||||
it('should parse OpenAI format tokens correctly', () => {
|
||||
const tracer = new N8nLlmTracing(executionFunctions);
|
||||
const llmResult: LLMResult = {
|
||||
generations: [],
|
||||
llmOutput: {
|
||||
tokenUsage: {
|
||||
completionTokens: 100,
|
||||
promptTokens: 50,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = tracer.options.tokensUsageParser(llmResult);
|
||||
|
||||
expect(result).toEqual({
|
||||
completionTokens: 100,
|
||||
promptTokens: 50,
|
||||
totalTokens: 150,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing token data', () => {
|
||||
const tracer = new N8nLlmTracing(executionFunctions);
|
||||
const llmResult: LLMResult = {
|
||||
generations: [],
|
||||
};
|
||||
|
||||
const result = tracer.options.tokensUsageParser(llmResult);
|
||||
|
||||
expect(result).toEqual({
|
||||
completionTokens: 0,
|
||||
promptTokens: 0,
|
||||
totalTokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined llmOutput', () => {
|
||||
const tracer = new N8nLlmTracing(executionFunctions);
|
||||
const llmResult: LLMResult = {
|
||||
generations: [],
|
||||
llmOutput: undefined,
|
||||
};
|
||||
|
||||
const result = tracer.options.tokensUsageParser(llmResult);
|
||||
|
||||
expect(result).toEqual({
|
||||
completionTokens: 0,
|
||||
promptTokens: 0,
|
||||
totalTokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom tokensUsageParser when provided', () => {
|
||||
// Custom parser for Cohere format
|
||||
const customParser = (result: LLMResult) => {
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
|
||||
result.generations?.forEach((generationArray) => {
|
||||
generationArray.forEach((gen) => {
|
||||
const inputTokens = gen.generationInfo?.meta?.tokens?.inputTokens ?? 0;
|
||||
const outputTokens = gen.generationInfo?.meta?.tokens?.outputTokens ?? 0;
|
||||
|
||||
totalInputTokens += inputTokens;
|
||||
totalOutputTokens += outputTokens;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
completionTokens: totalOutputTokens,
|
||||
promptTokens: totalInputTokens,
|
||||
totalTokens: totalInputTokens + totalOutputTokens,
|
||||
};
|
||||
};
|
||||
|
||||
const tracer = new N8nLlmTracing(executionFunctions, {
|
||||
tokensUsageParser: customParser,
|
||||
});
|
||||
|
||||
const llmResult: LLMResult = {
|
||||
generations: [
|
||||
[
|
||||
{
|
||||
text: 'Response 1',
|
||||
generationInfo: {
|
||||
meta: {
|
||||
tokens: {
|
||||
inputTokens: 30,
|
||||
outputTokens: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: 'Response 2',
|
||||
generationInfo: {
|
||||
meta: {
|
||||
tokens: {
|
||||
inputTokens: 20,
|
||||
outputTokens: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
const result = tracer.options.tokensUsageParser(llmResult);
|
||||
|
||||
expect(result).toEqual({
|
||||
completionTokens: 100, // 40 + 60
|
||||
promptTokens: 50, // 30 + 20
|
||||
totalTokens: 150,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle Anthropic format with custom parser', () => {
|
||||
const anthropicParser = (result: LLMResult) => {
|
||||
const usage = (result?.llmOutput?.usage as {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
}) ?? {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
};
|
||||
return {
|
||||
completionTokens: usage.output_tokens,
|
||||
promptTokens: usage.input_tokens,
|
||||
totalTokens: usage.input_tokens + usage.output_tokens,
|
||||
};
|
||||
};
|
||||
|
||||
const tracer = new N8nLlmTracing(executionFunctions, {
|
||||
tokensUsageParser: anthropicParser,
|
||||
});
|
||||
|
||||
const llmResult: LLMResult = {
|
||||
generations: [],
|
||||
llmOutput: {
|
||||
usage: {
|
||||
input_tokens: 75,
|
||||
output_tokens: 125,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = tracer.options.tokensUsageParser(llmResult);
|
||||
|
||||
expect(result).toEqual({
|
||||
completionTokens: 125,
|
||||
promptTokens: 75,
|
||||
totalTokens: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLLMEnd', () => {
|
||||
it('should process LLM output and use token usage when available', async () => {
|
||||
const tracer = new N8nLlmTracing(executionFunctions);
|
||||
const runId = 'test-run-id';
|
||||
|
||||
// Set up run details
|
||||
tracer.runsMap[runId] = {
|
||||
index: 0,
|
||||
messages: ['Test prompt'],
|
||||
options: { model: 'test-model' },
|
||||
};
|
||||
|
||||
const output: LLMResult = {
|
||||
generations: [
|
||||
[
|
||||
{
|
||||
text: 'Test response',
|
||||
generationInfo: { meta: {} },
|
||||
},
|
||||
],
|
||||
],
|
||||
llmOutput: {
|
||||
tokenUsage: {
|
||||
completionTokens: 50,
|
||||
promptTokens: 25,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await tracer.handleLLMEnd(output, runId);
|
||||
|
||||
expect(executionFunctions.addOutputData).toHaveBeenCalledWith(
|
||||
'ai_languageModel',
|
||||
0,
|
||||
[
|
||||
[
|
||||
{
|
||||
json: expect.objectContaining({
|
||||
response: { generations: output.generations },
|
||||
tokenUsage: {
|
||||
completionTokens: 50,
|
||||
promptTokens: 25,
|
||||
totalTokens: 75,
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
],
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use token estimates when actual usage is not available', async () => {
|
||||
const tracer = new N8nLlmTracing(executionFunctions);
|
||||
const runId = 'test-run-id';
|
||||
|
||||
// Set up run details and prompt estimate
|
||||
tracer.runsMap[runId] = {
|
||||
index: 0,
|
||||
messages: ['Test prompt'],
|
||||
options: { model: 'test-model' },
|
||||
};
|
||||
tracer.promptTokensEstimate = 30;
|
||||
|
||||
const output: LLMResult = {
|
||||
generations: [
|
||||
[
|
||||
{
|
||||
text: 'Test response',
|
||||
generationInfo: { meta: {} },
|
||||
},
|
||||
],
|
||||
],
|
||||
llmOutput: {},
|
||||
};
|
||||
|
||||
jest.spyOn(tracer, 'estimateTokensFromGeneration').mockResolvedValue(45);
|
||||
|
||||
await tracer.handleLLMEnd(output, runId);
|
||||
|
||||
expect(executionFunctions.addOutputData).toHaveBeenCalledWith(
|
||||
'ai_languageModel',
|
||||
0,
|
||||
[
|
||||
[
|
||||
{
|
||||
json: expect.objectContaining({
|
||||
response: { generations: output.generations },
|
||||
tokenUsageEstimate: {
|
||||
completionTokens: 45,
|
||||
promptTokens: 30,
|
||||
totalTokens: 75,
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
],
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLLMError', () => {
|
||||
it('should handle NodeError with custom error description mapper', async () => {
|
||||
const customMapper = jest.fn().mockReturnValue('Mapped error description');
|
||||
const tracer = new N8nLlmTracing(executionFunctions, {
|
||||
errorDescriptionMapper: customMapper,
|
||||
});
|
||||
|
||||
const runId = 'test-run-id';
|
||||
tracer.runsMap[runId] = { index: 0, messages: [], options: {} };
|
||||
|
||||
const error = new NodeApiError(executionFunctions.getNode(), {
|
||||
message: 'Test error',
|
||||
description: 'Original description',
|
||||
});
|
||||
|
||||
await tracer.handleLLMError(error, runId);
|
||||
|
||||
expect(customMapper).toHaveBeenCalledWith(error);
|
||||
expect(error.description).toBe('Mapped error description');
|
||||
expect(executionFunctions.addOutputData).toHaveBeenCalledWith('ai_languageModel', 0, error);
|
||||
});
|
||||
|
||||
it('should wrap non-NodeError in NodeOperationError', async () => {
|
||||
const tracer = new N8nLlmTracing(executionFunctions);
|
||||
const runId = 'test-run-id';
|
||||
tracer.runsMap[runId] = { index: 0, messages: [], options: {} };
|
||||
|
||||
const error = new Error('Regular error');
|
||||
|
||||
await tracer.handleLLMError(error, runId);
|
||||
|
||||
expect(executionFunctions.addOutputData).toHaveBeenCalledWith(
|
||||
'ai_languageModel',
|
||||
0,
|
||||
expect.any(NodeOperationError),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter out non-x- headers from error objects', async () => {
|
||||
const tracer = new N8nLlmTracing(executionFunctions);
|
||||
const runId = 'test-run-id';
|
||||
tracer.runsMap[runId] = { index: 0, messages: [], options: {} };
|
||||
|
||||
const error = {
|
||||
message: 'API Error',
|
||||
headers: {
|
||||
'x-request-id': 'keep-this',
|
||||
authorization: 'remove-this',
|
||||
'x-rate-limit': 'keep-this-too',
|
||||
'content-type': 'remove-this-too',
|
||||
},
|
||||
};
|
||||
|
||||
await tracer.handleLLMError(error as IDataObject, runId);
|
||||
|
||||
expect(error.headers).toEqual({
|
||||
'x-request-id': 'keep-this',
|
||||
'x-rate-limit': 'keep-this-too',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLLMStart', () => {
|
||||
it('should estimate tokens and create run details', async () => {
|
||||
const tracer = new N8nLlmTracing(executionFunctions);
|
||||
const runId = 'test-run-id';
|
||||
const prompts = ['Prompt 1', 'Prompt 2'];
|
||||
|
||||
jest.spyOn(tracer, 'estimateTokensFromStringList').mockResolvedValue(100);
|
||||
|
||||
const llm = {
|
||||
type: 'constructor',
|
||||
kwargs: { model: 'test-model' },
|
||||
};
|
||||
|
||||
await tracer.handleLLMStart(llm as unknown as Serialized, prompts, runId);
|
||||
|
||||
expect(tracer.estimateTokensFromStringList).toHaveBeenCalledWith(prompts);
|
||||
expect(tracer.promptTokensEstimate).toBe(100);
|
||||
expect(tracer.runsMap[runId]).toEqual({
|
||||
index: 0,
|
||||
options: { model: 'test-model' },
|
||||
messages: prompts,
|
||||
});
|
||||
expect(executionFunctions.addInputData).toHaveBeenCalledWith(
|
||||
'ai_languageModel',
|
||||
[
|
||||
[
|
||||
{
|
||||
json: {
|
||||
messages: prompts,
|
||||
estimatedTokens: 100,
|
||||
options: { model: 'test-model' },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||