From d574e0e602316ff788e91a4225ae456205bde4fe Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Wed, 11 Dec 2024 16:40:38 -0500 Subject: [PATCH] feat(tools): add openapi tool Signed-off-by: Akihiko Kuroda --- docs/tools.md | 1 + package.json | 6 +- src/tools/openapi.test.ts | 66 ++++++++++++ src/tools/openapi.ts | 175 ++++++++++++++++++++++++++++++++ tests/e2e/tools/openapi.test.ts | 98 ++++++++++++++++++ yarn.lock | 3 + 6 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 src/tools/openapi.test.ts create mode 100644 src/tools/openapi.ts create mode 100644 tests/e2e/tools/openapi.test.ts diff --git a/docs/tools.md b/docs/tools.md index d004c22b..acb1f25c 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -25,6 +25,7 @@ These tools extend the agent's abilities, allowing it to interact with external | `WebCrawlerTool` | Retrieve content of an arbitrary website. | | `OpenMeteoTool` | Retrieve current, previous, or upcoming weather for a given destination. | | `MilvusDatabaseTool` | Perform retrieval queries (search, insert, delete, manage collections) against a MilvusDatabaseTool database. | +| `OpenAPITool` | Send requests to and receive responses from API server. | | ➕ [Request](https://github.com/i-am-bee/bee-agent-framework/discussions) | | All examples can be found [here](/examples/tools). diff --git a/package.json b/package.json index 341b7202..45c6ea8f 100644 --- a/package.json +++ b/package.json @@ -208,7 +208,8 @@ "ollama": "^0.5.11", "openai": "^4.67.3", "openai-chat-tokens": "^0.2.8", - "sequelize": "^6.37.3" + "sequelize": "^6.37.3", + "yaml": "^2.6.1" }, "peerDependenciesMeta": { "@aws-sdk/client-bedrock-runtime": { @@ -258,6 +259,9 @@ }, "sequelize": { "optional": true + }, + "yaml": { + "optional": true } }, "devDependencies": { diff --git a/src/tools/openapi.test.ts b/src/tools/openapi.test.ts new file mode 100644 index 00000000..9bf8f35e --- /dev/null +++ b/src/tools/openapi.test.ts @@ -0,0 +1,66 @@ +import { OpenAPITool } from "@/tools/openapi.js"; +import { verifyDeserialization } from "@tests/e2e/utils.js"; +const cat_spec = + '{\ + "openapi": "3.0.0",\ + "info": {\ + "title": "Cat Facts API",\ + "description": "A simple API for cat facts",\ + "version": "1.0.0"\ + },\ + "servers": [\ + {\ + "url": "https://catfact.ninja",\ + "description": "Production server"\ + }\ + ],\ + "paths": {\ + "/fact": {\ + "get": {\ + "summary": "Get a random cat fact",\ + "description": "Returns a random cat fact.",\ + "responses": {\ + "200": {\ + "description": "Successful response",\ + "content": {\ + "application/json": {\ + "schema": {\ + "$ref": "#/components/schemas/Fact"\ + }\ + }\ + }\ + }\ + }\ + }\ + }\ + },\ + "components": {\ + "schemas": {\ + "Fact": {\ + "type": "object",\ + "properties": {\ + "fact": {\ + "type": "string",\ + "description": "The cat fact"\ + }\ + }\ + }\ + }\ + }\ + }'; + +describe("Base Tool", () => { + beforeEach(() => { + vi.clearAllTimers(); + }); + + describe("OpenAPITool", () => { + it("Serializes", () => { + const tool = new OpenAPITool({ name: "OpenAPITool", openApiSchema: cat_spec }); + + const serialized = tool.serialize(); + const deserialized = OpenAPITool.fromSerialized(serialized); + verifyDeserialization(tool, deserialized); + }); + }); +}); diff --git a/src/tools/openapi.ts b/src/tools/openapi.ts new file mode 100644 index 00000000..f219f780 --- /dev/null +++ b/src/tools/openapi.ts @@ -0,0 +1,175 @@ +/** + * Copyright 2024 IBM Corp. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { join } from "path"; + +import { + BaseToolOptions, + BaseToolRunOptions, + StringToolOutput, + Tool, + ToolError, + ToolEmitter, +} from "@/tools/base.js"; +import { Callback, Emitter } from "@/emitter/emitter.js"; +import { GetRunContext } from "@/context.js"; +import { ValueError } from "@/errors.js"; +import { SchemaObject } from "ajv"; +import { parse } from "yaml"; +import { isEmpty } from "remeda"; + +export interface OpenAPIToolOptions extends BaseToolOptions { + name: string; + description?: string; + openApiSchema: any; + apiKey?: string; + httpProxyUrl?: string; +} + +export interface OpenAPIEvents { + beforeFetch: Callback<{ url: URL }>; + afterFetch: Callback<{ data: OpenAPIToolOutput }>; +} + +export class OpenAPIToolOutput extends StringToolOutput { + constructor( + public readonly status: number, + public readonly statusText: string, + public readonly result = "", + ) { + super(); + this.status = status; + this.statusText = statusText; + this.result = result ?? ""; + } +} + +export class OpenAPITool extends Tool { + name = "OpenAPI"; + description = `OpenAPI tool that performs REST API requests to the servers and retrieves the response. The server API interfaces are defined in OpenAPI schema. +Only use the OpenAPI tool if you need to communicate to external servers.`; + openApiSchema: any; + protected apiKey?: string; + protected httpProxyUrl?: string; + + inputSchema(): SchemaObject { + return { + type: "object", + required: ["path", "method"], + oneOf: Object.entries(this.openApiSchema.paths).flatMap(([path, pathSpec]: [string, any]) => + Object.entries(pathSpec).map(([method, methodSpec]: [string, any]) => ({ + additionalProperties: false, + properties: { + path: { + const: path, + description: + "Do not replace variables in path, instead of, put them to the parameters object.", + }, + method: { const: method, description: methodSpec.summary || methodSpec.description }, + ...(methodSpec.requestBody?.content?.["application/json"]?.schema + ? { + body: methodSpec.requestBody?.content?.["application/json"]?.schema, + } + : {}), + ...(methodSpec.parameters + ? { + parameters: { + type: "object", + additionalProperties: false, + required: methodSpec.parameters + .filter((p: any) => p.required === true) + .map((p: any) => p.name), + properties: methodSpec.parameters.reduce( + (acc: any, p: any) => ({ + ...acc, + [p.name]: { ...p.schema, description: p.name }, + }), + {}, + ), + }, + } + : {}), + }, + })), + ), + } as const satisfies SchemaObject; + } + + public readonly emitter: ToolEmitter, OpenAPIToolOutput, OpenAPIEvents> = + Emitter.root.child({ + namespace: ["tool", "web", "openAPITool"], + creator: this, + }); + + static { + this.register(); + } + + public constructor(options: OpenAPIToolOptions) { + super(options); + this.openApiSchema = parse(options.openApiSchema); + if (!this.openApiSchema?.paths) { + throw new ValueError("Server is not specified!"); + } + } + + protected async _run( + input: Record, + _options: Partial, + run: GetRunContext, + ) { + let path: string = input.path || ""; + const url = new URL(this.openApiSchema.servers[0].url); + Object.keys(input.parameters ?? {}).forEach((key) => { + const value = input.parameters[key]; + const newPath = path.replace(`{${key}}`, value); + if (newPath == path) { + url.searchParams.append(key, value); + } else { + path = newPath; + } + }); + url.pathname = join(url.pathname, path); + // eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style + const headers: { [key: string]: string } = { Accept: "application/json" }; + if (this.apiKey) { + headers["Authorization"] = `Bearer ${this.apiKey}`; + } + await this.emitter.emit("beforeFetch", { url: url }); + try { + const response = await fetch(url.toString(), { + body: !isEmpty(input.body) ? input.body : undefined, + method: input.method.toLowerCase(), + headers: headers, + signal: run.signal, + }); + const text = await response.text(); + const output = new OpenAPIToolOutput(response.status, response.statusText, text); + await this.emitter.emit("afterFetch", { data: output }); + return output; + } catch (err) { + throw new ToolError(`Request to ${url} has failed.`, [err]); + } + } + createSnapshot() { + return { + ...super.createSnapshot(), + openApiSchema: this.openApiSchema, + apiKey: this.apiKey, + httpProxyUrl: this.httpProxyUrl, + }; + } +} diff --git a/tests/e2e/tools/openapi.test.ts b/tests/e2e/tools/openapi.test.ts new file mode 100644 index 00000000..3523b5bd --- /dev/null +++ b/tests/e2e/tools/openapi.test.ts @@ -0,0 +1,98 @@ +/** + * Copyright 2024 IBM Corp. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { beforeEach, expect } from "vitest"; +import { OpenAPITool } from "@/tools/openapi.js"; + +describe("OpenAPITool", () => { + let instance: OpenAPITool; + + // Simple API spec for a cat fact API + // Assisted by WCA@IBM + // Latest GenAI contribution: ibm/granite-20b-code-instruct-v2 + + const cat_spec = + '{\ + "openapi": "3.0.0",\ + "info": {\ + "title": "Cat Facts API",\ + "description": "A simple API for cat facts",\ + "version": "1.0.0"\ + },\ + "servers": [\ + {\ + "url": "https://catfact.ninja",\ + "description": "Production server"\ + }\ + ],\ + "paths": {\ + "/fact": {\ + "get": {\ + "summary": "Get a random cat fact",\ + "description": "Returns a random cat fact.",\ + "responses": {\ + "200": {\ + "description": "Successful response",\ + "content": {\ + "application/json": {\ + "schema": {\ + "$ref": "#/components/schemas/Fact"\ + }\ + }\ + }\ + }\ + }\ + }\ + }\ + },\ + "components": {\ + "schemas": {\ + "Fact": {\ + "type": "object",\ + "properties": {\ + "fact": {\ + "type": "string",\ + "description": "The cat fact"\ + }\ + }\ + }\ + }\ + }\ + }'; + + beforeEach(() => { + instance = new OpenAPITool({ + name: "Cat Facts", + description: "A simple API for cat facts", + openApiSchema: cat_spec, + }); + }); + + it("Runs", async () => { + const response = await instance.run( + { + path: "/fact", + method: "get", + }, + { + signal: AbortSignal.timeout(60 * 1000), + retryOptions: {}, + }, + ); + + expect(response.isEmpty()).toBe(false); + }); +}); diff --git a/yarn.lock b/yarn.lock index ed0f69ca..480e2dff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4915,6 +4915,7 @@ __metadata: openai: ^4.67.3 openai-chat-tokens: ^0.2.8 sequelize: ^6.37.3 + yaml: ^2.6.1 peerDependenciesMeta: "@aws-sdk/client-bedrock-runtime": optional: true @@ -4948,6 +4949,8 @@ __metadata: optional: true sequelize: optional: true + yaml: + optional: true languageName: unknown linkType: soft