mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🧵 feat: Implement Request Executor Pattern for Actions (#4566)
* chore: actions typing * fix(actions): implement request executor pattern to prevent concurrent execution issues BREAKING CHANGE: ActionRequest now uses a RequestExecutor pattern for isolated request state - Introduce RequestConfig class to store immutable configuration - Add RequestExecutor class to handle isolated request state for each execution - Modify ActionRequest to act as a facade creating new executors for each operation - Maintain backward compatibility through delegation and getters - Add TypeScript types for better type safety - Fix race conditions in concurrent executions with auth and params This change prevents state mutation issues when the same action is called multiple times concurrently, particularly when using authentication. Each request now gets its own isolated state through a new executor instance, solving race conditions while maintaining the existing API interface. * ci: test isolation/immutatability * chore: Update version to 0.7.51 in data-provider package * refactor(actions): refactor createActionTool to use request executor pattern
This commit is contained in:
parent
262176fec4
commit
1526b429c9
5 changed files with 414 additions and 74 deletions
|
|
@ -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<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
|
||||
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => 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<unknown>} */
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,16 @@ export type OAuthCredentials = {
|
|||
|
||||
export type Credentials = ApiKeyCredentials | OAuthCredentials;
|
||||
|
||||
type MediaTypeObject =
|
||||
| undefined
|
||||
| {
|
||||
[media: string]: OpenAPIV3.MediaTypeObject | undefined;
|
||||
};
|
||||
|
||||
type RequestBodyObject = Omit<OpenAPIV3.RequestBodyObject, 'content'> & {
|
||||
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<string, string> = {};
|
||||
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<string, unknown>)[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') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue