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
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:
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
type IVersionedNodeType,
|
||||
VersionedNodeType,
|
||||
type INodeTypeBaseDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { prettifyOperation } from './helpers/description';
|
||||
import { OpenAiV1 } from './v1/OpenAiV1.node';
|
||||
import { OpenAiV2 } from './v2/OpenAiV2.node';
|
||||
|
||||
export class OpenAi extends VersionedNodeType {
|
||||
constructor() {
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
displayName: 'OpenAI',
|
||||
name: 'openAi',
|
||||
icon: { light: 'file:openAi.svg', dark: 'file:openAi.dark.svg' },
|
||||
group: ['transform'],
|
||||
defaultVersion: 2.1,
|
||||
subtitle: `={{(${prettifyOperation})($parameter.resource, $parameter.operation)}}`,
|
||||
description: 'Message an assistant or GPT, analyze images, generate audio, etc.',
|
||||
codex: {
|
||||
alias: [
|
||||
'LangChain',
|
||||
'ChatGPT',
|
||||
'Sora',
|
||||
'DallE',
|
||||
'whisper',
|
||||
'audio',
|
||||
'transcribe',
|
||||
'tts',
|
||||
'assistant',
|
||||
],
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Agents', 'Miscellaneous', 'Root Nodes'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-langchain.openai/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
builderHint: {
|
||||
message:
|
||||
'For text generation, reasoning and tools, use AI Agent with OpenAI Chat Model. This OpenAI node is for specialized operations: image generation (DALL-E), audio (Whisper, TTS), and video generation (Sora).',
|
||||
relatedNodes: [
|
||||
{
|
||||
nodeType: '@n8n/n8n-nodes-langchain.agent',
|
||||
relationHint: 'Prefer for most LLM tasks',
|
||||
},
|
||||
{
|
||||
nodeType: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
relationHint: 'Prefer for most LLM tasks',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||
1: new OpenAiV1(baseDescription),
|
||||
1.1: new OpenAiV1(baseDescription),
|
||||
1.2: new OpenAiV1(baseDescription),
|
||||
1.3: new OpenAiV1(baseDescription),
|
||||
1.4: new OpenAiV1(baseDescription),
|
||||
1.5: new OpenAiV1(baseDescription),
|
||||
1.6: new OpenAiV1(baseDescription),
|
||||
1.7: new OpenAiV1(baseDescription),
|
||||
1.8: new OpenAiV1(baseDescription),
|
||||
2: new OpenAiV2(baseDescription),
|
||||
2.1: new OpenAiV2(baseDescription),
|
||||
};
|
||||
|
||||
super(nodeVersions, baseDescription);
|
||||
}
|
||||
}
|
||||
Vendored
+45
@@ -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'];
|
||||
};
|
||||
+130
@@ -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[];
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import type { ILoadOptionsFunctions } from 'n8n-workflow';
|
||||
|
||||
import * as transport from '../../transport';
|
||||
import { modelSearch } from '../listSearch';
|
||||
|
||||
jest.mock('../../transport');
|
||||
|
||||
describe('modelSearch', () => {
|
||||
let mockContext: jest.Mocked<ILoadOptionsFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = {
|
||||
getCredentials: jest.fn(),
|
||||
} as unknown as jest.Mocked<ILoadOptionsFunctions>;
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Official OpenAI API', () => {
|
||||
it('should return filtered models when using official OpenAI API', async () => {
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
url: 'https://api.openai.com/v1',
|
||||
});
|
||||
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'gpt-4' },
|
||||
{ id: 'gpt-3.5-turbo' },
|
||||
{ id: 'babbage-002' },
|
||||
{ id: 'whisper-1' },
|
||||
{ id: 'dall-e-3' },
|
||||
{ id: 'gpt-4o-realtime-preview' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await modelSearch.call(mockContext);
|
||||
|
||||
expect(result.results).toEqual([
|
||||
{ name: 'GPT-3.5-TURBO', value: 'gpt-3.5-turbo' },
|
||||
{ name: 'GPT-4', value: 'gpt-4' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should treat ai-assistant.n8n.io as official API', async () => {
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
url: 'https://ai-assistant.n8n.io/v1',
|
||||
});
|
||||
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValue({
|
||||
data: [{ id: 'gpt-4' }, { id: 'whisper-1' }, { id: 'dall-e-2' }],
|
||||
});
|
||||
|
||||
const result = await modelSearch.call(mockContext);
|
||||
|
||||
expect(result.results).toEqual([{ name: 'GPT-4', value: 'gpt-4' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom API', () => {
|
||||
it('should include all models for custom API endpoints', async () => {
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
url: 'https://custom-llm-provider.com/v1',
|
||||
});
|
||||
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'llama-3-70b' },
|
||||
{ id: 'mistral-large' },
|
||||
{ id: 'babbage-002' },
|
||||
{ id: 'whisper-1' },
|
||||
{ id: 'custom-model' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await modelSearch.call(mockContext);
|
||||
|
||||
expect(result.results).toEqual([
|
||||
{ name: 'BABBAGE-002', value: 'babbage-002' },
|
||||
{ name: 'CUSTOM-MODEL', value: 'custom-model' },
|
||||
{ name: 'LLAMA-3-70B', value: 'llama-3-70b' },
|
||||
{ name: 'MISTRAL-LARGE', value: 'mistral-large' },
|
||||
{ name: 'WHISPER-1', value: 'whisper-1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * as listSearch from './listSearch';
|
||||
export * as loadOptions from './loadOptions';
|
||||
@@ -0,0 +1,151 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
ILoadOptionsFunctions,
|
||||
INodeListSearchItems,
|
||||
INodeListSearchResult,
|
||||
} from 'n8n-workflow';
|
||||
import type { Assistant } from 'openai/resources/beta/assistants';
|
||||
import type { Model } from 'openai/resources/models';
|
||||
|
||||
import { shouldIncludeModel } from '../helpers/modelFiltering';
|
||||
import { apiRequest } from '../transport';
|
||||
|
||||
export async function fileSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const { data } = await apiRequest.call(this, 'GET', '/files');
|
||||
|
||||
if (filter) {
|
||||
const results: INodeListSearchItems[] = [];
|
||||
|
||||
for (const file of data || []) {
|
||||
if ((file.filename as string)?.toLowerCase().includes(filter.toLowerCase())) {
|
||||
results.push({
|
||||
name: file.filename as string,
|
||||
value: file.id as string,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
results,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
results: (data || []).map((file: IDataObject) => ({
|
||||
name: file.filename as string,
|
||||
value: file.id as string,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const getModelSearch =
|
||||
(filterCondition: (model: Model) => boolean) =>
|
||||
async (ctx: ILoadOptionsFunctions, filter?: string): Promise<INodeListSearchResult> => {
|
||||
let { data } = (await apiRequest.call(ctx, 'GET', '/models')) as { data: Model[] };
|
||||
|
||||
data = data?.filter((model) => filterCondition(model));
|
||||
|
||||
let results: INodeListSearchItems[] = [];
|
||||
|
||||
if (filter) {
|
||||
for (const model of data || []) {
|
||||
if (model.id?.toLowerCase().includes(filter.toLowerCase())) {
|
||||
results.push({
|
||||
name: model.id.toUpperCase(),
|
||||
value: model.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
results = (data || []).map((model) => ({
|
||||
name: model.id.toUpperCase(),
|
||||
value: model.id,
|
||||
}));
|
||||
}
|
||||
|
||||
results = results.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return {
|
||||
results,
|
||||
};
|
||||
};
|
||||
|
||||
export async function modelSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const credentials = await this.getCredentials<{ url: string }>('openAiApi');
|
||||
const url = credentials.url && new URL(credentials.url);
|
||||
const isCustomAPI = !!(url && !['api.openai.com', 'ai-assistant.n8n.io'].includes(url.hostname));
|
||||
return await getModelSearch((model) => shouldIncludeModel(model.id, isCustomAPI))(this, filter);
|
||||
}
|
||||
|
||||
export async function videoModelSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
return await getModelSearch((model) => model.id.includes('sora'))(this, filter);
|
||||
}
|
||||
|
||||
export async function imageModelSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
return await getModelSearch(
|
||||
(model) => model.id.includes('vision') || model.id.includes('gpt-4o'),
|
||||
)(this, filter);
|
||||
}
|
||||
|
||||
export async function assistantSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
paginationToken?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const { data, has_more, last_id } = (await apiRequest.call(this, 'GET', '/assistants', {
|
||||
headers: {
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
qs: {
|
||||
limit: 100,
|
||||
after: paginationToken,
|
||||
},
|
||||
})) as {
|
||||
data: Assistant[];
|
||||
has_more: boolean;
|
||||
last_id: string;
|
||||
first_id: string;
|
||||
};
|
||||
|
||||
if (has_more) {
|
||||
paginationToken = last_id;
|
||||
} else {
|
||||
paginationToken = undefined;
|
||||
}
|
||||
|
||||
if (filter) {
|
||||
const results: INodeListSearchItems[] = [];
|
||||
|
||||
for (const assistant of data || []) {
|
||||
if (assistant.name?.toLowerCase().includes(filter.toLowerCase())) {
|
||||
results.push({
|
||||
name: assistant.name,
|
||||
value: assistant.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
results,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
results: (data || []).map((assistant) => ({
|
||||
name: assistant.name ?? assistant.id,
|
||||
value: assistant.id,
|
||||
})),
|
||||
paginationToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../transport';
|
||||
|
||||
export async function getFiles(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
|
||||
const { data } = await apiRequest.call(this, 'GET', '/files', { qs: { purpose: 'assistants' } });
|
||||
|
||||
const returnData: INodePropertyOptions[] = [];
|
||||
|
||||
for (const file of data || []) {
|
||||
returnData.push({
|
||||
name: file.filename as string,
|
||||
value: file.id as string,
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -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.6185 17.2004 35.5597 17.2691 35.5172L25.2343 30.9171C25.6418 30.6858 25.8918 30.2521 25.8893 29.7833V18.5543L29.2556 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.0241 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="white"/>
|
||||
</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.6185 17.2004 35.5597 17.2691 35.5172L25.2343 30.9171C25.6418 30.6858 25.8918 30.2521 25.8893 29.7833V18.5543L29.2556 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.0241 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="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,404 @@
|
||||
import { AIMessage, HumanMessage } from '@langchain/core/messages';
|
||||
import type { Tool } from '@langchain/core/tools';
|
||||
import { BufferWindowMemory } from '@langchain/classic/memory';
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import {
|
||||
formatToOpenAIFunction,
|
||||
formatToOpenAITool,
|
||||
formatToOpenAIAssistantTool,
|
||||
formatToOpenAIResponsesTool,
|
||||
getChatMessages,
|
||||
} from '../helpers/utils';
|
||||
|
||||
jest.mock('zod-to-json-schema', () => ({
|
||||
zodToJsonSchema: jest.fn(),
|
||||
}));
|
||||
const mockZodToJsonSchema = jest.mocked(zodToJsonSchema);
|
||||
|
||||
describe('OpenAI message history', () => {
|
||||
it('should only get a limited number of messages', async () => {
|
||||
const memory = new BufferWindowMemory({
|
||||
returnMessages: true,
|
||||
k: 2,
|
||||
});
|
||||
expect(await getChatMessages(memory)).toEqual([]);
|
||||
|
||||
await memory.saveContext(
|
||||
[new HumanMessage({ content: 'human 1' })],
|
||||
[new AIMessage({ content: 'ai 1' })],
|
||||
);
|
||||
// `k` means turns, but `getChatMessages` returns messages, so a Human and an AI message.
|
||||
expect((await getChatMessages(memory)).length).toEqual(2);
|
||||
|
||||
await memory.saveContext(
|
||||
[new HumanMessage({ content: 'human 2' })],
|
||||
[new AIMessage({ content: 'ai 2' })],
|
||||
);
|
||||
expect((await getChatMessages(memory)).length).toEqual(4);
|
||||
expect((await getChatMessages(memory)).map((msg) => msg.content)).toEqual([
|
||||
'human 1',
|
||||
'ai 1',
|
||||
'human 2',
|
||||
'ai 2',
|
||||
]);
|
||||
|
||||
// We expect this to be trimmed...
|
||||
await memory.saveContext(
|
||||
[new HumanMessage({ content: 'human 3' })],
|
||||
[new AIMessage({ content: 'ai 3' })],
|
||||
);
|
||||
expect((await getChatMessages(memory)).length).toEqual(4);
|
||||
expect((await getChatMessages(memory)).map((msg) => msg.content)).toEqual([
|
||||
'human 2',
|
||||
'ai 2',
|
||||
'human 3',
|
||||
'ai 3',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAI formatting functions', () => {
|
||||
const createMockTool = (name: string, description: string): Tool =>
|
||||
({
|
||||
name,
|
||||
description,
|
||||
schema: z.object({}),
|
||||
func: jest.fn(),
|
||||
call: jest.fn(),
|
||||
returnDirect: false,
|
||||
verboseParsingErrors: false,
|
||||
lc_namespace: ['test'],
|
||||
lc_serializable: true,
|
||||
}) as unknown as Tool;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('formatToOpenAIFunction', () => {
|
||||
it('should format a tool to OpenAI function format', () => {
|
||||
const mockSchema = { type: 'object', properties: { name: { type: 'string' } } };
|
||||
mockZodToJsonSchema.mockReturnValue(mockSchema);
|
||||
|
||||
const tool = createMockTool('test-tool', 'A test tool');
|
||||
|
||||
const result = formatToOpenAIFunction(tool);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'test-tool',
|
||||
description: 'A test tool',
|
||||
parameters: mockSchema,
|
||||
});
|
||||
expect(mockZodToJsonSchema).toHaveBeenCalledWith(tool.schema);
|
||||
});
|
||||
|
||||
it('should handle tool with empty description', () => {
|
||||
const mockSchema = { type: 'object' };
|
||||
mockZodToJsonSchema.mockReturnValue(mockSchema);
|
||||
|
||||
const tool = createMockTool('test-tool', '');
|
||||
|
||||
const result = formatToOpenAIFunction(tool);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'test-tool',
|
||||
description: '',
|
||||
parameters: mockSchema,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatToOpenAITool', () => {
|
||||
it('should format a tool to OpenAI tool format', () => {
|
||||
const mockSchema = { type: 'object', properties: { value: { type: 'number' } } };
|
||||
mockZodToJsonSchema.mockReturnValue(mockSchema);
|
||||
|
||||
const tool = createMockTool('calculator', 'A calculator tool');
|
||||
|
||||
const result = formatToOpenAITool(tool);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'calculator',
|
||||
description: 'A calculator tool',
|
||||
parameters: mockSchema,
|
||||
},
|
||||
});
|
||||
expect(mockZodToJsonSchema).toHaveBeenCalledWith(tool.schema);
|
||||
});
|
||||
|
||||
it('should handle complex schema', () => {
|
||||
const mockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
limit: { type: 'number' },
|
||||
options: { type: 'object' },
|
||||
},
|
||||
required: ['query'],
|
||||
};
|
||||
mockZodToJsonSchema.mockReturnValue(mockSchema);
|
||||
|
||||
const tool = createMockTool('search-tool', 'Search functionality');
|
||||
|
||||
const result = formatToOpenAITool(tool);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search-tool',
|
||||
description: 'Search functionality',
|
||||
parameters: mockSchema,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatToOpenAIAssistantTool', () => {
|
||||
it('should format a tool to OpenAI assistant tool format', () => {
|
||||
const mockSchema = { type: 'object', properties: { message: { type: 'string' } } };
|
||||
mockZodToJsonSchema.mockReturnValue(mockSchema);
|
||||
|
||||
const tool = createMockTool('message-tool', 'Send a message');
|
||||
|
||||
const result = formatToOpenAIAssistantTool(tool);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'message-tool',
|
||||
description: 'Send a message',
|
||||
parameters: mockSchema,
|
||||
},
|
||||
});
|
||||
expect(mockZodToJsonSchema).toHaveBeenCalledWith(tool.schema);
|
||||
});
|
||||
|
||||
it('should handle tool with no required fields', () => {
|
||||
const mockSchema = { type: 'object', properties: {} };
|
||||
mockZodToJsonSchema.mockReturnValue(mockSchema);
|
||||
|
||||
const tool = createMockTool('simple-tool', 'A simple tool');
|
||||
|
||||
const result = formatToOpenAIAssistantTool(tool);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'simple-tool',
|
||||
description: 'A simple tool',
|
||||
parameters: mockSchema,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatToOpenAIResponsesTool', () => {
|
||||
it('should format a tool to OpenAI responses tool format with strict mode', () => {
|
||||
const mockSchema = {
|
||||
type: 'object',
|
||||
properties: { input: { type: 'string' } },
|
||||
required: ['input'],
|
||||
};
|
||||
mockZodToJsonSchema.mockReturnValue(mockSchema);
|
||||
|
||||
const tool = createMockTool('input-tool', 'Process input');
|
||||
|
||||
const result = formatToOpenAIResponsesTool(tool);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'function',
|
||||
name: 'input-tool',
|
||||
parameters: mockSchema,
|
||||
strict: true,
|
||||
description: 'Process input',
|
||||
});
|
||||
expect(mockZodToJsonSchema).toHaveBeenCalledWith(tool.schema);
|
||||
});
|
||||
|
||||
it('should format a tool with non-strict mode when not all properties are required', () => {
|
||||
const mockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
required: { type: 'string' },
|
||||
optional: { type: 'string' },
|
||||
},
|
||||
required: ['required'],
|
||||
};
|
||||
mockZodToJsonSchema.mockReturnValue(mockSchema);
|
||||
|
||||
const tool = createMockTool('mixed-tool', 'Tool with required and optional fields');
|
||||
|
||||
const result = formatToOpenAIResponsesTool(tool);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'function',
|
||||
name: 'mixed-tool',
|
||||
parameters: mockSchema,
|
||||
strict: false,
|
||||
description: 'Tool with required and optional fields',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle schema without required field', () => {
|
||||
const mockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
required: { type: 'string' },
|
||||
optional: { type: 'string' },
|
||||
},
|
||||
required: ['required'],
|
||||
};
|
||||
mockZodToJsonSchema.mockReturnValue(mockSchema);
|
||||
|
||||
const tool = createMockTool('no-required-tool', 'Tool with no required fields');
|
||||
|
||||
const result = formatToOpenAIResponsesTool(tool);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'function',
|
||||
name: 'no-required-tool',
|
||||
parameters: mockSchema,
|
||||
strict: false,
|
||||
description: 'Tool with no required fields',
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep additionalProperties:false when strict is true', () => {
|
||||
const mockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
required: { type: 'string' },
|
||||
},
|
||||
required: ['required'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
mockZodToJsonSchema.mockReturnValue(mockSchema);
|
||||
|
||||
const tool = createMockTool(
|
||||
'empty-additional-properties-tool',
|
||||
'Tool with empty additional properties',
|
||||
);
|
||||
|
||||
const result = formatToOpenAIResponsesTool(tool);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'function',
|
||||
name: 'empty-additional-properties-tool',
|
||||
parameters: { ...mockSchema, additionalProperties: false },
|
||||
strict: true,
|
||||
description: 'Tool with empty additional properties',
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep additionalProperties:true when strict is true', () => {
|
||||
const mockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
required: { type: 'string' },
|
||||
},
|
||||
required: ['required'],
|
||||
additionalProperties: true,
|
||||
};
|
||||
mockZodToJsonSchema.mockReturnValue(mockSchema);
|
||||
|
||||
const tool = createMockTool(
|
||||
'additional-properties-true-tool',
|
||||
'Tool with additional properties true',
|
||||
);
|
||||
|
||||
const result = formatToOpenAIResponsesTool(tool);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'function',
|
||||
name: 'additional-properties-true-tool',
|
||||
parameters: { ...mockSchema, additionalProperties: true },
|
||||
strict: true,
|
||||
description: 'Tool with additional properties true',
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep not empty additionalProperties when strict is true', () => {
|
||||
const mockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
required: { type: 'string' },
|
||||
},
|
||||
required: ['required'],
|
||||
additionalProperties: { type: 'string' },
|
||||
};
|
||||
mockZodToJsonSchema.mockReturnValue(mockSchema);
|
||||
|
||||
const tool = createMockTool(
|
||||
'not-empty-additional-properties-tool',
|
||||
'Tool with not empty additional properties',
|
||||
);
|
||||
|
||||
const result = formatToOpenAIResponsesTool(tool);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'function',
|
||||
name: 'not-empty-additional-properties-tool',
|
||||
parameters: { ...mockSchema, additionalProperties: { type: 'string' } },
|
||||
strict: true,
|
||||
description: 'Tool with not empty additional properties',
|
||||
});
|
||||
});
|
||||
|
||||
it('should change empty additionalProperties to false when strict is true', () => {
|
||||
const mockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
required: { type: 'string' },
|
||||
},
|
||||
required: ['required'],
|
||||
additionalProperties: {},
|
||||
};
|
||||
mockZodToJsonSchema.mockReturnValue(mockSchema);
|
||||
|
||||
const tool = createMockTool(
|
||||
'empty-additional-properties-tool',
|
||||
'Tool with empty additional properties',
|
||||
);
|
||||
|
||||
const result = formatToOpenAIResponsesTool(tool);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'function',
|
||||
name: 'empty-additional-properties-tool',
|
||||
parameters: { ...mockSchema, additionalProperties: false },
|
||||
strict: true,
|
||||
description: 'Tool with empty additional properties',
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep empty additionalProperties when strict is false', () => {
|
||||
const mockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
optional: { type: 'string' },
|
||||
},
|
||||
additionalProperties: {},
|
||||
};
|
||||
mockZodToJsonSchema.mockReturnValue(mockSchema);
|
||||
|
||||
const tool = createMockTool(
|
||||
'empty-additional-properties-tool',
|
||||
'Tool with empty additional properties',
|
||||
);
|
||||
|
||||
const result = formatToOpenAIResponsesTool(tool);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'function',
|
||||
name: 'empty-additional-properties-tool',
|
||||
parameters: { ...mockSchema, additionalProperties: {} },
|
||||
strict: false,
|
||||
description: 'Tool with empty additional properties',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
import pick from 'lodash/pick';
|
||||
import type { WorkflowTestData } from 'n8n-workflow';
|
||||
import path from 'node:path';
|
||||
|
||||
describe('OpenAI Workflow', () => {
|
||||
const baseUrl = 'https://api.openai.com/v1';
|
||||
const credentials = {
|
||||
openAiApi: { url: baseUrl },
|
||||
};
|
||||
|
||||
const testHarness = new NodeTestHarness({
|
||||
additionalPackagePaths: [path.dirname(require.resolve('n8n-nodes-base'))],
|
||||
});
|
||||
|
||||
const assistants = [
|
||||
{
|
||||
id: 'asst_abc123',
|
||||
object: 'assistant',
|
||||
created_at: 1698982736,
|
||||
name: 'Coding Tutor',
|
||||
description: null,
|
||||
model: 'gpt-4o',
|
||||
tools: [],
|
||||
tool_resources: {},
|
||||
metadata: {},
|
||||
top_p: 1.0,
|
||||
temperature: 1.0,
|
||||
response_format: 'auto',
|
||||
},
|
||||
{
|
||||
id: 'asst_abc456',
|
||||
object: 'assistant',
|
||||
created_at: 1698982718,
|
||||
name: 'My Assistant',
|
||||
description: null,
|
||||
model: 'gpt-4o',
|
||||
tools: [],
|
||||
tool_resources: {},
|
||||
metadata: {},
|
||||
top_p: 1.0,
|
||||
temperature: 1.0,
|
||||
response_format: 'auto',
|
||||
},
|
||||
];
|
||||
|
||||
const testData: WorkflowTestData = {
|
||||
description: 'List Assistants',
|
||||
input: {
|
||||
workflowData: testHarness.readWorkflowJSON('list-assistants.workflow.json'),
|
||||
},
|
||||
output: {
|
||||
nodeExecutionOrder: ['When clicking ‘Execute workflow’', 'OpenAI'],
|
||||
nodeData: {
|
||||
OpenAI: [
|
||||
assistants.map((assistant) => ({
|
||||
json: pick(assistant, ['id', 'model', 'name']),
|
||||
})),
|
||||
],
|
||||
},
|
||||
},
|
||||
nock: {
|
||||
baseUrl,
|
||||
mocks: [
|
||||
{
|
||||
method: 'get',
|
||||
path: '/assistants?limit=100',
|
||||
statusCode: 200,
|
||||
responseBody: {
|
||||
object: 'list',
|
||||
data: assistants,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
testHarness.setupTest(testData, { credentials });
|
||||
});
|
||||
+639
@@ -0,0 +1,639 @@
|
||||
import FormData from 'form-data';
|
||||
import get from 'lodash/get';
|
||||
import type { IDataObject, IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import * as assistant from '../../v1/actions/assistant';
|
||||
import * as audio from '../../v1/actions/audio';
|
||||
import * as file from '../../v1/actions/file';
|
||||
import * as image from '../../v1/actions/image';
|
||||
import * as text from '../../v1/actions/text';
|
||||
import * as transport from '../../transport';
|
||||
|
||||
const createExecuteFunctionsMock = (parameters: IDataObject) => {
|
||||
const nodeParameters = parameters;
|
||||
return {
|
||||
getExecutionCancelSignal() {
|
||||
return new AbortController().signal;
|
||||
},
|
||||
getNodeParameter(parameter: string) {
|
||||
return get(nodeParameters, parameter);
|
||||
},
|
||||
getNode() {
|
||||
return {};
|
||||
},
|
||||
getInputConnectionData() {
|
||||
return undefined;
|
||||
},
|
||||
helpers: {
|
||||
prepareBinaryData() {
|
||||
return {};
|
||||
},
|
||||
assertBinaryData() {
|
||||
return {
|
||||
filename: 'filenale.flac',
|
||||
contentType: 'audio/flac',
|
||||
};
|
||||
},
|
||||
getBinaryDataBuffer() {
|
||||
return 'data buffer data';
|
||||
},
|
||||
},
|
||||
} as unknown as IExecuteFunctions;
|
||||
};
|
||||
|
||||
describe('OpenAi, Assistant resource', () => {
|
||||
beforeEach(() => {
|
||||
(transport as any).apiRequest = jest.fn();
|
||||
});
|
||||
|
||||
it('create => should throw an error if an assistant with the same name already exists', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({
|
||||
data: [{ name: 'name' }],
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await assistant.create.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
name: 'name',
|
||||
options: {
|
||||
failIfExists: true,
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
expect(true).toBe(false);
|
||||
} catch (error) {
|
||||
expect(error.message).toBe("An assistant with the same name 'name' already exists");
|
||||
}
|
||||
});
|
||||
|
||||
it('create => should call apiRequest with correct parameters', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({});
|
||||
|
||||
await assistant.create.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
modelId: 'gpt-model',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
instructions: 'some instructions',
|
||||
codeInterpreter: true,
|
||||
knowledgeRetrieval: true,
|
||||
file_ids: [],
|
||||
options: {},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/assistants', {
|
||||
body: {
|
||||
description: 'description',
|
||||
instructions: 'some instructions',
|
||||
model: 'gpt-model',
|
||||
name: 'name',
|
||||
tool_resources: {
|
||||
code_interpreter: {
|
||||
file_ids: [],
|
||||
},
|
||||
file_search: {
|
||||
vector_stores: [
|
||||
{
|
||||
file_ids: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
tools: [{ type: 'code_interpreter' }, { type: 'file_search' }],
|
||||
},
|
||||
headers: { 'OpenAI-Beta': 'assistants=v2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('create => should throw error if more then 20 files selected', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({});
|
||||
|
||||
try {
|
||||
await assistant.create.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
file_ids: Array.from({ length: 25 }),
|
||||
options: {},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
expect(true).toBe(false);
|
||||
} catch (error) {
|
||||
expect(error.message).toBe(
|
||||
'The maximum number of files that can be attached to the assistant is 20',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('delete => should call apiRequest with correct parameters', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({});
|
||||
|
||||
await assistant.deleteAssistant.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
assistantId: 'assistant-id',
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('DELETE', '/assistants/assistant-id', {
|
||||
headers: { 'OpenAI-Beta': 'assistants=v2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('list => should call apiRequest with correct parameters', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({
|
||||
data: [
|
||||
{ name: 'name1', id: 'id-1', model: 'gpt-model', other: 'other' },
|
||||
{ name: 'name2', id: 'id-2', model: 'gpt-model', other: 'other' },
|
||||
{ name: 'name3', id: 'id-3', model: 'gpt-model', other: 'other' },
|
||||
],
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const response = await assistant.list.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
simplify: true,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(response).toEqual([
|
||||
{
|
||||
json: { name: 'name1', id: 'id-1', model: 'gpt-model' },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
{
|
||||
json: { name: 'name2', id: 'id-2', model: 'gpt-model' },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
{
|
||||
json: { name: 'name3', id: 'id-3', model: 'gpt-model' },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('update => should call apiRequest with correct parameters', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({
|
||||
tools: [{ type: 'existing_tool' }],
|
||||
});
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({});
|
||||
|
||||
await assistant.update.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
assistantId: 'assistant-id',
|
||||
options: {
|
||||
modelId: 'gpt-model',
|
||||
name: 'name',
|
||||
instructions: 'some instructions',
|
||||
codeInterpreter: true,
|
||||
knowledgeRetrieval: true,
|
||||
file_ids: [],
|
||||
removeCustomTools: false,
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('GET', '/assistants/assistant-id', {
|
||||
headers: { 'OpenAI-Beta': 'assistants=v2' },
|
||||
});
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/assistants/assistant-id', {
|
||||
body: {
|
||||
instructions: 'some instructions',
|
||||
model: 'gpt-model',
|
||||
name: 'name',
|
||||
tool_resources: {
|
||||
code_interpreter: {
|
||||
file_ids: [],
|
||||
},
|
||||
},
|
||||
tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }],
|
||||
},
|
||||
headers: { 'OpenAI-Beta': 'assistants=v2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('update => should call apiRequest with file_ids as an array for search', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({
|
||||
tools: [{ type: 'existing_tool' }],
|
||||
});
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({});
|
||||
|
||||
await assistant.update.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
assistantId: 'assistant-id',
|
||||
options: {
|
||||
modelId: 'gpt-model',
|
||||
name: 'name',
|
||||
instructions: 'some instructions',
|
||||
codeInterpreter: true,
|
||||
knowledgeRetrieval: true,
|
||||
file_ids: ['1234'],
|
||||
removeCustomTools: false,
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('GET', '/assistants/assistant-id', {
|
||||
headers: { 'OpenAI-Beta': 'assistants=v2' },
|
||||
});
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/assistants/assistant-id', {
|
||||
body: {
|
||||
instructions: 'some instructions',
|
||||
model: 'gpt-model',
|
||||
name: 'name',
|
||||
tool_resources: {
|
||||
code_interpreter: {
|
||||
file_ids: ['1234'],
|
||||
},
|
||||
},
|
||||
tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }],
|
||||
},
|
||||
headers: { 'OpenAI-Beta': 'assistants=v2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('update => should call apiRequest with file_ids as strings for search', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({
|
||||
tools: [{ type: 'existing_tool' }],
|
||||
});
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({});
|
||||
|
||||
await assistant.update.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
assistantId: 'assistant-id',
|
||||
options: {
|
||||
modelId: 'gpt-model',
|
||||
name: 'name',
|
||||
instructions: 'some instructions',
|
||||
codeInterpreter: true,
|
||||
knowledgeRetrieval: true,
|
||||
file_ids: '1234, 5678, 90',
|
||||
removeCustomTools: false,
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('GET', '/assistants/assistant-id', {
|
||||
headers: { 'OpenAI-Beta': 'assistants=v2' },
|
||||
});
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/assistants/assistant-id', {
|
||||
body: {
|
||||
instructions: 'some instructions',
|
||||
model: 'gpt-model',
|
||||
name: 'name',
|
||||
tool_resources: {
|
||||
code_interpreter: {
|
||||
file_ids: ['1234', '5678', '90'],
|
||||
},
|
||||
},
|
||||
tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }],
|
||||
},
|
||||
headers: { 'OpenAI-Beta': 'assistants=v2' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAi, Audio resource', () => {
|
||||
beforeEach(() => {
|
||||
(transport as any).apiRequest = jest.fn();
|
||||
});
|
||||
|
||||
it('generate => should call apiRequest with correct parameters', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({});
|
||||
|
||||
const returnData = await audio.generate.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
model: 'tts-model',
|
||||
input: 'input',
|
||||
voice: 'fable',
|
||||
options: {
|
||||
response_format: 'flac',
|
||||
speed: 1.25,
|
||||
binaryPropertyOutput: 'myData',
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(returnData.length).toEqual(1);
|
||||
expect(returnData[0].binary?.myData).toBeDefined();
|
||||
expect(returnData[0].pairedItem).toBeDefined();
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/audio/speech', {
|
||||
body: {
|
||||
input: 'input',
|
||||
model: 'tts-model',
|
||||
response_format: 'flac',
|
||||
speed: 1.25,
|
||||
voice: 'fable',
|
||||
},
|
||||
option: { encoding: 'arraybuffer', json: false, returnFullResponse: true, useStream: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('transcribe => should call apiRequest with correct parameters', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({ text: 'transcribtion' });
|
||||
|
||||
const returnData = await audio.transcribe.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
binaryPropertyName: 'myData',
|
||||
options: {
|
||||
language: 'en',
|
||||
temperature: 1.1,
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(returnData.length).toEqual(1);
|
||||
expect(returnData[0].pairedItem).toBeDefined();
|
||||
expect(returnData[0].json).toEqual({ text: 'transcribtion' });
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/audio/transcriptions',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'content-type': expect.stringMatching(/^multipart\/form-data; boundary=/),
|
||||
}),
|
||||
option: expect.objectContaining({
|
||||
formData: expect.any(FormData),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('translate => should call apiRequest with correct parameters', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({ text: 'translations' });
|
||||
|
||||
const returnData = await audio.translate.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
binaryPropertyName: 'myData',
|
||||
options: {},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(returnData.length).toEqual(1);
|
||||
expect(returnData[0].pairedItem).toBeDefined();
|
||||
expect(returnData[0].json).toEqual({ text: 'translations' });
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/audio/translations',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'content-type': expect.stringMatching(/^multipart\/form-data; boundary=/),
|
||||
}),
|
||||
option: expect.objectContaining({
|
||||
formData: expect.any(FormData),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAi, File resource', () => {
|
||||
beforeEach(() => {
|
||||
(transport as any).apiRequest = jest.fn();
|
||||
});
|
||||
|
||||
it('deleteFile => should call apiRequest with correct parameters', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({});
|
||||
|
||||
await file.deleteFile.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
fileId: 'file-id',
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('DELETE', '/files/file-id');
|
||||
});
|
||||
|
||||
it('list => should return list of files', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({
|
||||
data: [{ file: 'file1' }, { file: 'file2' }, { file: 'file3' }],
|
||||
});
|
||||
|
||||
const returnData = await file.list.execute.call(createExecuteFunctionsMock({ options: {} }), 2);
|
||||
|
||||
expect(returnData.length).toEqual(3);
|
||||
expect(returnData).toEqual([
|
||||
{
|
||||
json: { file: 'file1' },
|
||||
pairedItem: { item: 2 },
|
||||
},
|
||||
{
|
||||
json: { file: 'file2' },
|
||||
pairedItem: { item: 2 },
|
||||
},
|
||||
{
|
||||
json: { file: 'file3' },
|
||||
pairedItem: { item: 2 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('upload => should call apiRequest with correct parameters', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({ success: true });
|
||||
|
||||
const returnData = await file.upload.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
binaryPropertyName: 'myData',
|
||||
options: {},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(returnData.length).toEqual(1);
|
||||
expect(returnData[0].pairedItem).toBeDefined();
|
||||
expect(returnData[0].json).toEqual({ success: true });
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/files',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'content-type': expect.stringMatching(/^multipart\/form-data; boundary=/),
|
||||
}),
|
||||
option: expect.objectContaining({
|
||||
formData: expect.any(FormData),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAi, Image resource', () => {
|
||||
beforeEach(() => {
|
||||
(transport as any).apiRequest = jest.fn();
|
||||
});
|
||||
|
||||
it('generate => should call apiRequest with correct parameters, return binary', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({ data: [{ b64_json: 'image1' }] });
|
||||
|
||||
const returnData = await image.generate.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
model: 'dall-e-3',
|
||||
prompt: 'cat with a hat',
|
||||
options: {
|
||||
size: '1024x1024',
|
||||
style: 'vivid',
|
||||
quality: 'hd',
|
||||
binaryPropertyOutput: 'myData',
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(returnData.length).toEqual(1);
|
||||
expect(returnData[0].binary?.myData).toBeDefined();
|
||||
expect(returnData[0].pairedItem).toBeDefined();
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/images/generations', {
|
||||
body: {
|
||||
model: 'dall-e-3',
|
||||
prompt: 'cat with a hat',
|
||||
quality: 'hd',
|
||||
response_format: 'b64_json',
|
||||
size: '1024x1024',
|
||||
style: 'vivid',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('generate => should call apiRequest with correct parameters, return urls', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({ data: [{ url: 'image-url' }] });
|
||||
|
||||
const returnData = await image.generate.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
model: 'dall-e-3',
|
||||
prompt: 'cat with a hat',
|
||||
options: {
|
||||
size: '1024x1024',
|
||||
style: 'vivid',
|
||||
quality: 'hd',
|
||||
binaryPropertyOutput: 'myData',
|
||||
returnImageUrls: true,
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(returnData.length).toEqual(1);
|
||||
expect(returnData[0].pairedItem).toBeDefined();
|
||||
expect(returnData).toEqual([{ json: { url: 'image-url' }, pairedItem: { item: 0 } }]);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/images/generations', {
|
||||
body: {
|
||||
model: 'dall-e-3',
|
||||
prompt: 'cat with a hat',
|
||||
quality: 'hd',
|
||||
response_format: 'url',
|
||||
size: '1024x1024',
|
||||
style: 'vivid',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('analyze => should call apiRequest with correct parameters', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({ success: true });
|
||||
|
||||
const returnData = await image.analyze.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
text: 'image text',
|
||||
inputType: 'url',
|
||||
imageUrls: 'image-url1, image-url2',
|
||||
options: {
|
||||
detail: 'low',
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(returnData.length).toEqual(1);
|
||||
expect(returnData[0].pairedItem).toBeDefined();
|
||||
expect(returnData[0].json).toEqual({ success: true });
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/chat/completions', {
|
||||
body: {
|
||||
max_tokens: 300,
|
||||
messages: [
|
||||
{
|
||||
content: [
|
||||
{ text: 'image text', type: 'text' },
|
||||
{ image_url: { detail: 'low', url: 'image-url1' }, type: 'image_url' },
|
||||
{ image_url: { detail: 'low', url: 'image-url2' }, type: 'image_url' },
|
||||
],
|
||||
role: 'user',
|
||||
},
|
||||
],
|
||||
model: 'gpt-4-vision-preview',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAi, Text resource', () => {
|
||||
beforeEach(() => {
|
||||
(transport as any).apiRequest = jest.fn();
|
||||
});
|
||||
|
||||
it('classify => should call apiRequest with correct parameters', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({ results: [{ flagged: true }] });
|
||||
|
||||
const returnData = await text.classify.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
input: 'input',
|
||||
options: { useStableModel: true },
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(returnData.length).toEqual(1);
|
||||
expect(returnData[0].pairedItem).toBeDefined();
|
||||
expect(returnData[0].json).toEqual({ flagged: true });
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/moderations', {
|
||||
body: { input: 'input', model: 'text-moderation-stable' },
|
||||
});
|
||||
});
|
||||
|
||||
it('message => should call apiRequest with correct parameters, no tool call', async () => {
|
||||
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({
|
||||
choices: [{ message: { tool_calls: undefined } }],
|
||||
});
|
||||
|
||||
await text.message.execute.call(
|
||||
createExecuteFunctionsMock({
|
||||
modelId: 'gpt-model',
|
||||
messages: {
|
||||
values: [{ role: 'user', content: 'message' }],
|
||||
},
|
||||
|
||||
options: {},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/chat/completions', {
|
||||
body: {
|
||||
messages: [{ content: 'message', role: 'user' }],
|
||||
model: 'gpt-model',
|
||||
response_format: undefined,
|
||||
tools: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [0, 0],
|
||||
"id": "ce6133c3-2eb6-4262-8e0c-54015ed0f795",
|
||||
"name": "When clicking ‘Execute workflow’"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"resource": "assistant",
|
||||
"operation": "list"
|
||||
},
|
||||
"type": "@n8n/n8n-nodes-langchain.openAi",
|
||||
"typeVersion": 1.8,
|
||||
"position": [220, 0],
|
||||
"id": "070d2fcc-032c-4c3f-ae33-80a5352785f8",
|
||||
"name": "OpenAI",
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "123",
|
||||
"name": "OpenAi account"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"When clicking ‘Execute workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "OpenAI",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {}
|
||||
}
|
||||
+912
@@ -0,0 +1,912 @@
|
||||
import { mock, mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
|
||||
import * as binaryDataHelpers from '../../../../helpers/binary-data';
|
||||
import type { ChatResponse } from '../../../../helpers/interfaces';
|
||||
import * as transport from '../../../../transport';
|
||||
import { execute } from '../../../../v2/actions/image/analyze.operation';
|
||||
|
||||
jest.mock('../../../../helpers/binary-data');
|
||||
jest.mock('../../../../transport');
|
||||
|
||||
describe('Image Analyze Operation', () => {
|
||||
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
|
||||
let mockNode: INode;
|
||||
const apiRequestSpy = jest.spyOn(transport, 'apiRequest');
|
||||
const getBinaryDataFileSpy = jest.spyOn(binaryDataHelpers, 'getBinaryDataFile');
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
|
||||
mockNode = mock<INode>({
|
||||
id: 'test-node',
|
||||
name: 'OpenAI Image Analyze',
|
||||
type: 'n8n-nodes-base.openAi',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.helpers.binaryToBuffer = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('successful execution with URL input', () => {
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'gpt-4o',
|
||||
text: "What's in this image?",
|
||||
inputType: 'url',
|
||||
imageUrls: 'https://example.com/image1.jpg',
|
||||
simplify: true,
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
});
|
||||
|
||||
it('should analyze single image from URL with simplified output', async () => {
|
||||
const mockResponse = {
|
||||
id: 'response-123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'This image shows a beautiful landscape with mountains and a lake.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as ChatResponse;
|
||||
|
||||
apiRequestSpy.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
|
||||
body: {
|
||||
model: 'gpt-4o',
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'input_text',
|
||||
text: "What's in this image?",
|
||||
},
|
||||
{
|
||||
type: 'input_image',
|
||||
detail: 'auto',
|
||||
image_url: 'https://example.com/image1.jpg',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
max_output_tokens: 300,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: mockResponse.output,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should analyze multiple images from URLs', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'gpt-4o',
|
||||
text: 'Compare these images',
|
||||
inputType: 'url',
|
||||
imageUrls: 'https://example.com/image1.jpg, https://example.com/image2.png',
|
||||
simplify: true,
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
id: 'response-456',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'The first image shows a landscape, while the second shows a cityscape.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as ChatResponse;
|
||||
|
||||
apiRequestSpy.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
|
||||
body: {
|
||||
model: 'gpt-4o',
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'input_text',
|
||||
text: 'Compare these images',
|
||||
},
|
||||
{
|
||||
type: 'input_image',
|
||||
detail: 'auto',
|
||||
image_url: 'https://example.com/image1.jpg',
|
||||
},
|
||||
{
|
||||
type: 'input_image',
|
||||
detail: 'auto',
|
||||
image_url: 'https://example.com/image2.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
max_output_tokens: 300,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle URLs with extra whitespace', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'gpt-4o',
|
||||
text: 'Analyze these images',
|
||||
inputType: 'url',
|
||||
imageUrls: ' https://example.com/image1.jpg , https://example.com/image2.png ',
|
||||
simplify: true,
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
id: 'response-789',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Analysis complete.' }],
|
||||
},
|
||||
],
|
||||
} as ChatResponse;
|
||||
|
||||
apiRequestSpy.mockResolvedValue(mockResponse);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
|
||||
body: expect.objectContaining({
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
expect.objectContaining({ type: 'input_text' }),
|
||||
expect.objectContaining({
|
||||
type: 'input_image',
|
||||
image_url: 'https://example.com/image1.jpg',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'input_image',
|
||||
image_url: 'https://example.com/image2.png',
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom options for URL analysis', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'gpt-4o-mini',
|
||||
text: 'Describe this image in detail',
|
||||
inputType: 'url',
|
||||
imageUrls: 'https://example.com/detailed-image.jpg',
|
||||
simplify: false,
|
||||
options: {
|
||||
detail: 'high',
|
||||
maxTokens: 500,
|
||||
},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
id: 'response-detailed',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Detailed analysis...' }],
|
||||
},
|
||||
],
|
||||
} as ChatResponse;
|
||||
|
||||
apiRequestSpy.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
|
||||
body: {
|
||||
model: 'gpt-4o-mini',
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'input_text',
|
||||
text: 'Describe this image in detail',
|
||||
},
|
||||
{
|
||||
type: 'input_image',
|
||||
detail: 'high',
|
||||
image_url: 'https://example.com/detailed-image.jpg',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
max_output_tokens: 500,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: mockResponse,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful execution with binary input', () => {
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'gpt-4o',
|
||||
text: 'Analyze this image',
|
||||
inputType: 'base64',
|
||||
binaryPropertyName: 'image_data',
|
||||
simplify: true,
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
});
|
||||
|
||||
it('should analyze single binary image', async () => {
|
||||
const mockBinaryFile = {
|
||||
fileContent: Buffer.from('mock-image-data'),
|
||||
contentType: 'image/jpeg',
|
||||
filename: 'test.jpg',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: 'response-binary',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'This is a JPEG image showing...' }],
|
||||
},
|
||||
],
|
||||
} as ChatResponse;
|
||||
|
||||
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
|
||||
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
|
||||
mockBinaryFile.fileContent,
|
||||
);
|
||||
apiRequestSpy.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(getBinaryDataFileSpy).toHaveBeenCalledWith(mockExecuteFunctions, 0, 'image_data');
|
||||
expect(mockExecuteFunctions.helpers.binaryToBuffer).toHaveBeenCalledWith(
|
||||
mockBinaryFile.fileContent,
|
||||
);
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
|
||||
body: {
|
||||
model: 'gpt-4o',
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'input_text',
|
||||
text: 'Analyze this image',
|
||||
},
|
||||
{
|
||||
type: 'input_image',
|
||||
detail: 'auto',
|
||||
image_url: `data:image/jpeg;base64,${mockBinaryFile.fileContent.toString('base64')}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
max_output_tokens: 300,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: mockResponse.output,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should analyze multiple binary images', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'gpt-4o',
|
||||
text: 'Compare these images',
|
||||
inputType: 'base64',
|
||||
binaryPropertyName: 'image1, image2',
|
||||
simplify: true,
|
||||
options: { detail: 'low' },
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockBinaryFile1 = {
|
||||
fileContent: Buffer.from('mock-image-data-1'),
|
||||
contentType: 'image/png',
|
||||
filename: 'image1.png',
|
||||
};
|
||||
|
||||
const mockBinaryFile2 = {
|
||||
fileContent: Buffer.from('mock-image-data-2'),
|
||||
contentType: 'image/gif',
|
||||
filename: 'image2.gif',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: 'response-multi-binary',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Comparison of the two images...' }],
|
||||
},
|
||||
],
|
||||
} as ChatResponse;
|
||||
|
||||
getBinaryDataFileSpy
|
||||
.mockResolvedValueOnce(mockBinaryFile1)
|
||||
.mockResolvedValueOnce(mockBinaryFile2);
|
||||
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock)
|
||||
.mockResolvedValueOnce(mockBinaryFile1.fileContent)
|
||||
.mockResolvedValueOnce(mockBinaryFile2.fileContent);
|
||||
apiRequestSpy.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(getBinaryDataFileSpy).toHaveBeenCalledTimes(2);
|
||||
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(1, mockExecuteFunctions, 0, 'image1');
|
||||
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(2, mockExecuteFunctions, 0, 'image2');
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
|
||||
body: {
|
||||
model: 'gpt-4o',
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'input_text',
|
||||
text: 'Compare these images',
|
||||
},
|
||||
{
|
||||
type: 'input_image',
|
||||
detail: 'low',
|
||||
image_url: `data:image/png;base64,${mockBinaryFile1.fileContent.toString('base64')}`,
|
||||
},
|
||||
{
|
||||
type: 'input_image',
|
||||
detail: 'low',
|
||||
image_url: `data:image/gif;base64,${mockBinaryFile2.fileContent.toString('base64')}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
max_output_tokens: 300,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle binary property names with whitespace', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'gpt-4o',
|
||||
text: 'Analyze images',
|
||||
inputType: 'base64',
|
||||
binaryPropertyName: ' image1 , image2 ',
|
||||
simplify: true,
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockBinaryFile1 = {
|
||||
fileContent: Buffer.from('mock-image-data-1'),
|
||||
contentType: 'image/png',
|
||||
filename: 'image1.png',
|
||||
};
|
||||
|
||||
const mockBinaryFile2 = {
|
||||
fileContent: Buffer.from('mock-image-data-2'),
|
||||
contentType: 'image/jpeg',
|
||||
filename: 'image2.jpg',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: 'response-whitespace',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Analysis complete.' }],
|
||||
},
|
||||
],
|
||||
} as ChatResponse;
|
||||
|
||||
getBinaryDataFileSpy
|
||||
.mockResolvedValueOnce(mockBinaryFile1)
|
||||
.mockResolvedValueOnce(mockBinaryFile2);
|
||||
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock)
|
||||
.mockResolvedValueOnce(mockBinaryFile1.fileContent)
|
||||
.mockResolvedValueOnce(mockBinaryFile2.fileContent);
|
||||
apiRequestSpy.mockResolvedValue(mockResponse);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(1, mockExecuteFunctions, 0, 'image1');
|
||||
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(2, mockExecuteFunctions, 0, 'image2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parameter validation and edge cases', () => {
|
||||
it('should use default model when not specified', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(paramName: string, _index: number, defaultValue?: any) => {
|
||||
const params: Record<string, any> = {
|
||||
text: 'Analyze this image',
|
||||
inputType: 'url',
|
||||
imageUrls: 'https://example.com/image.jpg',
|
||||
simplify: true,
|
||||
options: {},
|
||||
};
|
||||
|
||||
if (paramName === 'modelId') {
|
||||
return defaultValue; // Should return 'gpt-4o' as default
|
||||
}
|
||||
|
||||
return params[paramName];
|
||||
},
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
id: 'response-default-model',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Analysis with default model.' }],
|
||||
},
|
||||
],
|
||||
} as ChatResponse;
|
||||
|
||||
apiRequestSpy.mockResolvedValue(mockResponse);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
|
||||
body: expect.objectContaining({
|
||||
model: 'gpt-4o',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default text when not specified', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(paramName: string, _index: number, defaultValue?: any) => {
|
||||
const params: Record<string, any> = {
|
||||
modelId: 'gpt-4o',
|
||||
inputType: 'url',
|
||||
imageUrls: 'https://example.com/image.jpg',
|
||||
simplify: true,
|
||||
options: {},
|
||||
};
|
||||
|
||||
if (paramName === 'text') {
|
||||
return defaultValue; // Should return empty string as default
|
||||
}
|
||||
|
||||
return params[paramName];
|
||||
},
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
id: 'response-default-text',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Analysis with default text.' }],
|
||||
},
|
||||
],
|
||||
} as ChatResponse;
|
||||
|
||||
apiRequestSpy.mockResolvedValue(mockResponse);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
|
||||
body: expect.objectContaining({
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'input_text',
|
||||
text: '',
|
||||
},
|
||||
expect.any(Object),
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default maxTokens when not specified in options', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'gpt-4o',
|
||||
text: 'Analyze this image',
|
||||
inputType: 'url',
|
||||
imageUrls: 'https://example.com/image.jpg',
|
||||
simplify: true,
|
||||
options: {}, // No maxTokens specified
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
id: 'response-default-tokens',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Analysis with default token limit.' }],
|
||||
},
|
||||
],
|
||||
} as ChatResponse;
|
||||
|
||||
apiRequestSpy.mockResolvedValue(mockResponse);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
|
||||
body: expect.objectContaining({
|
||||
max_output_tokens: 300,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default detail level when not specified in options', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'gpt-4o',
|
||||
text: 'Analyze this image',
|
||||
inputType: 'url',
|
||||
imageUrls: 'https://example.com/image.jpg',
|
||||
simplify: true,
|
||||
options: {}, // No detail specified
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
id: 'response-default-detail',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Analysis with default detail level.' }],
|
||||
},
|
||||
],
|
||||
} as ChatResponse;
|
||||
|
||||
apiRequestSpy.mockResolvedValue(mockResponse);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
|
||||
body: expect.objectContaining({
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
type: 'input_image',
|
||||
detail: 'auto',
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle mixed empty and valid URLs', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'gpt-4o',
|
||||
text: 'Analyze these images',
|
||||
inputType: 'url',
|
||||
imageUrls: 'https://example.com/image1.jpg, , https://example.com/image2.png',
|
||||
simplify: true,
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
id: 'response-mixed-urls',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Analysis of valid images.' }],
|
||||
},
|
||||
],
|
||||
} as ChatResponse;
|
||||
|
||||
apiRequestSpy.mockResolvedValue(mockResponse);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
|
||||
body: expect.objectContaining({
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
image_url: 'https://example.com/image1.jpg',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
image_url: '',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
image_url: 'https://example.com/image2.png',
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('output formatting', () => {
|
||||
it('should return simplified output when simplify is true', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'gpt-4o',
|
||||
text: 'Analyze this image',
|
||||
inputType: 'url',
|
||||
imageUrls: 'https://example.com/image.jpg',
|
||||
simplify: true,
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
id: 'response-simplified',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'This is the analysis result.' }],
|
||||
},
|
||||
],
|
||||
} as ChatResponse;
|
||||
|
||||
apiRequestSpy.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: mockResponse.output,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return full response when simplify is false', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'gpt-4o',
|
||||
text: 'Analyze this image',
|
||||
inputType: 'url',
|
||||
imageUrls: 'https://example.com/image.jpg',
|
||||
simplify: false,
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
id: 'response-full',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'This is the analysis result.' }],
|
||||
},
|
||||
],
|
||||
} as ChatResponse;
|
||||
|
||||
apiRequestSpy.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: mockResponse,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('options handling', () => {
|
||||
it('should apply all available options correctly', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'gpt-4o-mini',
|
||||
text: 'Provide detailed analysis',
|
||||
inputType: 'url',
|
||||
imageUrls: 'https://example.com/complex-image.jpg',
|
||||
simplify: false,
|
||||
options: {
|
||||
detail: 'high',
|
||||
maxTokens: 1000,
|
||||
},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
id: 'response-options',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Detailed analysis...' }],
|
||||
},
|
||||
],
|
||||
} as ChatResponse;
|
||||
|
||||
apiRequestSpy.mockResolvedValue(mockResponse);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
|
||||
body: {
|
||||
model: 'gpt-4o-mini',
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'input_text',
|
||||
text: 'Provide detailed analysis',
|
||||
},
|
||||
{
|
||||
type: 'input_image',
|
||||
detail: 'high',
|
||||
image_url: 'https://example.com/complex-image.jpg',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
max_output_tokens: 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle partial options correctly', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'gpt-4o',
|
||||
text: 'Quick analysis',
|
||||
inputType: 'url',
|
||||
imageUrls: 'https://example.com/image.jpg',
|
||||
simplify: true,
|
||||
options: {
|
||||
detail: 'low',
|
||||
// maxTokens not specified, should use default
|
||||
},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
id: 'response-partial-options',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Quick analysis result.' }],
|
||||
},
|
||||
],
|
||||
} as ChatResponse;
|
||||
|
||||
apiRequestSpy.mockResolvedValue(mockResponse);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
|
||||
body: expect.objectContaining({
|
||||
max_output_tokens: 300, // Default value
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
detail: 'low',
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Vendored
+692
@@ -0,0 +1,692 @@
|
||||
import FormData from 'form-data';
|
||||
import { mock, mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
|
||||
import * as binaryDataHelpers from '../../../../helpers/binary-data';
|
||||
import * as transport from '../../../../transport';
|
||||
import { execute } from '../../../../v2/actions/image/edit.operation';
|
||||
|
||||
jest.mock('../../../../helpers/binary-data');
|
||||
jest.mock('../../../../transport');
|
||||
jest.mock('form-data', () => jest.fn());
|
||||
|
||||
const mockFormData = jest.mocked(FormData);
|
||||
|
||||
describe('Image Edit Operation', () => {
|
||||
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
|
||||
let mockNode: INode;
|
||||
let mockFormDataInstance: jest.Mocked<FormData>;
|
||||
const apiRequestSpy = jest.spyOn(transport, 'apiRequest');
|
||||
const getBinaryDataFileSpy = jest.spyOn(binaryDataHelpers, 'getBinaryDataFile');
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
|
||||
mockNode = mock<INode>({
|
||||
id: 'test-node',
|
||||
name: 'OpenAI Image Edit',
|
||||
type: 'n8n-nodes-base.openAi',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.helpers.prepareBinaryData = jest.fn();
|
||||
mockExecuteFunctions.helpers.binaryToBuffer = jest.fn();
|
||||
|
||||
mockFormDataInstance = {
|
||||
append: jest.fn(),
|
||||
getHeaders: jest.fn().mockReturnValue({ 'content-type': 'multipart/form-data' }),
|
||||
} as unknown as jest.Mocked<FormData>;
|
||||
mockFormData.mockImplementation(() => mockFormDataInstance);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('successful execution with DALL-E 2', () => {
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
model: 'dall-e-2',
|
||||
prompt: 'Add a rainbow to this landscape',
|
||||
binaryPropertyName: 'image_data',
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
quality: 'standard',
|
||||
responseFormat: 'url',
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
});
|
||||
|
||||
it('should edit image with basic parameters', async () => {
|
||||
const mockBinaryFile = {
|
||||
fileContent: Buffer.from('mock-image-data'),
|
||||
contentType: 'image/png',
|
||||
filename: 'test.png',
|
||||
};
|
||||
|
||||
const mockApiResponse = {
|
||||
data: [
|
||||
{
|
||||
url: 'https://example.com/edited-image.png',
|
||||
revised_prompt: 'Add a rainbow to this landscape',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
|
||||
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
|
||||
mockBinaryFile.fileContent,
|
||||
);
|
||||
apiRequestSpy.mockResolvedValue(mockApiResponse);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(getBinaryDataFileSpy).toHaveBeenCalledWith(mockExecuteFunctions, 0, 'image_data');
|
||||
expect(mockExecuteFunctions.helpers.binaryToBuffer).toHaveBeenCalledWith(
|
||||
mockBinaryFile.fileContent,
|
||||
);
|
||||
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
|
||||
'image',
|
||||
mockBinaryFile.fileContent,
|
||||
{
|
||||
filename: 'test.png',
|
||||
contentType: 'image/png',
|
||||
},
|
||||
);
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
|
||||
'prompt',
|
||||
'Add a rainbow to this landscape',
|
||||
);
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('model', 'dall-e-2');
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('n', '1');
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('size', '1024x1024');
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('response_format', 'url');
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/images/edits', {
|
||||
option: { formData: mockFormDataInstance },
|
||||
headers: { 'content-type': 'multipart/form-data' },
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
url: 'https://example.com/edited-image.png',
|
||||
revised_prompt: 'Add a rainbow to this landscape',
|
||||
},
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle base64 response format', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
model: 'dall-e-2',
|
||||
prompt: 'Edit this image',
|
||||
binaryPropertyName: 'image_data',
|
||||
n: 1,
|
||||
size: '512x512',
|
||||
quality: 'standard',
|
||||
responseFormat: 'b64_json',
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockBinaryFile = {
|
||||
fileContent: Buffer.from('mock-image-data'),
|
||||
contentType: 'image/png',
|
||||
filename: 'test.png',
|
||||
};
|
||||
|
||||
const mockApiResponse = {
|
||||
data: [
|
||||
{
|
||||
b64_json: 'base64encodedimagedata',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockBinaryData = {
|
||||
data: 'base64encodedimagedata',
|
||||
mimeType: 'image/png',
|
||||
fileName: 'data',
|
||||
};
|
||||
|
||||
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
|
||||
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
|
||||
mockBinaryFile.fileContent,
|
||||
);
|
||||
apiRequestSpy.mockResolvedValue(mockApiResponse);
|
||||
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
|
||||
mockBinaryData,
|
||||
);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockExecuteFunctions.helpers.prepareBinaryData).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
'data',
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
data: undefined,
|
||||
mimeType: 'image/png',
|
||||
fileName: 'data',
|
||||
},
|
||||
binary: {
|
||||
data: mockBinaryData,
|
||||
},
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle multiple images generation', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
model: 'dall-e-2',
|
||||
prompt: 'Create variations of this image',
|
||||
binaryPropertyName: 'image_data',
|
||||
n: 3,
|
||||
size: '256x256',
|
||||
quality: 'standard',
|
||||
responseFormat: 'url',
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockBinaryFile = {
|
||||
fileContent: Buffer.from('mock-image-data'),
|
||||
contentType: 'image/png',
|
||||
filename: 'test.png',
|
||||
};
|
||||
|
||||
const mockApiResponse = {
|
||||
data: [
|
||||
{ url: 'https://example.com/edited-image-1.png' },
|
||||
{ url: 'https://example.com/edited-image-2.png' },
|
||||
{ url: 'https://example.com/edited-image-3.png' },
|
||||
],
|
||||
};
|
||||
|
||||
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
|
||||
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
|
||||
mockBinaryFile.fileContent,
|
||||
);
|
||||
apiRequestSpy.mockResolvedValue(mockApiResponse);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('n', '3');
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].json.url).toBe('https://example.com/edited-image-1.png');
|
||||
expect(result[1].json.url).toBe('https://example.com/edited-image-2.png');
|
||||
expect(result[2].json.url).toBe('https://example.com/edited-image-3.png');
|
||||
});
|
||||
|
||||
it('should handle image mask option', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
model: 'dall-e-2',
|
||||
prompt: 'Edit specific area of image',
|
||||
binaryPropertyName: 'image_data',
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
quality: 'standard',
|
||||
responseFormat: 'url',
|
||||
options: {
|
||||
imageMask: 'mask_data',
|
||||
user: 'test-user-123',
|
||||
},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockImageFile = {
|
||||
fileContent: Buffer.from('mock-image-data'),
|
||||
contentType: 'image/png',
|
||||
filename: 'test.png',
|
||||
};
|
||||
|
||||
const mockMaskFile = {
|
||||
fileContent: Buffer.from('mock-mask-data'),
|
||||
contentType: 'image/png',
|
||||
filename: 'mask.png',
|
||||
};
|
||||
|
||||
const mockApiResponse = {
|
||||
data: [{ url: 'https://example.com/edited-image.png' }],
|
||||
};
|
||||
|
||||
getBinaryDataFileSpy.mockResolvedValueOnce(mockImageFile).mockResolvedValueOnce(mockMaskFile);
|
||||
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock)
|
||||
.mockResolvedValueOnce(mockImageFile.fileContent)
|
||||
.mockResolvedValueOnce(mockMaskFile.fileContent);
|
||||
apiRequestSpy.mockResolvedValue(mockApiResponse);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(getBinaryDataFileSpy).toHaveBeenCalledTimes(2);
|
||||
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockExecuteFunctions,
|
||||
0,
|
||||
'image_data',
|
||||
);
|
||||
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(2, mockExecuteFunctions, 0, 'mask_data');
|
||||
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('image', mockImageFile.fileContent, {
|
||||
filename: 'test.png',
|
||||
contentType: 'image/png',
|
||||
});
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('mask', mockMaskFile.fileContent, {
|
||||
filename: 'mask.png',
|
||||
contentType: 'image/png',
|
||||
});
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('user', 'test-user-123');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful execution with GPT Image 1', () => {
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
model: 'gpt-image-1',
|
||||
prompt: 'Transform this image with AI magic',
|
||||
images: {
|
||||
values: [{ binaryPropertyName: 'image1' }, { binaryPropertyName: 'image2' }],
|
||||
},
|
||||
n: 1,
|
||||
size: '1536x1024',
|
||||
quality: 'high',
|
||||
options: {
|
||||
background: 'transparent',
|
||||
inputFidelity: 'high',
|
||||
outputFormat: 'webp',
|
||||
outputCompression: 85,
|
||||
},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
});
|
||||
|
||||
it('should edit image with GPT Image 1 model', async () => {
|
||||
const mockBinaryFile1 = {
|
||||
fileContent: Buffer.from('mock-image-data-1'),
|
||||
contentType: 'image/jpeg',
|
||||
filename: 'image1.jpg',
|
||||
};
|
||||
|
||||
const mockBinaryFile2 = {
|
||||
fileContent: Buffer.from('mock-image-data-2'),
|
||||
contentType: 'image/png',
|
||||
filename: 'image2.png',
|
||||
};
|
||||
|
||||
const mockApiResponse = {
|
||||
data: [
|
||||
{
|
||||
b64_json: 'base64encodedimagedata',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockBinaryData = {
|
||||
data: 'base64encodedimagedata',
|
||||
mimeType: 'image/webp',
|
||||
fileName: 'data',
|
||||
};
|
||||
|
||||
getBinaryDataFileSpy
|
||||
.mockResolvedValueOnce(mockBinaryFile1)
|
||||
.mockResolvedValueOnce(mockBinaryFile2);
|
||||
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock)
|
||||
.mockResolvedValueOnce(mockBinaryFile1.fileContent)
|
||||
.mockResolvedValueOnce(mockBinaryFile2.fileContent);
|
||||
apiRequestSpy.mockResolvedValue(mockApiResponse);
|
||||
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
|
||||
mockBinaryData,
|
||||
);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(getBinaryDataFileSpy).toHaveBeenCalledTimes(2);
|
||||
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(1, mockExecuteFunctions, 0, 'image1');
|
||||
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(2, mockExecuteFunctions, 0, 'image2');
|
||||
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
|
||||
'image[]',
|
||||
mockBinaryFile1.fileContent,
|
||||
{
|
||||
filename: 'image1.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
},
|
||||
);
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
|
||||
'image[]',
|
||||
mockBinaryFile2.fileContent,
|
||||
{
|
||||
filename: 'image2.png',
|
||||
contentType: 'image/png',
|
||||
},
|
||||
);
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
|
||||
'prompt',
|
||||
'Transform this image with AI magic',
|
||||
);
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('model', 'gpt-image-1');
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('background', 'transparent');
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('input_fidelity', 'high');
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('output_format', 'webp');
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('output_compression', '85');
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('quality', 'high');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
data: undefined,
|
||||
mimeType: 'image/webp',
|
||||
fileName: 'data',
|
||||
},
|
||||
binary: {
|
||||
data: mockBinaryData,
|
||||
},
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle default images parameter for GPT Image 1', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
model: 'gpt-image-1',
|
||||
prompt: 'Edit this image',
|
||||
images: {
|
||||
values: [{ binaryPropertyName: 'data' }],
|
||||
},
|
||||
n: 1,
|
||||
size: 'auto',
|
||||
quality: 'auto',
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockBinaryFile = {
|
||||
fileContent: Buffer.from('mock-image-data'),
|
||||
contentType: 'image/png',
|
||||
filename: 'data.png',
|
||||
};
|
||||
|
||||
const mockApiResponse = {
|
||||
data: [{ b64_json: 'base64encodedimagedata' }],
|
||||
};
|
||||
|
||||
const mockBinaryData = {
|
||||
data: 'base64encodedimagedata',
|
||||
mimeType: 'image/png',
|
||||
fileName: 'data',
|
||||
};
|
||||
|
||||
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
|
||||
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
|
||||
mockBinaryFile.fileContent,
|
||||
);
|
||||
apiRequestSpy.mockResolvedValue(mockApiResponse);
|
||||
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
|
||||
mockBinaryData,
|
||||
);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
|
||||
'image[]',
|
||||
mockBinaryFile.fileContent,
|
||||
{
|
||||
filename: 'data.png',
|
||||
contentType: 'image/png',
|
||||
},
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parameter validation and edge cases', () => {
|
||||
it('should handle missing response data', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
model: 'dall-e-2',
|
||||
prompt: 'Edit this image',
|
||||
binaryPropertyName: 'image_data',
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
quality: 'standard',
|
||||
responseFormat: 'url',
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockBinaryFile = {
|
||||
fileContent: Buffer.from('mock-image-data'),
|
||||
contentType: 'image/png',
|
||||
filename: 'test.png',
|
||||
};
|
||||
|
||||
const mockApiResponse = {};
|
||||
|
||||
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
|
||||
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
|
||||
mockBinaryFile.fileContent,
|
||||
);
|
||||
apiRequestSpy.mockResolvedValue(mockApiResponse);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle zero output compression value', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
model: 'gpt-image-1',
|
||||
prompt: 'Edit this image',
|
||||
images: {
|
||||
values: [{ binaryPropertyName: 'data' }],
|
||||
},
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
quality: 'auto',
|
||||
options: {
|
||||
outputCompression: 0,
|
||||
},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockBinaryFile = {
|
||||
fileContent: Buffer.from('mock-image-data'),
|
||||
contentType: 'image/png',
|
||||
filename: 'data.png',
|
||||
};
|
||||
|
||||
const mockApiResponse = {
|
||||
data: [{ b64_json: 'base64encodedimagedata' }],
|
||||
};
|
||||
|
||||
const mockBinaryData = {
|
||||
data: 'base64encodedimagedata',
|
||||
mimeType: 'image/png',
|
||||
fileName: 'data',
|
||||
};
|
||||
|
||||
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
|
||||
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
|
||||
mockBinaryFile.fileContent,
|
||||
);
|
||||
apiRequestSpy.mockResolvedValue(mockApiResponse);
|
||||
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
|
||||
mockBinaryData,
|
||||
);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('output_compression', '0');
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not append optional parameters when not provided', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
model: 'dall-e-2',
|
||||
prompt: 'Edit this image',
|
||||
binaryPropertyName: 'image_data',
|
||||
n: 0,
|
||||
size: '',
|
||||
quality: '',
|
||||
responseFormat: 'url',
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockBinaryFile = {
|
||||
fileContent: Buffer.from('mock-image-data'),
|
||||
contentType: 'image/png',
|
||||
filename: 'test.png',
|
||||
};
|
||||
|
||||
const mockApiResponse = {
|
||||
data: [{ url: 'https://example.com/edited-image.png' }],
|
||||
};
|
||||
|
||||
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
|
||||
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
|
||||
mockBinaryFile.fileContent,
|
||||
);
|
||||
apiRequestSpy.mockResolvedValue(mockApiResponse);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockFormDataInstance.append).not.toHaveBeenCalledWith('n', '0');
|
||||
expect(mockFormDataInstance.append).not.toHaveBeenCalledWith('size', '');
|
||||
expect(mockFormDataInstance.append).not.toHaveBeenCalledWith('quality', '');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FormData handling', () => {
|
||||
it('should create FormData with correct headers', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
model: 'dall-e-2',
|
||||
prompt: 'Edit this image',
|
||||
binaryPropertyName: 'image_data',
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
quality: 'standard',
|
||||
responseFormat: 'url',
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockBinaryFile = {
|
||||
fileContent: Buffer.from('mock-image-data'),
|
||||
contentType: 'image/png',
|
||||
filename: 'test.png',
|
||||
};
|
||||
|
||||
const mockApiResponse = {
|
||||
data: [{ url: 'https://example.com/edited-image.png' }],
|
||||
};
|
||||
|
||||
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
|
||||
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
|
||||
mockBinaryFile.fileContent,
|
||||
);
|
||||
apiRequestSpy.mockResolvedValue(mockApiResponse);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockFormDataInstance.getHeaders).toHaveBeenCalled();
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/images/edits', {
|
||||
option: { formData: mockFormDataInstance },
|
||||
headers: { 'content-type': 'multipart/form-data' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out empty binary property names for GPT Image 1', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
model: 'gpt-image-1',
|
||||
prompt: 'Edit this image',
|
||||
images: {
|
||||
values: [
|
||||
{ binaryPropertyName: 'image1' },
|
||||
{ binaryPropertyName: '' },
|
||||
{ binaryPropertyName: 'image2' },
|
||||
{ binaryPropertyName: undefined },
|
||||
{},
|
||||
],
|
||||
},
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
quality: 'auto',
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockBinaryFile1 = {
|
||||
fileContent: Buffer.from('mock-image-data-1'),
|
||||
contentType: 'image/png',
|
||||
filename: 'image1.png',
|
||||
};
|
||||
|
||||
const mockBinaryFile2 = {
|
||||
fileContent: Buffer.from('mock-image-data-2'),
|
||||
contentType: 'image/jpeg',
|
||||
filename: 'image2.jpg',
|
||||
};
|
||||
|
||||
const mockApiResponse = {
|
||||
data: [{ b64_json: 'base64encodedimagedata' }],
|
||||
};
|
||||
|
||||
const mockBinaryData = {
|
||||
data: 'base64encodedimagedata',
|
||||
mimeType: 'image/png',
|
||||
fileName: 'data',
|
||||
};
|
||||
|
||||
getBinaryDataFileSpy
|
||||
.mockResolvedValueOnce(mockBinaryFile1)
|
||||
.mockResolvedValueOnce(mockBinaryFile2);
|
||||
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock)
|
||||
.mockResolvedValueOnce(mockBinaryFile1.fileContent)
|
||||
.mockResolvedValueOnce(mockBinaryFile2.fileContent);
|
||||
apiRequestSpy.mockResolvedValue(mockApiResponse);
|
||||
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
|
||||
mockBinaryData,
|
||||
);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(getBinaryDataFileSpy).toHaveBeenCalledTimes(2);
|
||||
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(1, mockExecuteFunctions, 0, 'image1');
|
||||
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(2, mockExecuteFunctions, 0, 'image2');
|
||||
});
|
||||
});
|
||||
});
|
||||
Vendored
+102
@@ -0,0 +1,102 @@
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
|
||||
import * as transport from '../../../../transport';
|
||||
import * as classify from '../../../../v2/actions/text/classify.operation';
|
||||
|
||||
describe('OpenAI Classify Operation', () => {
|
||||
const executeFunctions = mockDeep<IExecuteFunctions>();
|
||||
const node = {
|
||||
id: '123',
|
||||
name: 'OpenAI Node',
|
||||
type: '@n8n/n8n-nodes-langchain.openAi',
|
||||
typeVersion: 2.1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
} as INode;
|
||||
const apiRequestSpy = jest.spyOn(transport, 'apiRequest');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should use omni-moderation-latest model when version is 2.1', async () => {
|
||||
executeFunctions.getNode.mockReturnValue(node);
|
||||
executeFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
const params = {
|
||||
input: 'Lorem ipsum',
|
||||
simplify: false,
|
||||
};
|
||||
return params[param as keyof typeof params];
|
||||
});
|
||||
apiRequestSpy.mockResolvedValueOnce({ results: [{ flagged: true }] });
|
||||
|
||||
const result = await classify.execute.call(executeFunctions, 0);
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/moderations', {
|
||||
body: { input: 'Lorem ipsum', model: 'omni-moderation-latest' },
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: { flagged: true },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use text-moderation-stable model when version is less than 2.1 and useStableModel is true', async () => {
|
||||
executeFunctions.getNode.mockReturnValue({
|
||||
...node,
|
||||
typeVersion: 2,
|
||||
});
|
||||
executeFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
const params = {
|
||||
input: 'Lorem ipsum',
|
||||
simplify: false,
|
||||
options: { useStableModel: true },
|
||||
};
|
||||
return params[param as keyof typeof params];
|
||||
});
|
||||
apiRequestSpy.mockResolvedValueOnce({ results: [{ flagged: true }] });
|
||||
|
||||
const result = await classify.execute.call(executeFunctions, 0);
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/moderations', {
|
||||
body: { input: 'Lorem ipsum', model: 'text-moderation-stable' },
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: { flagged: true },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use text-moderation-latest model when version is less than 2.1 and useStableModel is false', async () => {
|
||||
executeFunctions.getNode.mockReturnValue({
|
||||
...node,
|
||||
typeVersion: 2,
|
||||
});
|
||||
executeFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
const params = {
|
||||
input: 'Lorem ipsum',
|
||||
simplify: false,
|
||||
options: { useStableModel: false },
|
||||
};
|
||||
return params[param as keyof typeof params];
|
||||
});
|
||||
apiRequestSpy.mockResolvedValueOnce({ results: [{ flagged: true }] });
|
||||
|
||||
const result = await classify.execute.call(executeFunctions, 0);
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/moderations', {
|
||||
body: { input: 'Lorem ipsum', model: 'text-moderation-latest' },
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: { flagged: true },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Vendored
+994
@@ -0,0 +1,994 @@
|
||||
import { mock, mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { getConnectedTools } from '@utils/helpers';
|
||||
import { pollUntilAvailable } from '../../../../helpers/polling';
|
||||
import * as transport from '../../../../transport';
|
||||
import * as helpers from '../../../../v2/actions/text/helpers/responses';
|
||||
import { execute } from '../../../../v2/actions/text/response.operation';
|
||||
import { formatToOpenAIResponsesTool } from '../../../../helpers/utils';
|
||||
import type { Tool } from '@langchain/classic/tools';
|
||||
|
||||
jest.mock('../../../../transport');
|
||||
jest.mock('../../../../v2/actions/text/helpers/responses');
|
||||
jest.mock('@utils/helpers');
|
||||
jest.mock('../../../../helpers/polling');
|
||||
jest.mock('../../../../helpers/utils');
|
||||
|
||||
const mockFormatToOpenAIResponsesTool = formatToOpenAIResponsesTool as jest.MockedFunction<
|
||||
typeof formatToOpenAIResponsesTool
|
||||
>;
|
||||
const mockApiRequest = transport.apiRequest as jest.MockedFunction<typeof transport.apiRequest>;
|
||||
const mockCreateRequest = helpers.createRequest as jest.MockedFunction<
|
||||
typeof helpers.createRequest
|
||||
>;
|
||||
const mockGetConnectedTools = getConnectedTools as jest.MockedFunction<typeof getConnectedTools>;
|
||||
const mockPollUntilAvailable = pollUntilAvailable as jest.MockedFunction<typeof pollUntilAvailable>;
|
||||
|
||||
describe('OpenAI Response Operation', () => {
|
||||
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
|
||||
let mockNode: jest.Mocked<INode>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
|
||||
mockNode = mock<INode>({
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-langchain.openAi',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getExecutionCancelSignal.mockReturnValue(new AbortController().signal);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(param: string, _itemIndex: number, defaultValue?: unknown) => {
|
||||
const mockParams: Record<string, unknown> = {
|
||||
modelId: 'gpt-4o',
|
||||
'responses.values': [
|
||||
{
|
||||
role: 'user',
|
||||
type: 'text',
|
||||
content: 'Hello, how are you?',
|
||||
},
|
||||
],
|
||||
options: {},
|
||||
builtInTools: {},
|
||||
simplify: false,
|
||||
hideTools: 'show',
|
||||
'options.maxToolsIterations': 15,
|
||||
};
|
||||
return (mockParams[param] ?? defaultValue) as any;
|
||||
},
|
||||
);
|
||||
|
||||
mockFormatToOpenAIResponsesTool.mockImplementation((tool: Tool) => {
|
||||
return {
|
||||
type: 'function',
|
||||
name: tool.name,
|
||||
parameters: {},
|
||||
strict: false,
|
||||
description: tool.description,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
describe('Successful Execution', () => {
|
||||
it('should execute successfully with basic text message', async () => {
|
||||
const mockResponse = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'I am doing well, thank you for asking!',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [{ role: 'user', content: [{ type: 'input_text', text: 'Hello, how are you?' }] }],
|
||||
});
|
||||
mockApiRequest.mockResolvedValue(mockResponse);
|
||||
mockGetConnectedTools.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: mockResponse,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith('POST', '/responses', {
|
||||
body: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('should execute with simplified output enabled', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
if (param === 'simplify') return true;
|
||||
return 'default';
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'reasoning',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'some_reasoning_output', text: 'Response text' }],
|
||||
},
|
||||
{
|
||||
type: 'tool_call',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'some_tool_call_output', text: 'Response text' }],
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Response text' }],
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Response text 2' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [],
|
||||
});
|
||||
mockApiRequest.mockResolvedValue(mockResponse);
|
||||
mockGetConnectedTools.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Response text' }],
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Response text 2' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Background Mode', () => {
|
||||
it('should handle background mode execution with polling', async () => {
|
||||
const initialResponse = {
|
||||
id: 'resp_123',
|
||||
status: 'in_progress',
|
||||
output: [],
|
||||
};
|
||||
|
||||
const completedResponse = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Background response' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
if (param === 'options.backgroundMode.values.enabled') return true;
|
||||
if (param === 'options.backgroundMode.values.timeout') return 300;
|
||||
if (param === 'simplify') return false;
|
||||
return 'default';
|
||||
});
|
||||
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [],
|
||||
background: true,
|
||||
});
|
||||
mockApiRequest.mockResolvedValueOnce(initialResponse);
|
||||
mockPollUntilAvailable.mockResolvedValue(completedResponse);
|
||||
mockGetConnectedTools.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockPollUntilAvailable).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
300,
|
||||
10,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: completedResponse,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw error when background mode fails', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
if (param === 'options.backgroundMode.values.enabled') return true;
|
||||
if (param === 'simplify') return false;
|
||||
return 'default';
|
||||
});
|
||||
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [],
|
||||
background: true,
|
||||
});
|
||||
mockApiRequest.mockResolvedValueOnce({ id: 'resp_123', status: 'in_progress' });
|
||||
mockPollUntilAvailable.mockImplementation(async (context, pollFn, checkFn) => {
|
||||
const response = await pollFn();
|
||||
if (checkFn(response)) {
|
||||
throw new NodeOperationError(context.getNode(), 'Background mode error', {
|
||||
description: 'Background processing failed',
|
||||
});
|
||||
}
|
||||
return response;
|
||||
});
|
||||
|
||||
await expect(execute.call(mockExecuteFunctions, 0)).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Calls', () => {
|
||||
it('should execute tool calls with external tools', async () => {
|
||||
const mockTool = {
|
||||
name: 'test_tool',
|
||||
invoke: jest.fn().mockResolvedValue('Tool response'),
|
||||
schema: {
|
||||
typeName: 'ZodObject',
|
||||
_def: { typeName: 'ZodObject', shape: () => ({}) },
|
||||
parse: jest.fn(),
|
||||
safeParse: jest.fn(),
|
||||
},
|
||||
call: jest.fn(),
|
||||
description: 'Test tool',
|
||||
returnDirect: false,
|
||||
} as any;
|
||||
|
||||
const initialResponse = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_123',
|
||||
name: 'test_tool',
|
||||
arguments: JSON.stringify({ input: 'test input' }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const finalResponse = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Final response' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGetConnectedTools.mockResolvedValue([mockTool]);
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [],
|
||||
tools: [{ name: 'test_tool', type: 'function', parameters: {}, strict: false }],
|
||||
});
|
||||
mockApiRequest.mockResolvedValueOnce(initialResponse).mockResolvedValueOnce(finalResponse);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockTool.invoke).toHaveBeenCalledWith('test input');
|
||||
expect(mockApiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: finalResponse,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle tool call with object response', async () => {
|
||||
const mockTool = {
|
||||
name: 'test_tool',
|
||||
invoke: jest.fn().mockResolvedValue({ result: 'success', data: 'test data' }),
|
||||
schema: {
|
||||
typeName: 'ZodObject',
|
||||
_def: { typeName: 'ZodObject', shape: () => ({}) },
|
||||
parse: jest.fn(),
|
||||
safeParse: jest.fn(),
|
||||
},
|
||||
call: jest.fn(),
|
||||
description: 'Test tool',
|
||||
returnDirect: false,
|
||||
} as any;
|
||||
|
||||
const initialResponse = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_123',
|
||||
name: 'test_tool',
|
||||
arguments: JSON.stringify({ data: 'test input' }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const finalResponse = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Final response' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGetConnectedTools.mockResolvedValue([mockTool]);
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [],
|
||||
tools: [{ name: 'test_tool', type: 'function', parameters: {}, strict: false }],
|
||||
});
|
||||
mockApiRequest.mockResolvedValueOnce(initialResponse).mockResolvedValueOnce(finalResponse);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockTool.invoke).toHaveBeenCalledWith({ data: 'test input' });
|
||||
});
|
||||
|
||||
it('should respect max tool iterations limit', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
if (param === 'options.maxToolsIterations') return 2;
|
||||
return 'default';
|
||||
});
|
||||
|
||||
const mockTool = {
|
||||
name: 'test_tool',
|
||||
invoke: jest.fn().mockResolvedValue('Tool response'),
|
||||
schema: {
|
||||
typeName: 'ZodObject',
|
||||
_def: { typeName: 'ZodObject', shape: () => ({}) },
|
||||
parse: jest.fn(),
|
||||
safeParse: jest.fn(),
|
||||
},
|
||||
call: jest.fn(),
|
||||
description: 'Test tool',
|
||||
returnDirect: false,
|
||||
} as any;
|
||||
|
||||
const responseWithToolCalls = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_123',
|
||||
name: 'test_tool',
|
||||
arguments: JSON.stringify({ input: 'test input' }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGetConnectedTools.mockResolvedValue([mockTool]);
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [],
|
||||
tools: [{ name: 'test_tool', type: 'function', parameters: {}, strict: false }],
|
||||
});
|
||||
mockApiRequest.mockResolvedValue(responseWithToolCalls);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledTimes(3); // Initial + 2 iterations
|
||||
});
|
||||
|
||||
it('should handle abort signal during tool calls', async () => {
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
|
||||
mockExecuteFunctions.getExecutionCancelSignal.mockReturnValue(abortController.signal);
|
||||
|
||||
const mockTool = {
|
||||
name: 'test_tool',
|
||||
invoke: jest.fn().mockResolvedValue('Tool response'),
|
||||
schema: {
|
||||
typeName: 'ZodObject',
|
||||
_def: { typeName: 'ZodObject', shape: () => ({}) },
|
||||
parse: jest.fn(),
|
||||
safeParse: jest.fn(),
|
||||
},
|
||||
call: jest.fn(),
|
||||
description: 'Test tool',
|
||||
returnDirect: false,
|
||||
} as any;
|
||||
|
||||
const responseWithToolCalls = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_123',
|
||||
name: 'test_tool',
|
||||
arguments: JSON.stringify({ input: 'test input' }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGetConnectedTools.mockResolvedValue([mockTool]);
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [],
|
||||
tools: [{ name: 'test_tool', type: 'function', parameters: {}, strict: false }],
|
||||
});
|
||||
mockApiRequest.mockResolvedValue(responseWithToolCalls);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledTimes(1); // Only initial call
|
||||
});
|
||||
|
||||
it('should handle reasoning models with reasoning items in tool calls', async () => {
|
||||
const mockTool = {
|
||||
name: 'test_tool',
|
||||
invoke: jest.fn().mockResolvedValue('Tool response'),
|
||||
schema: {
|
||||
typeName: 'ZodObject',
|
||||
_def: { typeName: 'ZodObject', shape: () => ({}) },
|
||||
parse: jest.fn(),
|
||||
safeParse: jest.fn(),
|
||||
},
|
||||
call: jest.fn(),
|
||||
description: 'Test tool',
|
||||
returnDirect: false,
|
||||
} as any;
|
||||
|
||||
const initialResponse = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'reasoning',
|
||||
content: 'I need to use the test tool to get information',
|
||||
},
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_123',
|
||||
name: 'test_tool',
|
||||
arguments: JSON.stringify({ input: 'test input' }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const finalResponse = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Final response' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGetConnectedTools.mockResolvedValue([mockTool]);
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [],
|
||||
tools: [{ name: 'test_tool', type: 'function', parameters: {}, strict: false }],
|
||||
});
|
||||
mockApiRequest.mockResolvedValueOnce(initialResponse).mockResolvedValueOnce(finalResponse);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockTool.invoke).toHaveBeenCalledWith('test input');
|
||||
expect(mockApiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(mockApiRequest).toHaveBeenNthCalledWith(2, 'POST', '/responses', {
|
||||
body: {
|
||||
model: 'gpt-4o',
|
||||
input: [
|
||||
{
|
||||
type: 'reasoning',
|
||||
content: 'I need to use the test tool to get information',
|
||||
},
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_123',
|
||||
name: 'test_tool',
|
||||
arguments: JSON.stringify({ input: 'test input' }),
|
||||
},
|
||||
{
|
||||
call_id: 'call_123',
|
||||
output: 'Tool response',
|
||||
type: 'function_call_output',
|
||||
},
|
||||
],
|
||||
tools: [{ name: 'test_tool', type: 'function', parameters: {}, strict: false }],
|
||||
},
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: finalResponse,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not include function_call or reasoning items in the request if there is a conversation', async () => {
|
||||
const mockTool = {
|
||||
name: 'test_tool',
|
||||
invoke: jest.fn().mockResolvedValue('Tool response'),
|
||||
schema: {
|
||||
typeName: 'ZodObject',
|
||||
_def: { typeName: 'ZodObject', shape: () => ({}) },
|
||||
parse: jest.fn(),
|
||||
safeParse: jest.fn(),
|
||||
},
|
||||
call: jest.fn(),
|
||||
description: 'Test tool',
|
||||
returnDirect: false,
|
||||
} as any;
|
||||
|
||||
const initialResponse = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'reasoning',
|
||||
content: 'I need to use the test tool to get information',
|
||||
},
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_123',
|
||||
name: 'test_tool',
|
||||
arguments: JSON.stringify({ input: 'test input' }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const finalResponse = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Final response' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGetConnectedTools.mockResolvedValue([mockTool]);
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [],
|
||||
tools: [{ name: 'test_tool', type: 'function', parameters: {}, strict: false }],
|
||||
conversation: 'conv_123',
|
||||
});
|
||||
mockApiRequest.mockResolvedValueOnce(initialResponse).mockResolvedValueOnce(finalResponse);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockTool.invoke).toHaveBeenCalledWith('test input');
|
||||
expect(mockApiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(mockApiRequest).toHaveBeenNthCalledWith(2, 'POST', '/responses', {
|
||||
body: {
|
||||
model: 'gpt-4o',
|
||||
input: [
|
||||
{
|
||||
call_id: 'call_123',
|
||||
output: 'Tool response',
|
||||
type: 'function_call_output',
|
||||
},
|
||||
],
|
||||
tools: [{ name: 'test_tool', type: 'function', parameters: {}, strict: false }],
|
||||
conversation: 'conv_123',
|
||||
},
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: finalResponse,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle reasoning models with only reasoning items (no function calls)', async () => {
|
||||
const responseWithOnlyReasoning = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'reasoning',
|
||||
content: 'I am thinking about this problem',
|
||||
},
|
||||
{
|
||||
type: 'reasoning',
|
||||
content: 'I have reached a conclusion',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [],
|
||||
});
|
||||
mockApiRequest.mockResolvedValue(responseWithOnlyReasoning);
|
||||
mockGetConnectedTools.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
// Should not make additional API calls since there are no function calls
|
||||
expect(mockApiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: responseWithOnlyReasoning,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON Format Handling', () => {
|
||||
it('should handle JSON parsing errors gracefully', async () => {
|
||||
const mockResponse = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'invalid json',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [],
|
||||
text: { format: { type: 'json_object' } },
|
||||
});
|
||||
mockApiRequest.mockResolvedValue(mockResponse);
|
||||
mockGetConnectedTools.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: mockResponse,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty messages array', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
if (param === 'responses.values') return [];
|
||||
if (param === 'simplify') return false;
|
||||
return 'default';
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [],
|
||||
};
|
||||
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [],
|
||||
});
|
||||
mockApiRequest.mockResolvedValue(mockResponse);
|
||||
mockGetConnectedTools.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [],
|
||||
},
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle tools hidden for unsupported models', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
if (param === 'hideTools') return 'hide';
|
||||
if (param === 'simplify') return false;
|
||||
return 'default';
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Response without tools' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [],
|
||||
});
|
||||
mockApiRequest.mockResolvedValue(mockResponse);
|
||||
mockGetConnectedTools.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockGetConnectedTools).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: mockResponse,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle non-function-call items in output', async () => {
|
||||
const mockResponse = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Regular message' }],
|
||||
},
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_123',
|
||||
name: 'test_tool',
|
||||
arguments: JSON.stringify({ input: 'test input' }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockTool = {
|
||||
name: 'test_tool',
|
||||
invoke: jest.fn().mockResolvedValue('Tool response'),
|
||||
schema: {
|
||||
typeName: 'ZodObject',
|
||||
_def: { typeName: 'ZodObject', shape: () => ({}) },
|
||||
parse: jest.fn(),
|
||||
safeParse: jest.fn(),
|
||||
},
|
||||
call: jest.fn(),
|
||||
description: 'Test tool',
|
||||
returnDirect: false,
|
||||
} as any;
|
||||
|
||||
mockGetConnectedTools.mockResolvedValue([mockTool]);
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [],
|
||||
tools: [{ name: 'test_tool', type: 'function', parameters: {}, strict: false }],
|
||||
});
|
||||
mockApiRequest.mockResolvedValueOnce(mockResponse).mockResolvedValueOnce({
|
||||
...mockResponse,
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Final response' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockTool.invoke).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should use dynamic strict parameter calculation for tools', async () => {
|
||||
const mockTool = {
|
||||
name: 'test_tool',
|
||||
invoke: jest.fn().mockResolvedValue('Tool response'),
|
||||
schema: {
|
||||
typeName: 'ZodObject',
|
||||
_def: { typeName: 'ZodObject', shape: () => ({}) },
|
||||
parse: jest.fn(),
|
||||
safeParse: jest.fn(),
|
||||
},
|
||||
call: jest.fn(),
|
||||
description: 'Test tool',
|
||||
returnDirect: false,
|
||||
} as any;
|
||||
|
||||
// Mock the formatToOpenAIResponsesTool to return different strict values
|
||||
mockFormatToOpenAIResponsesTool.mockImplementation((tool: Tool) => {
|
||||
return {
|
||||
type: 'function',
|
||||
name: tool.name,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { input: { type: 'string' } },
|
||||
required: ['input'],
|
||||
},
|
||||
strict: true, // This should be calculated dynamically based on schema
|
||||
description: tool.description,
|
||||
};
|
||||
});
|
||||
|
||||
const responseWithToolCalls = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_123',
|
||||
name: 'test_tool',
|
||||
arguments: JSON.stringify({ input: 'test input' }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const finalResponse = {
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Final response' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGetConnectedTools.mockResolvedValue([mockTool]);
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [],
|
||||
tools: [{ name: 'test_tool', type: 'function', parameters: {}, strict: true }],
|
||||
});
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce(responseWithToolCalls)
|
||||
.mockResolvedValueOnce(finalResponse);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockFormatToOpenAIResponsesTool).toHaveBeenCalledWith(
|
||||
mockTool,
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockTool.invoke).toHaveBeenCalledWith('test input');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parameter Handling', () => {
|
||||
it('should pass correct parameters to createRequest', async () => {
|
||||
const mockMessages = [
|
||||
{
|
||||
role: 'user',
|
||||
type: 'text',
|
||||
content: 'Test message',
|
||||
},
|
||||
];
|
||||
|
||||
const mockOptions = {
|
||||
maxTokens: 100,
|
||||
temperature: 0.7,
|
||||
};
|
||||
|
||||
const mockBuiltInTools = {
|
||||
webSearch: { searchContextSize: 'high' },
|
||||
};
|
||||
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
|
||||
if (param === 'modelId') return 'gpt-4o';
|
||||
if (param === 'responses.values') return mockMessages;
|
||||
if (param === 'options') return mockOptions;
|
||||
if (param === 'builtInTools') return mockBuiltInTools;
|
||||
return 'default';
|
||||
});
|
||||
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [],
|
||||
});
|
||||
mockApiRequest.mockResolvedValue({
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [],
|
||||
});
|
||||
mockGetConnectedTools.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockCreateRequest).toHaveBeenCalledWith(0, {
|
||||
model: 'gpt-4o',
|
||||
messages: mockMessages,
|
||||
options: mockOptions,
|
||||
tools: undefined,
|
||||
builtInTools: mockBuiltInTools,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle default values for optional parameters', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(param: string, _itemIndex: number, defaultValue?: unknown) => {
|
||||
if (param === 'modelId') return 'gpt-4o';
|
||||
if (param === 'responses.values') return [];
|
||||
if (param === 'options') return {};
|
||||
if (param === 'builtInTools') return {};
|
||||
return defaultValue as any;
|
||||
},
|
||||
);
|
||||
|
||||
mockCreateRequest.mockResolvedValue({
|
||||
model: 'gpt-4o',
|
||||
input: [],
|
||||
});
|
||||
mockApiRequest.mockResolvedValue({
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [],
|
||||
});
|
||||
mockGetConnectedTools.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockCreateRequest).toHaveBeenCalledWith(0, {
|
||||
model: 'gpt-4o',
|
||||
messages: [],
|
||||
options: {},
|
||||
tools: undefined,
|
||||
builtInTools: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+1336
File diff suppressed because it is too large
Load Diff
Vendored
+488
@@ -0,0 +1,488 @@
|
||||
import { mock, mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import * as binaryDataHelpers from '../../../../helpers/binary-data';
|
||||
import type { VideoJob } from '../../../../helpers/interfaces';
|
||||
import * as pollingHelpers from '../../../../helpers/polling';
|
||||
import * as transport from '../../../../transport';
|
||||
import { execute } from '../../../../v2/actions/video/generate.operation';
|
||||
import FormData from 'form-data';
|
||||
|
||||
jest.mock('../../../../helpers/binary-data');
|
||||
jest.mock('../../../../helpers/polling');
|
||||
jest.mock('../../../../transport');
|
||||
|
||||
jest.mock('form-data', () => jest.fn());
|
||||
|
||||
const mockFormData = jest.mocked(FormData);
|
||||
|
||||
describe('Video Generate Operation', () => {
|
||||
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
|
||||
let mockNode: INode;
|
||||
let mockFormDataInstance: jest.Mocked<FormData>;
|
||||
const apiRequestSpy = jest.spyOn(transport, 'apiRequest');
|
||||
const getBinaryDataFileSpy = jest.spyOn(binaryDataHelpers, 'getBinaryDataFile');
|
||||
const pollUntilAvailableSpy = jest.spyOn(pollingHelpers, 'pollUntilAvailable');
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
|
||||
mockNode = mock<INode>({
|
||||
id: 'test-node',
|
||||
name: 'OpenAI Video Generate',
|
||||
type: 'n8n-nodes-base.openAi',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
|
||||
mockExecuteFunctions.helpers.prepareBinaryData = jest.fn();
|
||||
mockExecuteFunctions.helpers.binaryToBuffer = jest.fn();
|
||||
|
||||
mockFormDataInstance = {
|
||||
append: jest.fn(),
|
||||
getHeaders: jest.fn().mockReturnValue({ 'content-type': 'multipart/form-data' }),
|
||||
} as unknown as jest.Mocked<FormData>;
|
||||
mockFormData.mockImplementation(() => mockFormDataInstance);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('successful execution', () => {
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'sora-2',
|
||||
prompt: 'A cat playing with a ball',
|
||||
seconds: 4,
|
||||
size: '1280x720',
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate video with basic parameters', async () => {
|
||||
const mockVideoJob: VideoJob = {
|
||||
id: 'video-123',
|
||||
created_at: Date.now(),
|
||||
model: 'sora-2',
|
||||
object: 'video',
|
||||
seconds: '4',
|
||||
size: '1280x720',
|
||||
status: 'queued',
|
||||
};
|
||||
|
||||
const mockCompletedJob: VideoJob = {
|
||||
...mockVideoJob,
|
||||
status: 'completed',
|
||||
completed_at: Date.now(),
|
||||
};
|
||||
|
||||
const mockContentResponse = {
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: Buffer.from('mock-video-data'),
|
||||
};
|
||||
|
||||
const mockBinaryData = {
|
||||
data: 'base64-encoded-video',
|
||||
mimeType: 'video/mp4',
|
||||
fileName: 'data',
|
||||
};
|
||||
|
||||
apiRequestSpy.mockResolvedValueOnce(mockVideoJob).mockResolvedValueOnce(mockContentResponse);
|
||||
|
||||
pollUntilAvailableSpy.mockResolvedValue(mockCompletedJob);
|
||||
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
|
||||
mockBinaryData,
|
||||
);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(apiRequestSpy).toHaveBeenCalledTimes(2);
|
||||
expect(apiRequestSpy).toHaveBeenNthCalledWith(1, 'POST', '/videos', {
|
||||
option: { formData: expect.any(Object) },
|
||||
headers: expect.any(Object),
|
||||
});
|
||||
expect(apiRequestSpy).toHaveBeenNthCalledWith(2, 'GET', '/videos/video-123/content', {
|
||||
option: {
|
||||
useStream: true,
|
||||
resolveWithFullResponse: true,
|
||||
json: false,
|
||||
encoding: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(pollUntilAvailableSpy).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
300,
|
||||
10,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
data: undefined,
|
||||
mimeType: 'video/mp4',
|
||||
fileName: 'data',
|
||||
},
|
||||
binary: {
|
||||
data: mockBinaryData,
|
||||
},
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should generate video with custom options', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'sora-2',
|
||||
prompt: 'A beautiful sunset over mountains',
|
||||
seconds: 8,
|
||||
size: '1792x1024',
|
||||
options: {
|
||||
waitTime: 600,
|
||||
fileName: 'sunset_video',
|
||||
},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockVideoJob: VideoJob = {
|
||||
id: 'video-456',
|
||||
created_at: Date.now(),
|
||||
model: 'sora-2',
|
||||
object: 'video',
|
||||
seconds: '8',
|
||||
size: '1792x1024',
|
||||
status: 'completed',
|
||||
completed_at: Date.now(),
|
||||
};
|
||||
|
||||
const mockContentResponse = {
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: Buffer.from('mock-video-data'),
|
||||
};
|
||||
|
||||
const mockBinaryData = {
|
||||
data: 'base64-encoded-video',
|
||||
mimeType: 'video/mp4',
|
||||
fileName: 'sunset_video',
|
||||
};
|
||||
|
||||
apiRequestSpy.mockResolvedValueOnce(mockVideoJob).mockResolvedValueOnce(mockContentResponse);
|
||||
|
||||
pollUntilAvailableSpy.mockResolvedValue(mockVideoJob);
|
||||
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
|
||||
mockBinaryData,
|
||||
);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(pollUntilAvailableSpy).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
600,
|
||||
10,
|
||||
);
|
||||
|
||||
expect(mockExecuteFunctions.helpers.prepareBinaryData).toHaveBeenCalledWith(
|
||||
mockContentResponse.body,
|
||||
'sunset_video',
|
||||
'video/mp4',
|
||||
);
|
||||
|
||||
expect(result[0].json.fileName).toBe('sunset_video');
|
||||
});
|
||||
|
||||
it('should generate video with binary reference image', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'sora-2',
|
||||
prompt: 'Continue this scene with motion',
|
||||
seconds: 6,
|
||||
size: '1280x720',
|
||||
options: {
|
||||
binaryPropertyNameReference: 'reference_image',
|
||||
},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockBinaryFile = {
|
||||
fileContent: Buffer.from('mock-image-data'),
|
||||
contentType: 'image/jpeg',
|
||||
filename: 'reference.jpg',
|
||||
};
|
||||
|
||||
const mockVideoJob: VideoJob = {
|
||||
id: 'video-789',
|
||||
created_at: Date.now(),
|
||||
model: 'sora-2',
|
||||
object: 'video',
|
||||
seconds: '6',
|
||||
size: '1280x720',
|
||||
status: 'completed',
|
||||
};
|
||||
|
||||
const mockContentResponse = {
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: Buffer.from('mock-video-data'),
|
||||
};
|
||||
|
||||
const mockBinaryData = {
|
||||
data: 'base64-encoded-video',
|
||||
mimeType: 'video/mp4',
|
||||
fileName: 'data',
|
||||
};
|
||||
|
||||
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
|
||||
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
|
||||
mockBinaryFile.fileContent,
|
||||
);
|
||||
apiRequestSpy.mockResolvedValueOnce(mockVideoJob).mockResolvedValueOnce(mockContentResponse);
|
||||
pollUntilAvailableSpy.mockResolvedValue(mockVideoJob);
|
||||
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
|
||||
mockBinaryData,
|
||||
);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(getBinaryDataFileSpy).toHaveBeenCalledWith(mockExecuteFunctions, 0, 'reference_image');
|
||||
expect(mockExecuteFunctions.helpers.binaryToBuffer).toHaveBeenCalledWith(
|
||||
mockBinaryFile.fileContent,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].binary?.data).toBe(mockBinaryData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FormData handling', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create FormData with correct parameters', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'sora-2',
|
||||
prompt: 'Test video generation',
|
||||
seconds: 6,
|
||||
size: '1024x1792',
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockVideoJob: VideoJob = {
|
||||
id: 'video-formdata',
|
||||
created_at: Date.now(),
|
||||
model: 'sora-2',
|
||||
object: 'video',
|
||||
seconds: '6',
|
||||
size: '1024x1792',
|
||||
status: 'completed',
|
||||
};
|
||||
|
||||
const mockContentResponse = {
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: Buffer.from('mock-video-data'),
|
||||
};
|
||||
|
||||
const mockBinaryData = {
|
||||
data: 'base64-encoded-video',
|
||||
mimeType: 'video/mp4',
|
||||
fileName: 'data',
|
||||
};
|
||||
|
||||
apiRequestSpy.mockResolvedValueOnce(mockVideoJob).mockResolvedValueOnce(mockContentResponse);
|
||||
|
||||
pollUntilAvailableSpy.mockResolvedValue(mockVideoJob);
|
||||
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
|
||||
mockBinaryData,
|
||||
);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('model', 'sora-2');
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('prompt', 'Test video generation');
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('seconds', '6');
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith('size', '1024x1792');
|
||||
expect(mockFormDataInstance.getHeaders).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should append binary reference when provided', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'sora-2',
|
||||
prompt: 'Test with reference',
|
||||
seconds: 4,
|
||||
size: '1280x720',
|
||||
options: {
|
||||
binaryPropertyNameReference: 'reference',
|
||||
},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockBinaryFile = {
|
||||
fileContent: Buffer.from('mock-image-data'),
|
||||
contentType: 'image/png',
|
||||
filename: 'reference.png',
|
||||
};
|
||||
|
||||
const mockVideoJob: VideoJob = {
|
||||
id: 'video-with-ref',
|
||||
created_at: Date.now(),
|
||||
model: 'sora-2',
|
||||
object: 'video',
|
||||
seconds: '4',
|
||||
size: '1280x720',
|
||||
status: 'completed',
|
||||
};
|
||||
|
||||
const mockContentResponse = {
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: Buffer.from('mock-video-data'),
|
||||
};
|
||||
|
||||
const mockBinaryData = {
|
||||
data: 'base64-encoded-video',
|
||||
mimeType: 'video/mp4',
|
||||
fileName: 'data',
|
||||
};
|
||||
|
||||
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
|
||||
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
|
||||
mockBinaryFile.fileContent,
|
||||
);
|
||||
apiRequestSpy.mockResolvedValueOnce(mockVideoJob).mockResolvedValueOnce(mockContentResponse);
|
||||
pollUntilAvailableSpy.mockResolvedValue(mockVideoJob);
|
||||
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
|
||||
mockBinaryData,
|
||||
);
|
||||
|
||||
await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
|
||||
'input_reference',
|
||||
mockBinaryFile.fileContent,
|
||||
{
|
||||
filename: 'reference.png',
|
||||
contentType: 'image/png',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('binary data processing', () => {
|
||||
it('should process video content with correct MIME type', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'sora-2',
|
||||
prompt: 'Test MIME type',
|
||||
seconds: 4,
|
||||
size: '1280x720',
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockVideoJob: VideoJob = {
|
||||
id: 'video-mime',
|
||||
created_at: Date.now(),
|
||||
model: 'sora-2',
|
||||
object: 'video',
|
||||
seconds: '4',
|
||||
size: '1280x720',
|
||||
status: 'completed',
|
||||
};
|
||||
|
||||
const mockContentResponse = {
|
||||
headers: { 'content-type': 'video/webm' },
|
||||
body: Buffer.from('mock-webm-video-data'),
|
||||
};
|
||||
|
||||
const mockBinaryData = {
|
||||
data: 'base64-encoded-webm-video',
|
||||
mimeType: 'video/webm',
|
||||
fileName: 'data',
|
||||
};
|
||||
|
||||
apiRequestSpy.mockResolvedValueOnce(mockVideoJob).mockResolvedValueOnce(mockContentResponse);
|
||||
|
||||
pollUntilAvailableSpy.mockResolvedValue(mockVideoJob);
|
||||
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
|
||||
mockBinaryData,
|
||||
);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockExecuteFunctions.helpers.prepareBinaryData).toHaveBeenCalledWith(
|
||||
mockContentResponse.body,
|
||||
'data',
|
||||
'video/webm',
|
||||
);
|
||||
|
||||
expect(result[0].json.mimeType).toBe('video/webm');
|
||||
});
|
||||
|
||||
it('should handle missing content-type header', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params = {
|
||||
modelId: 'sora-2',
|
||||
prompt: 'Test no MIME type',
|
||||
seconds: 4,
|
||||
size: '1280x720',
|
||||
options: {},
|
||||
};
|
||||
return params[paramName as keyof typeof params];
|
||||
});
|
||||
|
||||
const mockVideoJob: VideoJob = {
|
||||
id: 'video-no-mime',
|
||||
created_at: Date.now(),
|
||||
model: 'sora-2',
|
||||
object: 'video',
|
||||
seconds: '4',
|
||||
size: '1280x720',
|
||||
status: 'completed',
|
||||
};
|
||||
|
||||
const mockContentResponse = {
|
||||
headers: {},
|
||||
body: Buffer.from('mock-video-data'),
|
||||
};
|
||||
|
||||
const mockBinaryData = {
|
||||
data: 'base64-encoded-video',
|
||||
mimeType: undefined,
|
||||
fileName: 'data',
|
||||
};
|
||||
|
||||
apiRequestSpy.mockResolvedValueOnce(mockVideoJob).mockResolvedValueOnce(mockContentResponse);
|
||||
|
||||
pollUntilAvailableSpy.mockResolvedValue(mockVideoJob);
|
||||
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
|
||||
mockBinaryData,
|
||||
);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockExecuteFunctions.helpers.prepareBinaryData).toHaveBeenCalledWith(
|
||||
mockContentResponse.body,
|
||||
'data',
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result[0].json.mimeType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
import lodashGet from 'lodash/get';
|
||||
import type { IDataObject, IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import * as transport from '../../transport';
|
||||
import * as create from '../../v2/actions/conversation/create.operation';
|
||||
import * as getOperation from '../../v2/actions/conversation/get.operation';
|
||||
import * as remove from '../../v2/actions/conversation/remove.operation';
|
||||
import * as update from '../../v2/actions/conversation/update.operation';
|
||||
|
||||
jest.mock('../../transport', () => ({
|
||||
apiRequest: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../v2/actions/text/helpers/responses', () => ({
|
||||
formatInputMessages: jest.fn().mockResolvedValue([
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'Hello' }],
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
const createExecuteFunctionsMock = (parameters: IDataObject): IExecuteFunctions => {
|
||||
const nodeParameters = parameters;
|
||||
return {
|
||||
getExecutionCancelSignal() {
|
||||
return new AbortController().signal;
|
||||
},
|
||||
getNodeParameter(parameter: string, _itemIndex: number, defaultValue?: unknown) {
|
||||
return lodashGet(nodeParameters, parameter, defaultValue);
|
||||
},
|
||||
getNode() {
|
||||
return {
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-langchain.openAi',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
},
|
||||
getInputConnectionData() {
|
||||
return undefined;
|
||||
},
|
||||
helpers: {
|
||||
prepareBinaryData: jest.fn().mockResolvedValue({
|
||||
data: 'base64data',
|
||||
mimeType: 'text/plain',
|
||||
fileName: 'test.txt',
|
||||
}),
|
||||
assertBinaryData: jest.fn().mockReturnValue({
|
||||
filename: 'test.txt',
|
||||
contentType: 'text/plain',
|
||||
}),
|
||||
getBinaryDataBuffer: jest.fn().mockReturnValue(Buffer.from('test data')),
|
||||
binaryToBuffer: jest.fn().mockResolvedValue(Buffer.from('test data')),
|
||||
},
|
||||
} as unknown as IExecuteFunctions;
|
||||
};
|
||||
|
||||
describe('OpenAI Conversation Operations', () => {
|
||||
const mockApiRequest = transport.apiRequest as jest.MockedFunction<typeof transport.apiRequest>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Create Operation', () => {
|
||||
it('should create a conversation with messages successfully', async () => {
|
||||
const mockResponse = {
|
||||
id: 'conv_1234567890',
|
||||
object: 'conversation',
|
||||
created_at: 1234567890,
|
||||
items: [
|
||||
{
|
||||
id: 'item_1',
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'Hello' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockApiRequest.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const executeFunctions = createExecuteFunctionsMock({
|
||||
'messages.values': [
|
||||
{
|
||||
role: 'user',
|
||||
type: 'text',
|
||||
content: 'Hello',
|
||||
},
|
||||
],
|
||||
options: {},
|
||||
});
|
||||
|
||||
const result = await create.execute.call(executeFunctions, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: mockResponse,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith('POST', '/conversations', {
|
||||
body: {
|
||||
items: expect.any(Array),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a conversation with metadata', async () => {
|
||||
const mockResponse = {
|
||||
id: 'conv_1234567890',
|
||||
object: 'conversation',
|
||||
created_at: 1234567890,
|
||||
};
|
||||
|
||||
mockApiRequest.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const executeFunctions = createExecuteFunctionsMock({
|
||||
'messages.values': [
|
||||
{
|
||||
role: 'user',
|
||||
type: 'text',
|
||||
content: 'Hello',
|
||||
},
|
||||
],
|
||||
options: {
|
||||
metadata: '{"custom": "value", "source": "test"}',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await create.execute.call(executeFunctions, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: mockResponse,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith('POST', '/conversations', {
|
||||
body: {
|
||||
items: expect.any(Array),
|
||||
metadata: { custom: 'value', source: 'test' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty metadata gracefully', async () => {
|
||||
const mockResponse = {
|
||||
id: 'conv_1234567890',
|
||||
object: 'conversation',
|
||||
};
|
||||
|
||||
mockApiRequest.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const executeFunctions = createExecuteFunctionsMock({
|
||||
'messages.values': [
|
||||
{
|
||||
role: 'user',
|
||||
type: 'text',
|
||||
content: 'Hello',
|
||||
},
|
||||
],
|
||||
options: {
|
||||
metadata: '{}',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await create.execute.call(executeFunctions, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: mockResponse,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith('POST', '/conversations', {
|
||||
body: {
|
||||
items: expect.any(Array),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for invalid JSON metadata', async () => {
|
||||
const executeFunctions = createExecuteFunctionsMock({
|
||||
'messages.values': [
|
||||
{
|
||||
role: 'user',
|
||||
type: 'text',
|
||||
content: 'Hello',
|
||||
},
|
||||
],
|
||||
options: {
|
||||
metadata: 'invalid json',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(create.execute.call(executeFunctions, 0)).rejects.toThrow(
|
||||
'Invalid JSON in metadata field',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Get Operation', () => {
|
||||
it('should retrieve a conversation successfully', async () => {
|
||||
const mockResponse = {
|
||||
id: 'conv_1234567890',
|
||||
object: 'conversation',
|
||||
created_at: 1234567890,
|
||||
items: [
|
||||
{
|
||||
id: 'item_1',
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'Hello' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockApiRequest.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const executeFunctions = createExecuteFunctionsMock({
|
||||
conversationId: 'conv_1234567890',
|
||||
});
|
||||
|
||||
const result = await getOperation.execute.call(executeFunctions, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: mockResponse,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith('GET', '/conversations/conv_1234567890');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Remove Operation', () => {
|
||||
it('should delete a conversation successfully', async () => {
|
||||
const mockResponse = {
|
||||
id: 'conv_1234567890',
|
||||
object: 'conversation',
|
||||
deleted: true,
|
||||
};
|
||||
|
||||
mockApiRequest.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const executeFunctions = createExecuteFunctionsMock({
|
||||
conversationId: 'conv_1234567890',
|
||||
});
|
||||
|
||||
const result = await remove.execute.call(executeFunctions, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: mockResponse,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith('DELETE', '/conversations/conv_1234567890');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update Operation', () => {
|
||||
it('should update a conversation with metadata successfully', async () => {
|
||||
const mockResponse = {
|
||||
id: 'conv_1234567890',
|
||||
object: 'conversation',
|
||||
updated_at: 1234567890,
|
||||
metadata: {
|
||||
custom: 'value',
|
||||
source: 'test',
|
||||
},
|
||||
};
|
||||
|
||||
mockApiRequest.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const executeFunctions = createExecuteFunctionsMock({
|
||||
conversationId: 'conv_1234567890',
|
||||
metadata: '{"custom": "value", "source": "test"}',
|
||||
});
|
||||
|
||||
const result = await update.execute.call(executeFunctions, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: mockResponse,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith('POST', '/conversations/conv_1234567890', {
|
||||
body: {
|
||||
metadata: { custom: 'value', source: 'test' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for invalid JSON metadata', async () => {
|
||||
const executeFunctions = createExecuteFunctionsMock({
|
||||
conversationId: 'conv_1234567890',
|
||||
metadata: 'invalid json',
|
||||
});
|
||||
|
||||
await expect(update.execute.call(executeFunctions, 0)).rejects.toThrow(
|
||||
'Invalid JSON in metadata field',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IHttpRequestMethods,
|
||||
ILoadOptionsFunctions,
|
||||
} from 'n8n-workflow';
|
||||
type RequestParameters = {
|
||||
headers?: IDataObject;
|
||||
body?: IDataObject | string;
|
||||
qs?: IDataObject;
|
||||
uri?: string;
|
||||
option?: IDataObject;
|
||||
};
|
||||
|
||||
export async function apiRequest(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
endpoint: string,
|
||||
parameters?: RequestParameters,
|
||||
) {
|
||||
const { body, qs, option } = parameters ?? {};
|
||||
|
||||
const credentials = await this.getCredentials('openAiApi');
|
||||
|
||||
let uri = `https://api.openai.com/v1${endpoint}`;
|
||||
let headers = parameters?.headers ?? {};
|
||||
if (credentials.url) {
|
||||
uri = `${credentials?.url}${endpoint}`;
|
||||
}
|
||||
|
||||
if (
|
||||
credentials.header &&
|
||||
typeof credentials.headerName === 'string' &&
|
||||
credentials.headerName &&
|
||||
typeof credentials.headerValue === 'string'
|
||||
) {
|
||||
headers = {
|
||||
...headers,
|
||||
[credentials.headerName]: credentials.headerValue,
|
||||
};
|
||||
}
|
||||
|
||||
const options = {
|
||||
headers,
|
||||
method,
|
||||
body,
|
||||
qs,
|
||||
uri,
|
||||
json: true,
|
||||
};
|
||||
|
||||
if (option && Object.keys(option).length !== 0) {
|
||||
Object.assign(options, option);
|
||||
}
|
||||
|
||||
const response = await this.helpers.requestWithAuthentication.call(this, 'openAiApi', options);
|
||||
|
||||
if (response && response.error === null) {
|
||||
response.error = undefined;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../index';
|
||||
|
||||
const mockedExecutionContext = {
|
||||
getCredentials: jest.fn(),
|
||||
helpers: {
|
||||
requestWithAuthentication: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
describe('apiRequest', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should call requestWithAuthentication with credentials URL if one is provided', async () => {
|
||||
mockedExecutionContext.getCredentials.mockResolvedValue({
|
||||
url: 'http://www.test/url/v1',
|
||||
});
|
||||
|
||||
// Act
|
||||
await apiRequest.call(mockedExecutionContext as unknown as IExecuteFunctions, 'GET', '/test', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// Assert
|
||||
|
||||
expect(mockedExecutionContext.getCredentials).toHaveBeenCalledWith('openAiApi');
|
||||
expect(mockedExecutionContext.helpers.requestWithAuthentication).toHaveBeenCalledWith(
|
||||
'openAiApi',
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'GET',
|
||||
uri: 'http://www.test/url/v1/test',
|
||||
json: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should call requestWithAuthentication with default URL if credentials URL is not provided', async () => {
|
||||
mockedExecutionContext.getCredentials.mockResolvedValue({});
|
||||
|
||||
// Act
|
||||
await apiRequest.call(mockedExecutionContext as unknown as IExecuteFunctions, 'GET', '/test', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// Assert
|
||||
|
||||
expect(mockedExecutionContext.getCredentials).toHaveBeenCalledWith('openAiApi');
|
||||
expect(mockedExecutionContext.helpers.requestWithAuthentication).toHaveBeenCalledWith(
|
||||
'openAiApi',
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'GET',
|
||||
uri: 'https://api.openai.com/v1/test',
|
||||
json: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize error: null to error: undefined', async () => {
|
||||
// Arrange
|
||||
mockedExecutionContext.getCredentials.mockResolvedValue({});
|
||||
mockedExecutionContext.helpers.requestWithAuthentication.mockResolvedValue({
|
||||
id: 'test',
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await apiRequest.call(
|
||||
mockedExecutionContext as unknown as IExecuteFunctions,
|
||||
'GET',
|
||||
'/test',
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(response.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type IExecuteFunctions,
|
||||
type INodeType,
|
||||
type INodeTypeBaseDescription,
|
||||
type INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { listSearch, loadOptions } from '../methods';
|
||||
import { router } from './actions/router';
|
||||
import { configureNodeInputs } from '../helpers/description';
|
||||
|
||||
import * as assistant from './actions/assistant';
|
||||
import * as audio from './actions/audio';
|
||||
import * as file from './actions/file';
|
||||
import * as image from './actions/image';
|
||||
import * as text from './actions/text';
|
||||
|
||||
export class OpenAiV1 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8],
|
||||
defaults: {
|
||||
name: 'OpenAI',
|
||||
},
|
||||
inputs: `={{(${configureNodeInputs})($parameter.resource, $parameter.operation, $parameter.hideTools, $parameter.memory ?? undefined)}}`,
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'openAiApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
|
||||
options: [
|
||||
{
|
||||
name: 'Assistant',
|
||||
value: 'assistant',
|
||||
},
|
||||
{
|
||||
name: 'Text',
|
||||
value: 'text',
|
||||
},
|
||||
{
|
||||
name: 'Image',
|
||||
value: 'image',
|
||||
},
|
||||
{
|
||||
name: 'Audio',
|
||||
value: 'audio',
|
||||
},
|
||||
{
|
||||
name: 'File',
|
||||
value: 'file',
|
||||
},
|
||||
],
|
||||
default: 'text',
|
||||
},
|
||||
...assistant.description,
|
||||
...audio.description,
|
||||
...file.description,
|
||||
...image.description,
|
||||
...text.description,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
methods = {
|
||||
listSearch,
|
||||
loadOptions,
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions) {
|
||||
return await router.call(this);
|
||||
}
|
||||
}
|
||||
Vendored
+290
@@ -0,0 +1,290 @@
|
||||
import type {
|
||||
INodeProperties,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
IDataObject,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
import { modelRLC } from '../descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
modelRLC('modelSearch'),
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'The name of the assistant. The maximum length is 256 characters.',
|
||||
placeholder: 'e.g. My Assistant',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Description',
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'The description of the assistant. The maximum length is 512 characters.',
|
||||
placeholder: 'e.g. My personal assistant',
|
||||
},
|
||||
{
|
||||
displayName: 'Instructions',
|
||||
name: 'instructions',
|
||||
type: 'string',
|
||||
description:
|
||||
'The system instructions that the assistant uses. The maximum length is 32768 characters.',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Code Interpreter',
|
||||
name: 'codeInterpreter',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to enable the code interpreter that allows the assistants to write and run Python code in a sandboxed execution environment, find more <a href="https://platform.openai.com/docs/assistants/tools/code-interpreter" target="_blank">here</a>',
|
||||
},
|
||||
{
|
||||
displayName: 'Knowledge Retrieval',
|
||||
name: 'knowledgeRetrieval',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to augments the assistant with knowledge from outside its model, such as proprietary product information or documents, find more <a href="https://platform.openai.com/docs/assistants/tools/knowledge-retrieval" target="_blank">here</a>',
|
||||
},
|
||||
//we want to display Files selector only when codeInterpreter true or knowledgeRetrieval true or both
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-multi-options
|
||||
displayName: 'Files',
|
||||
name: 'file_ids',
|
||||
type: 'multiOptions',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-multi-options
|
||||
description:
|
||||
'The files to be used by the assistant, there can be a maximum of 20 files attached to the assistant. You can use expression to pass file IDs as an array or comma-separated string.',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getFiles',
|
||||
},
|
||||
default: [],
|
||||
hint: "Add more files by using the 'Upload a File' operation",
|
||||
displayOptions: {
|
||||
show: {
|
||||
codeInterpreter: [true],
|
||||
},
|
||||
hide: {
|
||||
knowledgeRetrieval: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-multi-options
|
||||
displayName: 'Files',
|
||||
name: 'file_ids',
|
||||
type: 'multiOptions',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-multi-options
|
||||
description:
|
||||
'The files to be used by the assistant, there can be a maximum of 20 files attached to the assistant',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getFiles',
|
||||
},
|
||||
default: [],
|
||||
hint: "Add more files by using the 'Upload a File' operation",
|
||||
displayOptions: {
|
||||
show: {
|
||||
knowledgeRetrieval: [true],
|
||||
},
|
||||
hide: {
|
||||
codeInterpreter: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-multi-options
|
||||
displayName: 'Files',
|
||||
name: 'file_ids',
|
||||
type: 'multiOptions',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-multi-options
|
||||
description:
|
||||
'The files to be used by the assistant, there can be a maximum of 20 files attached to the assistant',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getFiles',
|
||||
},
|
||||
default: [],
|
||||
hint: "Add more files by using the 'Upload a File' operation",
|
||||
displayOptions: {
|
||||
show: {
|
||||
knowledgeRetrieval: [true],
|
||||
codeInterpreter: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'Add custom n8n tools when you <i>message</i> your assistant (rather than when creating it)',
|
||||
name: 'noticeTools',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Output Randomness (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. We generally recommend altering this or temperature but not both.',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Output Randomness (Top P)',
|
||||
name: 'topP',
|
||||
default: 1,
|
||||
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'An alternative to sampling with temperature, 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: 'Fail if Assistant Already Exists',
|
||||
name: 'failIfExists',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to fail an operation if the assistant with the same name already exists',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['create'],
|
||||
resource: ['assistant'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const model = this.getNodeParameter('modelId', i, '', { extractValue: true }) as string;
|
||||
const name = this.getNodeParameter('name', i) as string;
|
||||
const assistantDescription = this.getNodeParameter('description', i) as string;
|
||||
const instructions = this.getNodeParameter('instructions', i) as string;
|
||||
const codeInterpreter = this.getNodeParameter('codeInterpreter', i) as boolean;
|
||||
const knowledgeRetrieval = this.getNodeParameter('knowledgeRetrieval', i) as boolean;
|
||||
let file_ids = this.getNodeParameter('file_ids', i, []) as string[] | string;
|
||||
if (typeof file_ids === 'string') {
|
||||
file_ids = file_ids.split(',').map((file_id) => file_id.trim());
|
||||
}
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
if (options.failIfExists) {
|
||||
const assistants: string[] = [];
|
||||
|
||||
let has_more = true;
|
||||
let after: string | undefined;
|
||||
|
||||
do {
|
||||
const response = (await apiRequest.call(this, 'GET', '/assistants', {
|
||||
headers: {
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
qs: {
|
||||
limit: 100,
|
||||
after,
|
||||
},
|
||||
})) as { data: IDataObject[]; has_more: boolean; last_id: string };
|
||||
|
||||
for (const assistant of response.data || []) {
|
||||
assistants.push(assistant.name as string);
|
||||
}
|
||||
|
||||
has_more = response.has_more;
|
||||
|
||||
if (has_more) {
|
||||
after = response.last_id;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} while (has_more);
|
||||
|
||||
if (assistants.includes(name)) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`An assistant with the same name '${name}' already exists`,
|
||||
{ itemIndex: i },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (file_ids.length > 20) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'The maximum number of files that can be attached to the assistant is 20',
|
||||
{ itemIndex: i },
|
||||
);
|
||||
}
|
||||
|
||||
const body: IDataObject = {
|
||||
model,
|
||||
name,
|
||||
description: assistantDescription,
|
||||
instructions,
|
||||
};
|
||||
|
||||
const tools = [];
|
||||
|
||||
if (codeInterpreter) {
|
||||
tools.push({
|
||||
type: 'code_interpreter',
|
||||
});
|
||||
body.tool_resources = {
|
||||
...((body.tool_resources as object) ?? {}),
|
||||
code_interpreter: {
|
||||
file_ids,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (knowledgeRetrieval) {
|
||||
tools.push({
|
||||
type: 'file_search',
|
||||
});
|
||||
body.tool_resources = {
|
||||
...((body.tool_resources as object) ?? {}),
|
||||
file_search: {
|
||||
vector_stores: [
|
||||
{
|
||||
file_ids,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (tools.length) {
|
||||
body.tools = tools;
|
||||
}
|
||||
|
||||
const response = await apiRequest.call(this, 'POST', '/assistants', {
|
||||
body,
|
||||
headers: {
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
json: response,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/assistant/deleteAssistant.operation.ts
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
import { assistantRLC } from '../descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [assistantRLC];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['deleteAssistant'],
|
||||
resource: ['assistant'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const assistantId = this.getNodeParameter('assistantId', i, '', { extractValue: true }) as string;
|
||||
|
||||
const response = await apiRequest.call(this, 'DELETE', `/assistants/${assistantId}`, {
|
||||
headers: {
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
json: response,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as create from './create.operation';
|
||||
import * as deleteAssistant from './deleteAssistant.operation';
|
||||
import * as list from './list.operation';
|
||||
import * as message from './message.operation';
|
||||
import * as update from './update.operation';
|
||||
|
||||
export { create, deleteAssistant, message, list, update };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Create an Assistant',
|
||||
value: 'create',
|
||||
action: 'Create an assistant',
|
||||
description: 'Create a new assistant',
|
||||
},
|
||||
{
|
||||
name: 'Delete an Assistant',
|
||||
value: 'deleteAssistant',
|
||||
action: 'Delete an assistant',
|
||||
description: 'Delete an assistant from the account',
|
||||
},
|
||||
{
|
||||
name: 'List Assistants',
|
||||
value: 'list',
|
||||
action: 'List assistants',
|
||||
description: 'List assistants in the organization',
|
||||
},
|
||||
{
|
||||
name: 'Message an Assistant',
|
||||
value: 'message',
|
||||
action: 'Message an assistant',
|
||||
description: 'Send messages to an assistant',
|
||||
},
|
||||
{
|
||||
name: 'Update an Assistant',
|
||||
value: 'update',
|
||||
action: 'Update an assistant',
|
||||
description: 'Update an existing assistant',
|
||||
},
|
||||
],
|
||||
default: 'message',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['assistant'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
...create.description,
|
||||
...deleteAssistant.description,
|
||||
...message.description,
|
||||
...list.description,
|
||||
...update.description,
|
||||
];
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Simplify Output',
|
||||
name: 'simplify',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to return a simplified version of the response instead of the raw data',
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['list'],
|
||||
resource: ['assistant'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
let has_more = true;
|
||||
let after: string | undefined;
|
||||
|
||||
do {
|
||||
const response = await apiRequest.call(this, 'GET', '/assistants', {
|
||||
headers: {
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
qs: {
|
||||
limit: 100,
|
||||
after,
|
||||
},
|
||||
});
|
||||
|
||||
for (const assistant of response.data || []) {
|
||||
try {
|
||||
assistant.created_at = new Date(assistant.created_at * 1000).toISOString();
|
||||
} catch (error) {}
|
||||
|
||||
returnData.push({ json: assistant, pairedItem: { item: i } });
|
||||
}
|
||||
|
||||
has_more = response.has_more;
|
||||
|
||||
if (has_more) {
|
||||
after = response.last_id as string;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} while (has_more);
|
||||
|
||||
const simplify = this.getNodeParameter('simplify', i) as boolean;
|
||||
|
||||
if (simplify) {
|
||||
return returnData.map((item) => {
|
||||
const { id, name, model } = item.json;
|
||||
return {
|
||||
json: {
|
||||
id,
|
||||
name,
|
||||
model,
|
||||
},
|
||||
pairedItem: { item: i },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
Vendored
+318
@@ -0,0 +1,318 @@
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { AgentExecutor } from '@langchain/classic/agents';
|
||||
import type { OpenAIToolType } from '@langchain/classic/dist/experimental/openai_assistant/schema';
|
||||
import { OpenAIAssistantRunnable } from '@langchain/classic/experimental/openai_assistant';
|
||||
import type { BufferWindowMemory } from '@langchain/classic/memory';
|
||||
import omit from 'lodash/omit';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
ApplicationError,
|
||||
NodeConnectionTypes,
|
||||
NodeOperationError,
|
||||
updateDisplayOptions,
|
||||
} from 'n8n-workflow';
|
||||
import { OpenAI as OpenAIClient } from 'openai';
|
||||
|
||||
import { promptTypeOptionsDeprecated } from '@utils/descriptions';
|
||||
import { getConnectedTools, getPromptInputByType, mergeCustomHeaders } from '@utils/helpers';
|
||||
import { getTracingConfig } from '@utils/tracing';
|
||||
|
||||
import { formatToOpenAIAssistantTool, getChatMessages } from '../../../helpers/utils';
|
||||
import { assistantRLC } from '../descriptions';
|
||||
import { getProxyAgent } from '@n8n/ai-utilities';
|
||||
import { Container } from '@n8n/di';
|
||||
import { AiConfig } from '@n8n/config';
|
||||
import { checkDomainRestrictions } from '@utils/checkDomainRestrictions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
assistantRLC,
|
||||
{
|
||||
...promptTypeOptionsDeprecated,
|
||||
name: 'prompt',
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt (User Message)',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. Hello, how can you help me?',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
prompt: ['define'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Memory',
|
||||
name: 'memory',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Use memory connector',
|
||||
value: 'connector',
|
||||
description: 'Connect one of the supported memory nodes',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'Use thread ID',
|
||||
value: 'threadId',
|
||||
description: 'Specify the ID of the thread to continue',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.6 } }],
|
||||
},
|
||||
},
|
||||
default: 'connector',
|
||||
},
|
||||
{
|
||||
displayName: 'Thread ID',
|
||||
name: 'threadId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: '',
|
||||
description: 'The ID of the thread to continue, a new thread will be created if not specified',
|
||||
hint: 'If the thread ID is empty or undefined a new thread will be created and included in the response',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.6 } }],
|
||||
memory: ['threadId'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Connect your own custom n8n tools to this node on the canvas',
|
||||
name: 'noticeTools',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
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.8 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Max Retries',
|
||||
name: 'maxRetries',
|
||||
default: 2,
|
||||
description: 'Maximum number of retries to attempt',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Timeout',
|
||||
name: 'timeout',
|
||||
default: 10000,
|
||||
description: 'Maximum amount of time a request is allowed to take in milliseconds',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Preserve Original Tools',
|
||||
name: 'preserveOriginalTools',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to preserve the original tools of the assistant after the execution of this node, otherwise the tools will be replaced with the connected tools, if any, default is true',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['message'],
|
||||
resource: ['assistant'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
const mapChatMessageToThreadMessage = (
|
||||
message: BaseMessage,
|
||||
): OpenAIClient.Beta.Threads.ThreadCreateParams.Message => ({
|
||||
role: message._getType() === 'ai' ? 'assistant' : 'user',
|
||||
content: message.content.toString(),
|
||||
});
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const credentials = await this.getCredentials('openAiApi');
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
|
||||
const input = getPromptInputByType({
|
||||
ctx: this,
|
||||
i,
|
||||
inputKey: 'text',
|
||||
promptTypeKey: 'prompt',
|
||||
});
|
||||
|
||||
const assistantId = this.getNodeParameter('assistantId', i, '', { extractValue: true }) as string;
|
||||
|
||||
const options = this.getNodeParameter('options', i, {}) as {
|
||||
baseURL?: string;
|
||||
maxRetries: number;
|
||||
timeout: number;
|
||||
preserveOriginalTools?: boolean;
|
||||
};
|
||||
|
||||
if (options.baseURL) {
|
||||
checkDomainRestrictions(this, credentials, options.baseURL);
|
||||
}
|
||||
|
||||
const baseURL = (options.baseURL ?? credentials.url) as string;
|
||||
const { openAiDefaultHeaders } = Container.get(AiConfig);
|
||||
const defaultHeaders = mergeCustomHeaders(credentials, openAiDefaultHeaders ?? {});
|
||||
const timeout = options.timeout;
|
||||
|
||||
const client = new OpenAIClient({
|
||||
apiKey: credentials.apiKey as string,
|
||||
maxRetries: options.maxRetries ?? 2,
|
||||
timeout: timeout ?? 10000,
|
||||
baseURL,
|
||||
fetchOptions: {
|
||||
dispatcher: getProxyAgent(baseURL, {
|
||||
headersTimeout: timeout,
|
||||
bodyTimeout: timeout,
|
||||
}),
|
||||
},
|
||||
defaultHeaders,
|
||||
});
|
||||
|
||||
const agent = new OpenAIAssistantRunnable({ assistantId, client, asAgent: true });
|
||||
|
||||
const tools = await getConnectedTools(this, nodeVersion > 1, false);
|
||||
let assistantTools;
|
||||
|
||||
if (tools.length) {
|
||||
const transformedConnectedTools = tools?.map(formatToOpenAIAssistantTool) ?? [];
|
||||
const nativeToolsParsed: OpenAIToolType = [];
|
||||
|
||||
assistantTools = (await client.beta.assistants.retrieve(assistantId)).tools;
|
||||
|
||||
const useCodeInterpreter = assistantTools.some((tool) => tool.type === 'code_interpreter');
|
||||
if (useCodeInterpreter) {
|
||||
nativeToolsParsed.push({
|
||||
type: 'code_interpreter',
|
||||
});
|
||||
}
|
||||
|
||||
const useRetrieval = assistantTools.some((tool) => tool.type === 'file_search');
|
||||
if (useRetrieval) {
|
||||
nativeToolsParsed.push({
|
||||
type: 'file_search',
|
||||
});
|
||||
}
|
||||
|
||||
await client.beta.assistants.update(assistantId, {
|
||||
tools: [...nativeToolsParsed, ...transformedConnectedTools],
|
||||
});
|
||||
}
|
||||
|
||||
const agentExecutor = AgentExecutor.fromAgentAndTools({
|
||||
agent,
|
||||
tools: tools ?? [],
|
||||
});
|
||||
|
||||
const useMemoryConnector =
|
||||
nodeVersion >= 1.6 && this.getNodeParameter('memory', i) === 'connector';
|
||||
const memory =
|
||||
useMemoryConnector || nodeVersion < 1.6
|
||||
? ((await this.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as
|
||||
| BufferWindowMemory
|
||||
| undefined)
|
||||
: undefined;
|
||||
|
||||
const threadId =
|
||||
nodeVersion >= 1.6 && !useMemoryConnector
|
||||
? (this.getNodeParameter('threadId', i) as string)
|
||||
: undefined;
|
||||
|
||||
const chainValues: IDataObject = {
|
||||
content: input,
|
||||
signal: this.getExecutionCancelSignal(),
|
||||
timeout: options.timeout ?? 10000,
|
||||
};
|
||||
let thread: OpenAIClient.Beta.Threads.Thread;
|
||||
if (memory) {
|
||||
const chatMessages = await getChatMessages(memory);
|
||||
|
||||
// Construct a new thread from the chat history to map the memory
|
||||
if (chatMessages.length) {
|
||||
const first32Messages = chatMessages.slice(0, 32);
|
||||
// There is a undocumented limit of 32 messages per thread when creating a thread with messages
|
||||
const mappedMessages: OpenAIClient.Beta.Threads.ThreadCreateParams.Message[] =
|
||||
first32Messages.map(mapChatMessageToThreadMessage);
|
||||
|
||||
thread = await client.beta.threads.create({ messages: mappedMessages });
|
||||
const overLimitMessages = chatMessages.slice(32).map(mapChatMessageToThreadMessage);
|
||||
|
||||
// Send the remaining messages that exceed the limit of 32 sequentially
|
||||
for (const message of overLimitMessages) {
|
||||
await client.beta.threads.messages.create(thread.id, message);
|
||||
}
|
||||
|
||||
chainValues.threadId = thread.id;
|
||||
}
|
||||
} else if (threadId) {
|
||||
chainValues.threadId = threadId;
|
||||
}
|
||||
|
||||
let filteredResponse: IDataObject = {};
|
||||
try {
|
||||
const response = await agentExecutor.withConfig(getTracingConfig(this)).invoke(chainValues);
|
||||
if (memory) {
|
||||
await memory.saveContext({ input }, { output: response.output });
|
||||
|
||||
if (response.threadId && response.runId) {
|
||||
const threadRun = await client.beta.threads.runs.retrieve(response.runId, {
|
||||
thread_id: response.threadId,
|
||||
});
|
||||
response.usage = threadRun.usage;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
options.preserveOriginalTools !== false &&
|
||||
nodeVersion >= 1.3 &&
|
||||
(assistantTools ?? [])?.length
|
||||
) {
|
||||
await client.beta.assistants.update(assistantId, {
|
||||
tools: assistantTools,
|
||||
});
|
||||
}
|
||||
// Remove configuration properties and runId added by Langchain that are not relevant to the user
|
||||
filteredResponse = omit(response, ['signal', 'timeout', 'content', 'runId']) as IDataObject;
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApplicationError)) {
|
||||
throw new NodeOperationError(this.getNode(), error.message, { itemIndex: i });
|
||||
}
|
||||
}
|
||||
|
||||
return [{ json: filteredResponse, pairedItem: { item: i } }];
|
||||
}
|
||||
Vendored
+246
@@ -0,0 +1,246 @@
|
||||
import type {
|
||||
INodeProperties,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
IDataObject,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
import { assistantRLC, modelRLC } from '../descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
assistantRLC,
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Code Interpreter',
|
||||
name: 'codeInterpreter',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to enable the code interpreter that allows the assistants to write and run Python code in a sandboxed execution environment, find more <a href="https://platform.openai.com/docs/assistants/tools/code-interpreter" target="_blank">here</a>',
|
||||
},
|
||||
{
|
||||
displayName: 'Description',
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'The description of the assistant. The maximum length is 512 characters.',
|
||||
placeholder: 'e.g. My personal assistant',
|
||||
},
|
||||
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-multi-options
|
||||
displayName: 'Files',
|
||||
name: 'file_ids',
|
||||
type: 'multiOptions',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-multi-options
|
||||
description:
|
||||
'The files to be used by the assistant, there can be a maximum of 20 files attached to the assistant. You can use expression to pass file IDs as an array or comma-separated string.',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getFiles',
|
||||
},
|
||||
default: [],
|
||||
hint: "Add more files by using the 'Upload a File' operation, any existing files not selected here will be removed.",
|
||||
},
|
||||
{
|
||||
displayName: 'Instructions',
|
||||
name: 'instructions',
|
||||
type: 'string',
|
||||
description:
|
||||
'The system instructions that the assistant uses. The maximum length is 32768 characters.',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Knowledge Retrieval',
|
||||
name: 'knowledgeRetrieval',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to augments the assistant with knowledge from outside its model, such as proprietary product information or documents, find more <a href="https://platform.openai.com/docs/assistants/tools/knowledge-retrieval" target="_blank">here</a>',
|
||||
},
|
||||
{ ...modelRLC('modelSearch'), required: false },
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'The name of the assistant. The maximum length is 256 characters.',
|
||||
placeholder: 'e.g. My Assistant',
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Remove All Custom Tools (Functions)',
|
||||
name: 'removeCustomTools',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to remove all custom tools (functions) from the assistant',
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Output Randomness (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. We generally recommend altering this or temperature but not both.',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Output Randomness (Top P)',
|
||||
name: 'topP',
|
||||
default: 1,
|
||||
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'An alternative to sampling with temperature, 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['update'],
|
||||
resource: ['assistant'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
function getFileIds(file_ids: unknown): string[] {
|
||||
if (Array.isArray(file_ids)) {
|
||||
return file_ids;
|
||||
}
|
||||
|
||||
if (typeof file_ids === 'string') {
|
||||
return file_ids.split(',').map((file_id) => file_id.trim());
|
||||
}
|
||||
|
||||
throw new ApplicationError('Invalid file_ids type');
|
||||
}
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const assistantId = this.getNodeParameter('assistantId', i, '', { extractValue: true }) as string;
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
const {
|
||||
modelId,
|
||||
name,
|
||||
instructions,
|
||||
codeInterpreter,
|
||||
knowledgeRetrieval,
|
||||
file_ids,
|
||||
removeCustomTools,
|
||||
temperature,
|
||||
topP,
|
||||
} = options;
|
||||
|
||||
const assistantDescription = options.description as string;
|
||||
|
||||
const body: IDataObject = {};
|
||||
|
||||
if (file_ids) {
|
||||
const files = getFileIds(file_ids);
|
||||
if (files.length > 20) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'The maximum number of files that can be attached to the assistant is 20',
|
||||
{ itemIndex: i },
|
||||
);
|
||||
}
|
||||
|
||||
body.tool_resources = {
|
||||
...((body.tool_resources as object) ?? {}),
|
||||
code_interpreter: {
|
||||
file_ids: files,
|
||||
},
|
||||
// updating file_ids for file_search directly is not supported by OpenAI API
|
||||
// only updating vector_store_ids for file_search is supported
|
||||
// support for this to be added as part of ADO-2968
|
||||
// https://platform.openai.com/docs/api-reference/assistants/modifyAssistant
|
||||
};
|
||||
}
|
||||
|
||||
if (modelId) {
|
||||
body.model = this.getNodeParameter('options.modelId', i, '', { extractValue: true }) as string;
|
||||
}
|
||||
|
||||
if (name) {
|
||||
body.name = name;
|
||||
}
|
||||
|
||||
if (assistantDescription) {
|
||||
body.description = assistantDescription;
|
||||
}
|
||||
|
||||
if (instructions) {
|
||||
body.instructions = instructions;
|
||||
}
|
||||
|
||||
if (temperature) {
|
||||
body.temperature = temperature;
|
||||
}
|
||||
|
||||
if (topP) {
|
||||
body.topP = topP;
|
||||
}
|
||||
|
||||
let tools =
|
||||
((
|
||||
await apiRequest.call(this, 'GET', `/assistants/${assistantId}`, {
|
||||
headers: {
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
})
|
||||
).tools as IDataObject[]) || [];
|
||||
|
||||
if (codeInterpreter && !tools.find((tool) => tool.type === 'code_interpreter')) {
|
||||
tools.push({
|
||||
type: 'code_interpreter',
|
||||
});
|
||||
}
|
||||
|
||||
if (codeInterpreter === false && tools.find((tool) => tool.type === 'code_interpreter')) {
|
||||
tools = tools.filter((tool) => tool.type !== 'code_interpreter');
|
||||
}
|
||||
|
||||
if (knowledgeRetrieval && !tools.find((tool) => tool.type === 'file_search')) {
|
||||
tools.push({
|
||||
type: 'file_search',
|
||||
});
|
||||
}
|
||||
|
||||
if (knowledgeRetrieval === false && tools.find((tool) => tool.type === 'file_search')) {
|
||||
tools = tools.filter((tool) => tool.type !== 'file_search');
|
||||
}
|
||||
|
||||
if (removeCustomTools) {
|
||||
tools = tools.filter((tool) => tool.type !== 'function');
|
||||
}
|
||||
|
||||
body.tools = tools;
|
||||
|
||||
const response = await apiRequest.call(this, 'POST', `/assistants/${assistantId}`, {
|
||||
body,
|
||||
headers: {
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
json: response,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
import type {
|
||||
INodeProperties,
|
||||
IExecuteFunctions,
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
default: 'tts-1',
|
||||
options: [
|
||||
{
|
||||
name: 'TTS-1',
|
||||
value: 'tts-1',
|
||||
},
|
||||
{
|
||||
name: 'TTS-1-HD',
|
||||
value: 'tts-1-hd',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Text Input',
|
||||
name: 'input',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. The quick brown fox jumped over the lazy dog',
|
||||
description: 'The text to generate audio for. The maximum length is 4096 characters.',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Voice',
|
||||
name: 'voice',
|
||||
type: 'options',
|
||||
default: 'alloy',
|
||||
description: 'The voice to use when generating the audio',
|
||||
options: [
|
||||
{
|
||||
name: 'Alloy',
|
||||
value: 'alloy',
|
||||
},
|
||||
{
|
||||
name: 'Echo',
|
||||
value: 'echo',
|
||||
},
|
||||
{
|
||||
name: 'Fable',
|
||||
value: 'fable',
|
||||
},
|
||||
{
|
||||
name: 'Nova',
|
||||
value: 'nova',
|
||||
},
|
||||
{
|
||||
name: 'Onyx',
|
||||
value: 'onyx',
|
||||
},
|
||||
{
|
||||
name: 'Shimmer',
|
||||
value: 'shimmer',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Response Format',
|
||||
name: 'response_format',
|
||||
type: 'options',
|
||||
default: 'mp3',
|
||||
options: [
|
||||
{
|
||||
name: 'MP3',
|
||||
value: 'mp3',
|
||||
},
|
||||
{
|
||||
name: 'OPUS',
|
||||
value: 'opus',
|
||||
},
|
||||
{
|
||||
name: 'AAC',
|
||||
value: 'aac',
|
||||
},
|
||||
{
|
||||
name: 'FLAC',
|
||||
value: 'flac',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Audio Speed',
|
||||
name: 'speed',
|
||||
type: 'number',
|
||||
default: 1,
|
||||
typeOptions: {
|
||||
minValue: 0.25,
|
||||
maxValue: 4,
|
||||
numberPrecision: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Put Output in Field',
|
||||
name: 'binaryPropertyOutput',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
hint: 'The name of the output field to put the binary file data in',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['generate'],
|
||||
resource: ['audio'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const model = this.getNodeParameter('model', i) as string;
|
||||
const input = this.getNodeParameter('input', i) as string;
|
||||
const voice = this.getNodeParameter('voice', i) as string;
|
||||
let response_format = 'mp3';
|
||||
let speed = 1;
|
||||
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
if (options.response_format) {
|
||||
response_format = options.response_format as string;
|
||||
}
|
||||
|
||||
if (options.speed) {
|
||||
speed = options.speed as number;
|
||||
}
|
||||
|
||||
const body: IDataObject = {
|
||||
model,
|
||||
input,
|
||||
voice,
|
||||
response_format,
|
||||
speed,
|
||||
};
|
||||
|
||||
const option = {
|
||||
useStream: true,
|
||||
returnFullResponse: true,
|
||||
encoding: 'arraybuffer',
|
||||
json: false,
|
||||
};
|
||||
|
||||
const response = await apiRequest.call(this, 'POST', '/audio/speech', { body, option });
|
||||
|
||||
const binaryData = await this.helpers.prepareBinaryData(
|
||||
response,
|
||||
`audio.${response_format}`,
|
||||
`audio/${response_format}`,
|
||||
);
|
||||
|
||||
const binaryPropertyOutput = (options.binaryPropertyOutput as string) || 'data';
|
||||
|
||||
const newItem: INodeExecutionData = {
|
||||
json: {
|
||||
...binaryData,
|
||||
data: undefined,
|
||||
},
|
||||
pairedItem: { item: i },
|
||||
binary: {
|
||||
[binaryPropertyOutput]: binaryData,
|
||||
},
|
||||
};
|
||||
|
||||
return [newItem];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as generate from './generate.operation';
|
||||
import * as transcribe from './transcribe.operation';
|
||||
import * as translate from './translate.operation';
|
||||
|
||||
export { generate, transcribe, translate };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Generate Audio',
|
||||
value: 'generate',
|
||||
action: 'Generate audio',
|
||||
description: 'Creates audio from a text prompt',
|
||||
},
|
||||
{
|
||||
name: 'Transcribe a Recording',
|
||||
value: 'transcribe',
|
||||
action: 'Transcribe a recording',
|
||||
description: 'Transcribes audio into text',
|
||||
},
|
||||
{
|
||||
name: 'Translate a Recording',
|
||||
value: 'translate',
|
||||
action: 'Translate a recording',
|
||||
description: 'Translates audio into text in English',
|
||||
},
|
||||
],
|
||||
default: 'generate',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['audio'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'OpenAI API limits the size of the audio file to 25 MB',
|
||||
name: 'fileSizeLimitNotice',
|
||||
type: 'notice',
|
||||
default: ' ',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['audio'],
|
||||
operation: ['translate', 'transcribe'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...generate.description,
|
||||
...transcribe.description,
|
||||
...translate.description,
|
||||
];
|
||||
Vendored
+96
@@ -0,0 +1,96 @@
|
||||
import FormData from 'form-data';
|
||||
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { getBinaryDataFile } from '../../../helpers/binary-data';
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Input Data Field Name',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
placeholder: 'e.g. data',
|
||||
hint: 'The name of the input field containing the binary file data to be processed',
|
||||
description:
|
||||
'Name of the binary property which contains the audio file in one of these formats: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, or webm',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Language of the Audio File',
|
||||
name: 'language',
|
||||
type: 'string',
|
||||
description:
|
||||
'The language of the input audio. Supplying the input language in <a href="https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes" target="_blank">ISO-639-1</a> format will improve accuracy and latency.',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Output Randomness (Temperature)',
|
||||
name: 'temperature',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
numberPrecision: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['transcribe'],
|
||||
resource: ['audio'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const model = 'whisper-1';
|
||||
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i);
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('model', model);
|
||||
|
||||
if (options.language) {
|
||||
formData.append('language', options.language);
|
||||
}
|
||||
|
||||
if (options.temperature) {
|
||||
formData.append('temperature', options.temperature.toString());
|
||||
}
|
||||
|
||||
const { filename, contentType, fileContent } = await getBinaryDataFile(
|
||||
this,
|
||||
i,
|
||||
binaryPropertyName,
|
||||
);
|
||||
formData.append('file', fileContent, {
|
||||
filename,
|
||||
contentType,
|
||||
});
|
||||
|
||||
const response = await apiRequest.call(this, 'POST', '/audio/transcriptions', {
|
||||
option: { formData },
|
||||
headers: formData.getHeaders(),
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
json: response,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import FormData from 'form-data';
|
||||
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { getBinaryDataFile } from '../../../helpers/binary-data';
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Input Data Field Name',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
hint: 'The name of the input field containing the binary file data to be processed',
|
||||
placeholder: 'e.g. data',
|
||||
description:
|
||||
'Name of the binary property which contains the audio file in one of these formats: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, or webm',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Output Randomness (Temperature)',
|
||||
name: 'temperature',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
numberPrecision: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['translate'],
|
||||
resource: ['audio'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const model = 'whisper-1';
|
||||
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i);
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('model', model);
|
||||
|
||||
if (options.temperature) {
|
||||
formData.append('temperature', options.temperature.toString());
|
||||
}
|
||||
|
||||
const { filename, contentType, fileContent } = await getBinaryDataFile(
|
||||
this,
|
||||
i,
|
||||
binaryPropertyName,
|
||||
);
|
||||
formData.append('file', fileContent, {
|
||||
filename,
|
||||
contentType,
|
||||
});
|
||||
|
||||
const response = await apiRequest.call(this, 'POST', '/audio/translations', {
|
||||
option: { formData },
|
||||
headers: formData.getHeaders(),
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
json: response,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const modelRLC = (searchListMethod: string = 'modelSearch'): INodeProperties => ({
|
||||
displayName: 'Model',
|
||||
name: 'modelId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. gpt-4',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const assistantRLC: INodeProperties = {
|
||||
displayName: 'Assistant',
|
||||
name: 'assistantId',
|
||||
type: 'resourceLocator',
|
||||
description:
|
||||
'Assistant to respond to the message. You can add, modify or remove assistants in the <a href="https://platform.openai.com/playground?mode=assistant" target="_blank">playground</a>.',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'assistantSearch',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. asst_abc123',
|
||||
},
|
||||
],
|
||||
};
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'File',
|
||||
name: 'fileId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'fileSearch',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: 'file-[a-zA-Z0-9]',
|
||||
errorMessage: 'Not a valid File ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
placeholder: 'e.g. file-1234567890',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['deleteFile'],
|
||||
resource: ['file'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const fileId = this.getNodeParameter('fileId', i, '', { extractValue: true });
|
||||
|
||||
const response = await apiRequest.call(this, 'DELETE', `/files/${fileId}`);
|
||||
|
||||
return [
|
||||
{
|
||||
json: response,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as deleteFile from './deleteFile.operation';
|
||||
import * as list from './list.operation';
|
||||
import * as upload from './upload.operation';
|
||||
|
||||
export { upload, deleteFile, list };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Delete a File',
|
||||
value: 'deleteFile',
|
||||
action: 'Delete a file',
|
||||
description: 'Delete a file from the server',
|
||||
},
|
||||
{
|
||||
name: 'List Files',
|
||||
value: 'list',
|
||||
action: 'List files',
|
||||
description: "Returns a list of files that belong to the user's organization",
|
||||
},
|
||||
{
|
||||
name: 'Upload a File',
|
||||
value: 'upload',
|
||||
action: 'Upload a file',
|
||||
description: 'Upload a file that can be used across various endpoints',
|
||||
},
|
||||
],
|
||||
default: 'upload',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
...upload.description,
|
||||
...deleteFile.description,
|
||||
...list.description,
|
||||
];
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
INodeProperties,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Purpose',
|
||||
name: 'purpose',
|
||||
type: 'options',
|
||||
default: 'any',
|
||||
description: 'Only return files with the given purpose',
|
||||
options: [
|
||||
{
|
||||
name: 'Any [Default]',
|
||||
value: 'any',
|
||||
},
|
||||
{
|
||||
name: 'Assistants',
|
||||
value: 'assistants',
|
||||
},
|
||||
{
|
||||
name: 'Fine-Tune',
|
||||
value: 'fine-tune',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['list'],
|
||||
resource: ['file'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
const qs: IDataObject = {};
|
||||
|
||||
if (options.purpose && options.purpose !== 'any') {
|
||||
qs.purpose = options.purpose as string;
|
||||
}
|
||||
|
||||
const { data } = await apiRequest.call(this, 'GET', '/files', { qs });
|
||||
|
||||
return (data || []).map((file: IDataObject) => ({
|
||||
json: file,
|
||||
pairedItem: { item: i },
|
||||
}));
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import FormData from 'form-data';
|
||||
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { updateDisplayOptions, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { getBinaryDataFile } from '../../../helpers/binary-data';
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Input Data Field Name',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
hint: 'The name of the input field containing the binary file data to be processed',
|
||||
placeholder: 'e.g. data',
|
||||
description:
|
||||
'Name of the binary property which contains the file. The size of individual files can be a maximum of 512 MB or 2 million tokens for Assistants.',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Purpose',
|
||||
name: 'purpose',
|
||||
type: 'options',
|
||||
default: 'assistants',
|
||||
description:
|
||||
"The intended purpose of the uploaded file, the 'Fine-tuning' only supports .jsonl files",
|
||||
options: [
|
||||
{
|
||||
name: 'Assistants',
|
||||
value: 'assistants',
|
||||
},
|
||||
{
|
||||
name: 'Fine-Tune',
|
||||
value: 'fine-tune',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['upload'],
|
||||
resource: ['file'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i);
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('purpose', options.purpose || 'assistants');
|
||||
|
||||
const { filename, contentType, fileContent } = await getBinaryDataFile(
|
||||
this,
|
||||
i,
|
||||
binaryPropertyName,
|
||||
);
|
||||
formData.append('file', fileContent, {
|
||||
filename,
|
||||
contentType,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await apiRequest.call(this, 'POST', '/files', {
|
||||
option: { formData },
|
||||
headers: formData.getHeaders(),
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
json: response,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
if (
|
||||
error.message.includes('Bad request') &&
|
||||
error.description?.includes('Expected file to have JSONL format')
|
||||
) {
|
||||
throw new NodeOperationError(this.getNode(), 'The file content is not in JSONL format', {
|
||||
description:
|
||||
'Fine-tuning accepts only files in JSONL format, where every line is a valid JSON dictionary',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
import type {
|
||||
INodeProperties,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
IDataObject,
|
||||
} from 'n8n-workflow';
|
||||
import { updateDisplayOptions, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
import { modelRLC } from '../descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
...modelRLC('imageModelSearch'),
|
||||
displayOptions: { show: { '@version': [{ _cnd: { gte: 1.4 } }] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Text Input',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
placeholder: "e.g. What's in this image?",
|
||||
default: "What's in this image?",
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Input Type',
|
||||
name: 'inputType',
|
||||
type: 'options',
|
||||
default: 'url',
|
||||
options: [
|
||||
{
|
||||
name: 'Image URL(s)',
|
||||
value: 'url',
|
||||
},
|
||||
{
|
||||
name: 'Binary File(s)',
|
||||
value: 'base64',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'URL(s)',
|
||||
name: 'imageUrls',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. https://example.com/image.jpeg',
|
||||
description: 'URL(s) of the image(s) to analyze, multiple URLs can be added separated by comma',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
inputType: ['url'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Input Data Field Name',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
placeholder: 'e.g. data',
|
||||
hint: 'The name of the input field containing the binary file data to be processed',
|
||||
description: 'Name of the binary property which contains the image(s)',
|
||||
displayOptions: {
|
||||
show: {
|
||||
inputType: ['base64'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Simplify Output',
|
||||
name: 'simplify',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to simplify the response or not',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Detail',
|
||||
name: 'detail',
|
||||
type: 'options',
|
||||
default: 'auto',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto',
|
||||
value: 'auto',
|
||||
description:
|
||||
'Model will look at the image input size and decide if it should use the low or high setting',
|
||||
},
|
||||
{
|
||||
name: 'Low',
|
||||
value: 'low',
|
||||
description: 'Return faster responses and consume fewer tokens',
|
||||
},
|
||||
{
|
||||
name: 'High',
|
||||
value: 'high',
|
||||
description: 'Return more detailed responses, consumes more tokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Length of Description (Max Tokens)',
|
||||
description: 'Fewer tokens will result in shorter, less detailed image description',
|
||||
name: 'maxTokens',
|
||||
type: 'number',
|
||||
default: 300,
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['analyze'],
|
||||
resource: ['image'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
let model = 'gpt-4-vision-preview';
|
||||
if (this.getNode().typeVersion >= 1.4) {
|
||||
model = this.getNodeParameter('modelId', i, 'gpt-4o', { extractValue: true }) as string;
|
||||
}
|
||||
|
||||
const text = this.getNodeParameter('text', i, '') as string;
|
||||
const inputType = this.getNodeParameter('inputType', i) as string;
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
const content: IDataObject[] = [
|
||||
{
|
||||
type: 'text',
|
||||
text,
|
||||
},
|
||||
];
|
||||
|
||||
const detail = (options.detail as string) || 'auto';
|
||||
|
||||
if (inputType === 'url') {
|
||||
const imageUrls = (this.getNodeParameter('imageUrls', i) as string)
|
||||
.split(',')
|
||||
.map((url) => url.trim());
|
||||
|
||||
for (const url of imageUrls) {
|
||||
content.push({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url,
|
||||
detail,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i)
|
||||
.split(',')
|
||||
.map((propertyName) => propertyName.trim());
|
||||
|
||||
for (const propertyName of binaryPropertyName) {
|
||||
const binaryData = this.helpers.assertBinaryData(i, propertyName);
|
||||
|
||||
let fileBase64;
|
||||
if (binaryData.id) {
|
||||
const chunkSize = 256 * 1024;
|
||||
const stream = await this.helpers.getBinaryStream(binaryData.id, chunkSize);
|
||||
const buffer = await this.helpers.binaryToBuffer(stream);
|
||||
fileBase64 = buffer.toString('base64');
|
||||
} else {
|
||||
fileBase64 = binaryData.data;
|
||||
}
|
||||
|
||||
if (!binaryData) {
|
||||
throw new NodeOperationError(this.getNode(), 'No binary data exists on item!');
|
||||
}
|
||||
|
||||
content.push({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:${binaryData.mimeType};base64,${fileBase64}`,
|
||||
detail,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const body = {
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content,
|
||||
},
|
||||
],
|
||||
max_tokens: (options.maxTokens as number) || 300,
|
||||
};
|
||||
|
||||
let response = await apiRequest.call(this, 'POST', '/chat/completions', { body });
|
||||
|
||||
const simplify = this.getNodeParameter('simplify', i) as boolean;
|
||||
|
||||
if (simplify && response.choices) {
|
||||
response = { content: response.choices[0].message.content };
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: response,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
import type {
|
||||
INodeProperties,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
IDataObject,
|
||||
} from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
default: 'dall-e-3',
|
||||
description: 'The model to use for image generation',
|
||||
options: [
|
||||
{
|
||||
name: 'DALL·E 2',
|
||||
value: 'dall-e-2',
|
||||
},
|
||||
{
|
||||
name: 'DALL·E 3',
|
||||
value: 'dall-e-3',
|
||||
},
|
||||
{
|
||||
name: 'GPT Image 1',
|
||||
value: 'gpt-image-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. A cute cat eating a dinosaur',
|
||||
description:
|
||||
'A text description of the desired image(s). The maximum length is 1000 characters for dall-e-2 and 4000 characters for dall-e-3.',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Number of Images',
|
||||
name: 'n',
|
||||
default: 1,
|
||||
description: 'Number of images to generate',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
maxValue: 10,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['dall-e-2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Quality',
|
||||
name: 'dalleQuality',
|
||||
type: 'options',
|
||||
description:
|
||||
'The quality of the image that will be generated, HD creates images with finer details and greater consistency across the image',
|
||||
options: [
|
||||
{
|
||||
name: 'HD',
|
||||
value: 'hd',
|
||||
},
|
||||
{
|
||||
name: 'Standard',
|
||||
value: 'standard',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['dall-e-3'],
|
||||
},
|
||||
},
|
||||
default: 'standard',
|
||||
},
|
||||
{
|
||||
displayName: 'Quality',
|
||||
name: 'quality',
|
||||
type: 'options',
|
||||
description:
|
||||
'The quality of the image that will be generated, High creates images with finer details and greater consistency across the image',
|
||||
options: [
|
||||
{
|
||||
name: 'High',
|
||||
value: 'high',
|
||||
},
|
||||
{
|
||||
name: 'Medium',
|
||||
value: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'Low',
|
||||
value: 'low',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['gpt-image-1'],
|
||||
},
|
||||
},
|
||||
default: 'medium',
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Resolution',
|
||||
name: 'size',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: '256x256',
|
||||
value: '256x256',
|
||||
},
|
||||
{
|
||||
name: '512x512',
|
||||
value: '512x512',
|
||||
},
|
||||
{
|
||||
name: '1024x1024',
|
||||
value: '1024x1024',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['dall-e-2'],
|
||||
},
|
||||
},
|
||||
default: '1024x1024',
|
||||
},
|
||||
{
|
||||
displayName: 'Resolution',
|
||||
name: 'size',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: '1024x1024',
|
||||
value: '1024x1024',
|
||||
},
|
||||
{
|
||||
name: '1792x1024',
|
||||
value: '1792x1024',
|
||||
},
|
||||
{
|
||||
name: '1024x1792',
|
||||
value: '1024x1792',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['dall-e-3'],
|
||||
},
|
||||
},
|
||||
default: '1024x1024',
|
||||
},
|
||||
{
|
||||
displayName: 'Resolution',
|
||||
name: 'size',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: '1024x1024',
|
||||
value: '1024x1024',
|
||||
},
|
||||
{
|
||||
name: '1024x1536',
|
||||
value: '1024x1536',
|
||||
},
|
||||
{
|
||||
name: '1536x1024',
|
||||
value: '1536x1024',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['gpt-image-1'],
|
||||
},
|
||||
},
|
||||
default: '1024x1024',
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Style',
|
||||
name: 'style',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Natural',
|
||||
value: 'natural',
|
||||
description: 'Produce more natural looking images',
|
||||
},
|
||||
{
|
||||
name: 'Vivid',
|
||||
value: 'vivid',
|
||||
description: 'Lean towards generating hyper-real and dramatic images',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['dall-e-3'],
|
||||
},
|
||||
},
|
||||
default: 'vivid',
|
||||
},
|
||||
{
|
||||
displayName: 'Respond with Image URL(s)',
|
||||
name: 'returnImageUrls',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to return image URL(s) instead of binary file(s)',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'/model': ['gpt-image-1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Put Output in Field',
|
||||
name: 'binaryPropertyOutput',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
hint: 'The name of the output field to put the binary file data in',
|
||||
displayOptions: {
|
||||
show: {
|
||||
returnImageUrls: [false],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['generate'],
|
||||
resource: ['image'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const model = this.getNodeParameter('model', i) as string;
|
||||
const prompt = this.getNodeParameter('prompt', i) as string;
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
let response_format = 'b64_json';
|
||||
let binaryPropertyOutput = 'data';
|
||||
|
||||
if (options.returnImageUrls) {
|
||||
response_format = 'url';
|
||||
}
|
||||
|
||||
if (options.binaryPropertyOutput) {
|
||||
binaryPropertyOutput = options.binaryPropertyOutput as string;
|
||||
delete options.binaryPropertyOutput;
|
||||
}
|
||||
|
||||
if (options.dalleQuality) {
|
||||
options.quality = options.dalleQuality;
|
||||
delete options.dalleQuality;
|
||||
}
|
||||
|
||||
delete options.returnImageUrls;
|
||||
const body: IDataObject = {
|
||||
prompt,
|
||||
model,
|
||||
response_format: model !== 'gpt-image-1' ? response_format : undefined, // gpt-image-1 does not support response_format
|
||||
...options,
|
||||
};
|
||||
|
||||
const { data } = await apiRequest.call(this, 'POST', '/images/generations', { body });
|
||||
if (response_format === 'url') {
|
||||
return ((data as IDataObject[]) || []).map((entry) => ({
|
||||
json: entry,
|
||||
pairedItem: { item: i },
|
||||
}));
|
||||
} else {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
for (const entry of data) {
|
||||
const binaryData = await this.helpers.prepareBinaryData(
|
||||
Buffer.from(entry.b64_json as string, 'base64'),
|
||||
'data',
|
||||
);
|
||||
returnData.push({
|
||||
json: Object.assign({}, binaryData, {
|
||||
data: undefined,
|
||||
}),
|
||||
binary: {
|
||||
[binaryPropertyOutput]: binaryData,
|
||||
},
|
||||
pairedItem: { item: i },
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as analyze from './analyze.operation';
|
||||
import * as generate from './generate.operation';
|
||||
|
||||
export { generate, analyze };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Analyze Image',
|
||||
value: 'analyze',
|
||||
action: 'Analyze image',
|
||||
description: 'Take in images and answer questions about them',
|
||||
},
|
||||
{
|
||||
name: 'Generate an Image',
|
||||
value: 'generate',
|
||||
action: 'Generate an image',
|
||||
description: 'Creates an image from a text prompt',
|
||||
},
|
||||
],
|
||||
default: 'generate',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['image'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...generate.description,
|
||||
...analyze.description,
|
||||
];
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AllEntities } from 'n8n-workflow';
|
||||
|
||||
type NodeMap = {
|
||||
assistant: 'message' | 'create' | 'deleteAssistant' | 'list' | 'update';
|
||||
audio: 'generate' | 'transcribe' | 'translate';
|
||||
file: 'upload' | 'deleteFile' | 'list';
|
||||
image: 'generate' | 'analyze';
|
||||
text: 'message' | 'classify';
|
||||
};
|
||||
|
||||
export type OpenAiType = AllEntities<NodeMap>;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import * as audio from './audio';
|
||||
import { router } from './router';
|
||||
|
||||
describe('OpenAI router', () => {
|
||||
const mockExecuteFunctions = mockDeep<IExecuteFunctions>();
|
||||
const mockAudio = jest.spyOn(audio.transcribe, 'execute');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should handle NodeApiError undefined error chaining', async () => {
|
||||
const errorNode: INode = {
|
||||
id: 'error-node-id',
|
||||
name: 'ErrorNode',
|
||||
type: 'test.error',
|
||||
typeVersion: 1,
|
||||
position: [100, 200],
|
||||
parameters: {},
|
||||
};
|
||||
const nodeApiError = new NodeApiError(
|
||||
errorNode,
|
||||
{ message: 'API error occurred', error: { error: { message: 'Rate limit exceeded' } } },
|
||||
{ itemIndex: 0 },
|
||||
);
|
||||
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameter) =>
|
||||
parameter === 'resource' ? 'audio' : 'transcribe',
|
||||
);
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunctions.getNode.mockReturnValue(errorNode);
|
||||
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
|
||||
|
||||
mockAudio.mockRejectedValue(nodeApiError);
|
||||
|
||||
await expect(router.call(mockExecuteFunctions)).rejects.toThrow(NodeApiError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
NodeOperationError,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
NodeApiError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import * as assistant from './assistant';
|
||||
import * as audio from './audio';
|
||||
import * as file from './file';
|
||||
import * as image from './image';
|
||||
import type { OpenAiType } from './node.type';
|
||||
import * as text from './text';
|
||||
import { getCustomErrorMessage } from '../../helpers/error-handling';
|
||||
|
||||
export async function router(this: IExecuteFunctions) {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
const items = this.getInputData();
|
||||
const resource = this.getNodeParameter<OpenAiType>('resource', 0);
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
|
||||
const openAiTypeData = {
|
||||
resource,
|
||||
operation,
|
||||
} as OpenAiType;
|
||||
|
||||
let execute;
|
||||
switch (openAiTypeData.resource) {
|
||||
case 'assistant':
|
||||
execute = assistant[openAiTypeData.operation].execute;
|
||||
break;
|
||||
case 'audio':
|
||||
execute = audio[openAiTypeData.operation].execute;
|
||||
break;
|
||||
case 'file':
|
||||
execute = file[openAiTypeData.operation].execute;
|
||||
break;
|
||||
case 'image':
|
||||
execute = image[openAiTypeData.operation].execute;
|
||||
break;
|
||||
case 'text':
|
||||
execute = text[openAiTypeData.operation].execute;
|
||||
break;
|
||||
default:
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The operation "${operation}" is not supported!`,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
const responseData = await execute.call(this, i);
|
||||
|
||||
returnData.push(...responseData);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: error.message }, pairedItem: { item: i } });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (error instanceof NodeApiError) {
|
||||
// If the error is a rate limit error, we want to handle it differently
|
||||
const errorCode: string | undefined = (error.cause as any)?.error?.error?.code;
|
||||
if (errorCode) {
|
||||
const customErrorMessage = getCustomErrorMessage(errorCode);
|
||||
if (customErrorMessage) {
|
||||
error.message = customErrorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
error.context = {
|
||||
itemIndex: i,
|
||||
};
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new NodeOperationError(this.getNode(), error, {
|
||||
itemIndex: i,
|
||||
description: error.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Text Input',
|
||||
name: 'input',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. Sample text goes here',
|
||||
description: 'The input text to classify if it is violates the moderation policy',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Simplify Output',
|
||||
name: 'simplify',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to return a simplified version of the response instead of the raw data',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Use Stable Model',
|
||||
name: 'useStableModel',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to use the stable version of the model instead of the latest version, accuracy may be slightly lower',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['classify'],
|
||||
resource: ['text'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const input = this.getNodeParameter('input', i) as string;
|
||||
const options = this.getNodeParameter('options', i);
|
||||
const model = options.useStableModel ? 'text-moderation-stable' : 'text-moderation-latest';
|
||||
|
||||
const body = {
|
||||
input,
|
||||
model,
|
||||
};
|
||||
|
||||
const { results } = await apiRequest.call(this, 'POST', '/moderations', { body });
|
||||
|
||||
if (!results) return [];
|
||||
|
||||
const simplify = this.getNodeParameter('simplify', i) as boolean;
|
||||
|
||||
if (simplify && results) {
|
||||
return [
|
||||
{
|
||||
json: { flagged: results[0].flagged },
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
json: results[0],
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as classify from './classify.operation';
|
||||
import * as message from './message.operation';
|
||||
|
||||
export { classify, message };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Message a Model',
|
||||
value: 'message',
|
||||
action: 'Message a model',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-excess-final-period
|
||||
description: 'Create a completion with GPT 3, 4, etc.',
|
||||
},
|
||||
{
|
||||
name: 'Classify Text for Violations',
|
||||
value: 'classify',
|
||||
action: 'Classify text for violations',
|
||||
description: 'Check whether content complies with usage policies',
|
||||
},
|
||||
],
|
||||
default: 'message',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['text'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
...classify.description,
|
||||
...message.description,
|
||||
];
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
import type { Tool } from '@langchain/core/tools';
|
||||
import _omit from 'lodash/omit';
|
||||
import type {
|
||||
INodeProperties,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
IDataObject,
|
||||
} from 'n8n-workflow';
|
||||
import { jsonParse, updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { getConnectedTools } from '@utils/helpers';
|
||||
|
||||
import { MODELS_NOT_SUPPORT_FUNCTION_CALLS } from '../../../helpers/constants';
|
||||
import type { ChatCompletion } from '../../../helpers/interfaces';
|
||||
import { formatToOpenAIAssistantTool } from '../../../helpers/utils';
|
||||
import { apiRequest } from '../../../transport';
|
||||
import { modelRLC } from '../descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
modelRLC('modelSearch'),
|
||||
{
|
||||
displayName: 'Messages',
|
||||
name: 'messages',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
sortable: true,
|
||||
multipleValues: true,
|
||||
},
|
||||
placeholder: 'Add Message',
|
||||
default: { values: [{ content: '' }] },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'content',
|
||||
type: 'string',
|
||||
description: 'The content of the message to be send',
|
||||
default: '',
|
||||
placeholder: 'e.g. Hello, how can you help me?',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Role',
|
||||
name: 'role',
|
||||
type: 'options',
|
||||
description:
|
||||
"Role in shaping the model's response, it tells the model how it should behave and interact with the user",
|
||||
options: [
|
||||
{
|
||||
name: 'User',
|
||||
value: 'user',
|
||||
description: 'Send a message as a user and get a response from the model',
|
||||
},
|
||||
{
|
||||
name: 'Assistant',
|
||||
value: 'assistant',
|
||||
description: 'Tell the model to adopt a specific tone or personality',
|
||||
},
|
||||
{
|
||||
name: 'System',
|
||||
value: 'system',
|
||||
description:
|
||||
"Usually used to set the model's behavior or context for the next user message",
|
||||
},
|
||||
],
|
||||
default: 'user',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Simplify Output',
|
||||
name: 'simplify',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to return a simplified version of the response instead of the raw data',
|
||||
},
|
||||
{
|
||||
displayName: 'Output Content as JSON',
|
||||
name: 'jsonOutput',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether to attempt to return the response in JSON format. Compatible with GPT-4 Turbo and all GPT-3.5 Turbo models newer than gpt-3.5-turbo-1106.',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
displayName: 'Hide Tools',
|
||||
name: 'hideTools',
|
||||
type: 'hidden',
|
||||
default: 'hide',
|
||||
displayOptions: {
|
||||
show: {
|
||||
modelId: MODELS_NOT_SUPPORT_FUNCTION_CALLS,
|
||||
'@version': [{ _cnd: { gte: 1.2 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Connect your own custom n8n tools to this node on the canvas',
|
||||
name: 'noticeTools',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
hideTools: ['hide'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Frequency Penalty',
|
||||
name: 'frequency_penalty',
|
||||
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: 16,
|
||||
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: 'Number of Completions',
|
||||
name: 'n',
|
||||
default: 1,
|
||||
description:
|
||||
'How many completions to generate for each prompt. Note: Because this parameter generates many completions, it can quickly consume your token quota. Use carefully and ensure that you have reasonable settings for max_tokens and stop.',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Presence Penalty',
|
||||
name: 'presence_penalty',
|
||||
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: 'Output Randomness (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. We generally recommend altering this or temperature but not both.',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Output Randomness (Top P)',
|
||||
name: 'topP',
|
||||
default: 1,
|
||||
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'An alternative to sampling with temperature, 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: 'Reasoning Effort',
|
||||
name: 'reasoning_effort',
|
||||
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.
|
||||
'/modelId': [{ _cnd: { regex: '(^o1([-\\d]+)?$)|(^o[3-9].*)|(^gpt-5.*)' } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Max Tool Calls Iterations',
|
||||
name: 'maxToolsIterations',
|
||||
type: 'number',
|
||||
default: 15,
|
||||
description:
|
||||
'The maximum number of tool iteration cycles the LLM will run before stopping. A single iteration can contain multiple tool calls. Set to 0 for no limit.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.5 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['message'],
|
||||
resource: ['text'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
const model = this.getNodeParameter('modelId', i, '', { extractValue: true });
|
||||
let messages = this.getNodeParameter('messages.values', i, []) as IDataObject[];
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
const jsonOutput = this.getNodeParameter('jsonOutput', i, false) as boolean;
|
||||
const maxToolsIterations =
|
||||
nodeVersion >= 1.5 ? (this.getNodeParameter('options.maxToolsIterations', i, 15) as number) : 0;
|
||||
|
||||
const abortSignal = this.getExecutionCancelSignal();
|
||||
|
||||
if (options.maxTokens !== undefined) {
|
||||
options.max_completion_tokens = options.maxTokens;
|
||||
delete options.maxTokens;
|
||||
}
|
||||
|
||||
if (options.topP !== undefined) {
|
||||
options.top_p = options.topP;
|
||||
delete options.topP;
|
||||
}
|
||||
|
||||
let response_format;
|
||||
if (jsonOutput) {
|
||||
response_format = { type: 'json_object' };
|
||||
messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a helpful assistant designed to output JSON.',
|
||||
},
|
||||
...messages,
|
||||
];
|
||||
}
|
||||
|
||||
const hideTools = this.getNodeParameter('hideTools', i, '') as string;
|
||||
|
||||
let tools;
|
||||
let externalTools: Tool[] = [];
|
||||
|
||||
if (hideTools !== 'hide') {
|
||||
const enforceUniqueNames = nodeVersion > 1;
|
||||
externalTools = await getConnectedTools(this, enforceUniqueNames, false);
|
||||
}
|
||||
|
||||
if (externalTools.length) {
|
||||
tools = externalTools.length ? externalTools?.map(formatToOpenAIAssistantTool) : undefined;
|
||||
}
|
||||
|
||||
const body: IDataObject = {
|
||||
model,
|
||||
messages,
|
||||
tools,
|
||||
response_format,
|
||||
..._omit(options, ['maxToolsIterations']),
|
||||
};
|
||||
|
||||
let response = (await apiRequest.call(this, 'POST', '/chat/completions', {
|
||||
body,
|
||||
})) as ChatCompletion;
|
||||
|
||||
if (!response) return [];
|
||||
|
||||
let currentIteration = 1;
|
||||
let toolCalls = response?.choices[0]?.message?.tool_calls;
|
||||
|
||||
while (toolCalls?.length) {
|
||||
// Break the loop if the max iterations is reached or the execution is canceled
|
||||
if (
|
||||
abortSignal?.aborted ||
|
||||
(maxToolsIterations > 0 && currentIteration >= maxToolsIterations)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
messages.push(response.choices[0].message);
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
const functionName = toolCall.function.name;
|
||||
const functionArgs = toolCall.function.arguments;
|
||||
|
||||
let functionResponse;
|
||||
for (const tool of externalTools ?? []) {
|
||||
if (tool.name === functionName) {
|
||||
const parsedArgs: { input: string } = jsonParse(functionArgs);
|
||||
const functionInput = parsedArgs.input ?? parsedArgs ?? functionArgs;
|
||||
functionResponse = await tool.invoke(functionInput);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof functionResponse === 'object') {
|
||||
functionResponse = JSON.stringify(functionResponse);
|
||||
}
|
||||
|
||||
messages.push({
|
||||
tool_call_id: toolCall.id,
|
||||
role: 'tool',
|
||||
content: functionResponse,
|
||||
});
|
||||
}
|
||||
|
||||
response = (await apiRequest.call(this, 'POST', '/chat/completions', {
|
||||
body,
|
||||
})) as ChatCompletion;
|
||||
|
||||
toolCalls = response.choices[0].message.tool_calls;
|
||||
currentIteration += 1;
|
||||
}
|
||||
|
||||
if (response_format) {
|
||||
response.choices = response.choices.map((choice) => {
|
||||
try {
|
||||
choice.message.content = JSON.parse(choice.message.content);
|
||||
} catch (error) {}
|
||||
return choice;
|
||||
});
|
||||
}
|
||||
|
||||
const simplify = this.getNodeParameter('simplify', i) as boolean;
|
||||
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
if (simplify) {
|
||||
for (const entry of response.choices) {
|
||||
returnData.push({
|
||||
json: entry,
|
||||
pairedItem: { item: i },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
returnData.push({ json: response, pairedItem: { item: i } });
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type IExecuteFunctions,
|
||||
type INodeType,
|
||||
type INodeTypeBaseDescription,
|
||||
type INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { configureNodeInputs } from '../helpers/description';
|
||||
import { listSearch, loadOptions } from '../methods';
|
||||
import { router } from './actions/router';
|
||||
|
||||
import * as audio from './actions/audio';
|
||||
import * as conversation from './actions/conversation';
|
||||
import * as file from './actions/file';
|
||||
import * as image from './actions/image';
|
||||
import * as text from './actions/text';
|
||||
import * as video from './actions/video';
|
||||
|
||||
export class OpenAiV2 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
version: [2, 2.1],
|
||||
defaults: {
|
||||
name: 'OpenAI',
|
||||
},
|
||||
inputs: `={{(${configureNodeInputs})($parameter.resource, $parameter.operation, $parameter.hideTools, $parameter.memory ?? undefined)}}`,
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'openAiApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
|
||||
options: [
|
||||
{
|
||||
name: 'Text',
|
||||
value: 'text',
|
||||
builderHint: {
|
||||
message:
|
||||
'For text generation, reasoning and tools, use AI Agent with OpenAI Chat Model instead of this resource.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Image',
|
||||
value: 'image',
|
||||
},
|
||||
{
|
||||
name: 'Audio',
|
||||
value: 'audio',
|
||||
},
|
||||
{
|
||||
name: 'File',
|
||||
value: 'file',
|
||||
},
|
||||
{
|
||||
name: 'Conversation',
|
||||
value: 'conversation',
|
||||
},
|
||||
{
|
||||
name: 'Video',
|
||||
value: 'video',
|
||||
},
|
||||
],
|
||||
default: 'text',
|
||||
},
|
||||
...audio.description,
|
||||
...file.description,
|
||||
...image.description,
|
||||
...text.description,
|
||||
...conversation.description,
|
||||
...video.description,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
methods = {
|
||||
listSearch,
|
||||
loadOptions,
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions) {
|
||||
return await router.call(this);
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
import type {
|
||||
INodeProperties,
|
||||
IExecuteFunctions,
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
default: 'tts-1',
|
||||
options: [
|
||||
{
|
||||
name: 'TTS-1',
|
||||
value: 'tts-1',
|
||||
},
|
||||
{
|
||||
name: 'TTS-1-HD',
|
||||
value: 'tts-1-hd',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Text Input',
|
||||
name: 'input',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. The quick brown fox jumped over the lazy dog',
|
||||
description: 'The text to generate audio for. The maximum length is 4096 characters.',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Voice',
|
||||
name: 'voice',
|
||||
type: 'options',
|
||||
default: 'alloy',
|
||||
description: 'The voice to use when generating the audio',
|
||||
options: [
|
||||
{
|
||||
name: 'Alloy',
|
||||
value: 'alloy',
|
||||
},
|
||||
{
|
||||
name: 'Echo',
|
||||
value: 'echo',
|
||||
},
|
||||
{
|
||||
name: 'Fable',
|
||||
value: 'fable',
|
||||
},
|
||||
{
|
||||
name: 'Nova',
|
||||
value: 'nova',
|
||||
},
|
||||
{
|
||||
name: 'Onyx',
|
||||
value: 'onyx',
|
||||
},
|
||||
{
|
||||
name: 'Shimmer',
|
||||
value: 'shimmer',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Response Format',
|
||||
name: 'response_format',
|
||||
type: 'options',
|
||||
default: 'mp3',
|
||||
options: [
|
||||
{
|
||||
name: 'MP3',
|
||||
value: 'mp3',
|
||||
},
|
||||
{
|
||||
name: 'OPUS',
|
||||
value: 'opus',
|
||||
},
|
||||
{
|
||||
name: 'AAC',
|
||||
value: 'aac',
|
||||
},
|
||||
{
|
||||
name: 'FLAC',
|
||||
value: 'flac',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Audio Speed',
|
||||
name: 'speed',
|
||||
type: 'number',
|
||||
default: 1,
|
||||
typeOptions: {
|
||||
minValue: 0.25,
|
||||
maxValue: 4,
|
||||
numberPrecision: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Put Output in Field',
|
||||
name: 'binaryPropertyOutput',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
hint: 'The name of the output field to put the binary file data in',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['generate'],
|
||||
resource: ['audio'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const model = this.getNodeParameter('model', i) as string;
|
||||
const input = this.getNodeParameter('input', i) as string;
|
||||
const voice = this.getNodeParameter('voice', i) as string;
|
||||
let response_format = 'mp3';
|
||||
let speed = 1;
|
||||
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
if (options.response_format) {
|
||||
response_format = options.response_format as string;
|
||||
}
|
||||
|
||||
if (options.speed) {
|
||||
speed = options.speed as number;
|
||||
}
|
||||
|
||||
const body: IDataObject = {
|
||||
model,
|
||||
input,
|
||||
voice,
|
||||
response_format,
|
||||
speed,
|
||||
};
|
||||
|
||||
const option = {
|
||||
useStream: true,
|
||||
returnFullResponse: true,
|
||||
encoding: 'arraybuffer',
|
||||
json: false,
|
||||
};
|
||||
|
||||
const response = await apiRequest.call(this, 'POST', '/audio/speech', { body, option });
|
||||
|
||||
const binaryData = await this.helpers.prepareBinaryData(
|
||||
response,
|
||||
`audio.${response_format}`,
|
||||
`audio/${response_format}`,
|
||||
);
|
||||
|
||||
const binaryPropertyOutput = (options.binaryPropertyOutput as string) || 'data';
|
||||
|
||||
const newItem: INodeExecutionData = {
|
||||
json: {
|
||||
...binaryData,
|
||||
data: undefined,
|
||||
},
|
||||
pairedItem: { item: i },
|
||||
binary: {
|
||||
[binaryPropertyOutput]: binaryData,
|
||||
},
|
||||
};
|
||||
|
||||
return [newItem];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as generate from './generate.operation';
|
||||
import * as transcribe from './transcribe.operation';
|
||||
import * as translate from './translate.operation';
|
||||
|
||||
export { generate, transcribe, translate };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Generate Audio',
|
||||
value: 'generate',
|
||||
action: 'Generate audio',
|
||||
description: 'Creates audio from a text prompt',
|
||||
},
|
||||
{
|
||||
name: 'Transcribe a Recording',
|
||||
value: 'transcribe',
|
||||
action: 'Transcribe a recording',
|
||||
description: 'Transcribes audio into text',
|
||||
},
|
||||
{
|
||||
name: 'Translate a Recording',
|
||||
value: 'translate',
|
||||
action: 'Translate a recording',
|
||||
description: 'Translates audio into text in English',
|
||||
},
|
||||
],
|
||||
default: 'generate',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['audio'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'OpenAI API limits the size of the audio file to 25 MB',
|
||||
name: 'fileSizeLimitNotice',
|
||||
type: 'notice',
|
||||
default: ' ',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['audio'],
|
||||
operation: ['translate', 'transcribe'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...generate.description,
|
||||
...transcribe.description,
|
||||
...translate.description,
|
||||
];
|
||||
Vendored
+96
@@ -0,0 +1,96 @@
|
||||
import FormData from 'form-data';
|
||||
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { getBinaryDataFile } from '../../../helpers/binary-data';
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Input Data Field Name',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
placeholder: 'e.g. data',
|
||||
hint: 'The name of the input field containing the binary file data to be processed',
|
||||
description:
|
||||
'Name of the binary property which contains the audio file in one of these formats: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, or webm',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Language of the Audio File',
|
||||
name: 'language',
|
||||
type: 'string',
|
||||
description:
|
||||
'The language of the input audio. Supplying the input language in <a href="https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes" target="_blank">ISO-639-1</a> format will improve accuracy and latency.',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Output Randomness (Temperature)',
|
||||
name: 'temperature',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
numberPrecision: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['transcribe'],
|
||||
resource: ['audio'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const model = 'whisper-1';
|
||||
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i);
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('model', model);
|
||||
|
||||
if (options.language) {
|
||||
formData.append('language', options.language);
|
||||
}
|
||||
|
||||
if (options.temperature) {
|
||||
formData.append('temperature', options.temperature.toString());
|
||||
}
|
||||
|
||||
const { filename, contentType, fileContent } = await getBinaryDataFile(
|
||||
this,
|
||||
i,
|
||||
binaryPropertyName,
|
||||
);
|
||||
formData.append('file', fileContent, {
|
||||
filename,
|
||||
contentType,
|
||||
});
|
||||
|
||||
const response = await apiRequest.call(this, 'POST', '/audio/transcriptions', {
|
||||
option: { formData },
|
||||
headers: formData.getHeaders(),
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
json: response,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import FormData from 'form-data';
|
||||
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { getBinaryDataFile } from '../../../helpers/binary-data';
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Input Data Field Name',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
hint: 'The name of the input field containing the binary file data to be processed',
|
||||
placeholder: 'e.g. data',
|
||||
description:
|
||||
'Name of the binary property which contains the audio file in one of these formats: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, or webm',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Output Randomness (Temperature)',
|
||||
name: 'temperature',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
numberPrecision: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['translate'],
|
||||
resource: ['audio'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const model = 'whisper-1';
|
||||
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i);
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('model', model);
|
||||
|
||||
if (options.temperature) {
|
||||
formData.append('temperature', options.temperature.toString());
|
||||
}
|
||||
|
||||
const { filename, contentType, fileContent } = await getBinaryDataFile(
|
||||
this,
|
||||
i,
|
||||
binaryPropertyName,
|
||||
);
|
||||
formData.append('file', fileContent, {
|
||||
filename,
|
||||
contentType,
|
||||
});
|
||||
|
||||
const response = await apiRequest.call(this, 'POST', '/audio/translations', {
|
||||
option: { formData },
|
||||
headers: formData.getHeaders(),
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
json: response,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
Vendored
+107
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { isObjectEmpty, jsonParse, updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
import { metadataProperty, textMessageProperties } from '../descriptions';
|
||||
import { formatInputMessages } from '../text/helpers/responses';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Messages',
|
||||
name: 'messages',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
sortable: true,
|
||||
multipleValues: true,
|
||||
},
|
||||
placeholder: 'Add Message',
|
||||
default: { values: [{ type: 'text' }] },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Message',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Role',
|
||||
name: 'role',
|
||||
type: 'options',
|
||||
description:
|
||||
"Role in shaping the model's response, it tells the model how it should behave and interact with the user",
|
||||
options: [
|
||||
{
|
||||
name: 'User',
|
||||
value: 'user',
|
||||
description: 'Send a message as a user and get a response from the model',
|
||||
},
|
||||
{
|
||||
name: 'Assistant',
|
||||
value: 'assistant',
|
||||
description: 'Tell the model to adopt a specific tone or personality',
|
||||
},
|
||||
{
|
||||
name: 'System',
|
||||
value: 'system',
|
||||
description:
|
||||
"Usually used to set the model's behavior or context for the next user message",
|
||||
},
|
||||
],
|
||||
default: 'user',
|
||||
},
|
||||
{
|
||||
...textMessageProperties[0],
|
||||
displayOptions: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [metadataProperty],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['create'],
|
||||
resource: ['conversation'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const options = this.getNodeParameter('options', i, {}) as IDataObject;
|
||||
const messages = this.getNodeParameter('messages.values', i, []) as IDataObject[];
|
||||
|
||||
const body: IDataObject = {
|
||||
items: await formatInputMessages.call(this, i, messages),
|
||||
};
|
||||
|
||||
if (options.metadata) {
|
||||
const metadata = jsonParse(options.metadata as string, {
|
||||
errorMessage: 'Invalid JSON in metadata field',
|
||||
}) as IDataObject;
|
||||
if (!isObjectEmpty(metadata)) {
|
||||
body.metadata = metadata;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiRequest.call(this, 'POST', '/conversations', { body });
|
||||
|
||||
return [
|
||||
{
|
||||
json: response,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
Vendored
+38
@@ -0,0 +1,38 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Conversation ID',
|
||||
name: 'conversationId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'conv_1234567890',
|
||||
description: 'The ID of the conversation to retrieve',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['get'],
|
||||
resource: ['conversation'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const conversationId = this.getNodeParameter('conversationId', i, '') as string;
|
||||
|
||||
const response = await apiRequest.call(this, 'GET', `/conversations/${conversationId}`);
|
||||
|
||||
return [
|
||||
{
|
||||
json: response,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as create from './create.operation';
|
||||
import * as get from './get.operation';
|
||||
import * as remove from './remove.operation';
|
||||
import * as update from './update.operation';
|
||||
|
||||
export { create, get, remove, update };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Create',
|
||||
value: 'create',
|
||||
action: 'Create a conversation',
|
||||
description: 'Create a conversation',
|
||||
},
|
||||
{
|
||||
name: 'Get',
|
||||
value: 'get',
|
||||
action: 'Get a conversation',
|
||||
description: 'Get a conversation',
|
||||
},
|
||||
{
|
||||
name: 'Remove',
|
||||
value: 'remove',
|
||||
action: 'Remove a conversation',
|
||||
description: 'Remove a conversation',
|
||||
},
|
||||
{
|
||||
name: 'Update',
|
||||
value: 'update',
|
||||
action: 'Update a conversation',
|
||||
description: 'Update a conversation',
|
||||
},
|
||||
],
|
||||
default: 'create',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['conversation'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...create.description,
|
||||
...remove.description,
|
||||
...update.description,
|
||||
...get.description,
|
||||
];
|
||||
Vendored
+38
@@ -0,0 +1,38 @@
|
||||
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Conversation ID',
|
||||
name: 'conversationId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'conv_1234567890',
|
||||
description: 'The ID of the conversation to delete',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['remove'],
|
||||
resource: ['conversation'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const conversationId = this.getNodeParameter('conversationId', i, '') as string;
|
||||
|
||||
const response = await apiRequest.call(this, 'DELETE', `/conversations/${conversationId}`);
|
||||
|
||||
return [
|
||||
{
|
||||
json: response,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
Vendored
+62
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
INodeProperties,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
IDataObject,
|
||||
} from 'n8n-workflow';
|
||||
import { updateDisplayOptions, jsonParse } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
import { metadataProperty } from '../descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Conversation ID',
|
||||
name: 'conversationId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'conv_1234567890',
|
||||
description: 'The ID of the conversation to update',
|
||||
required: true,
|
||||
},
|
||||
{ ...metadataProperty, required: true },
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['update'],
|
||||
resource: ['conversation'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const conversationId = this.getNodeParameter('conversationId', i, '') as string;
|
||||
const metadata = this.getNodeParameter('metadata', i, '') as string;
|
||||
|
||||
if (!conversationId) {
|
||||
throw new Error('Conversation ID is required');
|
||||
}
|
||||
|
||||
if (!metadata) {
|
||||
throw new Error('Metadata is required');
|
||||
}
|
||||
|
||||
const body: IDataObject = {};
|
||||
|
||||
body.metadata = jsonParse(metadata, {
|
||||
errorMessage: 'Invalid JSON in metadata field',
|
||||
});
|
||||
|
||||
const response = await apiRequest.call(this, 'POST', `/conversations/${conversationId}`, {
|
||||
body,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
json: response,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
import type { INodeProperties, INodePropertyCollection } from 'n8n-workflow';
|
||||
|
||||
export const modelRLC = (searchListMethod: string = 'modelSearch'): INodeProperties => ({
|
||||
displayName: 'Model',
|
||||
name: 'modelId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. gpt-4',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const metadataProperty: INodeProperties = {
|
||||
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: '{}',
|
||||
};
|
||||
|
||||
const imageMessageProperties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Image Type',
|
||||
name: 'imageType',
|
||||
type: 'options',
|
||||
default: 'url',
|
||||
options: [
|
||||
{ name: 'Image URL', value: 'url' },
|
||||
{ name: 'File ID', value: 'fileId' },
|
||||
{ name: 'File Data', value: 'base64' },
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['image'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Image URL',
|
||||
name: 'imageUrl',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. https://example.com/image.jpeg',
|
||||
description: 'URL of the image to be sent',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['image'],
|
||||
imageType: ['url'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Image Data',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
placeholder: 'e.g. data',
|
||||
hint: 'The name of the input field containing the binary file data to be processed',
|
||||
description: 'Name of the binary property which contains the image(s)',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['image'],
|
||||
imageType: ['base64'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'File ID',
|
||||
name: 'fileId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'ID of the file to be sent',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['image'],
|
||||
imageType: ['fileId'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Detail',
|
||||
name: 'imageDetail',
|
||||
type: 'options',
|
||||
default: 'auto',
|
||||
description: 'The detail level of the image to be sent to the model',
|
||||
options: [
|
||||
{ name: 'Auto', value: 'auto' },
|
||||
{ name: 'Low', value: 'low' },
|
||||
{ name: 'High', value: 'high' },
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['image'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const textMessageProperties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'content',
|
||||
type: 'string',
|
||||
description: 'The content of the message to be send',
|
||||
default: '',
|
||||
placeholder: 'e.g. Hello, how can you help me?',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['text'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const fileMessageProperties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'File Type',
|
||||
name: 'fileType',
|
||||
type: 'options',
|
||||
default: 'url',
|
||||
options: [
|
||||
{ name: 'File URL', value: 'url' },
|
||||
{ name: 'File ID', value: 'fileId' },
|
||||
{ name: 'File Data', value: 'base64' },
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['file'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'File URL',
|
||||
name: 'fileUrl',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. https://example.com/file.pdf',
|
||||
description: 'URL of the file to be sent. Accepts base64 encoded files as well.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['file'],
|
||||
fileType: ['url'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'File ID',
|
||||
name: 'fileId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'ID of the file to be sent',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['file'],
|
||||
fileType: ['fileId'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'File Data',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
placeholder: 'e.g. data',
|
||||
hint: 'The name of the input field containing the binary file data to be processed',
|
||||
description: 'Name of the binary property which contains the file',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['file'],
|
||||
fileType: ['base64'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'File Name',
|
||||
name: 'fileName',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['file'],
|
||||
fileType: ['base64'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const messageOptions: INodePropertyCollection[] = [
|
||||
{
|
||||
displayName: 'Message',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Type',
|
||||
name: 'type',
|
||||
type: 'options',
|
||||
default: 'text',
|
||||
options: [
|
||||
{ name: 'Text', value: 'text' },
|
||||
{ name: 'Image', value: 'image' },
|
||||
{ name: 'File', value: 'file' },
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Role',
|
||||
name: 'role',
|
||||
type: 'options',
|
||||
description:
|
||||
"Role in shaping the model's response, it tells the model how it should behave and interact with the user",
|
||||
options: [
|
||||
{
|
||||
name: 'User',
|
||||
value: 'user',
|
||||
description: 'Send a message as a user and get a response from the model',
|
||||
},
|
||||
{
|
||||
name: 'Assistant',
|
||||
value: 'assistant',
|
||||
description: 'Tell the model to adopt a specific tone or personality',
|
||||
},
|
||||
{
|
||||
name: 'System',
|
||||
value: 'system',
|
||||
description:
|
||||
"Usually used to set the model's behavior or context for the next user message",
|
||||
},
|
||||
],
|
||||
default: 'user',
|
||||
},
|
||||
...textMessageProperties,
|
||||
...imageMessageProperties,
|
||||
...fileMessageProperties,
|
||||
],
|
||||
},
|
||||
];
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'File',
|
||||
name: 'fileId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'fileSearch',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: 'file-[a-zA-Z0-9]',
|
||||
errorMessage: 'Not a valid File ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
placeholder: 'e.g. file-1234567890',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['deleteFile'],
|
||||
resource: ['file'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const fileId = this.getNodeParameter('fileId', i, '', { extractValue: true });
|
||||
|
||||
const response = await apiRequest.call(this, 'DELETE', `/files/${fileId}`);
|
||||
|
||||
return [
|
||||
{
|
||||
json: response,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as deleteFile from './deleteFile.operation';
|
||||
import * as list from './list.operation';
|
||||
import * as upload from './upload.operation';
|
||||
|
||||
export { upload, deleteFile, list };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Delete a File',
|
||||
value: 'deleteFile',
|
||||
action: 'Delete a file',
|
||||
description: 'Delete a file from the server',
|
||||
},
|
||||
{
|
||||
name: 'List Files',
|
||||
value: 'list',
|
||||
action: 'List files',
|
||||
description: "Returns a list of files that belong to the user's organization",
|
||||
},
|
||||
{
|
||||
name: 'Upload a File',
|
||||
value: 'upload',
|
||||
action: 'Upload a file',
|
||||
description: 'Upload a file that can be used across various endpoints',
|
||||
},
|
||||
],
|
||||
default: 'upload',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
...upload.description,
|
||||
...deleteFile.description,
|
||||
...list.description,
|
||||
];
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
INodeProperties,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Purpose',
|
||||
name: 'purpose',
|
||||
type: 'options',
|
||||
default: 'any',
|
||||
description: 'Only return files with the given purpose',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
|
||||
options: [
|
||||
{
|
||||
name: 'Any [Default]',
|
||||
value: 'any',
|
||||
},
|
||||
{
|
||||
name: 'Assistants',
|
||||
value: 'assistants',
|
||||
},
|
||||
{
|
||||
name: 'Fine-Tune',
|
||||
value: 'fine-tune',
|
||||
},
|
||||
{
|
||||
name: 'Vision',
|
||||
value: 'vision',
|
||||
},
|
||||
{
|
||||
name: 'User Data',
|
||||
value: 'user_data',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['list'],
|
||||
resource: ['file'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
const qs: IDataObject = {};
|
||||
|
||||
if (options.purpose && options.purpose !== 'any') {
|
||||
qs.purpose = options.purpose as string;
|
||||
}
|
||||
|
||||
const { data } = await apiRequest.call(this, 'GET', '/files', { qs });
|
||||
|
||||
return (data || []).map((file: IDataObject) => ({
|
||||
json: file,
|
||||
pairedItem: { item: i },
|
||||
}));
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import FormData from 'form-data';
|
||||
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { updateDisplayOptions, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { getBinaryDataFile } from '../../../helpers/binary-data';
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Input Data Field Name',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
hint: 'The name of the input field containing the binary file data to be processed',
|
||||
placeholder: 'e.g. data',
|
||||
description:
|
||||
'Name of the binary property which contains the file. The size of individual files can be a maximum of 512 MB or 2 million tokens for Assistants.',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Purpose',
|
||||
name: 'purpose',
|
||||
type: 'options',
|
||||
default: 'user_data',
|
||||
description:
|
||||
"The intended purpose of the uploaded file, the 'Fine-tuning' only supports .jsonl files",
|
||||
options: [
|
||||
{
|
||||
name: 'Assistants',
|
||||
value: 'assistants',
|
||||
},
|
||||
{
|
||||
name: 'Fine-Tune',
|
||||
value: 'fine-tune',
|
||||
},
|
||||
{
|
||||
name: 'Vision',
|
||||
value: 'vision',
|
||||
},
|
||||
{
|
||||
name: 'User Data',
|
||||
value: 'user_data',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['upload'],
|
||||
resource: ['file'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i);
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('purpose', options.purpose || 'user_data');
|
||||
|
||||
const { filename, contentType, fileContent } = await getBinaryDataFile(
|
||||
this,
|
||||
i,
|
||||
binaryPropertyName,
|
||||
);
|
||||
formData.append('file', fileContent, {
|
||||
filename,
|
||||
contentType,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await apiRequest.call(this, 'POST', '/files', {
|
||||
option: { formData },
|
||||
headers: formData.getHeaders(),
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
json: response,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
if (
|
||||
error.message.includes('Bad request') &&
|
||||
error.description?.includes('Expected file to have JSONL format')
|
||||
) {
|
||||
throw new NodeOperationError(this.getNode(), 'The file content is not in JSONL format', {
|
||||
description:
|
||||
'Fine-tuning accepts only files in JSONL format, where every line is a valid JSON dictionary',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import type { ResponseInputImage } from 'openai/resources/responses/responses';
|
||||
import type { ChatContent, ChatResponse, ChatResponseRequest } from '../../../helpers/interfaces';
|
||||
import { apiRequest } from '../../../transport';
|
||||
import { modelRLC } from '../descriptions';
|
||||
import { getBinaryDataFile } from '../../../helpers/binary-data';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
...modelRLC('imageModelSearch'),
|
||||
},
|
||||
{
|
||||
displayName: 'Text Input',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
placeholder: "e.g. What's in this image?",
|
||||
default: "What's in this image?",
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Input Type',
|
||||
name: 'inputType',
|
||||
type: 'options',
|
||||
default: 'url',
|
||||
options: [
|
||||
{
|
||||
name: 'Image URL(s)',
|
||||
value: 'url',
|
||||
},
|
||||
{
|
||||
name: 'Binary File(s)',
|
||||
value: 'base64',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'URL(s)',
|
||||
name: 'imageUrls',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. https://example.com/image.jpeg',
|
||||
description: 'URL(s) of the image(s) to analyze, multiple URLs can be added separated by comma',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
inputType: ['url'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Input Data Field Name',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
placeholder: 'e.g. data',
|
||||
hint: 'The name of the input field containing the binary file data to be processed',
|
||||
description: 'Name of the binary property which contains the image(s)',
|
||||
displayOptions: {
|
||||
show: {
|
||||
inputType: ['base64'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Simplify Output',
|
||||
name: 'simplify',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to simplify the response or not',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Detail',
|
||||
name: 'detail',
|
||||
type: 'options',
|
||||
default: 'auto',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto',
|
||||
value: 'auto',
|
||||
description:
|
||||
'Model will look at the image input size and decide if it should use the low or high setting',
|
||||
},
|
||||
{
|
||||
name: 'Low',
|
||||
value: 'low',
|
||||
description: 'Return faster responses and consume fewer tokens',
|
||||
},
|
||||
{
|
||||
name: 'High',
|
||||
value: 'high',
|
||||
description: 'Return more detailed responses, consumes more tokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Length of Description (Max Tokens)',
|
||||
description: 'Fewer tokens will result in shorter, less detailed image description',
|
||||
name: 'maxTokens',
|
||||
type: 'number',
|
||||
default: 300,
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['analyze'],
|
||||
resource: ['image'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const model = this.getNodeParameter('modelId', i, 'gpt-4o', { extractValue: true }) as string;
|
||||
|
||||
const text = this.getNodeParameter('text', i, '') as string;
|
||||
const inputType = this.getNodeParameter('inputType', i) as string;
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
const content: ChatContent = [
|
||||
{
|
||||
type: 'input_text',
|
||||
text,
|
||||
},
|
||||
];
|
||||
|
||||
const detail = (options.detail as ResponseInputImage['detail']) || ('auto' as const);
|
||||
|
||||
if (inputType === 'url') {
|
||||
const imageUrls = (this.getNodeParameter('imageUrls', i) as string)
|
||||
.split(',')
|
||||
.map((url) => url.trim());
|
||||
|
||||
for (const url of imageUrls) {
|
||||
content.push({
|
||||
type: 'input_image',
|
||||
detail,
|
||||
image_url: url,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i)
|
||||
.split(',')
|
||||
.map((propertyName) => propertyName.trim());
|
||||
|
||||
for (const propertyName of binaryPropertyName) {
|
||||
const { fileContent, contentType } = await getBinaryDataFile(this, i, propertyName);
|
||||
const buffer = await this.helpers.binaryToBuffer(fileContent);
|
||||
const fileBase64 = buffer.toString('base64');
|
||||
|
||||
content.push({
|
||||
type: 'input_image',
|
||||
detail,
|
||||
image_url: `data:${contentType};base64,${fileBase64}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const body: ChatResponseRequest = {
|
||||
model,
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content,
|
||||
},
|
||||
],
|
||||
max_output_tokens: (options.maxTokens as number) || 300,
|
||||
};
|
||||
|
||||
const response = (await apiRequest.call(this, 'POST', '/responses', {
|
||||
body,
|
||||
})) as ChatResponse;
|
||||
|
||||
const simplify = this.getNodeParameter('simplify', i) as boolean;
|
||||
|
||||
if (simplify) {
|
||||
return [
|
||||
{
|
||||
json: response.output as unknown as IDataObject,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: response as unknown as IDataObject,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
+456
@@ -0,0 +1,456 @@
|
||||
import FormData from 'form-data';
|
||||
import type {
|
||||
IBinaryData,
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { getBinaryDataFile } from '../../../helpers/binary-data';
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
export const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
default: 'gpt-image-1',
|
||||
description: 'The model to use for image generation',
|
||||
options: [
|
||||
{
|
||||
name: 'DALL·E 2',
|
||||
value: 'dall-e-2',
|
||||
},
|
||||
{
|
||||
name: 'GPT Image 1',
|
||||
value: 'gpt-image-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
description:
|
||||
'A text description of the desired image(s). Maximum 1000 characters for dall-e-2, 32000 characters for gpt-image-1.',
|
||||
placeholder: 'A beautiful sunset over mountains',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Images',
|
||||
name: 'images',
|
||||
type: 'fixedCollection',
|
||||
placeholder: 'Add Image',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
multipleValueButtonText: 'Add Image',
|
||||
},
|
||||
default: { values: [{ binaryPropertyName: 'data' }] },
|
||||
description:
|
||||
'Add one or more binary fields to include images with your prompt. Each image should be a png, webp, or jpg file less than 50MB. You can provide up to 16 images.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['gpt-image-1'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Image',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Binary Field Name',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
placeholder: 'e.g. data',
|
||||
description: 'The name of the binary field containing the image data',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Binary Field Name',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
placeholder: 'e.g. data',
|
||||
hint: 'The name of the input field containing the binary file data to be processed',
|
||||
description:
|
||||
'Name of the binary property which contains the image. It should be a square png file less than 4MB.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['dall-e-2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Number of Images',
|
||||
name: 'n',
|
||||
type: 'number',
|
||||
default: 1,
|
||||
description: 'The number of images to generate. Must be between 1 and 10.',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
maxValue: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Size',
|
||||
name: 'size',
|
||||
type: 'options',
|
||||
default: '1024x1024',
|
||||
description: 'The size of the generated images',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
|
||||
options: [
|
||||
{
|
||||
name: '256x256',
|
||||
value: '256x256',
|
||||
},
|
||||
{
|
||||
name: '512x512',
|
||||
value: '512x512',
|
||||
},
|
||||
{
|
||||
name: '1024x1024',
|
||||
value: '1024x1024',
|
||||
},
|
||||
{
|
||||
name: '1024x1536 (Portrait)',
|
||||
value: '1024x1536',
|
||||
},
|
||||
{
|
||||
name: '1536x1024 (Landscape)',
|
||||
value: '1536x1024',
|
||||
},
|
||||
{
|
||||
name: 'Auto',
|
||||
value: 'auto',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Quality',
|
||||
name: 'quality',
|
||||
type: 'options',
|
||||
default: 'auto',
|
||||
description: 'The quality of the image that will be generated',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
|
||||
options: [
|
||||
{
|
||||
name: 'Auto',
|
||||
value: 'auto',
|
||||
},
|
||||
{
|
||||
name: 'High',
|
||||
value: 'high',
|
||||
},
|
||||
{
|
||||
name: 'Medium',
|
||||
value: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'Low',
|
||||
value: 'low',
|
||||
},
|
||||
{
|
||||
name: 'Standard',
|
||||
value: 'standard',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['gpt-image-1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Response Format',
|
||||
name: 'responseFormat',
|
||||
type: 'options',
|
||||
default: 'url',
|
||||
description:
|
||||
'The format in which the generated images are returned. URLs are only valid for 60 minutes after generation.',
|
||||
options: [
|
||||
{
|
||||
name: 'URL',
|
||||
value: 'url',
|
||||
},
|
||||
{
|
||||
name: 'Base64 JSON',
|
||||
value: 'b64_json',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['dall-e-2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Output Format',
|
||||
name: 'outputFormat',
|
||||
type: 'options',
|
||||
default: 'png',
|
||||
description:
|
||||
'The format in which the generated images are returned. Only supported for gpt-image-1.',
|
||||
options: [
|
||||
{
|
||||
name: 'PNG',
|
||||
value: 'png',
|
||||
},
|
||||
{
|
||||
name: 'JPEG',
|
||||
value: 'jpeg',
|
||||
},
|
||||
{
|
||||
name: 'WebP',
|
||||
value: 'webp',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['gpt-image-1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Output Compression',
|
||||
name: 'outputCompression',
|
||||
type: 'number',
|
||||
default: 100,
|
||||
description:
|
||||
'The compression level (0-100%) for the generated images. Only supported for gpt-image-1 with webp or jpeg output formats.',
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['gpt-image-1'],
|
||||
outputFormat: ['webp', 'jpeg'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'User',
|
||||
name: 'user',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse',
|
||||
placeholder: 'user-12345',
|
||||
},
|
||||
{
|
||||
displayName: 'Background',
|
||||
name: 'background',
|
||||
type: 'options',
|
||||
default: 'auto',
|
||||
description:
|
||||
'Allows to set transparency for the background of the generated image(s). Only supported for gpt-image-1.',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto',
|
||||
value: 'auto',
|
||||
},
|
||||
{
|
||||
name: 'Transparent',
|
||||
value: 'transparent',
|
||||
},
|
||||
{
|
||||
name: 'Opaque',
|
||||
value: 'opaque',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['gpt-image-1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Input Fidelity',
|
||||
name: 'inputFidelity',
|
||||
type: 'options',
|
||||
default: 'low',
|
||||
description:
|
||||
'Control how much effort the model will exert to match the style and features of input images. Only supported for gpt-image-1.',
|
||||
options: [
|
||||
{
|
||||
name: 'Low',
|
||||
value: 'low',
|
||||
},
|
||||
{
|
||||
name: 'High',
|
||||
value: 'high',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['gpt-image-1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Image Mask',
|
||||
name: 'imageMask',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
hint: 'The name of the input field containing the binary file data to be processed',
|
||||
description:
|
||||
'Name of the binary property which contains the image. An additional image whose fully transparent areas (e.g. where alpha is zero) indicate where image should be edited. If there are multiple images provided, the mask will be applied on the first image. Must be a valid PNG file, less than 4MB, and have the same dimensions as image.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['edit'],
|
||||
resource: ['image'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const model = this.getNodeParameter('model', i);
|
||||
const prompt = this.getNodeParameter('prompt', i);
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
const isGPTImage1 = model === 'gpt-image-1';
|
||||
const isDallE2 = model === 'dall-e-2';
|
||||
|
||||
const n = this.getNodeParameter('n', i, 1) as number;
|
||||
const size = this.getNodeParameter('size', i, '1024x1024') as string;
|
||||
const defaultResponseFormat = isGPTImage1 ? 'b64_json' : 'url';
|
||||
const responseFormat = this.getNodeParameter(
|
||||
'responseFormat',
|
||||
i,
|
||||
defaultResponseFormat,
|
||||
) as string;
|
||||
const quality = this.getNodeParameter('quality', i, 'auto') as string;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
if (isGPTImage1) {
|
||||
const imagesParam = this.getNodeParameter('images', i, {
|
||||
values: [{ binaryPropertyName: 'data' }],
|
||||
}) as { values: Array<{ binaryPropertyName: string | IBinaryData }> };
|
||||
|
||||
const imagesUi = imagesParam.values ?? [];
|
||||
const imageFieldNames = imagesUi.map((v) => v.binaryPropertyName).filter((n) => Boolean(n));
|
||||
|
||||
for (const fieldName of imageFieldNames) {
|
||||
const { fileContent, contentType, filename } = await getBinaryDataFile(this, i, fieldName);
|
||||
const buffer = await this.helpers.binaryToBuffer(fileContent);
|
||||
formData.append('image[]', buffer, {
|
||||
filename,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i);
|
||||
const { fileContent, contentType, filename } = await getBinaryDataFile(
|
||||
this,
|
||||
i,
|
||||
binaryPropertyName,
|
||||
);
|
||||
const buffer = await this.helpers.binaryToBuffer(fileContent);
|
||||
formData.append('image', buffer, {
|
||||
filename,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
|
||||
formData.append('prompt', prompt);
|
||||
formData.append('model', model);
|
||||
|
||||
if (n) {
|
||||
formData.append('n', n.toString());
|
||||
}
|
||||
if (size) {
|
||||
formData.append('size', size);
|
||||
}
|
||||
if (responseFormat && isDallE2) {
|
||||
formData.append('response_format', responseFormat);
|
||||
}
|
||||
if (options.user) {
|
||||
formData.append('user', options.user as string);
|
||||
}
|
||||
if (options.background && isGPTImage1) {
|
||||
formData.append('background', options.background as string);
|
||||
}
|
||||
if (options.inputFidelity && isGPTImage1) {
|
||||
formData.append('input_fidelity', options.inputFidelity as string);
|
||||
}
|
||||
if (options.outputFormat && isGPTImage1) {
|
||||
formData.append('output_format', options.outputFormat as string);
|
||||
}
|
||||
if (options.outputCompression !== undefined && options.outputCompression !== null) {
|
||||
formData.append('output_compression', String(Number(options.outputCompression)));
|
||||
}
|
||||
if (quality && isGPTImage1) {
|
||||
formData.append('quality', quality);
|
||||
}
|
||||
|
||||
if (options.imageMask && typeof options.imageMask === 'string') {
|
||||
const { fileContent, contentType, filename } = await getBinaryDataFile(
|
||||
this,
|
||||
i,
|
||||
options.imageMask,
|
||||
);
|
||||
const buffer = await this.helpers.binaryToBuffer(fileContent);
|
||||
formData.append('mask', buffer, {
|
||||
filename,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
|
||||
const response = (await apiRequest.call(this, 'POST', '/images/edits', {
|
||||
option: { formData },
|
||||
headers: formData.getHeaders(),
|
||||
})) as IDataObject;
|
||||
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
if (responseFormat === 'url') {
|
||||
const data = (response.data as IDataObject[]) || [];
|
||||
const entries = data.map((entry) => ({
|
||||
json: entry,
|
||||
pairedItem: { item: i },
|
||||
}));
|
||||
Array.prototype.push.apply(returnData, entries);
|
||||
} else {
|
||||
for (const entry of (response.data as IDataObject[]) || []) {
|
||||
const binaryData = await this.helpers.prepareBinaryData(
|
||||
Buffer.from(entry.b64_json as string, 'base64'),
|
||||
'data',
|
||||
);
|
||||
returnData.push({
|
||||
json: Object.assign({}, binaryData, {
|
||||
data: undefined,
|
||||
}),
|
||||
binary: {
|
||||
data: binaryData,
|
||||
},
|
||||
pairedItem: { item: i },
|
||||
});
|
||||
}
|
||||
}
|
||||
return returnData;
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
import type {
|
||||
INodeProperties,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
IDataObject,
|
||||
} from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
default: 'dall-e-3',
|
||||
description: 'The model to use for image generation',
|
||||
options: [
|
||||
{
|
||||
name: 'DALL·E 2',
|
||||
value: 'dall-e-2',
|
||||
},
|
||||
{
|
||||
name: 'DALL·E 3',
|
||||
value: 'dall-e-3',
|
||||
},
|
||||
{
|
||||
name: 'GPT Image 1',
|
||||
value: 'gpt-image-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. A cute cat eating a dinosaur',
|
||||
description:
|
||||
'A text description of the desired image(s). The maximum length is 1000 characters for dall-e-2 and 4000 characters for dall-e-3.',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Number of Images',
|
||||
name: 'n',
|
||||
default: 1,
|
||||
description: 'Number of images to generate',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
maxValue: 10,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['dall-e-2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Quality',
|
||||
name: 'dalleQuality',
|
||||
type: 'options',
|
||||
description:
|
||||
'The quality of the image that will be generated, HD creates images with finer details and greater consistency across the image',
|
||||
options: [
|
||||
{
|
||||
name: 'HD',
|
||||
value: 'hd',
|
||||
},
|
||||
{
|
||||
name: 'Standard',
|
||||
value: 'standard',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['dall-e-3'],
|
||||
},
|
||||
},
|
||||
default: 'standard',
|
||||
},
|
||||
{
|
||||
displayName: 'Quality',
|
||||
name: 'quality',
|
||||
type: 'options',
|
||||
description:
|
||||
'The quality of the image that will be generated, High creates images with finer details and greater consistency across the image',
|
||||
options: [
|
||||
{
|
||||
name: 'High',
|
||||
value: 'high',
|
||||
},
|
||||
{
|
||||
name: 'Medium',
|
||||
value: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'Low',
|
||||
value: 'low',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['gpt-image-1'],
|
||||
},
|
||||
},
|
||||
default: 'medium',
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Resolution',
|
||||
name: 'size',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: '256x256',
|
||||
value: '256x256',
|
||||
},
|
||||
{
|
||||
name: '512x512',
|
||||
value: '512x512',
|
||||
},
|
||||
{
|
||||
name: '1024x1024',
|
||||
value: '1024x1024',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['dall-e-2'],
|
||||
},
|
||||
},
|
||||
default: '1024x1024',
|
||||
},
|
||||
{
|
||||
displayName: 'Resolution',
|
||||
name: 'size',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: '1024x1024',
|
||||
value: '1024x1024',
|
||||
},
|
||||
{
|
||||
name: '1792x1024',
|
||||
value: '1792x1024',
|
||||
},
|
||||
{
|
||||
name: '1024x1792',
|
||||
value: '1024x1792',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['dall-e-3'],
|
||||
},
|
||||
},
|
||||
default: '1024x1024',
|
||||
},
|
||||
{
|
||||
displayName: 'Resolution',
|
||||
name: 'size',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: '1024x1024',
|
||||
value: '1024x1024',
|
||||
},
|
||||
{
|
||||
name: '1024x1536',
|
||||
value: '1024x1536',
|
||||
},
|
||||
{
|
||||
name: '1536x1024',
|
||||
value: '1536x1024',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['gpt-image-1'],
|
||||
},
|
||||
},
|
||||
default: '1024x1024',
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Style',
|
||||
name: 'style',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Natural',
|
||||
value: 'natural',
|
||||
description: 'Produce more natural looking images',
|
||||
},
|
||||
{
|
||||
name: 'Vivid',
|
||||
value: 'vivid',
|
||||
description: 'Lean towards generating hyper-real and dramatic images',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/model': ['dall-e-3'],
|
||||
},
|
||||
},
|
||||
default: 'vivid',
|
||||
},
|
||||
{
|
||||
displayName: 'Respond with Image URL(s)',
|
||||
name: 'returnImageUrls',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to return image URL(s) instead of binary file(s)',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'/model': ['gpt-image-1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Put Output in Field',
|
||||
name: 'binaryPropertyOutput',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
hint: 'The name of the output field to put the binary file data in',
|
||||
displayOptions: {
|
||||
show: {
|
||||
returnImageUrls: [false],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['generate'],
|
||||
resource: ['image'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const model = this.getNodeParameter('model', i) as string;
|
||||
const prompt = this.getNodeParameter('prompt', i) as string;
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
let response_format = 'b64_json';
|
||||
let binaryPropertyOutput = 'data';
|
||||
|
||||
if (options.returnImageUrls) {
|
||||
response_format = 'url';
|
||||
}
|
||||
|
||||
if (options.binaryPropertyOutput) {
|
||||
binaryPropertyOutput = options.binaryPropertyOutput as string;
|
||||
delete options.binaryPropertyOutput;
|
||||
}
|
||||
|
||||
if (options.dalleQuality) {
|
||||
options.quality = options.dalleQuality;
|
||||
delete options.dalleQuality;
|
||||
}
|
||||
|
||||
delete options.returnImageUrls;
|
||||
const body: IDataObject = {
|
||||
prompt,
|
||||
model,
|
||||
response_format: model !== 'gpt-image-1' ? response_format : undefined, // gpt-image-1 does not support response_format
|
||||
...options,
|
||||
};
|
||||
|
||||
const { data } = await apiRequest.call(this, 'POST', '/images/generations', { body });
|
||||
if (response_format === 'url') {
|
||||
return ((data as IDataObject[]) || []).map((entry) => ({
|
||||
json: entry,
|
||||
pairedItem: { item: i },
|
||||
}));
|
||||
} else {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
for (const entry of data) {
|
||||
const binaryData = await this.helpers.prepareBinaryData(
|
||||
Buffer.from(entry.b64_json as string, 'base64'),
|
||||
'data',
|
||||
);
|
||||
returnData.push({
|
||||
json: Object.assign({}, binaryData, {
|
||||
data: undefined,
|
||||
}),
|
||||
binary: {
|
||||
[binaryPropertyOutput]: binaryData,
|
||||
},
|
||||
pairedItem: { item: i },
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as analyze from './analyze.operation';
|
||||
import * as generate from './generate.operation';
|
||||
import * as edit from './edit.operation';
|
||||
|
||||
export { generate, analyze, edit };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Analyze Image',
|
||||
value: 'analyze',
|
||||
action: 'Analyze image',
|
||||
description: 'Take in images and answer questions about them',
|
||||
},
|
||||
{
|
||||
name: 'Generate an Image',
|
||||
value: 'generate',
|
||||
action: 'Generate an image',
|
||||
description: 'Creates an image from a text prompt',
|
||||
},
|
||||
{
|
||||
name: 'Edit Image',
|
||||
value: 'edit',
|
||||
action: 'Edit image',
|
||||
description: 'Edit an image',
|
||||
},
|
||||
],
|
||||
default: 'generate',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['image'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...generate.description,
|
||||
...analyze.description,
|
||||
...edit.description,
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AllEntities } from 'n8n-workflow';
|
||||
|
||||
type NodeMap = {
|
||||
audio: 'generate' | 'transcribe' | 'translate';
|
||||
file: 'upload' | 'deleteFile' | 'list';
|
||||
image: 'generate' | 'analyze' | 'edit';
|
||||
text: 'classify' | 'response';
|
||||
conversation: 'create' | 'get' | 'update' | 'remove';
|
||||
video: 'generate';
|
||||
};
|
||||
|
||||
export type OpenAiType = AllEntities<NodeMap>;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import * as audio from './audio';
|
||||
import { router } from './router';
|
||||
|
||||
describe('OpenAI router', () => {
|
||||
const mockExecuteFunctions = mockDeep<IExecuteFunctions>();
|
||||
const mockAudio = jest.spyOn(audio.transcribe, 'execute');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should handle NodeApiError undefined error chaining', async () => {
|
||||
const errorNode: INode = {
|
||||
id: 'error-node-id',
|
||||
name: 'ErrorNode',
|
||||
type: 'test.error',
|
||||
typeVersion: 1,
|
||||
position: [100, 200],
|
||||
parameters: {},
|
||||
};
|
||||
const nodeApiError = new NodeApiError(
|
||||
errorNode,
|
||||
{ message: 'API error occurred', error: { error: { message: 'Rate limit exceeded' } } },
|
||||
{ itemIndex: 0 },
|
||||
);
|
||||
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameter) =>
|
||||
parameter === 'resource' ? 'audio' : 'transcribe',
|
||||
);
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunctions.getNode.mockReturnValue(errorNode);
|
||||
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
|
||||
|
||||
mockAudio.mockRejectedValue(nodeApiError);
|
||||
|
||||
await expect(router.call(mockExecuteFunctions)).rejects.toThrow(NodeApiError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
NodeApiError,
|
||||
NodeOperationError,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { getCustomErrorMessage } from '../../helpers/error-handling';
|
||||
import type { OpenAiType } from './node.type';
|
||||
import * as audio from './audio';
|
||||
import * as conversation from './conversation';
|
||||
import * as file from './file';
|
||||
import * as image from './image';
|
||||
import * as text from './text';
|
||||
import * as video from './video';
|
||||
|
||||
export async function router(this: IExecuteFunctions) {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
const items = this.getInputData();
|
||||
const resource = this.getNodeParameter<OpenAiType>('resource', 0);
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
|
||||
const openAiTypeData = {
|
||||
resource,
|
||||
operation,
|
||||
} as OpenAiType;
|
||||
|
||||
let execute;
|
||||
switch (openAiTypeData.resource) {
|
||||
case 'audio':
|
||||
execute = audio[openAiTypeData.operation].execute;
|
||||
break;
|
||||
case 'file':
|
||||
execute = file[openAiTypeData.operation].execute;
|
||||
break;
|
||||
case 'image':
|
||||
execute = image[openAiTypeData.operation].execute;
|
||||
break;
|
||||
case 'text':
|
||||
execute = text[openAiTypeData.operation].execute;
|
||||
break;
|
||||
case 'conversation':
|
||||
execute = conversation[openAiTypeData.operation].execute;
|
||||
break;
|
||||
case 'video':
|
||||
execute = video[openAiTypeData.operation].execute;
|
||||
break;
|
||||
default:
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The operation "${operation}" is not supported!`,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
const responseData = await execute.call(this, i);
|
||||
|
||||
returnData.push(...responseData);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: error.message }, pairedItem: { item: i } });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (error instanceof NodeApiError) {
|
||||
// If the error is a rate limit error, we want to handle it differently
|
||||
const errorCode: string | undefined = (error.cause as any)?.error?.error?.code;
|
||||
if (errorCode) {
|
||||
const customErrorMessage = getCustomErrorMessage(errorCode);
|
||||
if (customErrorMessage) {
|
||||
error.message = customErrorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
error.context = {
|
||||
itemIndex: i,
|
||||
};
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new NodeOperationError(this.getNode(), error, {
|
||||
itemIndex: i,
|
||||
description: error.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Text Input',
|
||||
name: 'input',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. Sample text goes here',
|
||||
description: 'The input text to classify if it is violates the moderation policy',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Simplify Output',
|
||||
name: 'simplify',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to return a simplified version of the response instead of the raw data',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Use Stable Model',
|
||||
name: 'useStableModel',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to use the stable version of the model instead of the latest version, accuracy may be slightly lower',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { lt: 2.1 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['classify'],
|
||||
resource: ['text'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const input = this.getNodeParameter('input', i) as string;
|
||||
const version = this.getNode().typeVersion;
|
||||
let model = 'omni-moderation-latest';
|
||||
if (version < 2.1) {
|
||||
const options = this.getNodeParameter('options', i);
|
||||
model = options.useStableModel ? 'text-moderation-stable' : 'text-moderation-latest';
|
||||
}
|
||||
|
||||
const body = {
|
||||
input,
|
||||
model,
|
||||
};
|
||||
|
||||
const { results } = await apiRequest.call(this, 'POST', '/moderations', { body });
|
||||
|
||||
if (!results) return [];
|
||||
|
||||
const simplify = this.getNodeParameter('simplify', i) as boolean;
|
||||
|
||||
if (simplify && results) {
|
||||
return [
|
||||
{
|
||||
json: { flagged: results[0].flagged },
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
json: results[0],
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
import type { OpenAIClient } from '@langchain/openai';
|
||||
import get from 'lodash/get';
|
||||
import isObject from 'lodash/isObject';
|
||||
import { isObjectEmpty, jsonParse, type IDataObject, type IExecuteFunctions } from 'n8n-workflow';
|
||||
import type { ResponseInputImage } from 'openai/resources/responses/responses';
|
||||
|
||||
import { getBinaryDataFile } from '../../../../helpers/binary-data';
|
||||
import type {
|
||||
ChatContent,
|
||||
ChatInputItem,
|
||||
ChatResponseRequest,
|
||||
} from '../../../../helpers/interfaces';
|
||||
|
||||
const toArray = (str: string) => str.split(',').map((e) => e.trim());
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export async function formatInputMessages(
|
||||
this: IExecuteFunctions,
|
||||
i: number,
|
||||
messages: IDataObject[],
|
||||
) {
|
||||
return await Promise.all(
|
||||
messages.map<Promise<ChatInputItem>>(async (message) => {
|
||||
const role = message.role as ChatInputItem['role'];
|
||||
let content: ChatContent = [];
|
||||
if (message.type === 'text' || !message.type) {
|
||||
content = [{ type: 'input_text', text: message.content as string }];
|
||||
} else if (message.type === 'image') {
|
||||
const detail = (message.imageDetail as ResponseInputImage['detail']) || ('auto' as const);
|
||||
|
||||
if (message.imageType === 'base64') {
|
||||
const { fileContent, contentType } = await getBinaryDataFile(
|
||||
this,
|
||||
i,
|
||||
message.binaryPropertyName as string,
|
||||
);
|
||||
const buffer = await this.helpers.binaryToBuffer(fileContent);
|
||||
content = [
|
||||
{
|
||||
type: 'input_image',
|
||||
detail,
|
||||
image_url: `data:${contentType};base64,${buffer.toString('base64')}`,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
content = [
|
||||
{
|
||||
type: 'input_image',
|
||||
detail,
|
||||
...(message.imageType === 'url' && { image_url: message.imageUrl as string }),
|
||||
...(message.imageType === 'fileId' && { file_id: message.fileId as string }),
|
||||
},
|
||||
];
|
||||
}
|
||||
} else if (message.type === 'file') {
|
||||
if (message.fileType === 'base64') {
|
||||
const { fileContent, contentType } = await getBinaryDataFile(
|
||||
this,
|
||||
i,
|
||||
message.binaryPropertyName as string,
|
||||
);
|
||||
const buffer = await this.helpers.binaryToBuffer(fileContent);
|
||||
content = [
|
||||
{
|
||||
type: 'input_file',
|
||||
filename: message.fileName as string,
|
||||
file_data: `data:${contentType};base64,${buffer.toString('base64')}`,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
content = [
|
||||
{
|
||||
type: 'input_file',
|
||||
...(message.fileType === 'url' && { file_url: message.fileUrl as string }),
|
||||
...(message.fileType === 'fileId' && { file_id: message.fileId as string }),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
return { role, content };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateRequestOptions {
|
||||
model: string;
|
||||
messages: IDataObject[];
|
||||
options: IDataObject;
|
||||
builtInTools?: IDataObject;
|
||||
tools?: OpenAIClient.Responses.FunctionTool[];
|
||||
}
|
||||
|
||||
export async function createRequest(
|
||||
this: IExecuteFunctions,
|
||||
i: number,
|
||||
{ model, messages, options, builtInTools, tools }: CreateRequestOptions,
|
||||
): Promise<ChatResponseRequest> {
|
||||
const body: ChatResponseRequest = {
|
||||
model,
|
||||
input: await formatInputMessages.call(this, i, messages),
|
||||
parallel_tool_calls: get(options, 'parallelToolCalls', true) as boolean,
|
||||
store: get(options, 'store', true) as boolean,
|
||||
instructions: options.instructions as string,
|
||||
max_output_tokens: options.maxTokens as number,
|
||||
previous_response_id: options.previousResponseId as string,
|
||||
prompt_cache_key: options.promptCacheKey as string,
|
||||
safety_identifier: options.safetyIdentifier as string,
|
||||
service_tier: options.serviceTier as ChatResponseRequest['service_tier'],
|
||||
temperature: options.temperature as number,
|
||||
top_p: options.topP as number,
|
||||
top_logprobs: options.topLogprobs as number,
|
||||
tools,
|
||||
max_tool_calls: options.maxToolCalls as number,
|
||||
background: get(options, 'backgroundMode.values.enabled', false) as boolean,
|
||||
};
|
||||
|
||||
if (options.truncation !== undefined) {
|
||||
body.truncation = !!options.truncation ? 'auto' : 'disabled';
|
||||
}
|
||||
|
||||
if (options.conversationId) {
|
||||
body.conversation = options.conversationId as string;
|
||||
}
|
||||
|
||||
if (Array.isArray(options.include) && options.include?.length) {
|
||||
body.include = options.include as ChatResponseRequest['include'];
|
||||
}
|
||||
|
||||
if (options.metadata) {
|
||||
body.metadata = jsonParse(options.metadata as string, {
|
||||
errorMessage: 'Failed to parse metadata',
|
||||
});
|
||||
}
|
||||
|
||||
if (options.promptConfig) {
|
||||
const prompt = get(options, 'promptConfig.promptOptions') as IDataObject;
|
||||
body.prompt = removeEmptyProperties({
|
||||
id: prompt.promptId,
|
||||
version: prompt.version,
|
||||
...(prompt.variables && {
|
||||
variables: jsonParse(prompt.variables as string, {
|
||||
errorMessage: 'Failed to parse prompt variables',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (options.reasoning) {
|
||||
const reasoning = get(options, 'reasoning.reasoningOptions') as IDataObject;
|
||||
body.reasoning = removeEmptyProperties({
|
||||
effort: reasoning.effort,
|
||||
summary: reasoning.summary && reasoning.summary !== 'none' ? reasoning.summary : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.textFormat) {
|
||||
const textOptions = get(options, 'textFormat.textOptions') as IDataObject;
|
||||
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 if (textOptions.type === 'json_object') {
|
||||
textConfig.format = {
|
||||
type: textOptions.type,
|
||||
};
|
||||
body.input = [
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'You are a helpful assistant designed to output JSON.' },
|
||||
],
|
||||
},
|
||||
...body.input,
|
||||
];
|
||||
} else if (textOptions.type === 'text') {
|
||||
textConfig.format = {
|
||||
type: textOptions.type,
|
||||
};
|
||||
}
|
||||
|
||||
if (textConfig.format) {
|
||||
textConfig.format = removeEmptyProperties(textConfig.format);
|
||||
}
|
||||
|
||||
body.text = textConfig;
|
||||
}
|
||||
|
||||
if (builtInTools) {
|
||||
const newTools = body.tools ?? [];
|
||||
|
||||
const webSearchOptions = get(builtInTools, 'webSearch') as IDataObject | undefined;
|
||||
if (webSearchOptions) {
|
||||
let allowedDomains: string[] | undefined;
|
||||
const allowedDomainsRaw = get(webSearchOptions, 'allowedDomains', '') as string;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
newTools.push(
|
||||
removeEmptyProperties({
|
||||
type: 'web_search',
|
||||
search_context_size: get(webSearchOptions, 'searchContextSize', 'medium') as
|
||||
| 'low'
|
||||
| 'medium'
|
||||
| 'high',
|
||||
user_location: userLocation,
|
||||
...(allowedDomains && { filters: { allowed_domains: allowedDomains } }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (builtInTools.codeInterpreter) {
|
||||
newTools.push({
|
||||
type: 'code_interpreter',
|
||||
container: {
|
||||
type: 'auto',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (builtInTools.fileSearch) {
|
||||
const vectorStoreIds = get(builtInTools.fileSearch, 'vectorStoreIds', '[]') as string;
|
||||
const filters = get(builtInTools.fileSearch, 'filters', '{}') as string;
|
||||
newTools.push(
|
||||
removeEmptyProperties({
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
body.tools = newTools;
|
||||
}
|
||||
|
||||
return await removeEmptyProperties(body);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as classify from './classify.operation';
|
||||
import * as response from './response.operation';
|
||||
|
||||
export { classify, response };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Message a Model',
|
||||
value: 'response',
|
||||
action: 'Message a model',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-excess-final-period, n8n-nodes-base/node-param-description-missing-final-period
|
||||
description: 'Generate a model response with GPT 3, 4, 5, etc. using Responses API',
|
||||
},
|
||||
{
|
||||
name: 'Classify Text for Violations',
|
||||
value: 'classify',
|
||||
action: 'Classify text for violations',
|
||||
description: 'Check whether content complies with usage policies',
|
||||
},
|
||||
],
|
||||
default: 'response',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['text'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
...classify.description,
|
||||
...response.description,
|
||||
];
|
||||
+723
@@ -0,0 +1,723 @@
|
||||
import type { Tool } from '@langchain/core/tools';
|
||||
import { getConnectedTools } from '@utils/helpers';
|
||||
import get from 'lodash/get';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { jsonParse, NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
|
||||
import { MODELS_NOT_SUPPORT_FUNCTION_CALLS } from '../../../helpers/constants';
|
||||
import type { ChatResponse } from '../../../helpers/interfaces';
|
||||
import { formatToOpenAIResponsesTool } from '../../../helpers/utils';
|
||||
import { pollUntilAvailable } from '../../../helpers/polling';
|
||||
import { apiRequest } from '../../../transport';
|
||||
import { messageOptions, metadataProperty, modelRLC } from '../descriptions';
|
||||
import { createRequest } from './helpers/responses';
|
||||
|
||||
const jsonSchemaExample = `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["message"]
|
||||
}`;
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
modelRLC('modelSearch'),
|
||||
{
|
||||
displayName: 'Messages',
|
||||
name: 'responses',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
sortable: true,
|
||||
multipleValues: true,
|
||||
},
|
||||
placeholder: 'Add Message',
|
||||
default: { values: [{ type: 'text' }] },
|
||||
options: messageOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Simplify Output',
|
||||
name: 'simplify',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to return a simplified version of the response instead of the raw data',
|
||||
},
|
||||
{
|
||||
displayName: 'Hide Tools',
|
||||
name: 'hideTools',
|
||||
type: 'hidden',
|
||||
default: 'hide',
|
||||
displayOptions: {
|
||||
show: {
|
||||
modelId: MODELS_NOT_SUPPORT_FUNCTION_CALLS,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Connect your own custom n8n tools to this node on the canvas',
|
||||
name: 'noticeTools',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
hideTools: ['hide'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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',
|
||||
description:
|
||||
'High level guidance for the amount of context window space to use for the search',
|
||||
name: 'searchContextSize',
|
||||
type: 'options',
|
||||
default: 'medium',
|
||||
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.',
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
displayName: 'Include Additional Data',
|
||||
name: 'include',
|
||||
default: [],
|
||||
type: 'multiOptions',
|
||||
description: 'Specify additional output data to include in the model response',
|
||||
options: [
|
||||
{
|
||||
name: 'Code Interpreter Call Outputs',
|
||||
value: 'code_interpreter_call.outputs',
|
||||
},
|
||||
{
|
||||
name: 'Computer Call Output Image URL',
|
||||
value: 'computer_call_output.output.image_url',
|
||||
},
|
||||
{
|
||||
name: 'File Search Call Results',
|
||||
value: 'file_search_call.results',
|
||||
},
|
||||
{
|
||||
name: 'Message Input Image URL',
|
||||
value: 'message.input_image.image_url',
|
||||
},
|
||||
{
|
||||
name: 'Message Output Text Logprobs',
|
||||
value: 'message.output_text.logprobs',
|
||||
},
|
||||
{
|
||||
name: 'Reasoning Encrypted Content',
|
||||
value: 'reasoning.encrypted_content',
|
||||
},
|
||||
{
|
||||
name: 'Web Search Tool Call Sources',
|
||||
value: 'web_search_call.action.sources',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Instructions',
|
||||
name: 'instructions',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Instructions for the model to follow',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Maximum Number of Tokens',
|
||||
name: 'maxTokens',
|
||||
default: 16,
|
||||
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: 'Max Tool Calls Iterations',
|
||||
name: 'maxToolsIterations',
|
||||
type: 'number',
|
||||
default: 15,
|
||||
description:
|
||||
'The maximum number of tool iteration cycles the LLM will run before stopping. A single iteration can contain multiple tool calls. Set to 0 for no limit.',
|
||||
},
|
||||
{
|
||||
displayName: 'Max Built-in Tool Calls',
|
||||
name: 'maxToolCalls',
|
||||
type: 'number',
|
||||
default: 15,
|
||||
description:
|
||||
'The maximum number of total calls to built-in tools that can be processed in a response. This maximum number applies across all built-in tool calls, not per individual tool. Any further attempts to call a tool by the model will be ignored.',
|
||||
},
|
||||
metadataProperty,
|
||||
{
|
||||
displayName: 'Parallel Tool Calls',
|
||||
name: 'parallelToolCalls',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to allow parallel tool calls. If true, the model can call multiple tools at once.',
|
||||
},
|
||||
{
|
||||
displayName: 'Previous Response ID',
|
||||
name: 'previousResponseId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
// TODO: add display options?
|
||||
description:
|
||||
'The ID of the previous response to continue from. Cannot be used in conjunction with Conversation ID.',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
displayName: 'Reasoning',
|
||||
name: 'reasoning',
|
||||
type: 'fixedCollection',
|
||||
default: { reasoningOptions: [{ effort: 'medium', summary: 'none' }] },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Reasoning',
|
||||
name: 'reasoningOptions',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Effort',
|
||||
name: 'effort',
|
||||
type: 'options',
|
||||
default: 'medium',
|
||||
// TODO: allow only high for gpt-5-pro
|
||||
options: [
|
||||
{ name: 'Low', value: 'low' },
|
||||
{ name: 'Medium', value: 'medium' },
|
||||
{ name: 'High', value: 'high' },
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Summary',
|
||||
name: 'summary',
|
||||
type: 'options',
|
||||
default: 'auto',
|
||||
description:
|
||||
"A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process.",
|
||||
options: [
|
||||
{ name: 'None', value: 'none' },
|
||||
{ name: 'Auto', value: 'auto' },
|
||||
{ name: 'Concise', value: 'concise' },
|
||||
{ name: 'Detailed', value: 'detailed' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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.",
|
||||
},
|
||||
{
|
||||
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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Store',
|
||||
name: 'store',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to store the generated model response for later retrieval via API',
|
||||
},
|
||||
{
|
||||
displayName: 'Output 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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Output Randomness (Temperature)',
|
||||
name: 'temperature',
|
||||
type: 'number',
|
||||
default: 1,
|
||||
description:
|
||||
'What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. We generally recommend altering this or top_p but not both',
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
maxValue: 2,
|
||||
numberPrecision: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Output Randomness (Top P)',
|
||||
name: 'topP',
|
||||
default: 1,
|
||||
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'An alternative to sampling with temperature, 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: 'Truncation',
|
||||
name: 'truncation',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
"Whether to truncate the input to the model's context window size. When disabled will throw a 400 error instead.",
|
||||
},
|
||||
{
|
||||
displayName: 'Background Mode',
|
||||
name: 'backgroundMode',
|
||||
type: 'fixedCollection',
|
||||
default: { values: [{ backgroundMode: true }] },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Bakground',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Background Mode',
|
||||
name: 'enabled',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to run the model in background mode. If true, the model will run in background mode.',
|
||||
},
|
||||
{
|
||||
displayName: 'Timeout',
|
||||
name: 'timeout',
|
||||
type: 'number',
|
||||
default: 300,
|
||||
description:
|
||||
'The timeout for the background mode in seconds. If 0, the timeout is infinite.',
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
maxValue: 3600,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['response'],
|
||||
resource: ['text'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const model = this.getNodeParameter('modelId', i, '', { extractValue: true }) as string;
|
||||
const messages = this.getNodeParameter('responses.values', i, []) as IDataObject[];
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
const maxToolsIterations = this.getNodeParameter('options.maxToolsIterations', i, 15) as number;
|
||||
const builtInTools = this.getNodeParameter('builtInTools', i, {}) as IDataObject;
|
||||
const abortSignal = this.getExecutionCancelSignal();
|
||||
|
||||
const hideTools = this.getNodeParameter('hideTools', i, '') as string;
|
||||
|
||||
let tools;
|
||||
let externalTools: Tool[] = [];
|
||||
|
||||
if (hideTools !== 'hide') {
|
||||
const enforceUniqueNames = true;
|
||||
externalTools = await getConnectedTools(this, enforceUniqueNames, false);
|
||||
}
|
||||
|
||||
if (externalTools.length) {
|
||||
tools = externalTools.length ? externalTools?.map(formatToOpenAIResponsesTool) : undefined;
|
||||
}
|
||||
|
||||
const body = await createRequest.call(this, i, {
|
||||
model,
|
||||
messages,
|
||||
options,
|
||||
tools,
|
||||
builtInTools,
|
||||
});
|
||||
let response = (await apiRequest.call(this, 'POST', '/responses', {
|
||||
body,
|
||||
})) as ChatResponse;
|
||||
|
||||
if (body.background) {
|
||||
const timeoutSeconds = get(options, 'backgroundMode.values.timeout', 300) as number;
|
||||
response = await pollUntilAvailable(
|
||||
this,
|
||||
async () => {
|
||||
return (await apiRequest.call(this, 'GET', `/responses/${response.id}`)) as ChatResponse;
|
||||
},
|
||||
(response) => {
|
||||
if (response.error) {
|
||||
throw new NodeOperationError(this.getNode(), 'Background mode error', {
|
||||
description: response.error.message,
|
||||
});
|
||||
}
|
||||
return response.status === 'completed';
|
||||
},
|
||||
timeoutSeconds,
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response) return [];
|
||||
|
||||
// reasoning models such as gpt5 include reasoning items that must be included in the request
|
||||
const isToolRelatedCall: (item: { type: string }) => boolean = (item) =>
|
||||
item.type === 'function_call' || item.type === 'reasoning';
|
||||
|
||||
let toolCalls = response.output.filter(isToolRelatedCall);
|
||||
|
||||
const hasFunctionCall = () => toolCalls.some((item) => item.type === 'function_call');
|
||||
|
||||
let currentIteration = 1;
|
||||
// make sure there's actually a function call to answer
|
||||
while (toolCalls.length && hasFunctionCall()) {
|
||||
if (abortSignal?.aborted || (maxToolsIterations > 0 && currentIteration > maxToolsIterations)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// if there's conversation, we don't need to include function_call or reasoning items in the request
|
||||
// if we include them, OpenAI will throw "Duplicate item with id" error
|
||||
if (!body.conversation) {
|
||||
body.input.push.apply(body.input, toolCalls);
|
||||
}
|
||||
|
||||
for (const item of toolCalls) {
|
||||
if (item.type === 'function_call') {
|
||||
const functionName = item.name;
|
||||
const functionArgs = item.arguments;
|
||||
const callId = item.call_id;
|
||||
|
||||
let functionResponse;
|
||||
for (const tool of externalTools ?? []) {
|
||||
if (tool.name === functionName) {
|
||||
const parsedArgs: { input: string } = jsonParse(functionArgs);
|
||||
const functionInput = parsedArgs.input ?? parsedArgs ?? functionArgs;
|
||||
functionResponse = await tool.invoke(functionInput);
|
||||
}
|
||||
|
||||
if (typeof functionResponse === 'object') {
|
||||
functionResponse = JSON.stringify(functionResponse);
|
||||
}
|
||||
}
|
||||
|
||||
body.input.push({
|
||||
type: 'function_call_output',
|
||||
call_id: callId,
|
||||
output: functionResponse,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
response = (await apiRequest.call(this, 'POST', '/responses', {
|
||||
body,
|
||||
})) as ChatResponse;
|
||||
toolCalls = response.output.filter(isToolRelatedCall);
|
||||
|
||||
currentIteration++;
|
||||
}
|
||||
|
||||
const formatType = get(body, 'text.format.type');
|
||||
if (formatType === 'json_object' || formatType === 'json_schema') {
|
||||
try {
|
||||
response.output = response.output.map((item) => {
|
||||
if (item.type === 'message') {
|
||||
item.content = item.content.map((content) => {
|
||||
if (content.type === 'output_text') {
|
||||
content.text = JSON.parse(content.text);
|
||||
}
|
||||
return content;
|
||||
});
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
const simplify = this.getNodeParameter('simplify', i) as boolean;
|
||||
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
if (simplify) {
|
||||
const messages = response.output.filter((item) => item.type === 'message');
|
||||
returnData.push({
|
||||
json: {
|
||||
output: messages as unknown as IDataObject,
|
||||
},
|
||||
pairedItem: { item: i },
|
||||
});
|
||||
} else {
|
||||
returnData.push({ json: response as unknown as IDataObject, pairedItem: { item: i } });
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import FormData from 'form-data';
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { getBinaryDataFile } from '../../../helpers/binary-data';
|
||||
import type { VideoJob } from '../../../helpers/interfaces';
|
||||
import { pollUntilAvailable } from '../../../helpers/polling';
|
||||
import { apiRequest } from '../../../transport';
|
||||
import { modelRLC } from '../descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
modelRLC('videoModelSearch'),
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
default: 'A video of a cat playing with a ball',
|
||||
description: 'The prompt to generate a video from',
|
||||
required: true,
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Seconds',
|
||||
name: 'seconds',
|
||||
type: 'number',
|
||||
default: 4,
|
||||
description: 'Clip duration in seconds',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Size',
|
||||
name: 'size',
|
||||
type: 'options',
|
||||
default: '1280x720',
|
||||
description:
|
||||
'Output resolution formatted as width x height. 1024x1792 and 1792x1024 are only supported by Sora 2 Pro.',
|
||||
options: [
|
||||
{ name: '720x1280', value: '720x1280' },
|
||||
{ name: '1280x720', value: '1280x720' },
|
||||
{ name: '1024x1792', value: '1024x1792' },
|
||||
{ name: '1792x1024', value: '1792x1024' },
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Reference',
|
||||
description: 'Optional image reference that guides generation',
|
||||
name: 'binaryPropertyNameReference',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
placeholder: 'e.g. data',
|
||||
},
|
||||
{
|
||||
displayName: 'Wait Timeout',
|
||||
name: 'waitTime',
|
||||
type: 'number',
|
||||
default: 300,
|
||||
description: 'Time to wait for the video to be generated in seconds',
|
||||
typeOptions: {
|
||||
minValue: 5,
|
||||
maxValue: 7200,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Output Field Name',
|
||||
name: 'fileName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
hint: 'The name of the output field to put the binary file data in',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['generate'],
|
||||
resource: ['video'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const model = this.getNodeParameter('modelId', i, '', { extractValue: true }) as string;
|
||||
const prompt = this.getNodeParameter('prompt', i) as string;
|
||||
const seconds = this.getNodeParameter('seconds', i) as number;
|
||||
const size = this.getNodeParameter('size', i) as string;
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
const waitSeconds = (options.waitTime as number) || 300;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('model', model);
|
||||
formData.append('prompt', prompt);
|
||||
formData.append('seconds', seconds.toString());
|
||||
formData.append('size', size);
|
||||
|
||||
if (options.binaryPropertyNameReference) {
|
||||
const { fileContent, contentType, filename } = await getBinaryDataFile(
|
||||
this,
|
||||
i,
|
||||
options.binaryPropertyNameReference as string,
|
||||
);
|
||||
const buffer = await this.helpers.binaryToBuffer(fileContent);
|
||||
formData.append('input_reference', buffer, {
|
||||
filename,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
|
||||
const response = (await apiRequest.call(this, 'POST', '/videos', {
|
||||
option: { formData },
|
||||
headers: formData.getHeaders(),
|
||||
})) as VideoJob;
|
||||
|
||||
const finalResponse = await pollUntilAvailable(
|
||||
this,
|
||||
async () => {
|
||||
return (await apiRequest.call(this, 'GET', `/videos/${response.id}`)) as VideoJob;
|
||||
},
|
||||
(response) => {
|
||||
if (response.error) {
|
||||
throw new NodeOperationError(this.getNode(), 'Error generating video', {
|
||||
description: response.error.message,
|
||||
itemIndex: i,
|
||||
});
|
||||
}
|
||||
return response.status === 'completed';
|
||||
},
|
||||
waitSeconds,
|
||||
10,
|
||||
);
|
||||
|
||||
const contentResponse = await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/videos/${finalResponse.id}/content`,
|
||||
{
|
||||
option: {
|
||||
useStream: true,
|
||||
resolveWithFullResponse: true,
|
||||
json: false,
|
||||
encoding: null,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const mimeType = contentResponse.headers['content-type'];
|
||||
|
||||
const binaryData = await this.helpers.prepareBinaryData(
|
||||
contentResponse.body,
|
||||
(options.fileName as string) || 'data',
|
||||
mimeType,
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
json: Object.assign({}, binaryData, {
|
||||
data: undefined,
|
||||
}),
|
||||
binary: {
|
||||
data: binaryData,
|
||||
},
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as generate from './generate.operation';
|
||||
|
||||
export { generate };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Generate',
|
||||
value: 'generate',
|
||||
action: 'Generate a video',
|
||||
description: 'Creates a video from a text prompt',
|
||||
},
|
||||
],
|
||||
default: 'generate',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['video'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...generate.description,
|
||||
];
|
||||
Reference in New Issue
Block a user