From 0b4de85a08bf5a8de3734b62e6954da244a116b9 Mon Sep 17 00:00:00 2001 From: mgosal Date: Fri, 12 Sep 2025 13:14:32 +0100 Subject: [PATCH] feat(OpenAI Node): Support custom headers for model requests (#17835) Co-authored-by: Idir Ouhab Co-authored-by: Oleg Ivaniv --- .../llms/LMChatOpenAi/LmChatOpenAi.node.ts | 10 + .../nodes/llms/test/LmChatOpenAi.test.ts | 488 ++++++++++++++++++ .../nodes/vendors/OpenAi/transport/index.ts | 16 +- .../credentials/OpenAiApi.credentials.ts | 63 ++- .../test/OpenAiApi.credentials.test.ts | 155 ++++++ 5 files changed, 719 insertions(+), 13 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/nodes/llms/test/LmChatOpenAi.test.ts create mode 100644 packages/nodes-base/credentials/test/OpenAiApi.credentials.test.ts diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts index addf8bc27c..073a4eca6b 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts @@ -358,6 +358,16 @@ export class LmChatOpenAi implements INodeType { dispatcher: getProxyAgent(configuration.baseURL ?? 'https://api.openai.com/v1'), }; } + if ( + credentials.header && + typeof credentials.headerName === 'string' && + credentials.headerName && + typeof credentials.headerValue === 'string' + ) { + configuration.defaultHeaders = { + [credentials.headerName]: credentials.headerValue, + }; + } // Extra options to send to OpenAI, that are not directly supported by LangChain const modelKwargs: { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatOpenAi.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatOpenAi.test.ts new file mode 100644 index 0000000000..1565056989 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatOpenAi.test.ts @@ -0,0 +1,488 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +/* eslint-disable @typescript-eslint/unbound-method */ +import { ChatOpenAI } from '@langchain/openai'; +import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; +import type { INode, ISupplyDataFunctions } from 'n8n-workflow'; + +import { LmChatOpenAi } from '../LMChatOpenAi/LmChatOpenAi.node'; +import { N8nLlmTracing } from '../N8nLlmTracing'; +import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; + +jest.mock('@langchain/openai'); +jest.mock('../N8nLlmTracing'); +jest.mock('../n8nLlmFailedAttemptHandler'); +jest.mock('@utils/httpProxyAgent', () => ({ + getProxyAgent: jest.fn().mockReturnValue({}), +})); + +const MockedChatOpenAI = jest.mocked(ChatOpenAI); +const MockedN8nLlmTracing = jest.mocked(N8nLlmTracing); +const mockedMakeN8nLlmFailedAttemptHandler = jest.mocked(makeN8nLlmFailedAttemptHandler); + +describe('LmChatOpenAi', () => { + let lmChatOpenAi: LmChatOpenAi; + let mockContext: jest.Mocked; + + const mockNode: INode = { + id: '1', + name: 'OpenAI Chat Model', + typeVersion: 1.2, + type: 'n8n-nodes-langchain.lmChatOpenAi', + position: [0, 0], + parameters: {}, + }; + + const setupMockContext = (nodeOverrides: Partial = {}) => { + const node = { ...mockNode, ...nodeOverrides }; + mockContext = createMockExecuteFunction( + {}, + node, + ) as jest.Mocked; + + // Setup default mocks + mockContext.getCredentials = jest.fn().mockResolvedValue({ + apiKey: 'test-api-key', + }); + mockContext.getNode = jest.fn().mockReturnValue(node); + mockContext.getNodeParameter = jest.fn(); + + // Mock the constructors/functions properly + MockedN8nLlmTracing.mockImplementation(() => ({}) as any); + mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(jest.fn()); + + return mockContext; + }; + + beforeEach(() => { + lmChatOpenAi = new LmChatOpenAi(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('node description', () => { + it('should have correct node properties', () => { + expect(lmChatOpenAi.description).toMatchObject({ + displayName: 'OpenAI Chat Model', + name: 'lmChatOpenAi', + group: ['transform'], + version: [1, 1.1, 1.2], + description: 'For advanced usage with an AI chain', + }); + }); + + it('should have correct credentials configuration', () => { + expect(lmChatOpenAi.description.credentials).toEqual([ + { + name: 'openAiApi', + required: true, + }, + ]); + }); + + it('should have correct output configuration', () => { + expect(lmChatOpenAi.description.outputs).toEqual(['ai_languageModel']); + expect(lmChatOpenAi.description.outputNames).toEqual(['Model']); + }); + }); + + describe('supplyData', () => { + it('should create ChatOpenAI instance with basic configuration (version >= 1.2)', async () => { + const mockContext = setupMockContext({ typeVersion: 1.2 }); + + // Mock getNodeParameter to handle the proper parameter names for v1.2 + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'gpt-4o-mini'; + if (paramName === 'options') return {}; + return undefined; + }); + + const result = await lmChatOpenAi.supplyData.call(mockContext, 0); + + expect(mockContext.getCredentials).toHaveBeenCalledWith('openAiApi'); + expect(mockContext.getNodeParameter).toHaveBeenCalledWith('model.value', 0); + expect(mockContext.getNodeParameter).toHaveBeenCalledWith('options', 0, {}); + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'test-api-key', + model: 'gpt-4o-mini', + timeout: 60000, + maxRetries: 2, + configuration: {}, + callbacks: expect.arrayContaining([expect.any(Object)]), + modelKwargs: {}, + onFailedAttempt: expect.any(Function), + }), + ); + + expect(result).toEqual({ + response: expect.any(Object), + }); + }); + + it('should create ChatOpenAI instance with basic configuration (version < 1.2)', async () => { + const mockContext = setupMockContext({ typeVersion: 1.1 }); + + // Mock getNodeParameter to handle the proper parameter names for v1.1 + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'gpt-4o-mini'; + if (paramName === 'options') return {}; + return undefined; + }); + + await lmChatOpenAi.supplyData.call(mockContext, 0); + + expect(mockContext.getNodeParameter).toHaveBeenCalledWith('model', 0); + expect(mockContext.getNodeParameter).toHaveBeenCalledWith('options', 0, {}); + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'test-api-key', + model: 'gpt-4o-mini', + timeout: 60000, + maxRetries: 2, + configuration: {}, + callbacks: expect.arrayContaining([expect.any(Object)]), + modelKwargs: {}, + onFailedAttempt: expect.any(Function), + }), + ); + }); + + it('should handle custom baseURL from options', async () => { + const customBaseURL = 'https://custom-api.example.com/v1'; + const mockContext = setupMockContext(); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'gpt-4o-mini'; + if (paramName === 'options') + return { + baseURL: customBaseURL, + timeout: 30000, + maxRetries: 5, + }; + return undefined; + }); + + await lmChatOpenAi.supplyData.call(mockContext, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'test-api-key', + model: 'gpt-4o-mini', + baseURL: customBaseURL, + timeout: 30000, + maxRetries: 5, + configuration: { + baseURL: customBaseURL, + fetchOptions: { + dispatcher: {}, + }, + }, + callbacks: expect.arrayContaining([expect.any(Object)]), + modelKwargs: {}, + onFailedAttempt: expect.any(Function), + }), + ); + }); + + it('should handle custom baseURL from credentials', async () => { + const customURL = 'https://custom-openai.example.com/v1'; + const mockContext = setupMockContext(); + + mockContext.getCredentials.mockResolvedValue({ + apiKey: 'test-api-key', + url: customURL, + }); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'gpt-4o-mini'; + if (paramName === 'options') return {}; + return undefined; + }); + + await lmChatOpenAi.supplyData.call(mockContext, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'test-api-key', + model: 'gpt-4o-mini', + timeout: 60000, + maxRetries: 2, + configuration: { + baseURL: customURL, + fetchOptions: { + dispatcher: {}, + }, + }, + callbacks: expect.arrayContaining([expect.any(Object)]), + modelKwargs: {}, + onFailedAttempt: expect.any(Function), + }), + ); + }); + + it('should handle custom headers from credentials', async () => { + const mockContext = setupMockContext(); + + mockContext.getCredentials.mockResolvedValue({ + apiKey: 'test-api-key', + header: true, + headerName: 'X-Custom-Header', + headerValue: 'custom-value', + }); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'gpt-4o-mini'; + if (paramName === 'options') return {}; + return undefined; + }); + + await lmChatOpenAi.supplyData.call(mockContext, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'test-api-key', + model: 'gpt-4o-mini', + timeout: 60000, + maxRetries: 2, + configuration: { + defaultHeaders: { + 'X-Custom-Header': 'custom-value', + }, + }, + callbacks: expect.arrayContaining([expect.any(Object)]), + modelKwargs: {}, + onFailedAttempt: expect.any(Function), + }), + ); + }); + + it('should handle all available options', async () => { + const mockContext = setupMockContext(); + const options = { + frequencyPenalty: 0.5, + maxTokens: 1000, + presencePenalty: 0.3, + temperature: 0.8, + topP: 0.9, + timeout: 45000, + maxRetries: 3, + responseFormat: 'json_object' as const, + reasoningEffort: 'high' as const, + }; + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'gpt-4o-mini'; + if (paramName === 'options') return options; + return undefined; + }); + + await lmChatOpenAi.supplyData.call(mockContext, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'test-api-key', + model: 'gpt-4o-mini', + frequencyPenalty: 0.5, + maxTokens: 1000, + presencePenalty: 0.3, + temperature: 0.8, + topP: 0.9, + timeout: 45000, + maxRetries: 3, + responseFormat: 'json_object', + reasoningEffort: 'high', + configuration: {}, + callbacks: expect.arrayContaining([expect.any(Object)]), + modelKwargs: { + response_format: { type: 'json_object' }, + reasoning_effort: 'high', + }, + onFailedAttempt: expect.any(Function), + }), + ); + }); + + it('should only add valid reasoning effort to modelKwargs', async () => { + const mockContext = setupMockContext(); + const options = { + reasoningEffort: 'invalid' as 'low' | 'medium' | 'high', + }; + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'gpt-4o-mini'; + if (paramName === 'options') return options; + return undefined; + }); + + await lmChatOpenAi.supplyData.call(mockContext, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'test-api-key', + model: 'gpt-4o-mini', + reasoningEffort: 'invalid', + timeout: 60000, + maxRetries: 2, + configuration: {}, + callbacks: expect.arrayContaining([expect.any(Object)]), + modelKwargs: {}, // Should not include invalid reasoning_effort + onFailedAttempt: expect.any(Function), + }), + ); + }); + + it('should create N8nLlmTracing callback', async () => { + const mockContext = setupMockContext(); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'gpt-4o-mini'; + if (paramName === 'options') return {}; + return undefined; + }); + + await lmChatOpenAi.supplyData.call(mockContext, 0); + + expect(MockedN8nLlmTracing).toHaveBeenCalledWith(mockContext); + }); + + it('should create failed attempt handler', async () => { + const mockContext = setupMockContext(); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'gpt-4o-mini'; + if (paramName === 'options') return {}; + return undefined; + }); + + await lmChatOpenAi.supplyData.call(mockContext, 0); + + expect(mockedMakeN8nLlmFailedAttemptHandler).toHaveBeenCalledWith( + mockContext, + expect.any(Function), // openAiFailedAttemptHandler + ); + }); + + it('should use default values for timeout and maxRetries when not provided', async () => { + const mockContext = setupMockContext(); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'gpt-4o-mini'; + if (paramName === 'options') return {}; + return undefined; + }); + + await lmChatOpenAi.supplyData.call(mockContext, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + timeout: 60000, + maxRetries: 2, + }), + ); + }); + + it('should prioritize options.baseURL over credentials.url', async () => { + const optionsBaseURL = 'https://options-api.example.com/v1'; + const credentialsURL = 'https://credentials-api.example.com/v1'; + const mockContext = setupMockContext(); + + mockContext.getCredentials.mockResolvedValue({ + apiKey: 'test-api-key', + url: credentialsURL, + }); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'gpt-4o-mini'; + if (paramName === 'options') + return { + baseURL: optionsBaseURL, + }; + return undefined; + }); + + await lmChatOpenAi.supplyData.call(mockContext, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + configuration: { + baseURL: optionsBaseURL, + fetchOptions: { + dispatcher: {}, + }, + }, + }), + ); + }); + + it('should handle text response format correctly', async () => { + const mockContext = setupMockContext(); + const options = { + responseFormat: 'text' as const, + }; + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'gpt-4o-mini'; + if (paramName === 'options') return options; + return undefined; + }); + + await lmChatOpenAi.supplyData.call(mockContext, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + responseFormat: 'text', + modelKwargs: { + response_format: { type: 'text' }, + }, + }), + ); + }); + + it('should handle all reasoning effort values correctly', async () => { + const reasoningEffortValues = ['low', 'medium', 'high'] as const; + + for (const effort of reasoningEffortValues) { + const mockContext = setupMockContext(); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'gpt-4o-mini'; + if (paramName === 'options') + return { + reasoningEffort: effort, + }; + return undefined; + }); + + await lmChatOpenAi.supplyData.call(mockContext, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + reasoningEffort: effort, + modelKwargs: { + reasoning_effort: effort, + }, + }), + ); + + jest.clearAllMocks(); + } + }); + }); + + describe('methods', () => { + beforeEach(() => { + setupMockContext(); + }); + + it('should have searchModels method', () => { + expect(lmChatOpenAi.methods).toEqual({ + listSearch: { + searchModels: expect.any(Function), + }, + }); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/index.ts index 619869bf9d..f96a49b406 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/index.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/transport/index.ts @@ -18,16 +18,28 @@ export async function apiRequest( endpoint: string, parameters?: RequestParameters, ) { - const { body, qs, option, headers } = parameters ?? {}; + 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, diff --git a/packages/nodes-base/credentials/OpenAiApi.credentials.ts b/packages/nodes-base/credentials/OpenAiApi.credentials.ts index 226c1b7c4f..162ee0db14 100644 --- a/packages/nodes-base/credentials/OpenAiApi.credentials.ts +++ b/packages/nodes-base/credentials/OpenAiApi.credentials.ts @@ -1,7 +1,8 @@ import type { - IAuthenticateGeneric, + ICredentialDataDecryptedObject, ICredentialTestRequest, ICredentialType, + IHttpRequestOptions, INodeProperties, } from 'n8n-workflow'; @@ -37,17 +38,38 @@ export class OpenAiApi implements ICredentialType { default: 'https://api.openai.com/v1', description: 'Override the default base URL for the API', }, - ]; - - authenticate: IAuthenticateGeneric = { - type: 'generic', - properties: { - headers: { - Authorization: '=Bearer {{$credentials.apiKey}}', - 'OpenAI-Organization': '={{$credentials.organizationId}}', - }, + { + displayName: 'Add Custom Header', + name: 'header', + type: 'boolean', + default: false, }, - }; + { + displayName: 'Header Name', + name: 'headerName', + type: 'string', + displayOptions: { + show: { + header: [true], + }, + }, + default: '', + }, + { + displayName: 'Header Value', + name: 'headerValue', + type: 'string', + typeOptions: { + password: true, + }, + displayOptions: { + show: { + header: [true], + }, + }, + default: '', + }, + ]; test: ICredentialTestRequest = { request: { @@ -55,4 +77,23 @@ export class OpenAiApi implements ICredentialType { url: '/models', }, }; + + async authenticate( + credentials: ICredentialDataDecryptedObject, + requestOptions: IHttpRequestOptions, + ): Promise { + requestOptions.headers = { + Authorization: 'Bearer ' + credentials.apiKey, + 'OpenAI-Organization': credentials.organizationId, + }; + if ( + credentials.header && + typeof credentials.headerName === 'string' && + credentials.headerName && + typeof credentials.headerValue === 'string' + ) { + requestOptions.headers[credentials.headerName] = credentials.headerValue; + } + return requestOptions; + } } diff --git a/packages/nodes-base/credentials/test/OpenAiApi.credentials.test.ts b/packages/nodes-base/credentials/test/OpenAiApi.credentials.test.ts new file mode 100644 index 0000000000..d612c5799e --- /dev/null +++ b/packages/nodes-base/credentials/test/OpenAiApi.credentials.test.ts @@ -0,0 +1,155 @@ +import type { ICredentialDataDecryptedObject, IHttpRequestOptions } from 'n8n-workflow'; + +import { OpenAiApi } from '../OpenAiApi.credentials'; + +describe('OpenAiApi Credential', () => { + const openAiApi = new OpenAiApi(); + + it('should have correct properties', () => { + expect(openAiApi.name).toBe('openAiApi'); + expect(openAiApi.displayName).toBe('OpenAi'); + expect(openAiApi.documentationUrl).toBe('openAi'); + expect(openAiApi.properties).toHaveLength(6); + expect(openAiApi.test.request.baseURL).toBe('={{$credentials?.url}}'); + expect(openAiApi.test.request.url).toBe('/models'); + }); + + describe('authenticate', () => { + it('should add Authorization header with API key only', async () => { + const credentials: ICredentialDataDecryptedObject = { + apiKey: 'sk-test123456789', + }; + + const requestOptions: IHttpRequestOptions = { + headers: {}, + url: '/models', + baseURL: 'https://api.openai.com/v1', + }; + + const result = await openAiApi.authenticate(credentials, requestOptions); + + expect(result.headers).toEqual({ + Authorization: 'Bearer sk-test123456789', + 'OpenAI-Organization': undefined, + }); + }); + + it('should add Authorization and Organization headers', async () => { + const credentials: ICredentialDataDecryptedObject = { + apiKey: 'sk-test123456789', + organizationId: 'org-123', + }; + + const requestOptions: IHttpRequestOptions = { + headers: {}, + url: '/models', + baseURL: 'https://api.openai.com/v1', + }; + + const result = await openAiApi.authenticate(credentials, requestOptions); + + expect(result.headers).toEqual({ + Authorization: 'Bearer sk-test123456789', + 'OpenAI-Organization': 'org-123', + }); + }); + + it('should add custom header when header toggle is enabled', async () => { + const credentials: ICredentialDataDecryptedObject = { + apiKey: 'sk-test123456789', + organizationId: 'org-123', + header: true, + headerName: 'X-Custom-Header', + headerValue: 'custom-value-123', + }; + + const requestOptions: IHttpRequestOptions = { + headers: {}, + url: '/models', + baseURL: 'https://api.openai.com/v1', + }; + + const result = await openAiApi.authenticate(credentials, requestOptions); + + expect(result.headers).toEqual({ + Authorization: 'Bearer sk-test123456789', + 'OpenAI-Organization': 'org-123', + 'X-Custom-Header': 'custom-value-123', + }); + }); + + it('should not add custom header when header toggle is disabled', async () => { + const credentials: ICredentialDataDecryptedObject = { + apiKey: 'sk-test123456789', + header: false, + headerName: 'X-Custom-Header', + headerValue: 'custom-value-123', + }; + + const requestOptions: IHttpRequestOptions = { + headers: {}, + url: '/models', + baseURL: 'https://api.openai.com/v1', + }; + + const result = await openAiApi.authenticate(credentials, requestOptions); + + expect(result.headers).toEqual({ + Authorization: 'Bearer sk-test123456789', + 'OpenAI-Organization': undefined, + }); + expect(result.headers?.['X-Custom-Header']).toBeUndefined(); + }); + + it('should preserve existing headers', async () => { + const credentials: ICredentialDataDecryptedObject = { + apiKey: 'sk-test123456789', + header: true, + headerName: 'X-Custom-Header', + headerValue: 'custom-value-123', + }; + + const requestOptions: IHttpRequestOptions = { + url: '/models', + baseURL: 'https://api.openai.com/v1', + }; + + const result = await openAiApi.authenticate(credentials, requestOptions); + + const raw = + typeof (result.headers as any)?.get === 'function' + ? Object.fromEntries((result.headers as unknown as Headers).entries()) + : (result.headers as Record); + + const headers = Object.fromEntries(Object.entries(raw).map(([k, v]) => [k.toLowerCase(), v])); + + expect(headers).toEqual( + expect.objectContaining({ + authorization: 'Bearer sk-test123456789', + 'x-custom-header': 'custom-value-123', + 'openai-organization': undefined, + }), + ); + }); + + it('should handle empty organization ID', async () => { + const credentials: ICredentialDataDecryptedObject = { + apiKey: 'sk-test123456789', + organizationId: '', + }; + + const requestOptions: IHttpRequestOptions = { + headers: {}, + url: '/models', + baseURL: 'https://api.openai.com/v1', + }; + + const result = await openAiApi.authenticate(credentials, requestOptions); + + expect(result.headers).toEqual({ + Authorization: 'Bearer sk-test123456789', + 'OpenAI-Organization': '', + }); + }); + }); +});