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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,45 @@
import { shouldIncludeModel } from '../modelFiltering';
describe('shouldIncludeModel', () => {
const testCases: Array<{ modelId: string; officialAPI: boolean }> = [
// Excluded model types
{ modelId: 'babbage-002', officialAPI: false },
{ modelId: 'davinci-002', officialAPI: false },
{ modelId: 'computer-use-preview', officialAPI: false },
{ modelId: 'dall-e-3', officialAPI: false },
{ modelId: 'text-embedding-ada-002', officialAPI: false },
{ modelId: 'tts-1', officialAPI: false },
{ modelId: 'whisper-1', officialAPI: false },
{ modelId: 'omni-moderation-latest', officialAPI: false },
{ modelId: 'sora-1', officialAPI: false },
{ modelId: 'gpt-4o-realtime-preview', officialAPI: false }, // infix check for -realtime
{ modelId: 'gpt-3.5-turbo-instruct', officialAPI: false }, // gpt-* with instruct
// Included models (standard chat models)
{ modelId: 'gpt-4', officialAPI: true },
{ modelId: 'gpt-4o', officialAPI: true },
{ modelId: 'o1-preview', officialAPI: true },
{ modelId: 'ft:gpt-3.5-turbo', officialAPI: true }, // fine-tuned models
// Edge cases
{ modelId: 'llama-3-70b-instruct', officialAPI: true }, // non-gpt instruct is allowed
{ modelId: 'custom-model', officialAPI: true }, // arbitrary custom model names
];
describe('Custom API behavior', () => {
it.each(testCases)('should include "$modelId"', ({ modelId }) => {
expect(shouldIncludeModel(modelId, true)).toBe(true);
});
});
describe('Official OpenAI API filtering', () => {
const testCasesWithAction = testCases.map((tc) => ({
...tc,
action: tc.officialAPI ? 'include' : 'exclude',
}));
it.each(testCasesWithAction)('should $action "$modelId"', ({ modelId, officialAPI }) => {
expect(shouldIncludeModel(modelId, false)).toBe(officialAPI);
});
});
});
@@ -0,0 +1,27 @@
import type { IBinaryData, IExecuteFunctions } from 'n8n-workflow';
/** Chunk size to use for streaming. 256Kb */
const CHUNK_SIZE = 256 * 1024;
/**
* Gets the binary data file for the given item index and given property name.
* Returns the file name, content type and the file content. Uses streaming
* when possible.
*/
export async function getBinaryDataFile(
ctx: IExecuteFunctions,
itemIdx: number,
binaryPropertyData: string | IBinaryData,
) {
const binaryData = ctx.helpers.assertBinaryData(itemIdx, binaryPropertyData);
const fileContent = binaryData.id
? await ctx.helpers.getBinaryStream(binaryData.id, CHUNK_SIZE)
: await ctx.helpers.getBinaryDataBuffer(itemIdx, binaryPropertyData);
return {
filename: binaryData.fileName,
contentType: binaryData.mimeType,
fileContent,
};
}
@@ -0,0 +1,22 @@
export const MODELS_NOT_SUPPORT_FUNCTION_CALLS = [
'gpt-3.5-turbo-16k-0613',
'dall-e-3',
'text-embedding-3-large',
'dall-e-2',
'whisper-1',
'tts-1-hd-1106',
'tts-1-hd',
'gpt-4-0314',
'text-embedding-3-small',
'gpt-4-32k-0314',
'gpt-3.5-turbo-0301',
'gpt-4-vision-preview',
'gpt-3.5-turbo-16k',
'gpt-3.5-turbo-instruct-0914',
'tts-1',
'davinci-002',
'gpt-3.5-turbo-instruct',
'babbage-002',
'tts-1-1106',
'text-embedding-ada-002',
];
@@ -0,0 +1,62 @@
import type { INodeInputConfiguration } from 'n8n-workflow';
export const prettifyOperation = (resource: string, operation: string) => {
if (operation === 'deleteAssistant') {
return 'Delete Assistant';
}
if (operation === 'deleteFile') {
return 'Delete File';
}
if (operation === 'classify') {
return 'Classify Text';
}
if (operation === 'message' && resource === 'text') {
return 'Message Model';
}
const capitalize = (str: string) => {
const chars = str.split('');
chars[0] = chars[0].toUpperCase();
return chars.join('');
};
if (['transcribe', 'translate'].includes(operation)) {
resource = 'recording';
}
if (operation === 'list') {
resource = resource + 's';
}
return `${capitalize(operation)} ${capitalize(resource)}`;
};
/* istanbul ignore next */
export const configureNodeInputs = (
resource: string,
operation: string,
hideTools: string,
memory: string | undefined,
) => {
if (resource === 'assistant' && operation === 'message') {
const inputs: INodeInputConfiguration[] = [
{ type: 'main' },
{ type: 'ai_tool', displayName: 'Tools' },
];
if (memory !== 'threadId') {
inputs.push({ type: 'ai_memory', displayName: 'Memory', maxConnections: 1 });
}
return inputs;
}
if (resource === 'text' && (operation === 'message' || operation === 'response')) {
if (hideTools === 'hide') {
return ['main'];
}
return [{ type: 'main' }, { type: 'ai_tool', displayName: 'Tools' }];
}
return ['main'];
};
@@ -0,0 +1,130 @@
import { OperationalError } from 'n8n-workflow';
import { RateLimitError } from 'openai';
import { OpenAIError } from 'openai/error';
import { openAiFailedAttemptHandler, getCustomErrorMessage, isOpenAiError } from './error-handling';
describe('error-handling', () => {
describe('getCustomErrorMessage', () => {
it('should return the correct custom error message for known error codes', () => {
expect(getCustomErrorMessage('insufficient_quota')).toBe(
'Insufficient quota detected. <a href="https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-langchain.openai/common-issues/#insufficient-quota" target="_blank">Learn more</a> about resolving this issue',
);
expect(getCustomErrorMessage('rate_limit_exceeded')).toBe('OpenAI: Rate limit reached');
});
it('should return undefined for unknown error codes', () => {
expect(getCustomErrorMessage('unknown_error_code')).toBeUndefined();
});
});
describe('isOpenAiError', () => {
it('should return true if the error is an instance of OpenAIError', () => {
const error = new OpenAIError('Test error');
expect(isOpenAiError(error)).toBe(true);
});
it('should return false if the error is not an instance of OpenAIError', () => {
const error = new Error('Test error');
expect(isOpenAiError(error)).toBe(false);
});
});
describe('openAiFailedAttemptHandler', () => {
it('should handle RateLimitError and modify the error message', () => {
const error = new RateLimitError(
429,
{ code: 'rate_limit_exceeded' },
'Rate limit exceeded',
new Headers(),
);
try {
openAiFailedAttemptHandler(error);
} catch (e) {
expect(e).toBeInstanceOf(OperationalError);
expect(e.level).toBe('warning');
expect(e.cause).toBe(error);
expect(e.message).toBe('OpenAI: Rate limit reached');
}
});
it('should throw the error if it is not a RateLimitError', () => {
const error = new Error('Test error');
expect(() => openAiFailedAttemptHandler(error)).not.toThrow();
});
describe('non-chat model error handling', () => {
it('should throw helpful error when model requires Responses API', () => {
const error = {
status: 404,
type: 'invalid_request_error',
param: 'model',
message:
'This is not a chat model and thus not supported in the v1/chat/completions endpoint. Did you mean to use v1/completions?',
code: null,
};
expect(() => openAiFailedAttemptHandler(error)).toThrow(OperationalError);
expect(() => openAiFailedAttemptHandler(error)).toThrow(
'This model requires the Responses API. Enable "Use Responses API" in the OpenAI Chat Model node options to use this model.',
);
});
it('should not throw for 404 errors with different type', () => {
const error = {
status: 404,
type: 'not_found_error',
param: 'model',
message: 'This is not a chat model',
};
expect(() => openAiFailedAttemptHandler(error)).not.toThrow();
});
it('should not throw for 404 errors with different param', () => {
const error = {
status: 404,
type: 'invalid_request_error',
param: 'api_key',
message: 'This is not a chat model',
};
expect(() => openAiFailedAttemptHandler(error)).not.toThrow();
});
it('should not throw for 404 errors with different message', () => {
const error = {
status: 404,
type: 'invalid_request_error',
param: 'model',
message: 'Model not found',
};
expect(() => openAiFailedAttemptHandler(error)).not.toThrow();
});
it('should not throw for errors with different status', () => {
const error = {
status: 400,
type: 'invalid_request_error',
param: 'model',
message: 'This is not a chat model',
};
expect(() => openAiFailedAttemptHandler(error)).not.toThrow();
});
it('should not throw for null or undefined errors', () => {
expect(() => openAiFailedAttemptHandler(null)).not.toThrow();
expect(() => openAiFailedAttemptHandler(undefined)).not.toThrow();
});
it('should not throw for non-object errors', () => {
expect(() => openAiFailedAttemptHandler('string error')).not.toThrow();
expect(() => openAiFailedAttemptHandler(123)).not.toThrow();
});
});
});
});
@@ -0,0 +1,52 @@
import { OperationalError } from 'n8n-workflow';
import { RateLimitError } from 'openai';
import { OpenAIError } from 'openai/error';
const errorMap: Record<string, string> = {
insufficient_quota:
'Insufficient quota detected. <a href="https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-langchain.openai/common-issues/#insufficient-quota" target="_blank">Learn more</a> about resolving this issue',
rate_limit_exceeded: 'OpenAI: Rate limit reached',
};
export function getCustomErrorMessage(errorCode: string): string | undefined {
return errorMap[errorCode];
}
export function isOpenAiError(error: any): error is OpenAIError {
return error instanceof OpenAIError;
}
function isNonChatModelError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) {
return false;
}
return (
'status' in error &&
error.status === 404 &&
'type' in error &&
error.type === 'invalid_request_error' &&
'param' in error &&
error.param === 'model' &&
'message' in error &&
typeof error.message === 'string' &&
error.message.includes('not a chat model')
);
}
export const openAiFailedAttemptHandler = (error: unknown) => {
if (isNonChatModelError(error)) {
throw new OperationalError(
'This model requires the Responses API. Enable "Use Responses API" in the OpenAI Chat Model node options to use this model.',
{ cause: error },
);
}
if (error instanceof RateLimitError) {
// If the error is a rate limit error, we want to handle it differently
// because OpenAI has multiple different rate limit errors
const errorCode = error?.code;
const errorMessage =
getCustomErrorMessage(errorCode ?? 'rate_limit_exceeded') ?? errorMap.rate_limit_exceeded;
throw new OperationalError(errorMessage, { cause: error });
}
};
@@ -0,0 +1,124 @@
import { type OpenAIClient } from '@langchain/openai';
import type { IDataObject } from 'n8n-workflow';
import type {
ComputerTool,
CustomTool,
FileSearchTool,
FunctionTool,
ResponseInputContent,
ResponseInputItem,
Tool,
WebSearchTool as OpenAIChatWebSearchTool,
} from 'openai/resources/responses/responses';
export type ChatResponse = OpenAIClient.Responses.Response;
export type ChatContent = ResponseInputContent[];
export type ChatInputItem = OpenAIClient.Responses.ResponseInputItem.Message;
// FIXME: remove these overrides, when langchain-openai is updated with the new types
export type WebSearchTool = Omit<OpenAIChatWebSearchTool, 'type'> & {
type: 'web_search';
filters?: {
allowed_domains?: string[];
};
};
export type ChatTool =
| FunctionTool
| FileSearchTool
| WebSearchTool
| ComputerTool
| Tool.CodeInterpreter
| Tool.ImageGeneration
| Tool.LocalShell
| CustomTool;
export type ChatResponseRequest = Omit<
OpenAIClient.Responses.ResponseCreateParamsNonStreaming,
'input'
> & {
max_tool_calls?: number;
conversation?:
| string
| {
id: string;
};
input: ResponseInputItem[];
top_logprobs?: number;
tools?: ChatTool[];
};
export type ChatCompletion = {
id: string;
object: string;
created: number;
model: string;
choices: Array<{
index: number;
message: {
role: string;
content: string;
tool_calls?: Array<{
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
}>;
};
finish_reason?: 'tool_calls';
}>;
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
system_fingerprint: string;
};
export type ThreadMessage = {
id: string;
object: string;
created_at: number;
thread_id: string;
role: string;
content: Array<{
type: string;
text: {
value: string;
annotations: string[];
};
}>;
file_ids: string[];
assistant_id: string;
run_id: string;
metadata: IDataObject;
};
export type ExternalApiCallOptions = {
callExternalApi: boolean;
url: string;
path: string;
method: string;
requestOptions: IDataObject;
sendParametersIn: string;
};
export type VideoJob = {
id: string;
completed_at?: number;
created_at: number;
error?: {
code: string;
message: string;
};
expires_at?: number;
model: string;
object: 'video';
progress?: number;
remixed_from_video_id?: string;
seconds: string;
size: string;
status: 'completed' | 'queued' | 'in_progress';
};
@@ -0,0 +1,30 @@
/**
* Determines whether a model should be included in the model list based on
* whether it's a custom API and the model's ID.
*
* @param modelId - The ID of the model to check
* @param isCustomAPI - Whether this is a custom API (not official OpenAI)
* @returns true if the model should be included, false otherwise
*/
export function shouldIncludeModel(modelId: string, isCustomAPI: boolean): boolean {
// For custom APIs, include all models
if (isCustomAPI) {
return true;
}
// For official OpenAI API, exclude certain model types
return !(
modelId.startsWith('babbage') ||
modelId.startsWith('davinci') ||
modelId.startsWith('computer-use') ||
modelId.startsWith('dall-e') ||
modelId.startsWith('text-embedding') ||
modelId.startsWith('tts') ||
modelId.includes('-tts') ||
modelId.startsWith('whisper') ||
modelId.startsWith('omni-moderation') ||
modelId.startsWith('sora') ||
modelId.includes('-realtime') ||
(modelId.startsWith('gpt-') && modelId.includes('instruct'))
);
}
@@ -0,0 +1,38 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
export async function pollUntilAvailable<TResponse>(
ctx: IExecuteFunctions,
request: () => Promise<TResponse>,
check: (response: TResponse) => boolean,
timeoutSeconds: number,
intervalSeconds = 5,
): Promise<TResponse> {
const abortSignal = ctx.getExecutionCancelSignal();
let response: TResponse | undefined;
const startTime = Date.now();
while (!response || !check(response)) {
const elapsedTime = Date.now() - startTime;
if (elapsedTime >= timeoutSeconds * 1000) {
throw new NodeApiError(ctx.getNode(), {
message: 'Timeout reached',
code: 500,
});
}
if (abortSignal?.aborted) {
throw new NodeApiError(ctx.getNode(), {
message: 'Execution was cancelled',
code: 500,
});
}
response = await request();
// Wait before the next polling attempt
await new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1000));
}
return response;
}
@@ -0,0 +1,90 @@
import type { BaseMessage } from '@langchain/core/messages';
import type { Tool } from '@langchain/core/tools';
import type { OpenAIClient } from '@langchain/openai';
import type { BufferWindowMemory } from '@langchain/classic/memory';
import { isObjectEmpty } from 'n8n-workflow';
import { zodToJsonSchema } from 'zod-to-json-schema';
// Copied from langchain(`langchain/src/tools/convert_to_openai.ts`)
// since these functions are not exported
/**
* Formats a `Tool` instance into a format that is compatible
* with OpenAI's ChatCompletionFunctions. It uses the `zodToJsonSchema`
* function to convert the schema of the tool into a JSON
* schema, which is then used as the parameters for the OpenAI function.
*/
export function formatToOpenAIFunction(
tool: Tool,
): OpenAIClient.Chat.ChatCompletionCreateParams.Function {
return {
name: tool.name,
description: tool.description,
parameters: zodToJsonSchema(tool.schema),
};
}
export function formatToOpenAITool(tool: Tool): OpenAIClient.Chat.ChatCompletionTool {
const schema = zodToJsonSchema(tool.schema);
return {
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: schema,
},
};
}
export function formatToOpenAIAssistantTool(tool: Tool): OpenAIClient.Beta.AssistantTool {
return {
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: zodToJsonSchema(tool.schema),
},
};
}
const requireStrict = (schema: any) => {
if (!schema.required) {
return false;
}
// when strict:true, Responses API requires `required` to be present and all properties to be included
if (schema.properties) {
const propertyNames = Object.keys(schema.properties);
const somePropertyMissingFromRequired = propertyNames.some(
(propertyName) => !schema.required.includes(propertyName),
);
const requireStrict = !somePropertyMissingFromRequired;
return requireStrict;
}
return false;
};
export function formatToOpenAIResponsesTool(tool: Tool): OpenAIClient.Responses.FunctionTool {
const schema = zodToJsonSchema(tool.schema) as any;
const strict = requireStrict(schema);
// when strict:true, Responses API requires `additionalProperties` either to be true/false or an object with properties
const isAdditionalPropertiesEmpty =
schema.additionalProperties &&
typeof schema.additionalProperties === 'object' &&
isObjectEmpty(schema.additionalProperties);
if (isAdditionalPropertiesEmpty && strict) {
schema.additionalProperties = false;
}
return {
type: 'function',
name: tool.name,
parameters: schema,
strict,
description: tool.description,
};
}
export async function getChatMessages(memory: BufferWindowMemory): Promise<BaseMessage[]> {
return (await memory.loadMemoryVariables({}))[memory.memoryKey] as BaseMessage[];
}