diff --git a/packages/components/nodes/chatmodels/ChatOpenAI/ChatOpenAI.test.ts b/packages/components/nodes/chatmodels/ChatOpenAI/ChatOpenAI.test.ts new file mode 100644 index 00000000000..8a0d116922b --- /dev/null +++ b/packages/components/nodes/chatmodels/ChatOpenAI/ChatOpenAI.test.ts @@ -0,0 +1,95 @@ +jest.mock('@langchain/openai', () => ({ + ChatOpenAI: jest.fn(), + ChatOpenAIFields: {} +})) + +jest.mock('undici', () => ({ + ProxyAgent: jest.fn().mockImplementation((url) => ({ proxyUrl: url })) +})) + +jest.mock('../../../src/utils', () => ({ + getBaseClasses: jest.fn().mockReturnValue(['BaseChatModel']), + getCredentialData: jest.fn(), + getCredentialParam: jest.fn(), + isReasoningModelOpenAI: jest.fn().mockReturnValue(false) +})) + +jest.mock('../../../src/modelLoader', () => ({ + MODEL_TYPE: { CHAT: 'chat' }, + getModels: jest.fn() +})) + +jest.mock('./FlowiseChatOpenAI', () => ({ + ChatOpenAI: jest.fn().mockImplementation((id, fields) => ({ + id, + fields, + setMultiModalOption: jest.fn() + })) +})) + +import { ProxyAgent } from 'undici' +import { getCredentialData, getCredentialParam } from '../../../src/utils' + +const { ChatOpenAI } = require('./FlowiseChatOpenAI') +const { nodeClass: ChatOpenAINode } = require('./ChatOpenAI') + +describe('ChatOpenAI node', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('reuses the proxy dispatcher across init calls', async () => { + ;(getCredentialData as jest.Mock).mockResolvedValue({ openAIApiKey: 'sk-test' }) + ;(getCredentialParam as jest.Mock).mockImplementation((key, credentialData) => credentialData[key]) + + const node = new ChatOpenAINode() + const nodeData = { + credential: 'cred-1', + inputs: { + modelName: 'gpt-4o-mini', + temperature: '0.2', + proxyUrl: 'http://corporate-proxy.example.com:3128', + baseOptions: { + 'OpenAI-Beta': 'assistants=v2' + } + } + } + + await node.init( + { + ...nodeData, + id: 'chatOpenAI_0' + }, + '', + {} + ) + await node.init( + { + ...nodeData, + id: 'chatOpenAI_1' + }, + '', + {} + ) + + expect(ProxyAgent).toHaveBeenCalledWith('http://corporate-proxy.example.com:3128') + expect(ProxyAgent).toHaveBeenCalledTimes(1) + expect(ChatOpenAI).toHaveBeenCalledTimes(2) + expect(ChatOpenAI.mock.calls[1][1].configuration.fetchOptions.dispatcher).toBe( + ChatOpenAI.mock.calls[0][1].configuration.fetchOptions.dispatcher + ) + expect(ChatOpenAI).toHaveBeenCalledWith( + 'chatOpenAI_0', + expect.objectContaining({ + configuration: { + defaultHeaders: { + 'OpenAI-Beta': 'assistants=v2' + }, + fetchOptions: { + dispatcher: { proxyUrl: 'http://corporate-proxy.example.com:3128' } + } + } + }) + ) + }) +}) diff --git a/packages/components/nodes/chatmodels/ChatOpenAI/ChatOpenAI.ts b/packages/components/nodes/chatmodels/ChatOpenAI/ChatOpenAI.ts index 03c13fee9b0..4756a937a83 100644 --- a/packages/components/nodes/chatmodels/ChatOpenAI/ChatOpenAI.ts +++ b/packages/components/nodes/chatmodels/ChatOpenAI/ChatOpenAI.ts @@ -5,6 +5,18 @@ import { getBaseClasses, getCredentialData, getCredentialParam, isReasoningModel import { ChatOpenAI } from './FlowiseChatOpenAI' import { getModels, MODEL_TYPE } from '../../../src/modelLoader' import { OpenAI as OpenAIClient } from 'openai' +import { ProxyAgent } from 'undici' + +const proxyAgents = new Map() + +const getProxyAgent = (proxyUrl: string): ProxyAgent => { + let proxyAgent = proxyAgents.get(proxyUrl) + if (!proxyAgent) { + proxyAgent = new ProxyAgent(proxyUrl) + proxyAgents.set(proxyUrl, proxyAgent) + } + return proxyAgent +} class ChatOpenAI_ChatModels implements INode { label: string @@ -200,6 +212,14 @@ class ChatOpenAI_ChatModels implements INode { description: 'Override the default base URL for the API, e.g., "https://api.example.com/v2/', additionalParams: true }, + { + label: 'Proxy Url', + name: 'proxyUrl', + type: 'string', + optional: true, + description: 'Proxy URL to use for OpenAI API requests, e.g., "http://proxy.example.com:3128"', + additionalParams: true + }, { label: 'Base Options', name: 'baseOptions', @@ -230,6 +250,7 @@ class ChatOpenAI_ChatModels implements INode { const streaming = nodeData.inputs?.streaming as boolean const strictToolCalling = nodeData.inputs?.strictToolCalling as boolean const basePath = nodeData.inputs?.basepath as string + const proxyUrl = nodeData.inputs?.proxyUrl as string const baseOptions = nodeData.inputs?.baseOptions const reasoningEffort = nodeData.inputs?.reasoningEffort as OpenAIClient.ReasoningEffort | null const reasoningSummary = nodeData.inputs?.reasoningSummary as 'auto' | 'concise' | 'detailed' | null @@ -286,10 +307,17 @@ class ChatOpenAI_ChatModels implements INode { } } - if (basePath || parsedBaseOptions) { + if (basePath || parsedBaseOptions || proxyUrl) { obj.configuration = { baseURL: basePath, - defaultHeaders: parsedBaseOptions + defaultHeaders: parsedBaseOptions, + ...(proxyUrl + ? { + fetchOptions: { + dispatcher: getProxyAgent(proxyUrl) + } + } + : {}) } } diff --git a/packages/components/package.json b/packages/components/package.json index 03b6e8bcbef..45950f2dfa7 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -208,6 +208,7 @@ "srt-parser-2": "^1.2.3", "supergateway": "3.0.1", "typeorm": "^0.3.28", + "undici": "6.23.0", "uuid": "^10.0.0", "vm2": "3.11.2", "weaviate-client": "3.12.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fc25eaa29a..e28cfa7d405 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -734,6 +734,9 @@ importers: typeorm: specifier: ^0.3.28 version: 0.3.30(babel-plugin-macros@3.1.0)(ioredis@5.3.2)(mongodb@6.3.0(@aws-sdk/credential-providers@3.1002.0)(socks@2.8.1))(mysql2@3.11.4)(pg@8.11.3)(redis@4.6.13)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.4.6(@swc/helpers@0.5.23))(@types/node@24.13.2)(typescript@5.5.2)) + undici: + specifier: 6.23.0 + version: 6.23.0 uuid: specifier: ^10.0.0 version: 10.0.0 @@ -19425,6 +19428,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@6.23.0: + resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} + engines: {node: '>=18.17'} + undici@6.27.0: resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} engines: {node: '>=18.17'} @@ -43948,6 +43955,8 @@ snapshots: undici-types@7.18.2: {} + undici@6.23.0: {} + undici@6.27.0: {} undici@7.28.0: {}