From f9715674e4133f28f37d74816238dfaa131fbb73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dimitri=20Lavren=C3=BCk?= <20122620+dlavrenuek@users.noreply.github.com> Date: Mon, 22 Dec 2025 11:00:47 +0100 Subject: [PATCH] feat: All requests to OpenAI include a platform header (#23463) --- .../src/configs/__tests__/ai.config.test.ts | 17 +++++++++ packages/@n8n/config/src/configs/ai.config.ts | 5 +++ packages/@n8n/config/src/index.ts | 1 + packages/@n8n/config/test/config.test.ts | 2 ++ .../OpenAiAssistant/OpenAiAssistant.node.ts | 5 +++ .../EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts | 8 ++++- .../llms/LMChatOpenAi/LmChatOpenAi.node.ts | 9 ++++- .../llms/LMChatOpenAi/methods/loadModels.ts | 4 +++ .../nodes/llms/LMOpenAi/LmOpenAi.node.ts | 4 +++ .../nodes/llms/test/LmChatOpenAi.test.ts | 19 ++++++++-- .../v1/actions/assistant/message.operation.ts | 4 +++ .../request-helper-functions.test.ts | 35 +++++++++++++++++++ .../utils/request-helper-functions.ts | 11 ++++++ 13 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 packages/@n8n/config/src/configs/__tests__/ai.config.test.ts diff --git a/packages/@n8n/config/src/configs/__tests__/ai.config.test.ts b/packages/@n8n/config/src/configs/__tests__/ai.config.test.ts new file mode 100644 index 00000000000..d5f4db7d26f --- /dev/null +++ b/packages/@n8n/config/src/configs/__tests__/ai.config.test.ts @@ -0,0 +1,17 @@ +import { Container } from '@n8n/di'; + +import { AiConfig } from '../ai.config'; + +describe('AiConfig', () => { + beforeEach(() => { + Container.reset(); + jest.clearAllMocks(); + }); + + it('should not poison openAiDefaultHeaders object globally when modified', () => { + const { openAiDefaultHeaders } = Container.get(AiConfig); + openAiDefaultHeaders.test = 'ok'; + expect(openAiDefaultHeaders.test).toBe('ok'); + expect(Container.get(AiConfig).openAiDefaultHeaders.test).toBeFalsy(); + }); +}); diff --git a/packages/@n8n/config/src/configs/ai.config.ts b/packages/@n8n/config/src/configs/ai.config.ts index e158c4e09b5..201fba2ebf4 100644 --- a/packages/@n8n/config/src/configs/ai.config.ts +++ b/packages/@n8n/config/src/configs/ai.config.ts @@ -5,4 +5,9 @@ export class AiConfig { /** Whether AI features are enabled. */ @Env('N8N_AI_ENABLED') enabled: boolean = false; + + get openAiDefaultHeaders(): Record { + // eslint-disable-next-line @typescript-eslint/naming-convention + return { 'openai-platform': 'org-qkmJQuJ2WnvoIKMr2UJwIJkZ' }; + } } diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index 3347ee5eab3..178a0486f47 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -39,6 +39,7 @@ import { WorkflowsConfig } from './configs/workflows.config'; import { Config, Env, Nested } from './decorators'; export { Config, Env, Nested } from './decorators'; +export { AiConfig } from './configs/ai.config'; export { DatabaseConfig, SqliteConfig } from './configs/database.config'; export { InstanceSettingsConfig } from './configs/instance-settings-config'; export type { TaskRunnerMode } from './configs/runners.config'; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index fa07bd2708e..32df061b93a 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -408,6 +408,7 @@ describe('GlobalConfig', () => { prefix: 'n8n', }, externalFrontendHooksUrls: '', + // @ts-expect-error structuredClone ignores properties defined as a getter ai: { enabled: false, }, @@ -438,6 +439,7 @@ describe('GlobalConfig', () => { N8N_DYNAMIC_BANNERS_ENABLED: 'false', }; const config = Container.get(GlobalConfig); + expect(structuredClone(config)).toEqual({ ...defaultConfig, database: { diff --git a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts index cd35b01c52c..5ccb1e06198 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts @@ -14,6 +14,8 @@ import { getConnectedTools } from '@utils/helpers'; import { getTracingConfig } from '@utils/tracing'; import { formatToOpenAIAssistantTool } from './utils'; +import { Container } from '@n8n/di'; +import { AiConfig } from '@n8n/config'; export class OpenAiAssistant implements INodeType { description: INodeTypeDescription = { @@ -339,11 +341,14 @@ export class OpenAiAssistant implements INodeType { throw new NodeOperationError(this.getNode(), 'The ‘text‘ parameter is empty.'); } + const { openAiDefaultHeaders: defaultHeaders } = Container.get(AiConfig); + const client = new OpenAIClient({ apiKey: credentials.apiKey as string, maxRetries: options.maxRetries ?? 2, timeout: options.timeout ?? 10000, baseURL: options.baseURL, + defaultHeaders, }); let agent; const nativeToolsParsed: OpenAIToolType = nativeTools.map((tool) => ({ type: tool })); diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts index d1f43937a28..d0100c582da 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts @@ -13,6 +13,8 @@ import { logWrapper } from '@utils/logWrapper'; import { getProxyAgent } from '@utils/httpProxyAgent'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; +import { Container } from '@n8n/di'; +import { AiConfig } from '@n8n/config'; const modelParameter: INodeProperties = { displayName: 'Model', @@ -245,7 +247,11 @@ export class EmbeddingsOpenAi implements INodeType { options.timeout = undefined; } - const configuration: ClientOptions = {}; + const { openAiDefaultHeaders: defaultHeaders } = Container.get(AiConfig); + + const configuration: ClientOptions = { + defaultHeaders, + }; if (options.baseURL) { configuration.baseURL = options.baseURL; } else if (credentials.url) { 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 c92f633f81d..7ec3df81f4e 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts @@ -19,6 +19,8 @@ import { N8nLlmTracing } from '../N8nLlmTracing'; import { formatBuiltInTools, prepareAdditionalResponsesParams } from './common'; import { searchModels } from './methods/loadModels'; import type { ModelOptions } from './types'; +import { Container } from '@n8n/di'; +import { AiConfig } from '@n8n/config'; const INCLUDE_JSON_WARNING: INodeProperties = { displayName: @@ -739,7 +741,11 @@ export class LmChatOpenAi implements INodeType { const options = this.getNodeParameter('options', itemIndex, {}) as ModelOptions; - const configuration: ClientOptions = {}; + const { openAiDefaultHeaders: defaultHeaders } = Container.get(AiConfig); + + const configuration: ClientOptions = { + defaultHeaders, + }; if (options.baseURL) { configuration.baseURL = options.baseURL; @@ -759,6 +765,7 @@ export class LmChatOpenAi implements INodeType { typeof credentials.headerValue === 'string' ) { configuration.defaultHeaders = { + ...configuration.defaultHeaders, [credentials.headerName]: credentials.headerValue, }; } diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts index 2ca0f183d98..a7ed9480a8b 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts @@ -3,6 +3,8 @@ import OpenAI from 'openai'; import { shouldIncludeModel } from '../../../vendors/OpenAi/helpers/modelFiltering'; import { getProxyAgent } from '@utils/httpProxyAgent'; +import { Container } from '@n8n/di'; +import { AiConfig } from '@n8n/config'; export async function searchModels( this: ILoadOptionsFunctions, @@ -13,6 +15,7 @@ export async function searchModels( (this.getNodeParameter('options.baseURL', '') as string) || (credentials.url as string) || 'https://api.openai.com/v1'; + const { openAiDefaultHeaders: defaultHeaders } = Container.get(AiConfig); const openai = new OpenAI({ baseURL, @@ -20,6 +23,7 @@ export async function searchModels( fetchOptions: { dispatcher: getProxyAgent(baseURL), }, + defaultHeaders, }); const { data: models = [] } = await openai.models.list(); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts index 7e1f18fef71..98d08af670c 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts @@ -9,6 +9,8 @@ import type { } from 'n8n-workflow'; import { getProxyAgent } from '@utils/httpProxyAgent'; +import { Container } from '@n8n/di'; +import { AiConfig } from '@n8n/config'; import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; import { N8nLlmTracing } from '../N8nLlmTracing'; @@ -249,10 +251,12 @@ export class LmOpenAi implements INodeType { topP?: number; }; + const { openAiDefaultHeaders: defaultHeaders } = Container.get(AiConfig); const configuration: ClientOptions = { fetchOptions: { dispatcher: getProxyAgent(options.baseURL ?? 'https://api.openai.com/v1'), }, + defaultHeaders, }; if (options.baseURL) { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatOpenAi.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatOpenAi.test.ts index 4ecb22192cb..34de406ab51 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatOpenAi.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatOpenAi.test.ts @@ -1,6 +1,8 @@ /* eslint-disable n8n-nodes-base/node-filename-against-convention */ /* eslint-disable @typescript-eslint/unbound-method */ import { ChatOpenAI } from '@langchain/openai'; +import { AiConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; import type { IDataObject, INode, ISupplyDataFunctions } from 'n8n-workflow'; @@ -21,6 +23,7 @@ const MockedChatOpenAI = jest.mocked(ChatOpenAI); const MockedN8nLlmTracing = jest.mocked(N8nLlmTracing); const mockedMakeN8nLlmFailedAttemptHandler = jest.mocked(makeN8nLlmFailedAttemptHandler); const mockedCommon = jest.mocked(common); +const { openAiDefaultHeaders: defaultHeaders } = Container.get(AiConfig); describe('LmChatOpenAi', () => { let lmChatOpenAi: LmChatOpenAi; @@ -113,7 +116,9 @@ describe('LmChatOpenAi', () => { model: 'gpt-4o-mini', timeout: 60000, maxRetries: 2, - configuration: {}, + configuration: { + defaultHeaders, + }, callbacks: expect.arrayContaining([expect.any(Object)]), modelKwargs: {}, onFailedAttempt: expect.any(Function), @@ -145,7 +150,9 @@ describe('LmChatOpenAi', () => { model: 'gpt-4o-mini', timeout: 60000, maxRetries: 2, - configuration: {}, + configuration: { + defaultHeaders, + }, callbacks: expect.arrayContaining([expect.any(Object)]), modelKwargs: {}, onFailedAttempt: expect.any(Function), @@ -182,6 +189,7 @@ describe('LmChatOpenAi', () => { fetchOptions: { dispatcher: {}, }, + defaultHeaders, }, callbacks: expect.arrayContaining([expect.any(Object)]), modelKwargs: {}, @@ -218,6 +226,7 @@ describe('LmChatOpenAi', () => { fetchOptions: { dispatcher: {}, }, + defaultHeaders, }, callbacks: expect.arrayContaining([expect.any(Object)]), modelKwargs: {}, @@ -252,6 +261,7 @@ describe('LmChatOpenAi', () => { maxRetries: 2, configuration: { defaultHeaders: { + ...defaultHeaders, 'X-Custom-Header': 'custom-value', }, }, @@ -295,7 +305,9 @@ describe('LmChatOpenAi', () => { topP: 0.9, timeout: 45000, maxRetries: 3, - configuration: {}, + configuration: { + defaultHeaders, + }, callbacks: expect.arrayContaining([expect.any(Object)]), modelKwargs: { response_format: { type: 'json_object' }, @@ -406,6 +418,7 @@ describe('LmChatOpenAi', () => { fetchOptions: { dispatcher: {}, }, + defaultHeaders, }, }), ); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/assistant/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/assistant/message.operation.ts index 9c90bf616f5..ec933e1a9fd 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/assistant/message.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/assistant/message.operation.ts @@ -25,6 +25,8 @@ import { getTracingConfig } from '@utils/tracing'; import { formatToOpenAIAssistantTool, getChatMessages } from '../../../helpers/utils'; import { assistantRLC } from '../descriptions'; import { getProxyAgent } from '@utils/httpProxyAgent'; +import { Container } from '@n8n/di'; +import { AiConfig } from '@n8n/config'; const properties: INodeProperties[] = [ assistantRLC, @@ -179,6 +181,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise { expect(response.status).toBe(200); expect(response.data).toEqual({ success: true }); }); + + it('should include vendor headers in requests to OpenAi', async () => { + const { openAiDefaultHeaders } = Container.get(AiConfig); + nock('https://api.openai.com', { + reqheaders: openAiDefaultHeaders, + }) + .get('/chat') + .reply(200, { success: true }); + + const response = await invokeAxios({ + url: 'https://api.openai.com/chat', + }); + + expect(response.status).toBe(200); + expect(response.data).toEqual({ success: true }); + }); }); describe('removeEmptyBody', () => { @@ -867,6 +885,23 @@ describe('Request Helper Functions', () => { expect(response).toEqual({ success: true }); scope.done(); }); + + it('should include vendor headers in requests to OpenAi', async () => { + const { openAiDefaultHeaders } = Container.get(AiConfig); + const scope = nock('https://api.openai.com', { + reqheaders: openAiDefaultHeaders, + }) + .get('/chat') + .reply(200, { success: true }); + + const response = await httpRequest({ + method: 'GET', + url: 'https://api.openai.com/chat', + headers: { 'X-Custom-Header': 'custom-value' }, + }); + expect(response).toEqual({ success: true }); + scope.done(); + }); }); describe('refreshOAuth2Token', () => { diff --git a/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts b/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts index b0c0b1cdebb..5ec50fb6fc6 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts @@ -14,6 +14,7 @@ import type { OAuth2CredentialData, } from '@n8n/client-oauth2'; import { ClientOAuth2 } from '@n8n/client-oauth2'; +import { AiConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import type { AxiosError, AxiosHeaders, AxiosRequestConfig, AxiosResponse } from 'axios'; import axios from 'axios'; @@ -145,6 +146,15 @@ function setAxiosAgents( config.httpsAgent = createHttpsProxyAgent(customProxyUrl, targetUrl, agentOptions); } +function applyVendorHeaders(config: AxiosRequestConfig) { + if ([config.url, config.baseURL].some((url) => url?.startsWith('https://api.openai.com/'))) { + config.headers = { + ...Container.get(AiConfig).openAiDefaultHeaders, + ...(config.headers || {}), + }; + } +} + axios.interceptors.request.use((config) => { // If no content-type is set by us, prevent axios from force-setting the content-type to `application/x-www-form-urlencoded` if (config.data === undefined) { @@ -152,6 +162,7 @@ axios.interceptors.request.use((config) => { } setAxiosAgents(config); + applyVendorHeaders(config); return config; });