mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
476 lines
15 KiB
TypeScript
476 lines
15 KiB
TypeScript
|
|
import axios from 'axios';
|
||
|
|
import { OpenAPIV3 } from 'openapi-types';
|
||
|
|
import {
|
||
|
|
resolveRef,
|
||
|
|
ActionRequest,
|
||
|
|
openapiToFunction,
|
||
|
|
FunctionSignature,
|
||
|
|
validateAndParseOpenAPISpec,
|
||
|
|
} from '../src/actions';
|
||
|
|
import { getWeatherOpenapiSpec, whimsicalOpenapiSpec, scholarAIOpenapiSpec } from './openapiSpecs';
|
||
|
|
import { AuthorizationTypeEnum, AuthTypeEnum } from '../src/types/assistants';
|
||
|
|
import type { FlowchartSchema } from './openapiSpecs';
|
||
|
|
import type { ParametersSchema } from '../src/actions';
|
||
|
|
|
||
|
|
jest.mock('axios');
|
||
|
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||
|
|
|
||
|
|
describe('FunctionSignature', () => {
|
||
|
|
it('creates a function signature and converts to JSON tool', () => {
|
||
|
|
const signature = new FunctionSignature('testFunction', 'A test function', {
|
||
|
|
param1: { type: 'string' },
|
||
|
|
} as unknown as ParametersSchema);
|
||
|
|
expect(signature.name).toBe('testFunction');
|
||
|
|
expect(signature.description).toBe('A test function');
|
||
|
|
expect(signature.toObjectTool()).toEqual({
|
||
|
|
type: 'function',
|
||
|
|
function: {
|
||
|
|
name: 'testFunction',
|
||
|
|
description: 'A test function',
|
||
|
|
parameters: {
|
||
|
|
param1: { type: 'string' },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('ActionRequest', () => {
|
||
|
|
// Mocking responses for each method
|
||
|
|
beforeEach(() => {
|
||
|
|
mockedAxios.get.mockResolvedValue({ data: { success: true, method: 'GET' } });
|
||
|
|
mockedAxios.post.mockResolvedValue({ data: { success: true, method: 'POST' } });
|
||
|
|
mockedAxios.put.mockResolvedValue({ data: { success: true, method: 'PUT' } });
|
||
|
|
mockedAxios.delete.mockResolvedValue({ data: { success: true, method: 'DELETE' } });
|
||
|
|
mockedAxios.patch.mockResolvedValue({ data: { success: true, method: 'PATCH' } });
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
jest.clearAllMocks();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should make a GET request', async () => {
|
||
|
|
const actionRequest = new ActionRequest(
|
||
|
|
'https://example.com',
|
||
|
|
'/test',
|
||
|
|
'GET',
|
||
|
|
'testOp',
|
||
|
|
false,
|
||
|
|
'application/json',
|
||
|
|
);
|
||
|
|
await 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' });
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('ActionRequest', () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
mockedAxios.get.mockClear();
|
||
|
|
mockedAxios.post.mockClear();
|
||
|
|
mockedAxios.put.mockClear();
|
||
|
|
mockedAxios.delete.mockClear();
|
||
|
|
mockedAxios.patch.mockClear();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles GET requests', async () => {
|
||
|
|
mockedAxios.get.mockResolvedValue({ data: { success: true } });
|
||
|
|
const actionRequest = new ActionRequest(
|
||
|
|
'https://example.com',
|
||
|
|
'/get',
|
||
|
|
'GET',
|
||
|
|
'testGet',
|
||
|
|
false,
|
||
|
|
'application/json',
|
||
|
|
);
|
||
|
|
await actionRequest.setParams({ param: 'test' });
|
||
|
|
const response = await actionRequest.execute();
|
||
|
|
expect(mockedAxios.get).toHaveBeenCalled();
|
||
|
|
expect(response.data.success).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles POST requests', async () => {
|
||
|
|
mockedAxios.post.mockResolvedValue({ data: { success: true } });
|
||
|
|
const actionRequest = new ActionRequest(
|
||
|
|
'https://example.com',
|
||
|
|
'/post',
|
||
|
|
'POST',
|
||
|
|
'testPost',
|
||
|
|
false,
|
||
|
|
'application/json',
|
||
|
|
);
|
||
|
|
await actionRequest.setParams({ param: 'test' });
|
||
|
|
const response = await actionRequest.execute();
|
||
|
|
expect(mockedAxios.post).toHaveBeenCalled();
|
||
|
|
expect(response.data.success).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles PUT requests', async () => {
|
||
|
|
mockedAxios.put.mockResolvedValue({ data: { success: true } });
|
||
|
|
const actionRequest = new ActionRequest(
|
||
|
|
'https://example.com',
|
||
|
|
'/put',
|
||
|
|
'PUT',
|
||
|
|
'testPut',
|
||
|
|
false,
|
||
|
|
'application/json',
|
||
|
|
);
|
||
|
|
await actionRequest.setParams({ param: 'test' });
|
||
|
|
const response = await actionRequest.execute();
|
||
|
|
expect(mockedAxios.put).toHaveBeenCalled();
|
||
|
|
expect(response.data.success).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles DELETE requests', async () => {
|
||
|
|
mockedAxios.delete.mockResolvedValue({ data: { success: true } });
|
||
|
|
const actionRequest = new ActionRequest(
|
||
|
|
'https://example.com',
|
||
|
|
'/delete',
|
||
|
|
'DELETE',
|
||
|
|
'testDelete',
|
||
|
|
false,
|
||
|
|
'application/json',
|
||
|
|
);
|
||
|
|
await actionRequest.setParams({ param: 'test' });
|
||
|
|
const response = await actionRequest.execute();
|
||
|
|
expect(mockedAxios.delete).toHaveBeenCalled();
|
||
|
|
expect(response.data.success).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles PATCH requests', async () => {
|
||
|
|
mockedAxios.patch.mockResolvedValue({ data: { success: true } });
|
||
|
|
const actionRequest = new ActionRequest(
|
||
|
|
'https://example.com',
|
||
|
|
'/patch',
|
||
|
|
'PATCH',
|
||
|
|
'testPatch',
|
||
|
|
false,
|
||
|
|
'application/json',
|
||
|
|
);
|
||
|
|
await actionRequest.setParams({ param: 'test' });
|
||
|
|
const response = await actionRequest.execute();
|
||
|
|
expect(mockedAxios.patch).toHaveBeenCalled();
|
||
|
|
expect(response.data.success).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('throws an error for unsupported HTTP methods', async () => {
|
||
|
|
const actionRequest = new ActionRequest(
|
||
|
|
'https://example.com',
|
||
|
|
'/invalid',
|
||
|
|
'INVALID',
|
||
|
|
'testInvalid',
|
||
|
|
false,
|
||
|
|
'application/json',
|
||
|
|
);
|
||
|
|
await expect(actionRequest.execute()).rejects.toThrow('Unsupported HTTP method: INVALID');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('throws an error for unsupported HTTP method', async () => {
|
||
|
|
const actionRequest = new ActionRequest(
|
||
|
|
'https://example.com',
|
||
|
|
'/test',
|
||
|
|
'INVALID',
|
||
|
|
'testOp',
|
||
|
|
false,
|
||
|
|
'application/json',
|
||
|
|
);
|
||
|
|
await expect(actionRequest.execute()).rejects.toThrow('Unsupported HTTP method: INVALID');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Authentication Handling', () => {
|
||
|
|
it('correctly sets Basic Auth header', async () => {
|
||
|
|
const actionRequest = new ActionRequest(
|
||
|
|
'https://example.com',
|
||
|
|
'/test',
|
||
|
|
'GET',
|
||
|
|
'testOp',
|
||
|
|
false,
|
||
|
|
'application/json',
|
||
|
|
);
|
||
|
|
|
||
|
|
const api_key = 'user:pass';
|
||
|
|
const encodedCredentials = Buffer.from('user:pass').toString('base64');
|
||
|
|
|
||
|
|
actionRequest.setAuth({
|
||
|
|
auth: {
|
||
|
|
type: AuthTypeEnum.ServiceHttp,
|
||
|
|
authorization_type: AuthorizationTypeEnum.Basic,
|
||
|
|
},
|
||
|
|
api_key,
|
||
|
|
});
|
||
|
|
|
||
|
|
await actionRequest.setParams({ param1: 'value1' });
|
||
|
|
await actionRequest.execute();
|
||
|
|
expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/test', {
|
||
|
|
headers: expect.objectContaining({
|
||
|
|
Authorization: `Basic ${encodedCredentials}`,
|
||
|
|
}),
|
||
|
|
params: expect.anything(),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('correctly sets Bearer token', async () => {
|
||
|
|
const actionRequest = new ActionRequest(
|
||
|
|
'https://example.com',
|
||
|
|
'/test',
|
||
|
|
'GET',
|
||
|
|
'testOp',
|
||
|
|
false,
|
||
|
|
'application/json',
|
||
|
|
);
|
||
|
|
actionRequest.setAuth({
|
||
|
|
auth: {
|
||
|
|
type: AuthTypeEnum.ServiceHttp,
|
||
|
|
authorization_type: AuthorizationTypeEnum.Bearer,
|
||
|
|
},
|
||
|
|
api_key: 'token123',
|
||
|
|
});
|
||
|
|
await actionRequest.setParams({ param1: 'value1' });
|
||
|
|
await actionRequest.execute();
|
||
|
|
expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/test', {
|
||
|
|
headers: expect.objectContaining({
|
||
|
|
Authorization: 'Bearer token123',
|
||
|
|
}),
|
||
|
|
params: expect.anything(),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('correctly sets API Key', async () => {
|
||
|
|
const actionRequest = new ActionRequest(
|
||
|
|
'https://example.com',
|
||
|
|
'/test',
|
||
|
|
'GET',
|
||
|
|
'testOp',
|
||
|
|
false,
|
||
|
|
'application/json',
|
||
|
|
);
|
||
|
|
// Updated to match ActionMetadata structure
|
||
|
|
actionRequest.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
|
||
|
|
custom_auth_header: 'X-API-KEY',
|
||
|
|
},
|
||
|
|
api_key: 'abc123',
|
||
|
|
});
|
||
|
|
await actionRequest.setParams({ param1: 'value1' });
|
||
|
|
await actionRequest.execute();
|
||
|
|
expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/test', {
|
||
|
|
headers: expect.objectContaining({
|
||
|
|
'X-API-KEY': 'abc123',
|
||
|
|
}),
|
||
|
|
params: expect.anything(),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('resolveRef', () => {
|
||
|
|
it('correctly resolves $ref references in the OpenAPI spec', () => {
|
||
|
|
const openapiSpec = whimsicalOpenapiSpec;
|
||
|
|
const flowchartRequestRef = (
|
||
|
|
openapiSpec.paths['/ai.chatgpt.render-flowchart']?.post
|
||
|
|
?.requestBody as OpenAPIV3.RequestBodyObject
|
||
|
|
)?.content['application/json'].schema;
|
||
|
|
expect(flowchartRequestRef).toBeDefined();
|
||
|
|
const resolvedFlowchartRequest = resolveRef(
|
||
|
|
flowchartRequestRef as OpenAPIV3.RequestBodyObject,
|
||
|
|
openapiSpec.components,
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(resolvedFlowchartRequest).toBeDefined();
|
||
|
|
expect(resolvedFlowchartRequest.type).toBe('object');
|
||
|
|
const properties = resolvedFlowchartRequest.properties as FlowchartSchema;
|
||
|
|
expect(properties).toBeDefined();
|
||
|
|
expect(properties.mermaid).toBeDefined();
|
||
|
|
expect(properties.mermaid.type).toBe('string');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('openapiToFunction', () => {
|
||
|
|
it('converts OpenAPI spec to function signatures and request builders', () => {
|
||
|
|
const { functionSignatures, requestBuilders } = openapiToFunction(getWeatherOpenapiSpec);
|
||
|
|
expect(functionSignatures.length).toBe(1);
|
||
|
|
expect(functionSignatures[0].name).toBe('GetCurrentWeather');
|
||
|
|
|
||
|
|
const parameters = functionSignatures[0].parameters as ParametersSchema & {
|
||
|
|
properties: {
|
||
|
|
location: {
|
||
|
|
type: 'string';
|
||
|
|
};
|
||
|
|
locations: {
|
||
|
|
type: 'array';
|
||
|
|
items: {
|
||
|
|
type: 'object';
|
||
|
|
properties: {
|
||
|
|
city: {
|
||
|
|
type: 'string';
|
||
|
|
};
|
||
|
|
state: {
|
||
|
|
type: 'string';
|
||
|
|
};
|
||
|
|
countryCode: {
|
||
|
|
type: 'string';
|
||
|
|
};
|
||
|
|
time: {
|
||
|
|
type: 'string';
|
||
|
|
};
|
||
|
|
};
|
||
|
|
};
|
||
|
|
};
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
expect(parameters).toBeDefined();
|
||
|
|
expect(parameters.properties.locations).toBeDefined();
|
||
|
|
expect(parameters.properties.locations.type).toBe('array');
|
||
|
|
expect(parameters.properties.locations.items.type).toBe('object');
|
||
|
|
|
||
|
|
expect(parameters.properties.locations.items.properties.city.type).toBe('string');
|
||
|
|
expect(parameters.properties.locations.items.properties.state.type).toBe('string');
|
||
|
|
expect(parameters.properties.locations.items.properties.countryCode.type).toBe('string');
|
||
|
|
expect(parameters.properties.locations.items.properties.time.type).toBe('string');
|
||
|
|
|
||
|
|
expect(requestBuilders).toHaveProperty('GetCurrentWeather');
|
||
|
|
expect(requestBuilders.GetCurrentWeather).toBeInstanceOf(ActionRequest);
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('openapiToFunction with $ref resolution', () => {
|
||
|
|
it('correctly converts OpenAPI spec to function signatures and request builders, resolving $ref references', () => {
|
||
|
|
const { functionSignatures, requestBuilders } = openapiToFunction(whimsicalOpenapiSpec);
|
||
|
|
|
||
|
|
expect(functionSignatures.length).toBeGreaterThan(0);
|
||
|
|
|
||
|
|
const postRenderFlowchartSignature = functionSignatures.find(
|
||
|
|
(sig) => sig.name === 'postRenderFlowchart',
|
||
|
|
);
|
||
|
|
expect(postRenderFlowchartSignature).toBeDefined();
|
||
|
|
expect(postRenderFlowchartSignature?.name).toBe('postRenderFlowchart');
|
||
|
|
expect(postRenderFlowchartSignature?.parameters).toBeDefined();
|
||
|
|
|
||
|
|
expect(requestBuilders).toHaveProperty('postRenderFlowchart');
|
||
|
|
const postRenderFlowchartRequestBuilder = requestBuilders['postRenderFlowchart'];
|
||
|
|
expect(postRenderFlowchartRequestBuilder).toBeDefined();
|
||
|
|
expect(postRenderFlowchartRequestBuilder.method).toBe('post');
|
||
|
|
expect(postRenderFlowchartRequestBuilder.path).toBe('/ai.chatgpt.render-flowchart');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
const invalidServerURL = 'Could not find a valid URL in `servers`';
|
||
|
|
|
||
|
|
describe('validateAndParseOpenAPISpec', () => {
|
||
|
|
it('validates a correct OpenAPI spec successfully', () => {
|
||
|
|
const validSpec = JSON.stringify({
|
||
|
|
openapi: '3.0.0',
|
||
|
|
info: { title: 'Test API', version: '1.0.0' },
|
||
|
|
servers: [{ url: 'https://test.api' }],
|
||
|
|
paths: { '/test': {} },
|
||
|
|
components: { schemas: {} },
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = validateAndParseOpenAPISpec(validSpec);
|
||
|
|
expect(result.status).toBe(true);
|
||
|
|
expect(result.message).toBe('OpenAPI spec is valid.');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns an error for spec with no servers', () => {
|
||
|
|
const noServerSpec = JSON.stringify({
|
||
|
|
openapi: '3.0.0',
|
||
|
|
info: { title: 'Test API', version: '1.0.0' },
|
||
|
|
paths: { '/test': {} },
|
||
|
|
components: { schemas: {} },
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = validateAndParseOpenAPISpec(noServerSpec);
|
||
|
|
expect(result.status).toBe(false);
|
||
|
|
expect(result.message).toBe(invalidServerURL);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns an error for spec with empty server URL', () => {
|
||
|
|
const emptyURLSpec = `{
|
||
|
|
"openapi": "3.1.0",
|
||
|
|
"info": {
|
||
|
|
"title": "Untitled",
|
||
|
|
"description": "Your OpenAPI specification",
|
||
|
|
"version": "v1.0.0"
|
||
|
|
},
|
||
|
|
"servers": [
|
||
|
|
{
|
||
|
|
"url": ""
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"paths": {},
|
||
|
|
"components": {
|
||
|
|
"schemas": {}
|
||
|
|
}
|
||
|
|
}`;
|
||
|
|
|
||
|
|
const result = validateAndParseOpenAPISpec(emptyURLSpec);
|
||
|
|
expect(result.status).toBe(false);
|
||
|
|
expect(result.message).toBe(invalidServerURL);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns an error for spec with no paths', () => {
|
||
|
|
const noPathsSpec = JSON.stringify({
|
||
|
|
openapi: '3.0.0',
|
||
|
|
info: { title: 'Test API', version: '1.0.0' },
|
||
|
|
servers: [{ url: 'https://test.api' }],
|
||
|
|
components: { schemas: {} },
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = validateAndParseOpenAPISpec(noPathsSpec);
|
||
|
|
expect(result.status).toBe(false);
|
||
|
|
expect(result.message).toBe('No paths found in the OpenAPI spec.');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('detects missing components in spec', () => {
|
||
|
|
const missingComponentSpec = JSON.stringify({
|
||
|
|
openapi: '3.0.0',
|
||
|
|
info: { title: 'Test API', version: '1.0.0' },
|
||
|
|
servers: [{ url: 'https://test.api' }],
|
||
|
|
paths: {
|
||
|
|
'/test': {
|
||
|
|
get: {
|
||
|
|
responses: {
|
||
|
|
'200': {
|
||
|
|
content: {
|
||
|
|
'application/json': { schema: { $ref: '#/components/schemas/Missing' } },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = validateAndParseOpenAPISpec(missingComponentSpec);
|
||
|
|
expect(result.status).toBe(true);
|
||
|
|
expect(result.message).toContain('reference to unknown component Missing');
|
||
|
|
expect(result.spec).toBeDefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles invalid spec formats', () => {
|
||
|
|
const invalidSpec = 'not a valid spec';
|
||
|
|
|
||
|
|
const result = validateAndParseOpenAPISpec(invalidSpec);
|
||
|
|
expect(result.status).toBe(false);
|
||
|
|
expect(result.message).toBe(invalidServerURL);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles YAML spec and correctly converts to Function Signatures', () => {
|
||
|
|
const result = validateAndParseOpenAPISpec(scholarAIOpenapiSpec);
|
||
|
|
expect(result.status).toBe(true);
|
||
|
|
|
||
|
|
const spec = result.spec;
|
||
|
|
expect(spec).toBeDefined();
|
||
|
|
|
||
|
|
const { functionSignatures, requestBuilders } = openapiToFunction(spec as OpenAPIV3.Document);
|
||
|
|
expect(functionSignatures.length).toBe(3);
|
||
|
|
expect(requestBuilders).toHaveProperty('searchAbstracts');
|
||
|
|
expect(requestBuilders).toHaveProperty('getFullText');
|
||
|
|
expect(requestBuilders).toHaveProperty('saveCitation');
|
||
|
|
});
|
||
|
|
});
|