feat: All requests to OpenAI include a platform header (#23463)

This commit is contained in:
Dimitri Lavrenük
2025-12-22 11:00:47 +01:00
committed by GitHub
parent 11f8597d4a
commit f9715674e4
13 changed files with 119 additions and 5 deletions
@@ -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();
});
});
@@ -5,4 +5,9 @@ export class AiConfig {
/** Whether AI features are enabled. */
@Env('N8N_AI_ENABLED')
enabled: boolean = false;
get openAiDefaultHeaders(): Record<string, string> {
// eslint-disable-next-line @typescript-eslint/naming-convention
return { 'openai-platform': 'org-qkmJQuJ2WnvoIKMr2UJwIJkZ' };
}
}
+1
View File
@@ -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';
+2
View File
@@ -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: {
@@ -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 }));
@@ -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) {
@@ -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,
};
}
@@ -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();
@@ -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) {
@@ -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,
},
}),
);
@@ -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<INode
};
const baseURL = (options.baseURL ?? credentials.url) as string;
const { openAiDefaultHeaders: defaultHeaders } = Container.get(AiConfig);
const client = new OpenAIClient({
apiKey: credentials.apiKey as string,
@@ -188,6 +191,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
fetchOptions: {
dispatcher: getProxyAgent(baseURL),
},
defaultHeaders,
});
const agent = new OpenAIAssistantRunnable({ assistantId, client, asAgent: true });
@@ -1,3 +1,5 @@
import { AiConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import FormData from 'form-data';
import type { Agent as HttpsAgent } from 'https';
import { mock, mockDeep } from 'jest-mock-extended';
@@ -235,6 +237,22 @@ describe('Request Helper Functions', () => {
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', () => {
@@ -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;
});