diff --git a/api/server/services/ActionService.js b/api/server/services/ActionService.js index da69548b43..6bca75b989 100644 --- a/api/server/services/ActionService.js +++ b/api/server/services/ActionService.js @@ -119,18 +119,24 @@ async function loadActionSets(searchParams) { * @param {string | undefined} [params.name] - The name of the tool. * @param {string | undefined} [params.description] - The description for the tool. * @param {import('zod').ZodTypeAny | undefined} [params.zodSchema] - The Zod schema for tool input validation/definition - * @returns { Promsie unknown}> } An object with `_call` method to execute the tool input. + * @returns { Promise unknown}> } An object with `_call` method to execute the tool input. */ async function createActionTool({ action, requestBuilder, zodSchema, name, description }) { action.metadata = await decryptMetadata(action.metadata); /** @type {(toolInput: Object | string) => Promise} */ const _call = async (toolInput) => { try { - requestBuilder.setParams(toolInput); + const executor = requestBuilder.createExecutor(); + + // Chain the operations + const preparedExecutor = executor.setParams(toolInput); + if (action.metadata.auth && action.metadata.auth.type !== AuthTypeEnum.None) { - await requestBuilder.setAuth(action.metadata); + await preparedExecutor.setAuth(action.metadata); } - const res = await requestBuilder.execute(); + + const res = await preparedExecutor.execute(); + if (typeof res.data === 'object') { return JSON.stringify(res.data); } diff --git a/package-lock.json b/package-lock.json index 92c1a16cca..ca01a8def1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36522,7 +36522,7 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.7.5", + "version": "0.7.51", "license": "ISC", "dependencies": { "@types/js-yaml": "^4.0.9", diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index 0009f6055a..0170ec7402 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -1,6 +1,6 @@ { "name": "librechat-data-provider", - "version": "0.7.5", + "version": "0.7.51", "description": "data services for librechat apps", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/packages/data-provider/specs/actions.spec.ts b/packages/data-provider/specs/actions.spec.ts index 3446c1a786..6a09bb6b04 100644 --- a/packages/data-provider/specs/actions.spec.ts +++ b/packages/data-provider/specs/actions.spec.ts @@ -65,7 +65,7 @@ describe('ActionRequest', () => { false, 'application/json', ); - await actionRequest.setParams({ param1: 'value1' }); + actionRequest.setParams({ param1: 'value1' }); const response = await actionRequest.execute(); expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/test', expect.anything()); expect(response.data).toEqual({ success: true, method: 'GET' }); @@ -90,7 +90,7 @@ describe('ActionRequest', () => { false, 'application/json', ); - await actionRequest.setParams({ param: 'test' }); + actionRequest.setParams({ param: 'test' }); const response = await actionRequest.execute(); expect(mockedAxios.get).toHaveBeenCalled(); expect(response.data.success).toBe(true); @@ -106,7 +106,7 @@ describe('ActionRequest', () => { false, 'application/json', ); - await actionRequest.setParams({ param: 'test' }); + actionRequest.setParams({ param: 'test' }); const response = await actionRequest.execute(); expect(mockedAxios.post).toHaveBeenCalled(); expect(response.data.success).toBe(true); @@ -122,7 +122,7 @@ describe('ActionRequest', () => { false, 'application/json', ); - await actionRequest.setParams({ param: 'test' }); + actionRequest.setParams({ param: 'test' }); const response = await actionRequest.execute(); expect(mockedAxios.put).toHaveBeenCalled(); expect(response.data.success).toBe(true); @@ -138,7 +138,7 @@ describe('ActionRequest', () => { false, 'application/json', ); - await actionRequest.setParams({ param: 'test' }); + actionRequest.setParams({ param: 'test' }); const response = await actionRequest.execute(); expect(mockedAxios.delete).toHaveBeenCalled(); expect(response.data.success).toBe(true); @@ -154,7 +154,7 @@ describe('ActionRequest', () => { false, 'application/json', ); - await actionRequest.setParams({ param: 'test' }); + actionRequest.setParams({ param: 'test' }); const response = await actionRequest.execute(); expect(mockedAxios.patch).toHaveBeenCalled(); expect(response.data.success).toBe(true); @@ -169,7 +169,7 @@ describe('ActionRequest', () => { false, 'application/json', ); - await expect(actionRequest.execute()).rejects.toThrow('Unsupported HTTP method: INVALID'); + await expect(actionRequest.execute()).rejects.toThrow('Unsupported HTTP method: invalid'); }); it('replaces path parameters with values from toolInput', async () => { @@ -182,20 +182,21 @@ describe('ActionRequest', () => { 'application/json', ); - await actionRequest.setParams({ + const executor = actionRequest.createExecutor(); + executor.setParams({ stocksTicker: 'AAPL', multiplier: 5, startDate: '2023-01-01', endDate: '2023-12-31', }); - expect(actionRequest.path).toBe('/stocks/AAPL/bars/5'); - expect(actionRequest.params).toEqual({ + expect(executor.path).toBe('/stocks/AAPL/bars/5'); + expect(executor.params).toEqual({ startDate: '2023-01-01', endDate: '2023-12-31', }); - await actionRequest.execute(); + await executor.execute(); expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/stocks/AAPL/bars/5', { headers: expect.anything(), params: { @@ -215,7 +216,271 @@ describe('ActionRequest', () => { false, 'application/json', ); - await expect(actionRequest.execute()).rejects.toThrow('Unsupported HTTP method: INVALID'); + await expect(actionRequest.execute()).rejects.toThrow('Unsupported HTTP method: invalid'); + }); + + describe('ActionRequest Concurrent Execution', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedAxios.get.mockImplementation(async (url, config) => ({ + data: { url, params: config?.params, headers: config?.headers }, + })); + }); + + it('maintains isolated state between concurrent executions with different parameters', async () => { + const actionRequest = new ActionRequest( + 'https://example.com', + '/math/sqrt/{number}', + 'GET', + 'getSqrt', + false, + 'application/json', + ); + + // Simulate concurrent requests with different numbers + const numbers = [20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]; + const requests = numbers.map((num) => ({ + number: num.toString(), + precision: '2', + })); + + const responses = await Promise.all( + requests.map((params) => { + const executor = actionRequest.createExecutor(); + return executor.setParams(params).execute(); + }), + ); + + // Verify each response used the correct path parameter + responses.forEach((response, index) => { + const expectedUrl = `https://example.com/math/sqrt/${numbers[index]}`; + expect(response.data.url).toBe(expectedUrl); + expect(response.data.params).toEqual({ precision: '2' }); + }); + + // Verify the correct number of calls were made + expect(mockedAxios.get).toHaveBeenCalledTimes(numbers.length); + }); + + it('maintains isolated authentication state between concurrent executions', async () => { + const actionRequest = new ActionRequest( + 'https://example.com', + '/secure/resource/{id}', + 'GET', + 'getResource', + false, + 'application/json', + ); + + const requests = [ + { + params: { id: '1' }, + auth: { + auth: { + type: AuthTypeEnum.ServiceHttp, + authorization_type: AuthorizationTypeEnum.Bearer, + }, + api_key: 'token1', + }, + }, + { + params: { id: '2' }, + auth: { + auth: { + type: AuthTypeEnum.ServiceHttp, + authorization_type: AuthorizationTypeEnum.Bearer, + }, + api_key: 'token2', + }, + }, + ]; + + const responses = await Promise.all( + requests.map(async ({ params, auth }) => { + const executor = actionRequest.createExecutor(); + return (await executor.setParams(params).setAuth(auth)).execute(); + }), + ); + + // Verify each response had its own auth token + responses.forEach((response, index) => { + const expectedUrl = `https://example.com/secure/resource/${index + 1}`; + expect(response.data.url).toBe(expectedUrl); + expect(response.data.headers).toMatchObject({ + Authorization: `Bearer token${index + 1}`, + }); + }); + }); + + it('handles mixed authentication types concurrently', async () => { + const actionRequest = new ActionRequest( + 'https://example.com', + '/api/{version}/data', + 'GET', + 'getData', + false, + 'application/json', + ); + + const requests = [ + { + params: { version: 'v1' }, + auth: { + auth: { + type: AuthTypeEnum.ServiceHttp, + authorization_type: AuthorizationTypeEnum.Bearer, + }, + api_key: 'bearer_token', + }, + }, + { + params: { version: 'v2' }, + auth: { + auth: { + type: AuthTypeEnum.ServiceHttp, + authorization_type: AuthorizationTypeEnum.Basic, + }, + api_key: 'basic:auth', + }, + }, + { + params: { version: 'v3' }, + auth: { + auth: { + type: AuthTypeEnum.ServiceHttp, + authorization_type: AuthorizationTypeEnum.Custom, + custom_auth_header: 'X-API-Key', + }, + api_key: 'custom_key', + }, + }, + ]; + + const responses = await Promise.all( + requests.map(async ({ params, auth }) => { + const executor = actionRequest.createExecutor(); + return (await executor.setParams(params).setAuth(auth)).execute(); + }), + ); + + // Verify each response had the correct auth type and headers + expect(responses[0].data.headers).toMatchObject({ + Authorization: 'Bearer bearer_token', + }); + + expect(responses[1].data.headers).toMatchObject({ + Authorization: `Basic ${Buffer.from('basic:auth').toString('base64')}`, + }); + + expect(responses[2].data.headers).toMatchObject({ + 'X-API-Key': 'custom_key', + }); + }); + + it('maintains parameter integrity during concurrent path parameter replacement', async () => { + const actionRequest = new ActionRequest( + 'https://example.com', + '/users/{userId}/posts/{postId}', + 'GET', + 'getUserPost', + false, + 'application/json', + ); + + const requests = [ + { userId: '1', postId: 'a', filter: 'recent' }, + { userId: '2', postId: 'b', filter: 'popular' }, + { userId: '3', postId: 'c', filter: 'trending' }, + ]; + + const responses = await Promise.all( + requests.map((params) => { + const executor = actionRequest.createExecutor(); + return executor.setParams(params).execute(); + }), + ); + + responses.forEach((response, index) => { + const expectedUrl = `https://example.com/users/${requests[index].userId}/posts/${requests[index].postId}`; + expect(response.data.url).toBe(expectedUrl); + expect(response.data.params).toEqual({ filter: requests[index].filter }); + }); + }); + + it('preserves original ActionRequest state after multiple executions', async () => { + const actionRequest = new ActionRequest( + 'https://example.com', + '/original/{param}', + 'GET', + 'testOp', + false, + 'application/json', + ); + + // Store original values + const originalPath = actionRequest.path; + const originalDomain = actionRequest.domain; + const originalMethod = actionRequest.method; + + // Perform multiple concurrent executions + await Promise.all([ + actionRequest.createExecutor().setParams({ param: '1' }).execute(), + actionRequest.createExecutor().setParams({ param: '2' }).execute(), + actionRequest.createExecutor().setParams({ param: '3' }).execute(), + ]); + + // Verify original ActionRequest remains unchanged + expect(actionRequest.path).toBe(originalPath); + expect(actionRequest.domain).toBe(originalDomain); + expect(actionRequest.method).toBe(originalMethod); + }); + + it('shares immutable configuration between executors from the same ActionRequest', () => { + const actionRequest = new ActionRequest( + 'https://example.com', + '/api/{version}/data', + 'GET', + 'getData', + false, + 'application/json', + ); + + // Create multiple executors + const executor1 = actionRequest.createExecutor(); + const executor2 = actionRequest.createExecutor(); + const executor3 = actionRequest.createExecutor(); + + // Test that the configuration properties are shared + [executor1, executor2, executor3].forEach((executor) => { + expect(executor.getConfig()).toBeDefined(); + expect(executor.getConfig()).toEqual({ + domain: 'https://example.com', + basePath: '/api/{version}/data', + method: 'GET', + operation: 'getData', + isConsequential: false, + contentType: 'application/json', + }); + }); + + // Verify that config objects are the exact same instance (shared reference) + expect(executor1.getConfig()).toBe(executor2.getConfig()); + expect(executor2.getConfig()).toBe(executor3.getConfig()); + + // Verify that modifying mutable state doesn't affect other executors + executor1.setParams({ version: 'v1' }); + executor2.setParams({ version: 'v2' }); + executor3.setParams({ version: 'v3' }); + + expect(executor1.path).toBe('/api/v1/data'); + expect(executor2.path).toBe('/api/v2/data'); + expect(executor3.path).toBe('/api/v3/data'); + + // Verify that the original config remains unchanged + expect(executor1.getConfig().basePath).toBe('/api/{version}/data'); + expect(executor2.getConfig().basePath).toBe('/api/{version}/data'); + expect(executor3.getConfig().basePath).toBe('/api/{version}/data'); + }); }); }); @@ -233,7 +498,8 @@ describe('Authentication Handling', () => { const api_key = 'user:pass'; const encodedCredentials = Buffer.from('user:pass').toString('base64'); - actionRequest.setAuth({ + const executor = actionRequest.createExecutor(); + await executor.setParams({ param1: 'value1' }).setAuth({ auth: { type: AuthTypeEnum.ServiceHttp, authorization_type: AuthorizationTypeEnum.Basic, @@ -241,13 +507,13 @@ describe('Authentication Handling', () => { api_key, }); - await actionRequest.setParams({ param1: 'value1' }); - await actionRequest.execute(); + await executor.execute(); expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/test', { headers: expect.objectContaining({ Authorization: `Basic ${encodedCredentials}`, + 'Content-Type': 'application/json', }), - params: expect.anything(), + params: { param1: 'value1' }, }); }); @@ -260,20 +526,23 @@ describe('Authentication Handling', () => { false, 'application/json', ); - actionRequest.setAuth({ + + const executor = actionRequest.createExecutor(); + await executor.setParams({ param1: 'value1' }).setAuth({ auth: { type: AuthTypeEnum.ServiceHttp, authorization_type: AuthorizationTypeEnum.Bearer, }, api_key: 'token123', }); - await actionRequest.setParams({ param1: 'value1' }); - await actionRequest.execute(); + + await executor.execute(); expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/test', { headers: expect.objectContaining({ Authorization: 'Bearer token123', + 'Content-Type': 'application/json', }), - params: expect.anything(), + params: { param1: 'value1' }, }); }); @@ -286,22 +555,24 @@ describe('Authentication Handling', () => { false, 'application/json', ); - // Updated to match ActionMetadata structure - actionRequest.setAuth({ + + const executor = actionRequest.createExecutor(); + await executor.setParams({ param1: 'value1' }).setAuth({ auth: { - type: AuthTypeEnum.ServiceHttp, // Assuming this is a valid enum or value for your context - authorization_type: AuthorizationTypeEnum.Custom, // Assuming Custom means using a custom header + type: AuthTypeEnum.ServiceHttp, + authorization_type: AuthorizationTypeEnum.Custom, custom_auth_header: 'X-API-KEY', }, api_key: 'abc123', }); - await actionRequest.setParams({ param1: 'value1' }); - await actionRequest.execute(); + + await executor.execute(); expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/test', { headers: expect.objectContaining({ 'X-API-KEY': 'abc123', + 'Content-Type': 'application/json', }), - params: expect.anything(), + params: { param1: 'value1' }, }); }); }); @@ -312,7 +583,7 @@ describe('resolveRef', () => { const flowchartRequestRef = ( openapiSpec.paths['/ai.chatgpt.render-flowchart']?.post ?.requestBody as OpenAPIV3.RequestBodyObject - )?.content['application/json'].schema; + ).content['application/json'].schema; expect(flowchartRequestRef).toBeDefined(); const resolvedFlowchartRequest = resolveRef( flowchartRequestRef as OpenAPIV3.RequestBodyObject, diff --git a/packages/data-provider/src/actions.ts b/packages/data-provider/src/actions.ts index daec113eed..386ca34e74 100644 --- a/packages/data-provider/src/actions.ts +++ b/packages/data-provider/src/actions.ts @@ -33,6 +33,16 @@ export type OAuthCredentials = { export type Credentials = ApiKeyCredentials | OAuthCredentials; +type MediaTypeObject = + | undefined + | { + [media: string]: OpenAPIV3.MediaTypeObject | undefined; + }; + +type RequestBodyObject = Omit & { + content: MediaTypeObject; +}; + export function sha1(input: string) { return crypto.createHash('sha1').update(input).digest('hex'); } @@ -130,39 +140,31 @@ export class FunctionSignature { }; } } - -export class ActionRequest { - domain: string; - path: string; - method: string; - operation: string; - operationHash?: string; - isConsequential: boolean; - contentType: string; - params?: object; - +class RequestConfig { constructor( - domain: string, - path: string, - method: string, - operation: string, - isConsequential: boolean, - contentType: string, - ) { - this.domain = domain; - this.path = path; - this.method = method; - this.operation = operation; - this.isConsequential = isConsequential; - this.contentType = contentType; - } + readonly domain: string, + readonly basePath: string, + readonly method: string, + readonly operation: string, + readonly isConsequential: boolean, + readonly contentType: string, + ) {} +} +class RequestExecutor { + path: string; + params?: object; + private operationHash?: string; private authHeaders: Record = {}; private authToken?: string; + constructor(private config: RequestConfig) { + this.path = config.basePath; + } + setParams(params: object) { this.operationHash = sha1(JSON.stringify(params)); - this.params = params; + this.params = Object.assign({}, params); for (const [key, value] of Object.entries(params)) { const paramPattern = `{${key}}`; @@ -171,11 +173,12 @@ export class ActionRequest { delete (this.params as Record)[key]; } } + return this; } async setAuth(metadata: ActionMetadata) { if (!metadata.auth) { - return; + return this; } const { @@ -220,7 +223,6 @@ export class ActionRequest { ) { this.authHeaders[custom_auth_header] = api_key; } else if (isOAuth) { - // TODO: WIP - OAuth support if (!this.authToken) { const tokenResponse = await axios.post( client_url, @@ -238,16 +240,17 @@ export class ActionRequest { } this.authHeaders['Authorization'] = `Bearer ${this.authToken}`; } + return this; } async execute() { - const url = createURL(this.domain, this.path); + const url = createURL(this.config.domain, this.path); const headers = { ...this.authHeaders, - 'Content-Type': this.contentType, + 'Content-Type': this.config.contentType, }; - const method = this.method.toLowerCase(); + const method = this.config.method.toLowerCase(); if (method === 'get') { return axios.get(url, { headers, params: this.params }); @@ -260,13 +263,73 @@ export class ActionRequest { } else if (method === 'patch') { return axios.patch(url, this.params, { headers }); } else { - throw new Error(`Unsupported HTTP method: ${this.method}`); + throw new Error(`Unsupported HTTP method: ${method}`); } } + + getConfig() { + return this.config; + } +} + +export class ActionRequest { + private config: RequestConfig; + + constructor( + domain: string, + path: string, + method: string, + operation: string, + isConsequential: boolean, + contentType: string, + ) { + this.config = new RequestConfig(domain, path, method, operation, isConsequential, contentType); + } + + // Add getters to maintain backward compatibility + get domain() { + return this.config.domain; + } + get path() { + return this.config.basePath; + } + get method() { + return this.config.method; + } + get operation() { + return this.config.operation; + } + get isConsequential() { + return this.config.isConsequential; + } + get contentType() { + return this.config.contentType; + } + + createExecutor() { + return new RequestExecutor(this.config); + } + + // Maintain backward compatibility by delegating to a new executor + setParams(params: object) { + const executor = this.createExecutor(); + executor.setParams(params); + return executor; + } + + async setAuth(metadata: ActionMetadata) { + const executor = this.createExecutor(); + return executor.setAuth(metadata); + } + + async execute() { + const executor = this.createExecutor(); + return executor.execute(); + } } export function resolveRef( - schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject | OpenAPIV3.RequestBodyObject, + schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject | RequestBodyObject, components?: OpenAPIV3.ComponentsObject, ): OpenAPIV3.SchemaObject { if ('$ref' in schema && components) { @@ -324,17 +387,17 @@ export function openapiToFunction( openapiSpec.components, ); parametersSchema.properties[paramObj.name] = resolvedSchema; - if (paramObj.required) { + if (paramObj.required === true) { parametersSchema.required.push(paramObj.name); } } } if (operationObj.requestBody) { - const requestBody = operationObj.requestBody as OpenAPIV3.RequestBodyObject; + const requestBody = operationObj.requestBody as RequestBodyObject; const content = requestBody.content; - const contentType = Object.keys(content)[0]; - const schema = content[contentType]?.schema; + const contentType = Object.keys(content ?? {})[0]; + const schema = content?.[contentType]?.schema; const resolvedSchema = resolveRef( schema as OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject, openapiSpec.components, @@ -356,7 +419,7 @@ export function openapiToFunction( path, method, operationId, - !!operationObj['x-openai-isConsequential'], // Custom extension for consequential actions + !!(operationObj['x-openai-isConsequential'] ?? false), // Custom extension for consequential actions operationObj.requestBody ? 'application/json' : 'application/x-www-form-urlencoded', ); @@ -414,10 +477,10 @@ export function validateAndParseOpenAPISpec(specString: string): ValidationResul for (const [path, methods] of Object.entries(paths)) { for (const [httpMethod, operation] of Object.entries(methods as OpenAPIV3.PathItemObject)) { // Ensure operation is a valid operation object - const { responses } = operation as OpenAPIV3.OperationObject; + const { responses } = operation as OpenAPIV3.OperationObject | { responses: undefined }; if (typeof operation === 'object' && responses) { for (const [statusCode, response] of Object.entries(responses)) { - const content = (response as OpenAPIV3.ResponseObject).content; + const content = (response as OpenAPIV3.ResponseObject).content as MediaTypeObject; if (content && content['application/json'] && content['application/json'].schema) { const schema = content['application/json'].schema; if ('$ref' in schema && typeof schema.$ref === 'string') {