mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
🗺️ feat: Add Parameter Location Mapping for OpenAPI actions (#6858)
* fix: action parameters are assigned to the correct location (query, parameter, header, body) * removed copy/paste error * added unit tests, only add contenttype if specified --------- Co-authored-by: Peter Rothlaender <peter.rothlaender@ginkgo.com>
This commit is contained in:
parent
16aa5ed466
commit
6edd93f99e
2 changed files with 311 additions and 19 deletions
|
|
@ -206,6 +206,244 @@ describe('ActionRequest', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles GET requests with header and query parameters', async () => {
|
||||||
|
mockedAxios.get.mockResolvedValue({ data: { success: true } });
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
'api-version': '2025-01-01',
|
||||||
|
'some-header': 'header-var',
|
||||||
|
};
|
||||||
|
|
||||||
|
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
|
||||||
|
'api-version': 'query',
|
||||||
|
'some-header': 'header',
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionRequest = new ActionRequest(
|
||||||
|
'https://example.com',
|
||||||
|
'/get',
|
||||||
|
'GET',
|
||||||
|
'testGET',
|
||||||
|
false,
|
||||||
|
'',
|
||||||
|
loc,
|
||||||
|
);
|
||||||
|
const executer = actionRequest.setParams(data);
|
||||||
|
const response = await executer.execute();
|
||||||
|
expect(mockedAxios.get).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const [url, config] = mockedAxios.get.mock.calls[0];
|
||||||
|
expect(url).toBe('https://example.com/get');
|
||||||
|
expect(config?.headers).toEqual({
|
||||||
|
'some-header': 'header-var',
|
||||||
|
});
|
||||||
|
expect(config?.params).toEqual({
|
||||||
|
'api-version': '2025-01-01',
|
||||||
|
});
|
||||||
|
expect(response.data.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles GET requests with header and path parameters', async () => {
|
||||||
|
mockedAxios.get.mockResolvedValue({ data: { success: true } });
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
'user-id': '1',
|
||||||
|
'some-header': 'header-var',
|
||||||
|
};
|
||||||
|
|
||||||
|
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
|
||||||
|
'user-id': 'path',
|
||||||
|
'some-header': 'header',
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionRequest = new ActionRequest(
|
||||||
|
'https://example.com',
|
||||||
|
'/getwithpath/{user-id}',
|
||||||
|
'GET',
|
||||||
|
'testGETwithpath',
|
||||||
|
false,
|
||||||
|
'',
|
||||||
|
loc,
|
||||||
|
);
|
||||||
|
const executer = actionRequest.setParams(data);
|
||||||
|
const response = await executer.execute();
|
||||||
|
expect(mockedAxios.get).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const [url, config] = mockedAxios.get.mock.calls[0];
|
||||||
|
expect(url).toBe('https://example.com/getwithpath/1');
|
||||||
|
expect(config?.headers).toEqual({
|
||||||
|
'some-header': 'header-var',
|
||||||
|
});
|
||||||
|
expect(config?.params).toEqual({
|
||||||
|
});
|
||||||
|
expect(response.data.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles POST requests with body, header and query parameters', async () => {
|
||||||
|
mockedAxios.post.mockResolvedValue({ data: { success: true } });
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
'api-version': '2025-01-01',
|
||||||
|
'message': 'a body parameter',
|
||||||
|
'some-header': 'header-var',
|
||||||
|
};
|
||||||
|
|
||||||
|
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
|
||||||
|
'api-version': 'query',
|
||||||
|
'message': 'body',
|
||||||
|
'some-header': 'header',
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionRequest = new ActionRequest(
|
||||||
|
'https://example.com',
|
||||||
|
'/post',
|
||||||
|
'POST',
|
||||||
|
'testPost',
|
||||||
|
false,
|
||||||
|
'application/json',
|
||||||
|
loc,
|
||||||
|
);
|
||||||
|
const executer = actionRequest.setParams(data);
|
||||||
|
const response = await executer.execute();
|
||||||
|
expect(mockedAxios.post).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const [url, body, config] = mockedAxios.post.mock.calls[0];
|
||||||
|
expect(url).toBe('https://example.com/post');
|
||||||
|
expect(body).toEqual({ message: 'a body parameter' });
|
||||||
|
expect(config?.headers).toEqual({
|
||||||
|
'some-header': 'header-var',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
});
|
||||||
|
expect(config?.params).toEqual({
|
||||||
|
'api-version': '2025-01-01',
|
||||||
|
});
|
||||||
|
expect(response.data.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles PUT requests with body, header and query parameters', async () => {
|
||||||
|
mockedAxios.put.mockResolvedValue({ data: { success: true } });
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
'api-version': '2025-01-01',
|
||||||
|
'message': 'a body parameter',
|
||||||
|
'some-header': 'header-var',
|
||||||
|
};
|
||||||
|
|
||||||
|
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
|
||||||
|
'api-version': 'query',
|
||||||
|
'message': 'body',
|
||||||
|
'some-header': 'header',
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionRequest = new ActionRequest(
|
||||||
|
'https://example.com',
|
||||||
|
'/put',
|
||||||
|
'PUT',
|
||||||
|
'testPut',
|
||||||
|
false,
|
||||||
|
'application/json',
|
||||||
|
loc,
|
||||||
|
);
|
||||||
|
const executer = actionRequest.setParams(data);
|
||||||
|
const response = await executer.execute();
|
||||||
|
expect(mockedAxios.put).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const [url, body, config] = mockedAxios.put.mock.calls[0];
|
||||||
|
expect(url).toBe('https://example.com/put');
|
||||||
|
expect(body).toEqual({ message: 'a body parameter' });
|
||||||
|
expect(config?.headers).toEqual({
|
||||||
|
'some-header': 'header-var',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
});
|
||||||
|
expect(config?.params).toEqual({
|
||||||
|
'api-version': '2025-01-01',
|
||||||
|
});
|
||||||
|
expect(response.data.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles PATCH requests with body, header and query parameters', async () => {
|
||||||
|
mockedAxios.patch.mockResolvedValue({ data: { success: true } });
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
'api-version': '2025-01-01',
|
||||||
|
'message': 'a body parameter',
|
||||||
|
'some-header': 'header-var',
|
||||||
|
};
|
||||||
|
|
||||||
|
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
|
||||||
|
'api-version': 'query',
|
||||||
|
'message': 'body',
|
||||||
|
'some-header': 'header',
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionRequest = new ActionRequest(
|
||||||
|
'https://example.com',
|
||||||
|
'/patch',
|
||||||
|
'PATCH',
|
||||||
|
'testPatch',
|
||||||
|
false,
|
||||||
|
'application/json',
|
||||||
|
loc,
|
||||||
|
);
|
||||||
|
const executer = actionRequest.setParams(data);
|
||||||
|
const response = await executer.execute();
|
||||||
|
expect(mockedAxios.patch).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const [url, body, config] = mockedAxios.patch.mock.calls[0];
|
||||||
|
expect(url).toBe('https://example.com/patch');
|
||||||
|
expect(body).toEqual({ message: 'a body parameter' });
|
||||||
|
expect(config?.headers).toEqual({
|
||||||
|
'some-header': 'header-var',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
});
|
||||||
|
expect(config?.params).toEqual({
|
||||||
|
'api-version': '2025-01-01',
|
||||||
|
});
|
||||||
|
expect(response.data.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles DELETE requests with body, header and query parameters', async () => {
|
||||||
|
mockedAxios.delete.mockResolvedValue({ data: { success: true } });
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
'api-version': '2025-01-01',
|
||||||
|
'message-id': '1',
|
||||||
|
'some-header': 'header-var',
|
||||||
|
};
|
||||||
|
|
||||||
|
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
|
||||||
|
'api-version': 'query',
|
||||||
|
'message-id': 'body',
|
||||||
|
'some-header': 'header',
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionRequest = new ActionRequest(
|
||||||
|
'https://example.com',
|
||||||
|
'/delete',
|
||||||
|
'DELETE',
|
||||||
|
'testDelete',
|
||||||
|
false,
|
||||||
|
'application/json',
|
||||||
|
loc,
|
||||||
|
);
|
||||||
|
const executer = actionRequest.setParams(data);
|
||||||
|
const response = await executer.execute();
|
||||||
|
expect(mockedAxios.delete).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const [url, config] = mockedAxios.delete.mock.calls[0];
|
||||||
|
expect(url).toBe('https://example.com/delete');
|
||||||
|
expect(config?.data).toEqual({ 'message-id': '1' });
|
||||||
|
expect(config?.headers).toEqual({
|
||||||
|
'some-header': 'header-var',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
});
|
||||||
|
expect(config?.params).toEqual({
|
||||||
|
'api-version': '2025-01-01',
|
||||||
|
});
|
||||||
|
expect(response.data.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws an error for unsupported HTTP method', async () => {
|
it('throws an error for unsupported HTTP method', async () => {
|
||||||
|
|
|
||||||
|
|
@ -167,12 +167,13 @@ class RequestConfig {
|
||||||
readonly operation: string,
|
readonly operation: string,
|
||||||
readonly isConsequential: boolean,
|
readonly isConsequential: boolean,
|
||||||
readonly contentType: string,
|
readonly contentType: string,
|
||||||
|
readonly parameterLocations?: Record<string, 'query' | 'path' | 'header' | 'body'>,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
class RequestExecutor {
|
class RequestExecutor {
|
||||||
path: string;
|
path: string;
|
||||||
params?: object;
|
params?: Record<string, unknown>;
|
||||||
private operationHash?: string;
|
private operationHash?: string;
|
||||||
private authHeaders: Record<string, string> = {};
|
private authHeaders: Record<string, string> = {};
|
||||||
private authToken?: string;
|
private authToken?: string;
|
||||||
|
|
@ -181,15 +182,28 @@ class RequestExecutor {
|
||||||
this.path = config.basePath;
|
this.path = config.basePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
setParams(params: object) {
|
setParams(params: Record<string, unknown>) {
|
||||||
this.operationHash = sha1(JSON.stringify(params));
|
this.operationHash = sha1(JSON.stringify(params));
|
||||||
this.params = Object.assign({}, params);
|
this.params = { ...params } as Record<string, unknown>;
|
||||||
|
if (this.config.parameterLocations) {
|
||||||
|
//Substituting “Path” Parameters:
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (this.config.parameterLocations[key] === 'path') {
|
||||||
|
const paramPattern = `{${key}}`;
|
||||||
|
if (this.path.includes(paramPattern)) {
|
||||||
|
this.path = this.path.replace(paramPattern, encodeURIComponent(String(value)));
|
||||||
|
delete this.params[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: if no locations are defined, perform path substitution for all keys.
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
const paramPattern = `{${key}}`;
|
const paramPattern = `{${key}}`;
|
||||||
if (this.path.includes(paramPattern)) {
|
if (this.path.includes(paramPattern)) {
|
||||||
this.path = this.path.replace(paramPattern, encodeURIComponent(value as string));
|
this.path = this.path.replace(paramPattern, encodeURIComponent(String(value)));
|
||||||
delete (this.params as Record<string, unknown>)[key];
|
delete this.params[key];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
|
|
@ -275,23 +289,46 @@ class RequestExecutor {
|
||||||
|
|
||||||
async execute() {
|
async execute() {
|
||||||
const url = createURL(this.config.domain, this.path);
|
const url = createURL(this.config.domain, this.path);
|
||||||
const headers = {
|
const headers: Record<string, string> = {
|
||||||
...this.authHeaders,
|
...this.authHeaders,
|
||||||
'Content-Type': this.config.contentType,
|
...(this.config.contentType ? { 'Content-Type': this.config.contentType } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const method = this.config.method.toLowerCase();
|
const method = this.config.method.toLowerCase();
|
||||||
const axios = _axios.create();
|
const axios = _axios.create();
|
||||||
|
|
||||||
|
// Initialize separate containers for query and body parameters.
|
||||||
|
const queryParams: Record<string, unknown> = {};
|
||||||
|
const bodyParams: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (this.config.parameterLocations && this.params) {
|
||||||
|
for (const key of Object.keys(this.params)) {
|
||||||
|
// Determine parameter placement; default to "query" for GET and "body" for others.
|
||||||
|
const loc: 'query' | 'path' | 'header' | 'body' = this.config.parameterLocations[key] || (method === 'get' ? 'query' : 'body');
|
||||||
|
|
||||||
|
const val = this.params[key];
|
||||||
|
if (loc === 'query') {
|
||||||
|
queryParams[key] = val;
|
||||||
|
} else if (loc === 'header') {
|
||||||
|
headers[key] = String(val);
|
||||||
|
} else if (loc === 'body') {
|
||||||
|
bodyParams[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.params) {
|
||||||
|
Object.assign(queryParams, this.params);
|
||||||
|
Object.assign(bodyParams, this.params);
|
||||||
|
}
|
||||||
|
|
||||||
if (method === 'get') {
|
if (method === 'get') {
|
||||||
return axios.get(url, { headers, params: this.params });
|
return axios.get(url, { headers, params: queryParams });
|
||||||
} else if (method === 'post') {
|
} else if (method === 'post') {
|
||||||
return axios.post(url, this.params, { headers });
|
return axios.post(url, bodyParams, { headers, params: queryParams });
|
||||||
} else if (method === 'put') {
|
} else if (method === 'put') {
|
||||||
return axios.put(url, this.params, { headers });
|
return axios.put(url, bodyParams, { headers, params: queryParams });
|
||||||
} else if (method === 'delete') {
|
} else if (method === 'delete') {
|
||||||
return axios.delete(url, { headers, data: this.params });
|
return axios.delete(url, { headers, data: bodyParams, params: queryParams });
|
||||||
} else if (method === 'patch') {
|
} else if (method === 'patch') {
|
||||||
return axios.patch(url, this.params, { headers });
|
return axios.patch(url, bodyParams, { headers, params: queryParams });
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unsupported HTTP method: ${method}`);
|
throw new Error(`Unsupported HTTP method: ${method}`);
|
||||||
}
|
}
|
||||||
|
|
@ -312,8 +349,9 @@ export class ActionRequest {
|
||||||
operation: string,
|
operation: string,
|
||||||
isConsequential: boolean,
|
isConsequential: boolean,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
|
parameterLocations?: Record<string, 'query' | 'path' | 'header' | 'body'>,
|
||||||
) {
|
) {
|
||||||
this.config = new RequestConfig(domain, path, method, operation, isConsequential, contentType);
|
this.config = new RequestConfig(domain, path, method, operation, isConsequential, contentType, parameterLocations);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add getters to maintain backward compatibility
|
// Add getters to maintain backward compatibility
|
||||||
|
|
@ -341,7 +379,7 @@ export class ActionRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maintain backward compatibility by delegating to a new executor
|
// Maintain backward compatibility by delegating to a new executor
|
||||||
setParams(params: object) {
|
setParams(params: Record<string, unknown>) {
|
||||||
const executor = this.createExecutor();
|
const executor = this.createExecutor();
|
||||||
executor.setParams(params);
|
executor.setParams(params);
|
||||||
return executor;
|
return executor;
|
||||||
|
|
@ -406,6 +444,7 @@ export function openapiToFunction(
|
||||||
// Iterate over each path and method in the OpenAPI spec
|
// Iterate over each path and method in the OpenAPI spec
|
||||||
for (const [path, methods] of Object.entries(openapiSpec.paths)) {
|
for (const [path, methods] of Object.entries(openapiSpec.paths)) {
|
||||||
for (const [method, operation] of Object.entries(methods as OpenAPIV3.PathsObject)) {
|
for (const [method, operation] of Object.entries(methods as OpenAPIV3.PathsObject)) {
|
||||||
|
const paramLocations: Record<string, 'query' | 'path' | 'header' | 'body'> = {};
|
||||||
const operationObj = operation as OpenAPIV3.OperationObject & {
|
const operationObj = operation as OpenAPIV3.OperationObject & {
|
||||||
'x-openai-isConsequential'?: boolean;
|
'x-openai-isConsequential'?: boolean;
|
||||||
} & {
|
} & {
|
||||||
|
|
@ -445,6 +484,14 @@ export function openapiToFunction(
|
||||||
if (resolvedParam.required) {
|
if (resolvedParam.required) {
|
||||||
parametersSchema.required.push(paramName);
|
parametersSchema.required.push(paramName);
|
||||||
}
|
}
|
||||||
|
// Record the parameter location from the OpenAPI "in" field.
|
||||||
|
paramLocations[paramName] =
|
||||||
|
(resolvedParam.in === 'query' ||
|
||||||
|
resolvedParam.in === 'path' ||
|
||||||
|
resolvedParam.in === 'header' ||
|
||||||
|
resolvedParam.in === 'body')
|
||||||
|
? resolvedParam.in
|
||||||
|
: 'query';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -464,6 +511,12 @@ export function openapiToFunction(
|
||||||
if (resolvedSchema.required) {
|
if (resolvedSchema.required) {
|
||||||
parametersSchema.required.push(...resolvedSchema.required);
|
parametersSchema.required.push(...resolvedSchema.required);
|
||||||
}
|
}
|
||||||
|
// Mark requestBody properties as belonging to the "body"
|
||||||
|
if (resolvedSchema.properties) {
|
||||||
|
for (const key in resolvedSchema.properties) {
|
||||||
|
paramLocations[key] = 'body';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const functionSignature = new FunctionSignature(
|
const functionSignature = new FunctionSignature(
|
||||||
|
|
@ -481,6 +534,7 @@ export function openapiToFunction(
|
||||||
operationId,
|
operationId,
|
||||||
!!(operationObj['x-openai-isConsequential'] ?? false),
|
!!(operationObj['x-openai-isConsequential'] ?? false),
|
||||||
operationObj.requestBody ? 'application/json' : '',
|
operationObj.requestBody ? 'application/json' : '',
|
||||||
|
paramLocations,
|
||||||
);
|
);
|
||||||
|
|
||||||
requestBuilders[operationId] = actionRequest;
|
requestBuilders[operationId] = actionRequest;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue