mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
feat: All requests to OpenAI include a platform header (#23463)
This commit is contained in:
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
+7
-1
@@ -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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
Vendored
+4
@@ -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 });
|
||||
|
||||
+35
@@ -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', () => {
|
||||
|
||||
+11
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user