Feature/Revamp of OpenAPI Toolkit (#3312)

Revamp of OpenAPI Toolkit
This commit is contained in:
Henry Heng 2024-10-05 13:33:44 +01:00 committed by GitHub
parent c9d8b8716b
commit f5cedb2460
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 33006 additions and 32677 deletions

View File

@ -1,9 +1,10 @@
import { load } from 'js-yaml' import { load } from 'js-yaml'
import { BaseLanguageModel } from '@langchain/core/language_models/base'
import { OpenApiToolkit } from 'langchain/agents'
import { JsonSpec, JsonObject } from './core'
import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface' import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface'
import { getCredentialData, getCredentialParam, getFileFromStorage } from '../../../src' import { getFileFromStorage, getVars, IDatabaseEntity, IVariable } from '../../../src'
import $RefParser from '@apidevtools/json-schema-ref-parser'
import { z, ZodSchema, ZodTypeAny } from 'zod'
import { defaultCode, DynamicStructuredTool, howToUseCode } from './core'
import { DataSource } from 'typeorm'
class OpenAPIToolkit_Tools implements INode { class OpenAPIToolkit_Tools implements INode {
label: string label: string
@ -20,69 +21,265 @@ class OpenAPIToolkit_Tools implements INode {
constructor() { constructor() {
this.label = 'OpenAPI Toolkit' this.label = 'OpenAPI Toolkit'
this.name = 'openAPIToolkit' this.name = 'openAPIToolkit'
this.version = 1.0 this.version = 2.0
this.type = 'OpenAPIToolkit' this.type = 'OpenAPIToolkit'
this.icon = 'openapi.svg' this.icon = 'openapi.svg'
this.category = 'Tools' this.category = 'Tools'
this.description = 'Load OpenAPI specification' this.description = 'Load OpenAPI specification, and converts each API endpoint to a tool'
this.credential = {
label: 'Connect Credential',
name: 'credential',
type: 'credential',
description: 'Only needed if the YAML OpenAPI Spec requires authentication',
optional: true,
credentialNames: ['openAPIAuth']
}
this.inputs = [ this.inputs = [
{
label: 'Language Model',
name: 'model',
type: 'BaseLanguageModel'
},
{ {
label: 'YAML File', label: 'YAML File',
name: 'yamlFile', name: 'yamlFile',
type: 'file', type: 'file',
fileType: '.yaml' fileType: '.yaml'
},
{
label: 'Return Direct',
name: 'returnDirect',
description: 'Return the output of the tool directly to the user',
type: 'boolean',
optional: true
},
{
label: 'Headers',
name: 'headers',
type: 'json',
description: 'Request headers to be sent with the API request. For example, {"Authorization": "Bearer token"}',
additionalParams: true,
optional: true
},
{
label: 'Custom Code',
name: 'customCode',
type: 'code',
hint: {
label: 'How to use',
value: howToUseCode
},
codeExample: defaultCode,
description: `Custom code to return the output of the tool. The code should be a function that takes in the input and returns a string`,
hideCodeExecute: true,
default: defaultCode,
additionalParams: true
} }
] ]
this.baseClasses = [this.type, 'Tool'] this.baseClasses = [this.type, 'Tool']
} }
async init(nodeData: INodeData, _: string, options: ICommonObject): Promise<any> { async init(nodeData: INodeData, _: string, options: ICommonObject): Promise<any> {
const model = nodeData.inputs?.model as BaseLanguageModel const toolReturnDirect = nodeData.inputs?.returnDirect as boolean
const yamlFileBase64 = nodeData.inputs?.yamlFile as string const yamlFileBase64 = nodeData.inputs?.yamlFile as string
const customCode = nodeData.inputs?.customCode as string
const _headers = nodeData.inputs?.headers as string
const credentialData = await getCredentialData(nodeData.credential ?? '', options) const headers = typeof _headers === 'object' ? _headers : _headers ? JSON.parse(_headers) : {}
const openAPIToken = getCredentialParam('openAPIToken', credentialData, nodeData)
let data: JsonObject let data
if (yamlFileBase64.startsWith('FILE-STORAGE::')) { if (yamlFileBase64.startsWith('FILE-STORAGE::')) {
const file = yamlFileBase64.replace('FILE-STORAGE::', '') const file = yamlFileBase64.replace('FILE-STORAGE::', '')
const chatflowid = options.chatflowid const chatflowid = options.chatflowid
const fileData = await getFileFromStorage(file, chatflowid) const fileData = await getFileFromStorage(file, chatflowid)
const utf8String = fileData.toString('utf-8') const utf8String = fileData.toString('utf-8')
data = load(utf8String) as JsonObject data = load(utf8String)
} else { } else {
const splitDataURI = yamlFileBase64.split(',') const splitDataURI = yamlFileBase64.split(',')
splitDataURI.pop() splitDataURI.pop()
const bf = Buffer.from(splitDataURI.pop() || '', 'base64') const bf = Buffer.from(splitDataURI.pop() || '', 'base64')
const utf8String = bf.toString('utf-8') const utf8String = bf.toString('utf-8')
data = load(utf8String) as JsonObject data = load(utf8String)
} }
if (!data) { if (!data) {
throw new Error('Failed to load OpenAPI spec') throw new Error('Failed to load OpenAPI spec')
} }
const headers: ICommonObject = { const _data: any = await $RefParser.dereference(data)
'Content-Type': 'application/json'
}
if (openAPIToken) headers.Authorization = `Bearer ${openAPIToken}`
const toolkit = new OpenApiToolkit(new JsonSpec(data), model, headers)
return toolkit.tools const baseUrl = _data.servers[0]?.url
if (!baseUrl) {
throw new Error('OpenAPI spec does not contain a server URL')
}
const appDataSource = options.appDataSource as DataSource
const databaseEntities = options.databaseEntities as IDatabaseEntity
const variables = await getVars(appDataSource, databaseEntities, nodeData)
const flow = { chatflowId: options.chatflowid }
const tools = getTools(_data.paths, baseUrl, headers, variables, flow, toolReturnDirect, customCode)
return tools
} }
} }
const jsonSchemaToZodSchema = (schema: any, requiredList: string[], keyName: string): ZodSchema<any> => {
if (schema.properties) {
// Handle object types by recursively processing properties
const zodShape: Record<string, ZodTypeAny> = {}
for (const key in schema.properties) {
zodShape[key] = jsonSchemaToZodSchema(schema.properties[key], requiredList, key)
}
return z.object(zodShape)
} else if (schema.oneOf) {
// Handle oneOf by mapping each option to a Zod schema
const zodSchemas = schema.oneOf.map((subSchema: any) => jsonSchemaToZodSchema(subSchema, requiredList, keyName))
return z.union(zodSchemas)
} else if (schema.enum) {
// Handle enum types
return requiredList.includes(keyName)
? z.enum(schema.enum).describe(schema?.description ?? keyName)
: z
.enum(schema.enum)
.describe(schema?.description ?? keyName)
.optional()
} else if (schema.type === 'string') {
return requiredList.includes(keyName)
? z.string({ required_error: `${keyName} required` }).describe(schema?.description ?? keyName)
: z
.string()
.describe(schema?.description ?? keyName)
.optional()
} else if (schema.type === 'array') {
return z.array(jsonSchemaToZodSchema(schema.items, requiredList, keyName))
} else if (schema.type === 'boolean') {
return requiredList.includes(keyName)
? z.number({ required_error: `${keyName} required` }).describe(schema?.description ?? keyName)
: z
.number()
.describe(schema?.description ?? keyName)
.optional()
} else if (schema.type === 'number') {
return requiredList.includes(keyName)
? z.boolean({ required_error: `${keyName} required` }).describe(schema?.description ?? keyName)
: z
.boolean()
.describe(schema?.description ?? keyName)
.optional()
}
// Fallback to unknown type if unrecognized
return z.unknown()
}
const extractParameters = (param: ICommonObject, paramZodObj: ICommonObject) => {
const paramSchema = param.schema
const paramName = param.name
const paramDesc = param.description || param.name
if (paramSchema.type === 'string') {
if (param.required) {
paramZodObj[paramName] = z.string({ required_error: `${paramName} required` }).describe(paramDesc)
} else {
paramZodObj[paramName] = z.string().describe(paramDesc).optional()
}
} else if (paramSchema.type === 'number') {
if (param.required) {
paramZodObj[paramName] = z.number({ required_error: `${paramName} required` }).describe(paramDesc)
} else {
paramZodObj[paramName] = z.number().describe(paramDesc).optional()
}
} else if (paramSchema.type === 'boolean') {
if (param.required) {
paramZodObj[paramName] = z.boolean({ required_error: `${paramName} required` }).describe(paramDesc)
} else {
paramZodObj[paramName] = z.boolean().describe(paramDesc).optional()
}
}
return paramZodObj
}
const getTools = (
paths: any[],
baseUrl: string,
headers: ICommonObject,
variables: IVariable[],
flow: ICommonObject,
returnDirect: boolean,
customCode?: string
) => {
const tools = []
for (const path in paths) {
// example of path: "/engines"
const methods = paths[path]
for (const method in methods) {
// example of method: "get"
const spec = methods[method]
const toolName = spec.operationId
const toolDesc = spec.description || spec.summary || toolName
let zodObj: ICommonObject = {}
if (spec.parameters) {
// Get parameters with in = path
let paramZodObjPath: ICommonObject = {}
for (const param of spec.parameters.filter((param: any) => param.in === 'path')) {
paramZodObjPath = extractParameters(param, paramZodObjPath)
}
// Get parameters with in = query
let paramZodObjQuery: ICommonObject = {}
for (const param of spec.parameters.filter((param: any) => param.in === 'query')) {
paramZodObjQuery = extractParameters(param, paramZodObjQuery)
}
// Combine path and query parameters
zodObj = {
...zodObj,
PathParameters: z.object(paramZodObjPath),
QueryParameters: z.object(paramZodObjQuery)
}
}
if (spec.requestBody) {
let content: any = {}
if (spec.requestBody.content['application/json']) {
content = spec.requestBody.content['application/json']
} else if (spec.requestBody.content['application/x-www-form-urlencoded']) {
content = spec.requestBody.content['application/x-www-form-urlencoded']
} else if (spec.requestBody.content['multipart/form-data']) {
content = spec.requestBody.content['multipart/form-data']
} else if (spec.requestBody.content['text/plain']) {
content = spec.requestBody.content['text/plain']
}
const requestBodySchema = content.schema
if (requestBodySchema) {
const requiredList = requestBodySchema.required || []
const requestBodyZodObj = jsonSchemaToZodSchema(requestBodySchema, requiredList, 'properties')
zodObj = {
...zodObj,
RequestBody: requestBodyZodObj
}
} else {
zodObj = {
...zodObj,
input: z.string().describe('Query input').optional()
}
}
}
if (!spec.parameters && !spec.requestBody) {
zodObj = {
input: z.string().describe('Query input').optional()
}
}
const toolObj = {
name: toolName,
description: toolDesc,
schema: z.object(zodObj),
baseUrl: `${baseUrl}${path}`,
method: method,
headers,
customCode
}
const dynamicStructuredTool = new DynamicStructuredTool(toolObj)
dynamicStructuredTool.setVariables(variables)
dynamicStructuredTool.setFlowObject(flow)
dynamicStructuredTool.returnDirect = returnDirect
tools.push(dynamicStructuredTool)
}
}
return tools
}
module.exports = { nodeClass: OpenAPIToolkit_Tools } module.exports = { nodeClass: OpenAPIToolkit_Tools }

View File

@ -1,140 +1,256 @@
import jsonpointer from 'jsonpointer' import { z } from 'zod'
import { Serializable } from '@langchain/core/load/serializable' import { RequestInit } from 'node-fetch'
import { Tool, ToolParams } from '@langchain/core/tools' import { NodeVM } from '@flowiseai/nodevm'
import { RunnableConfig } from '@langchain/core/runnables'
import { StructuredTool, ToolParams } from '@langchain/core/tools'
import { CallbackManagerForToolRun, Callbacks, CallbackManager, parseCallbackConfigArg } from '@langchain/core/callbacks/manager'
import { availableDependencies, defaultAllowBuiltInDep, prepareSandboxVars } from '../../../src/utils'
import { ICommonObject } from '../../../src/Interface'
export type Json = string | number | boolean | null | { [key: string]: Json } | Json[] interface HttpRequestObject {
PathParameters?: Record<string, any>
QueryParameters?: Record<string, any>
RequestBody?: Record<string, any>
}
export type JsonObject = { [key: string]: Json } export const defaultCode = `const fetch = require('node-fetch');
const url = $url;
const options = $options;
/** try {
* Represents a JSON object in the LangChain framework. Provides methods const response = await fetch(url, options);
* to get keys and values from the JSON object. const resp = await response.json();
*/ return JSON.stringify(resp);
export class JsonSpec extends Serializable { } catch (error) {
lc_namespace = ['langchain', 'tools', 'json'] console.error(error);
return '';
}
`
export const howToUseCode = `- **Libraries:**
You can use any libraries imported in Flowise.
obj: JsonObject - **Tool Input Arguments:**
Tool input arguments are available as the following variables:
- \`$PathParameters\`
- \`$QueryParameters\`
- \`$RequestBody\`
maxValueLength = 4000 - **HTTP Requests:**
By default, you can get the following values for making HTTP requests:
- \`$url\`
- \`$options\`
constructor(obj: JsonObject, max_value_length = 4000) { - **Default Flow Config:**
super(...arguments) You can access the default flow configuration using these variables:
this.obj = obj - \`$flow.sessionId\`
this.maxValueLength = max_value_length - \`$flow.chatId\`
- \`$flow.chatflowId\`
- \`$flow.input\`
- \`$flow.state\`
- **Custom Variables:**
You can get custom variables using the syntax:
- \`$vars.<variable-name>\`
- **Return Value:**
The function must return a **string** value at the end.
\`\`\`js
${defaultCode}
\`\`\`
`
const getUrl = (baseUrl: string, requestObject: HttpRequestObject) => {
let url = baseUrl
// Add PathParameters to URL if present
if (requestObject.PathParameters) {
for (const [key, value] of Object.entries(requestObject.PathParameters)) {
url = url.replace(`{${key}}`, encodeURIComponent(String(value)))
}
} }
/** // Add QueryParameters to URL if present
* Retrieves all keys at a given path in the JSON object. if (requestObject.QueryParameters) {
* @param input The path to the keys in the JSON object, provided as a string in JSON pointer syntax. const queryParams = new URLSearchParams(requestObject.QueryParameters as Record<string, string>)
* @returns A string containing all keys at the given path, separated by commas. url += `?${queryParams.toString()}`
*/
public getKeys(input: string): string {
const pointer = jsonpointer.compile(input)
const res = pointer.get(this.obj) as Json
if (typeof res === 'object' && !Array.isArray(res) && res !== null) {
return Object.keys(res)
.map((i) => i.replaceAll('~', '~0').replaceAll('/', '~1'))
.join(', ')
}
throw new Error(`Value at ${input} is not a dictionary, get the value directly instead.`)
} }
/** return url
* Retrieves the value at a given path in the JSON object. }
* @param input The path to the value in the JSON object, provided as a string in JSON pointer syntax.
* @returns The value at the given path in the JSON object, as a string. If the value is a large dictionary or exceeds the maximum length, a message is returned instead.
*/
public getValue(input: string): string {
const pointer = jsonpointer.compile(input)
const res = pointer.get(this.obj) as Json
if (res === null || res === undefined) { class ToolInputParsingException extends Error {
throw new Error(`Value at ${input} is null or undefined.`) output?: string
}
const str = typeof res === 'object' ? JSON.stringify(res) : res.toString() constructor(message: string, output?: string) {
if (typeof res === 'object' && !Array.isArray(res) && str.length > this.maxValueLength) { super(message)
return `Value is a large dictionary, should explore its keys directly.` this.output = output
}
if (str.length > this.maxValueLength) {
return `${str.slice(0, this.maxValueLength)}...`
}
return str
} }
} }
export interface JsonToolFields extends ToolParams { export interface BaseDynamicToolInput extends ToolParams {
jsonSpec: JsonSpec name: string
description: string
returnDirect?: boolean
} }
/** export interface DynamicStructuredToolInput<
* A tool in the LangChain framework that lists all keys at a given path // eslint-disable-next-line
* in a JSON object. T extends z.ZodObject<any, any, any, any> = z.ZodObject<any, any, any, any>
*/ > extends BaseDynamicToolInput {
export class JsonListKeysTool extends Tool { func?: (input: z.infer<T>, runManager?: CallbackManagerForToolRun) => Promise<string>
static lc_name() { schema: T
return 'JsonListKeysTool' baseUrl: string
} method: string
headers: ICommonObject
customCode?: string
}
name = 'json_list_keys' export class DynamicStructuredTool<
// eslint-disable-next-line
T extends z.ZodObject<any, any, any, any> = z.ZodObject<any, any, any, any>
> extends StructuredTool {
name: string
jsonSpec: JsonSpec description: string
constructor(jsonSpec: JsonSpec) baseUrl: string
constructor(fields: JsonToolFields) method: string
constructor(fields: JsonSpec | JsonToolFields) { headers: ICommonObject
if (!('jsonSpec' in fields)) {
// eslint-disable-next-line no-param-reassign customCode?: string
fields = { jsonSpec: fields }
} func: DynamicStructuredToolInput['func']
// @ts-ignore
schema: T
private variables: any[]
private flowObj: any
constructor(fields: DynamicStructuredToolInput<T>) {
super(fields) super(fields)
this.name = fields.name
this.jsonSpec = fields.jsonSpec this.description = fields.description
this.func = fields.func
this.returnDirect = fields.returnDirect ?? this.returnDirect
this.schema = fields.schema
this.baseUrl = fields.baseUrl
this.method = fields.method
this.headers = fields.headers
this.customCode = fields.customCode
} }
/** @ignore */ async call(
async _call(input: string) { arg: z.output<T>,
try { configArg?: RunnableConfig | Callbacks,
return this.jsonSpec.getKeys(input) tags?: string[],
} catch (error) { flowConfig?: { sessionId?: string; chatId?: string; input?: string; state?: ICommonObject }
return `${error}` ): Promise<string> {
const config = parseCallbackConfigArg(configArg)
if (config.runName === undefined) {
config.runName = this.name
} }
} let parsed
description = `Can be used to list all keys at a given path.
Before calling this you should be SURE that the path to this exists.
The input is a text representation of the path to the json as json pointer syntax (e.g. /key1/0/key2).`
}
/**
* A tool in the LangChain framework that retrieves the value at a given
* path in a JSON object.
*/
export class JsonGetValueTool extends Tool {
static lc_name() {
return 'JsonGetValueTool'
}
name = 'json_get_value'
constructor(public jsonSpec: JsonSpec) {
super()
}
/** @ignore */
async _call(input: string) {
try { try {
return this.jsonSpec.getValue(input) parsed = await this.schema.parseAsync(arg)
} catch (error) { } catch (e) {
return `${error}` throw new ToolInputParsingException(`Received tool input did not match expected schema`, JSON.stringify(arg))
} }
const callbackManager_ = await CallbackManager.configure(
config.callbacks,
this.callbacks,
config.tags || tags,
this.tags,
config.metadata,
this.metadata,
{ verbose: this.verbose }
)
const runManager = await callbackManager_?.handleToolStart(
this.toJSON(),
typeof parsed === 'string' ? parsed : JSON.stringify(parsed),
undefined,
undefined,
undefined,
undefined,
config.runName
)
let result
try {
result = await this._call(parsed, runManager, flowConfig)
} catch (e) {
await runManager?.handleToolError(e)
throw e
}
if (result && typeof result !== 'string') {
result = JSON.stringify(result)
}
await runManager?.handleToolEnd(result)
return result
} }
description = `Can be used to see value in string format at a given path. // @ts-ignore
Before calling this you should be SURE that the path to this exists. protected async _call(
The input is a text representation of the path to the json as json pointer syntax (e.g. /key1/0/key2).` arg: z.output<T>,
_?: CallbackManagerForToolRun,
flowConfig?: { sessionId?: string; chatId?: string; input?: string; state?: ICommonObject }
): Promise<string> {
let sandbox: any = {}
if (typeof arg === 'object' && Object.keys(arg).length) {
for (const item in arg) {
sandbox[`$${item}`] = arg[item]
}
}
sandbox['$vars'] = prepareSandboxVars(this.variables)
// inject flow properties
if (this.flowObj) {
sandbox['$flow'] = { ...this.flowObj, ...flowConfig }
}
const callOptions: RequestInit = {
method: this.method,
headers: {
'Content-Type': 'application/json',
...this.headers
}
}
if (arg.RequestBody && this.method.toUpperCase() !== 'GET') {
callOptions.body = JSON.stringify(arg.RequestBody)
}
sandbox['$options'] = callOptions
const completeUrl = getUrl(this.baseUrl, arg)
sandbox['$url'] = completeUrl
const builtinDeps = process.env.TOOL_FUNCTION_BUILTIN_DEP
? defaultAllowBuiltInDep.concat(process.env.TOOL_FUNCTION_BUILTIN_DEP.split(','))
: defaultAllowBuiltInDep
const externalDeps = process.env.TOOL_FUNCTION_EXTERNAL_DEP ? process.env.TOOL_FUNCTION_EXTERNAL_DEP.split(',') : []
const deps = availableDependencies.concat(externalDeps)
const options = {
console: 'inherit',
sandbox,
require: {
external: { modules: deps },
builtin: builtinDeps
}
} as any
const vm = new NodeVM(options)
const response = await vm.run(`module.exports = async function() {${this.customCode || defaultCode}}()`, __dirname)
return response
}
setVariables(variables: any[]) {
this.variables = variables
}
setFlowObject(flow: any) {
this.flowObj = flow
}
} }

View File

@ -20,6 +20,7 @@
}, },
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"dependencies": { "dependencies": {
"@apidevtools/json-schema-ref-parser": "^11.7.0",
"@aws-sdk/client-bedrock-runtime": "3.422.0", "@aws-sdk/client-bedrock-runtime": "3.422.0",
"@aws-sdk/client-dynamodb": "^3.360.0", "@aws-sdk/client-dynamodb": "^3.360.0",
"@aws-sdk/client-s3": "^3.427.0", "@aws-sdk/client-s3": "^3.427.0",

File diff suppressed because it is too large Load Diff