mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30: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 () => {
|
||||
|
|
|
|||
|
|
@ -167,12 +167,13 @@ class RequestConfig {
|
|||
readonly operation: string,
|
||||
readonly isConsequential: boolean,
|
||||
readonly contentType: string,
|
||||
readonly parameterLocations?: Record<string, 'query' | 'path' | 'header' | 'body'>,
|
||||
) {}
|
||||
}
|
||||
|
||||
class RequestExecutor {
|
||||
path: string;
|
||||
params?: object;
|
||||
params?: Record<string, unknown>;
|
||||
private operationHash?: string;
|
||||
private authHeaders: Record<string, string> = {};
|
||||
private authToken?: string;
|
||||
|
|
@ -181,15 +182,28 @@ class RequestExecutor {
|
|||
this.path = config.basePath;
|
||||
}
|
||||
|
||||
setParams(params: object) {
|
||||
setParams(params: Record<string, unknown>) {
|
||||
this.operationHash = sha1(JSON.stringify(params));
|
||||
this.params = Object.assign({}, params);
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const paramPattern = `{${key}}`;
|
||||
if (this.path.includes(paramPattern)) {
|
||||
this.path = this.path.replace(paramPattern, encodeURIComponent(value as string));
|
||||
delete (this.params as Record<string, unknown>)[key];
|
||||
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)) {
|
||||
const paramPattern = `{${key}}`;
|
||||
if (this.path.includes(paramPattern)) {
|
||||
this.path = this.path.replace(paramPattern, encodeURIComponent(String(value)));
|
||||
delete this.params[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return this;
|
||||
|
|
@ -275,23 +289,46 @@ class RequestExecutor {
|
|||
|
||||
async execute() {
|
||||
const url = createURL(this.config.domain, this.path);
|
||||
const headers = {
|
||||
const headers: Record<string, string> = {
|
||||
...this.authHeaders,
|
||||
'Content-Type': this.config.contentType,
|
||||
...(this.config.contentType ? { 'Content-Type': this.config.contentType } : {}),
|
||||
};
|
||||
|
||||
const method = this.config.method.toLowerCase();
|
||||
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') {
|
||||
return axios.get(url, { headers, params: this.params });
|
||||
return axios.get(url, { headers, params: queryParams });
|
||||
} else if (method === 'post') {
|
||||
return axios.post(url, this.params, { headers });
|
||||
return axios.post(url, bodyParams, { headers, params: queryParams });
|
||||
} else if (method === 'put') {
|
||||
return axios.put(url, this.params, { headers });
|
||||
return axios.put(url, bodyParams, { headers, params: queryParams });
|
||||
} 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') {
|
||||
return axios.patch(url, this.params, { headers });
|
||||
return axios.patch(url, bodyParams, { headers, params: queryParams });
|
||||
} else {
|
||||
throw new Error(`Unsupported HTTP method: ${method}`);
|
||||
}
|
||||
|
|
@ -312,8 +349,9 @@ export class ActionRequest {
|
|||
operation: string,
|
||||
isConsequential: boolean,
|
||||
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
|
||||
|
|
@ -341,7 +379,7 @@ export class ActionRequest {
|
|||
}
|
||||
|
||||
// Maintain backward compatibility by delegating to a new executor
|
||||
setParams(params: object) {
|
||||
setParams(params: Record<string, unknown>) {
|
||||
const executor = this.createExecutor();
|
||||
executor.setParams(params);
|
||||
return executor;
|
||||
|
|
@ -406,6 +444,7 @@ export function openapiToFunction(
|
|||
// Iterate over each path and method in the OpenAPI spec
|
||||
for (const [path, methods] of Object.entries(openapiSpec.paths)) {
|
||||
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 & {
|
||||
'x-openai-isConsequential'?: boolean;
|
||||
} & {
|
||||
|
|
@ -445,6 +484,14 @@ export function openapiToFunction(
|
|||
if (resolvedParam.required) {
|
||||
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) {
|
||||
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(
|
||||
|
|
@ -481,6 +534,7 @@ export function openapiToFunction(
|
|||
operationId,
|
||||
!!(operationObj['x-openai-isConsequential'] ?? false),
|
||||
operationObj.requestBody ? 'application/json' : '',
|
||||
paramLocations,
|
||||
);
|
||||
|
||||
requestBuilders[operationId] = actionRequest;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue