400 lines
15 KiB
JavaScript
400 lines
15 KiB
JavaScript
|
import { ChatOpenAI } from "@langchain/openai";
|
||
|
import { ChatPromptTemplate, HumanMessagePromptTemplate, } from "@langchain/core/prompts";
|
||
|
import { OpenAPISpec } from "../../util/openapi.js";
|
||
|
import { BaseChain } from "../base.js";
|
||
|
import { LLMChain } from "../llm_chain.js";
|
||
|
import { SequentialChain } from "../sequential_chain.js";
|
||
|
import { JsonOutputFunctionsParser } from "../../output_parsers/openai_functions.js";
|
||
|
/**
|
||
|
* Formats a URL by replacing path parameters with their corresponding
|
||
|
* values.
|
||
|
* @param url The URL to format.
|
||
|
* @param pathParams The path parameters to replace in the URL.
|
||
|
* @returns The formatted URL.
|
||
|
*/
|
||
|
function formatURL(url, pathParams) {
|
||
|
const expectedPathParamNames = [...url.matchAll(/{(.*?)}/g)].map((match) => match[1]);
|
||
|
const newParams = {};
|
||
|
for (const paramName of expectedPathParamNames) {
|
||
|
const cleanParamName = paramName.replace(/^\.;/, "").replace(/\*$/, "");
|
||
|
const value = pathParams[cleanParamName];
|
||
|
let formattedValue;
|
||
|
if (Array.isArray(value)) {
|
||
|
if (paramName.startsWith(".")) {
|
||
|
const separator = paramName.endsWith("*") ? "." : ",";
|
||
|
formattedValue = `.${value.join(separator)}`;
|
||
|
}
|
||
|
else if (paramName.startsWith(",")) {
|
||
|
const separator = paramName.endsWith("*") ? `${cleanParamName}=` : ",";
|
||
|
formattedValue = `${cleanParamName}=${value.join(separator)}`;
|
||
|
}
|
||
|
else {
|
||
|
formattedValue = value.join(",");
|
||
|
}
|
||
|
}
|
||
|
else if (typeof value === "object") {
|
||
|
const kvSeparator = paramName.endsWith("*") ? "=" : ",";
|
||
|
const kvStrings = Object.entries(value).map(([k, v]) => k + kvSeparator + v);
|
||
|
let entrySeparator;
|
||
|
if (paramName.startsWith(".")) {
|
||
|
entrySeparator = ".";
|
||
|
formattedValue = ".";
|
||
|
}
|
||
|
else if (paramName.startsWith(";")) {
|
||
|
entrySeparator = ";";
|
||
|
formattedValue = ";";
|
||
|
}
|
||
|
else {
|
||
|
entrySeparator = ",";
|
||
|
formattedValue = "";
|
||
|
}
|
||
|
formattedValue += kvStrings.join(entrySeparator);
|
||
|
}
|
||
|
else {
|
||
|
if (paramName.startsWith(".")) {
|
||
|
formattedValue = `.${value}`;
|
||
|
}
|
||
|
else if (paramName.startsWith(";")) {
|
||
|
formattedValue = `;${cleanParamName}=${value}`;
|
||
|
}
|
||
|
else {
|
||
|
formattedValue = value;
|
||
|
}
|
||
|
}
|
||
|
newParams[paramName] = formattedValue;
|
||
|
}
|
||
|
let formattedUrl = url;
|
||
|
for (const [key, newValue] of Object.entries(newParams)) {
|
||
|
formattedUrl = formattedUrl.replace(`{${key}}`, newValue);
|
||
|
}
|
||
|
return formattedUrl;
|
||
|
}
|
||
|
/**
|
||
|
* Converts OpenAPI parameters to JSON schema format.
|
||
|
* @param params The OpenAPI parameters to convert.
|
||
|
* @param spec The OpenAPI specification that contains the parameters.
|
||
|
* @returns The JSON schema representation of the OpenAPI parameters.
|
||
|
*/
|
||
|
function convertOpenAPIParamsToJSONSchema(params, spec) {
|
||
|
return params.reduce((jsonSchema, param) => {
|
||
|
let schema;
|
||
|
if (param.schema) {
|
||
|
schema = spec.getSchema(param.schema);
|
||
|
// eslint-disable-next-line no-param-reassign
|
||
|
jsonSchema.properties[param.name] = convertOpenAPISchemaToJSONSchema(schema, spec);
|
||
|
}
|
||
|
else if (param.content) {
|
||
|
const mediaTypeSchema = Object.values(param.content)[0].schema;
|
||
|
if (mediaTypeSchema) {
|
||
|
schema = spec.getSchema(mediaTypeSchema);
|
||
|
}
|
||
|
if (!schema) {
|
||
|
return jsonSchema;
|
||
|
}
|
||
|
if (schema.description === undefined) {
|
||
|
schema.description = param.description ?? "";
|
||
|
}
|
||
|
// eslint-disable-next-line no-param-reassign
|
||
|
jsonSchema.properties[param.name] = convertOpenAPISchemaToJSONSchema(schema, spec);
|
||
|
}
|
||
|
else {
|
||
|
return jsonSchema;
|
||
|
}
|
||
|
if (param.required && Array.isArray(jsonSchema.required)) {
|
||
|
jsonSchema.required.push(param.name);
|
||
|
}
|
||
|
return jsonSchema;
|
||
|
}, {
|
||
|
type: "object",
|
||
|
properties: {},
|
||
|
required: [],
|
||
|
additionalProperties: {},
|
||
|
});
|
||
|
}
|
||
|
// OpenAI throws errors on extraneous schema properties, e.g. if "required" is set on individual ones
|
||
|
/**
|
||
|
* Converts OpenAPI schemas to JSON schema format.
|
||
|
* @param schema The OpenAPI schema to convert.
|
||
|
* @param spec The OpenAPI specification that contains the schema.
|
||
|
* @returns The JSON schema representation of the OpenAPI schema.
|
||
|
*/
|
||
|
export function convertOpenAPISchemaToJSONSchema(schema, spec) {
|
||
|
if (schema.type === "object") {
|
||
|
return Object.keys(schema.properties ?? {}).reduce((jsonSchema, propertyName) => {
|
||
|
if (!schema.properties) {
|
||
|
return jsonSchema;
|
||
|
}
|
||
|
const openAPIProperty = spec.getSchema(schema.properties[propertyName]);
|
||
|
if (openAPIProperty.type === undefined) {
|
||
|
return jsonSchema;
|
||
|
}
|
||
|
// eslint-disable-next-line no-param-reassign
|
||
|
jsonSchema.properties[propertyName] = convertOpenAPISchemaToJSONSchema(openAPIProperty, spec);
|
||
|
if ((openAPIProperty.required ||
|
||
|
schema.required?.includes(propertyName)) &&
|
||
|
jsonSchema.required !== undefined) {
|
||
|
jsonSchema.required.push(propertyName);
|
||
|
}
|
||
|
return jsonSchema;
|
||
|
}, {
|
||
|
type: "object",
|
||
|
properties: {},
|
||
|
required: [],
|
||
|
additionalProperties: {},
|
||
|
});
|
||
|
}
|
||
|
if (schema.type === "array") {
|
||
|
return {
|
||
|
type: "array",
|
||
|
items: convertOpenAPISchemaToJSONSchema(schema.items ?? {}, spec),
|
||
|
minItems: schema.minItems,
|
||
|
maxItems: schema.maxItems,
|
||
|
};
|
||
|
}
|
||
|
return {
|
||
|
type: schema.type ?? "string",
|
||
|
};
|
||
|
}
|
||
|
/**
|
||
|
* Converts an OpenAPI specification to OpenAI functions.
|
||
|
* @param spec The OpenAPI specification to convert.
|
||
|
* @returns An object containing the OpenAI functions derived from the OpenAPI specification and a default execution method.
|
||
|
*/
|
||
|
function convertOpenAPISpecToOpenAIFunctions(spec) {
|
||
|
if (!spec.document.paths) {
|
||
|
return { openAIFunctions: [] };
|
||
|
}
|
||
|
const openAIFunctions = [];
|
||
|
const nameToCallMap = {};
|
||
|
for (const path of Object.keys(spec.document.paths)) {
|
||
|
const pathParameters = spec.getParametersForPath(path);
|
||
|
for (const method of spec.getMethodsForPath(path)) {
|
||
|
const operation = spec.getOperation(path, method);
|
||
|
if (!operation) {
|
||
|
return { openAIFunctions: [] };
|
||
|
}
|
||
|
const operationParametersByLocation = pathParameters
|
||
|
.concat(spec.getParametersForOperation(operation))
|
||
|
.reduce((operationParams, param) => {
|
||
|
if (!operationParams[param.in]) {
|
||
|
// eslint-disable-next-line no-param-reassign
|
||
|
operationParams[param.in] = [];
|
||
|
}
|
||
|
operationParams[param.in].push(param);
|
||
|
return operationParams;
|
||
|
}, {});
|
||
|
const paramLocationToRequestArgNameMap = {
|
||
|
query: "params",
|
||
|
header: "headers",
|
||
|
cookie: "cookies",
|
||
|
path: "path_params",
|
||
|
};
|
||
|
const requestArgsSchema = {};
|
||
|
for (const paramLocation of Object.keys(paramLocationToRequestArgNameMap)) {
|
||
|
if (operationParametersByLocation[paramLocation]) {
|
||
|
requestArgsSchema[paramLocationToRequestArgNameMap[paramLocation]] =
|
||
|
convertOpenAPIParamsToJSONSchema(operationParametersByLocation[paramLocation], spec);
|
||
|
}
|
||
|
}
|
||
|
const requestBody = spec.getRequestBodyForOperation(operation);
|
||
|
if (requestBody?.content !== undefined) {
|
||
|
const requestBodySchemas = {};
|
||
|
for (const [mediaType, mediaTypeObject] of Object.entries(requestBody.content)) {
|
||
|
if (mediaTypeObject.schema !== undefined) {
|
||
|
const schema = spec.getSchema(mediaTypeObject.schema);
|
||
|
requestBodySchemas[mediaType] = convertOpenAPISchemaToJSONSchema(schema, spec);
|
||
|
}
|
||
|
}
|
||
|
const mediaTypes = Object.keys(requestBodySchemas);
|
||
|
if (mediaTypes.length === 1) {
|
||
|
requestArgsSchema.data = requestBodySchemas[mediaTypes[0]];
|
||
|
}
|
||
|
else if (mediaTypes.length > 1) {
|
||
|
requestArgsSchema.data = {
|
||
|
anyOf: Object.values(requestBodySchemas),
|
||
|
};
|
||
|
}
|
||
|
}
|
||
|
const openAIFunction = {
|
||
|
name: OpenAPISpec.getCleanedOperationId(operation, path, method),
|
||
|
description: operation.description ?? operation.summary ?? "",
|
||
|
parameters: {
|
||
|
type: "object",
|
||
|
properties: requestArgsSchema,
|
||
|
// All remaining top-level parameters are required
|
||
|
required: Object.keys(requestArgsSchema),
|
||
|
},
|
||
|
};
|
||
|
openAIFunctions.push(openAIFunction);
|
||
|
const baseUrl = (spec.baseUrl ?? "").endsWith("/")
|
||
|
? (spec.baseUrl ?? "").slice(0, -1)
|
||
|
: spec.baseUrl ?? "";
|
||
|
nameToCallMap[openAIFunction.name] = {
|
||
|
method,
|
||
|
url: baseUrl + path,
|
||
|
};
|
||
|
}
|
||
|
}
|
||
|
return {
|
||
|
openAIFunctions,
|
||
|
defaultExecutionMethod: async (name,
|
||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
requestArgs, options) => {
|
||
|
const { headers: customHeaders, params: customParams, ...rest } = options ?? {};
|
||
|
const { method, url } = nameToCallMap[name];
|
||
|
const requestParams = requestArgs.params ?? {};
|
||
|
const nonEmptyParams = Object.keys(requestParams).reduce(
|
||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
(filteredArgs, argName) => {
|
||
|
if (requestParams[argName] !== "" &&
|
||
|
requestParams[argName] !== null &&
|
||
|
requestParams[argName] !== undefined) {
|
||
|
// eslint-disable-next-line no-param-reassign
|
||
|
filteredArgs[argName] = requestParams[argName];
|
||
|
}
|
||
|
return filteredArgs;
|
||
|
}, {});
|
||
|
const queryString = new URLSearchParams({
|
||
|
...nonEmptyParams,
|
||
|
...customParams,
|
||
|
}).toString();
|
||
|
const pathParams = requestArgs.path_params;
|
||
|
const formattedUrl = formatURL(url, pathParams) +
|
||
|
(queryString.length ? `?${queryString}` : "");
|
||
|
const headers = {};
|
||
|
let body;
|
||
|
if (requestArgs.data !== undefined) {
|
||
|
let contentType = "text/plain";
|
||
|
if (typeof requestArgs.data !== "string") {
|
||
|
if (typeof requestArgs.data === "object") {
|
||
|
contentType = "application/json";
|
||
|
}
|
||
|
body = JSON.stringify(requestArgs.data);
|
||
|
}
|
||
|
else {
|
||
|
body = requestArgs.data;
|
||
|
}
|
||
|
headers["content-type"] = contentType;
|
||
|
}
|
||
|
const response = await fetch(formattedUrl, {
|
||
|
...requestArgs,
|
||
|
method,
|
||
|
headers: {
|
||
|
...headers,
|
||
|
...requestArgs.headers,
|
||
|
...customHeaders,
|
||
|
},
|
||
|
body,
|
||
|
...rest,
|
||
|
});
|
||
|
let output;
|
||
|
if (response.status < 200 || response.status > 299) {
|
||
|
output = `${response.status}: ${response.statusText} for ${name} called with ${JSON.stringify(queryString)}`;
|
||
|
}
|
||
|
else {
|
||
|
output = await response.text();
|
||
|
}
|
||
|
return output;
|
||
|
},
|
||
|
};
|
||
|
}
|
||
|
/**
|
||
|
* A chain for making simple API requests.
|
||
|
*/
|
||
|
class SimpleRequestChain extends BaseChain {
|
||
|
static lc_name() {
|
||
|
return "SimpleRequestChain";
|
||
|
}
|
||
|
constructor(config) {
|
||
|
super();
|
||
|
Object.defineProperty(this, "requestMethod", {
|
||
|
enumerable: true,
|
||
|
configurable: true,
|
||
|
writable: true,
|
||
|
value: void 0
|
||
|
});
|
||
|
Object.defineProperty(this, "inputKey", {
|
||
|
enumerable: true,
|
||
|
configurable: true,
|
||
|
writable: true,
|
||
|
value: "function"
|
||
|
});
|
||
|
Object.defineProperty(this, "outputKey", {
|
||
|
enumerable: true,
|
||
|
configurable: true,
|
||
|
writable: true,
|
||
|
value: "response"
|
||
|
});
|
||
|
this.requestMethod = config.requestMethod;
|
||
|
}
|
||
|
get inputKeys() {
|
||
|
return [this.inputKey];
|
||
|
}
|
||
|
get outputKeys() {
|
||
|
return [this.outputKey];
|
||
|
}
|
||
|
_chainType() {
|
||
|
return "simple_request_chain";
|
||
|
}
|
||
|
/** @ignore */
|
||
|
async _call(values, _runManager) {
|
||
|
const inputKeyValue = values[this.inputKey];
|
||
|
const methodName = inputKeyValue.name;
|
||
|
const args = inputKeyValue.arguments;
|
||
|
const response = await this.requestMethod(methodName, args);
|
||
|
return { [this.outputKey]: response };
|
||
|
}
|
||
|
}
|
||
|
/**
|
||
|
* Create a chain for querying an API from a OpenAPI spec.
|
||
|
* @param spec OpenAPISpec or url/file/text string corresponding to one.
|
||
|
* @param options Custom options passed into the chain
|
||
|
* @returns OpenAPIChain
|
||
|
*/
|
||
|
export async function createOpenAPIChain(spec, options = {}) {
|
||
|
let convertedSpec;
|
||
|
if (typeof spec === "string") {
|
||
|
try {
|
||
|
convertedSpec = await OpenAPISpec.fromURL(spec);
|
||
|
}
|
||
|
catch (e) {
|
||
|
try {
|
||
|
convertedSpec = OpenAPISpec.fromString(spec);
|
||
|
}
|
||
|
catch (e) {
|
||
|
throw new Error(`Unable to parse spec from source ${spec}.`);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
else {
|
||
|
convertedSpec = OpenAPISpec.fromObject(spec);
|
||
|
}
|
||
|
const { openAIFunctions, defaultExecutionMethod } = convertOpenAPISpecToOpenAIFunctions(convertedSpec);
|
||
|
if (defaultExecutionMethod === undefined) {
|
||
|
throw new Error(`Could not parse any valid operations from the provided spec.`);
|
||
|
}
|
||
|
const { llm = new ChatOpenAI({ modelName: "gpt-3.5-turbo-0613" }), prompt = ChatPromptTemplate.fromMessages([
|
||
|
HumanMessagePromptTemplate.fromTemplate("Use the provided API's to respond to this user query:\n\n{query}"),
|
||
|
]), requestChain = new SimpleRequestChain({
|
||
|
requestMethod: async (name, args) => defaultExecutionMethod(name, args, {
|
||
|
headers: options.headers,
|
||
|
params: options.params,
|
||
|
}),
|
||
|
}), llmChainInputs = {}, verbose, ...rest } = options;
|
||
|
const formatChain = new LLMChain({
|
||
|
llm,
|
||
|
prompt,
|
||
|
outputParser: new JsonOutputFunctionsParser({ argsOnly: false }),
|
||
|
outputKey: "function",
|
||
|
llmKwargs: { functions: openAIFunctions },
|
||
|
...llmChainInputs,
|
||
|
});
|
||
|
return new SequentialChain({
|
||
|
chains: [formatChain, requestChain],
|
||
|
outputVariables: ["response"],
|
||
|
inputVariables: formatChain.inputKeys,
|
||
|
verbose,
|
||
|
...rest,
|
||
|
});
|
||
|
}
|