diff --git a/packages/ai/.env.example b/packages/ai/.env.example new file mode 100644 index 0000000000000..b1eba74a54b79 --- /dev/null +++ b/packages/ai/.env.example @@ -0,0 +1,7 @@ +OPENAI_API_KEY= + +# Get these by following https://pipedream.com/docs/connect/managed-auth/quickstart/#getting-started +PIPEDREAM_CLIENT_ID= +PIPEDREAM_CLIENT_SECRET= +PIPEDREAM_PROJECT_ID= +PIPEDREAM_PROJECT_ENVIRONMENT= \ No newline at end of file diff --git a/packages/ai/.gitignore b/packages/ai/.gitignore new file mode 100644 index 0000000000000..ecff56526ba62 --- /dev/null +++ b/packages/ai/.gitignore @@ -0,0 +1,3 @@ +.env +node_modules +dist diff --git a/packages/ai/README.md b/packages/ai/README.md new file mode 100644 index 0000000000000..252bf4cc0efa3 --- /dev/null +++ b/packages/ai/README.md @@ -0,0 +1,37 @@ +# @pipedream/ai + +> This library is in alpha status. The API is subject to breaking changes. + +Create a .env file based on the .env.example file. + +Basic example: +```ts +import { OpenAiTools } from "@pipedream/ai" +import { OpenAI } from "openai" + +const openai = new OpenAI() + +// Replace with a unique identifier for your user +const userId = + +const openAiTools = new OpenAiTools(userId) +const tools = await openAiTools.getTools({ + app: "slack", +}) + +const completion = await openai.chat.completions.create({ + messages: [ + { + role: "user", + content: "Send a joke to #random channel", + }, + ], + model: "gpt-4o", + tools, +}) + +const results = await openAiTools.handleCompletion(completion) + +console.log(JSON.stringify(results, null, 2)) + +``` \ No newline at end of file diff --git a/packages/ai/package.json b/packages/ai/package.json new file mode 100644 index 0000000000000..ffc0d31ab90f6 --- /dev/null +++ b/packages/ai/package.json @@ -0,0 +1,33 @@ +{ + "name": "@pipedream/ai", + "type": "module", + "version": "0.0.1", + "description": "Pipedream AI", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist", + "package.json" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@pipedream/sdk": "workspace:^", + "zod": "^3.24.4", + "zod-to-json-schema": "^3.24.5" + }, + "devDependencies": { + "bun": "^1.2.13", + "openai": "^4.77.0" + } +} diff --git a/packages/ai/src/configurablePropsToZod.ts b/packages/ai/src/configurablePropsToZod.ts new file mode 100644 index 0000000000000..38f75df68c79f --- /dev/null +++ b/packages/ai/src/configurablePropsToZod.ts @@ -0,0 +1,107 @@ +import { + ConfigurableProps, V1Component, +} from "@pipedream/sdk"; +import { + z, ZodRawShape, +} from "zod"; +import { + extractEnumValues, toNonEmptyTuple, +} from "./lib/helpers"; + +export const configurablePropsToZod = ( + component: V1Component, + options?: { + asyncOptionsDescription?: string; + configureComponentDescription?: string; + configurableProps?: ConfigurableProps; + }, +) => { + const schema: ZodRawShape = {}; + + for (const cp of options?.configurableProps || + (component.configurable_props as ConfigurableProps)) { + if (cp.hidden) { + continue; + } + + if (cp.type === "app") { + // XXX handle directly in implementation + continue; + } else if (cp.type === "string") { + if (cp.options && Array.isArray(cp.options) && cp.options.length > 0) { + const enumValues = toNonEmptyTuple(extractEnumValues(cp.options)); + if (enumValues) { + schema[cp.name] = z.enum(enumValues); + } else { + schema[cp.name] = z.string(); + } + } else { + schema[cp.name] = z.string(); + } + } else if (cp.type === "string[]") { + if (cp.options && Array.isArray(cp.options) && cp.options.length > 0) { + const enumValues = toNonEmptyTuple(extractEnumValues(cp.options)); + if (enumValues) { + schema[cp.name] = z.array(z.enum(enumValues)); + } else { + schema[cp.name] = z.array(z.string()); + } + } else { + schema[cp.name] = z.array(z.string()); + } + } else if (cp.type === "$.discord.channel") { + schema[cp.name] = z.string(); + } else if (cp.type === "$.discord.channel[]") { + schema[cp.name] = z.array(z.string()); + } else if (cp.type === "object") { + schema[cp.name] = z.object({}).passthrough(); + } else if (cp.type === "any") { + schema[cp.name] = z.any(); + } else if (cp.type === "integer") { + schema[cp.name] = z.number().int(); + } else if (cp.type === "integer[]") { + schema[cp.name] = z.array(z.number().int()); + } else if (cp.type === "boolean") { + schema[cp.name] = z.boolean(); + } else if (cp.type === "boolean[]") { + schema[cp.name] = z.array(z.boolean()); + // ignore alerts, as no user input required + } else { + console.error("unhandled type. Skipping", cp.name, cp.type); + } + + if (schema[cp.name]) { + if (cp.optional) { + schema[cp.name] = schema[cp.name].optional().nullable(); + } + + let description: string = cp.description || ""; + + if (cp.hidden) { + description += + "\n\nIMPORTANT: This property is hidden. Do not configure it and leave it blank.\n"; + } + + if (cp.remoteOptions) { + if (options?.asyncOptionsDescription) { + if (options.configureComponentDescription) { + description += `\n\n${options.asyncOptionsDescription}`; + } + } else { + if (options?.configureComponentDescription) { + description += `\n\n${options.configureComponentDescription}`; + } + } + // if (cp.name.includes("id")) { + // description += `\n\nIMPORTANT: An ID is required for this property. If you don't have the id and only have the name, use the "${CONFIGURE_COMPONENT_TOOL_NAME}" tool to get the values.`; + // } + } + + if (description.trim()) { + schema[cp.name] = schema[cp.name].describe(description.trim()); + } + } + } + + return schema; +}; diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts new file mode 100644 index 0000000000000..f35fcf05ff9ce --- /dev/null +++ b/packages/ai/src/index.ts @@ -0,0 +1,3 @@ +export { + OpenAiTools, +} from "./tool-sets/openai" diff --git a/packages/ai/src/lib/componentAppKey.ts b/packages/ai/src/lib/componentAppKey.ts new file mode 100644 index 0000000000000..e5873c86cb498 --- /dev/null +++ b/packages/ai/src/lib/componentAppKey.ts @@ -0,0 +1,5 @@ +import { ConfigurableProps } from "@pipedream/sdk"; + +export const componentAppKey = (configuredProps: ConfigurableProps) => { + return configuredProps.find((prop) => prop.type === "app")?.app; +}; diff --git a/packages/ai/src/lib/config.ts b/packages/ai/src/lib/config.ts new file mode 100644 index 0000000000000..a5072074fc6e1 --- /dev/null +++ b/packages/ai/src/lib/config.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +const configSchema = z.object({ + PIPEDREAM_CLIENT_ID: z.string().min(1, { + message: "PIPEDREAM_CLIENT_ID is required", + }), + PIPEDREAM_CLIENT_SECRET: z.string().min(1, { + message: "PIPEDREAM_CLIENT_SECRET is required", + }), + PIPEDREAM_PROJECT_ID: z.string().min(1, { + message: "PIPEDREAM_PROJECT_ID is required", + }), + PIPEDREAM_PROJECT_ENVIRONMENT: z.enum([ + "development", + "production", + ]), +}); + +export const config = configSchema.parse(process?.env); + +export type Config = z.infer; diff --git a/packages/ai/src/lib/helpers.ts b/packages/ai/src/lib/helpers.ts new file mode 100644 index 0000000000000..b1e102bed8e15 --- /dev/null +++ b/packages/ai/src/lib/helpers.ts @@ -0,0 +1,15 @@ +export function toNonEmptyTuple( + arr: T[], +): [T, ...T[]] | undefined { + return arr.length > 0 + ? (arr as [T, ...T[]]) + : undefined; +} + +type EnumLike = string | { value: string }; + +export function extractEnumValues(values: EnumLike[]): string[] { + return values.map((v) => (typeof v === "string" + ? v + : v.value)); +} diff --git a/packages/ai/src/lib/pd-client.ts b/packages/ai/src/lib/pd-client.ts new file mode 100644 index 0000000000000..1d74a470e6006 --- /dev/null +++ b/packages/ai/src/lib/pd-client.ts @@ -0,0 +1,11 @@ +import { createBackendClient } from "@pipedream/sdk"; +import { config } from "./config"; + +export const pd = createBackendClient({ + credentials: { + clientId: config.PIPEDREAM_CLIENT_ID, + clientSecret: config.PIPEDREAM_CLIENT_SECRET, + }, + projectId: config.PIPEDREAM_PROJECT_ID, + environment: config.PIPEDREAM_PROJECT_ENVIRONMENT, +}); diff --git a/packages/ai/src/tool-sets/core.ts b/packages/ai/src/tool-sets/core.ts new file mode 100644 index 0000000000000..a00e107211c33 --- /dev/null +++ b/packages/ai/src/tool-sets/core.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; +import { configurablePropsToZod } from "../configurablePropsToZod"; +import { pd } from "../lib/pd-client"; +import { + Account, V1Component, +} from "@pipedream/sdk"; +import { componentAppKey } from "../lib/componentAppKey"; + +type Tool = { + name: string; + description?: string; + schema: z.ZodObject; + execute: (args: Record) => Promise; +}; + +export class CoreTools { + userId: string; + tools: Tool[] = []; + + constructor(userId: string) { + this.userId = userId; + } + + async getTools(options?: { app?: string; query?: string }) { + const { data: components } = await pd.getComponents({ + app: options?.app, + q: options?.query, + }); + + for (const component of components) { + this.tools.push({ + name: component.name.replace(/[^a-zA-Z0-9_-]/g, "_"), + description: component.description, + schema: z.object(configurablePropsToZod(component)), + execute: (args) => this.executeTool(component, args), + }); + } + + return this.tools; + } + + async getTool(name: string) { + return this.tools.find((tool) => tool.name === name); + } + + async executeTool(component: V1Component, args: Record) { + const appKey = componentAppKey(component.configurable_props); + + if (!appKey) { + throw new Error("App name not found"); + } + + const authProvision = await this.getAuthProvision({ + app: appKey, + uuid: this.userId, + }); + + if (typeof authProvision === "string") { + return authProvision; + } + + return pd.runAction({ + actionId: component.key, + configuredProps: { + ...args, + [appKey]: { + authProvisionId: authProvision.id, + }, + }, + externalUserId: this.userId, + }); + } + + async getAuthProvision({ + app, + uuid, + }: { + app: string; + uuid: string; + }): Promise { + const authProvisions = await pd.getAccounts({ + external_user_id: uuid, + include_credentials: false, + app, + }); + + const authProvision = authProvisions.data.find((ap) => ap.healthy); + + if (!authProvision) { + const token = await pd.createConnectToken({ + external_user_id: uuid, + webhook_uri: "https://eokyfjps7uqmmrk.m.pipedream.net", // https://pipedream.com/@pd/p_G6Ck6Mk/ + }); + return ` + The user MUST be shown the following URL so they can click on it to connect their account and you MUST NOT modify the URL or it will break: https://pipedream.com/_static/connect.html?token=${token.token}&connectLink=true&app=${encodeURIComponent(app)} + `.trim(); + } + + return authProvision; + } +} diff --git a/packages/ai/src/tool-sets/openai.ts b/packages/ai/src/tool-sets/openai.ts new file mode 100644 index 0000000000000..7321e20e69d43 --- /dev/null +++ b/packages/ai/src/tool-sets/openai.ts @@ -0,0 +1,51 @@ +import OpenAI from "openai/index.mjs"; +import { ChatCompletionTool } from "openai/resources/chat/completions"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { CoreTools } from "./core"; + +export class OpenAiTools { + core: CoreTools; + constructor(userId: string) { + this.core = new CoreTools(userId); + } + + async getTools(options?: { + app?: string; + query?: string; + }): Promise { + const tools = await this.core.getTools(options); + return tools.map((tool) => { + return { + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: zodToJsonSchema(tool.schema), + }, + }; + }); + } + + async handleCompletion(completion: OpenAI.Chat.Completions.ChatCompletion) { + const toolCalls = completion.choices[0].message.tool_calls; + if (!toolCalls) { + return; + } + const results = await Promise.all( + toolCalls.map(async (toolCall) => { + const toolName = toolCall.function.name; + const tool = await this.core.getTool(toolName); + if (!tool) { + throw new Error(`Tool ${toolName} not found`); + } + + const args = JSON.parse(toolCall.function.arguments); + const parsedArgs = tool.schema.parse(args); + + const result = await tool.execute(parsedArgs); + return result; + }), + ); + return results; + } +} diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json new file mode 100644 index 0000000000000..5ad2727caf990 --- /dev/null +++ b/packages/ai/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "declaration": true, + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 265297b272cae..24eeae7eebb45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2143,8 +2143,7 @@ importers: specifier: ^1.5.1 version: 1.6.6 - components/cerebras: - specifiers: {} + components/cerebras: {} components/certifier: dependencies: @@ -8561,8 +8560,7 @@ importers: specifier: ^1.5.1 version: 1.6.6 - components/neon_postgres: - specifiers: {} + components/neon_postgres: {} components/nerv: {} @@ -9598,8 +9596,7 @@ importers: specifier: ^3.0.3 version: 3.0.3 - components/pdforge: - specifiers: {} + components/pdforge: {} components/peach: dependencies: @@ -10876,8 +10873,7 @@ importers: components/renderform: {} - components/rendi: - specifiers: {} + components/rendi: {} components/rentcast: dependencies: @@ -11434,8 +11430,7 @@ importers: components/scrapein_: {} - components/scrapeless: - specifiers: {} + components/scrapeless: {} components/scrapeninja: dependencies: @@ -15397,6 +15392,25 @@ importers: specifier: ^6.0.0 version: 6.2.0 + packages/ai: + dependencies: + '@pipedream/sdk': + specifier: workspace:^ + version: link:../sdk + zod: + specifier: ^3.24.4 + version: 3.24.4 + zod-to-json-schema: + specifier: ^3.24.5 + version: 3.24.5(zod@3.24.4) + devDependencies: + bun: + specifier: ^1.2.13 + version: 1.2.13 + openai: + specifier: ^4.77.0 + version: 4.97.0(ws@8.18.0)(zod@3.24.4) + packages/browsers: dependencies: '@sparticuz/chromium': @@ -18351,6 +18365,61 @@ packages: resolution: {integrity: sha512-wU5J8rUoo32oSef/rFpOT1HIjLjAv3qIDHkw1QIhODV3OpAVHi5oVzlouozg9obUmZKtbZ0qUe/m7FP0y0yBzA==} engines: {node: '>=8.12.0'} + '@oven/bun-darwin-aarch64@1.2.13': + resolution: {integrity: sha512-AOU4O9jxRp2TXeqoEfOjEaUNZb3+SUPBN8TIEnUjpnyLWPoYJGCeNdQuCDcUkmF3MJEmEuJdyF1IeOITozpC6A==} + cpu: [arm64] + os: [darwin] + + '@oven/bun-darwin-x64-baseline@1.2.13': + resolution: {integrity: sha512-bZpIUOvx9np07AmH5MVXGYHWZ40m2vCpNV74fma6sCzBlssJclS2V3BZgO+lLvtUKSqnW3HAyJBGsRF34wPbNw==} + cpu: [x64] + os: [darwin] + + '@oven/bun-darwin-x64@1.2.13': + resolution: {integrity: sha512-kJ2iOvxY8uz5/nu+8zIjKf4LmRIHBH9pJJM2q+tA47U04Tod6k6rtntDOI8SdmRe2M5c87RfbadWdxhpYHFIWQ==} + cpu: [x64] + os: [darwin] + + '@oven/bun-linux-aarch64-musl@1.2.13': + resolution: {integrity: sha512-P56m718KXeyu4Vq5fsESFktfu+0Us1jhu/ZzgHYFRYJcm/hjs6AUA/RJtUAifFy5PNAM5IJdrYl3xPsE8Wa+pg==} + cpu: [aarch64] + os: [linux] + + '@oven/bun-linux-aarch64@1.2.13': + resolution: {integrity: sha512-hocSJmblX4CCjP1HpaM64I65erB+CONUCCwKzGGOfLGLobVi+vn/G56UaYWsje1y/Z7WlVaUSgKYVWl7EJ6T9g==} + cpu: [arm64] + os: [linux] + + '@oven/bun-linux-x64-baseline@1.2.13': + resolution: {integrity: sha512-9n1ai2ejEpxEMqpbHQMWFyvacq3MYsB7gh5mxRlFwhNFPCWu/Sv6gyrO+q2vkOYgcEIGhJb6dqJ6L9vBNaL61A==} + cpu: [x64] + os: [linux] + + '@oven/bun-linux-x64-musl-baseline@1.2.13': + resolution: {integrity: sha512-VI8hVdfqk0QmbAbyrsIdo2O95n3fkbt72E0h3Wu69cHD1iKJqRXG28R8QoHdehoLSJnKVzRTwsUzHp764nefWQ==} + cpu: [x64] + os: [linux] + + '@oven/bun-linux-x64-musl@1.2.13': + resolution: {integrity: sha512-w5Ob+GM3Ww4yRA6f1N845o6wEvuwHSmipFUGaRaVp4UELrFnIV9G3pmrlBbYHFnWhk13o8Q7H1/4ZphOkCRJmQ==} + cpu: [x64] + os: [linux] + + '@oven/bun-linux-x64@1.2.13': + resolution: {integrity: sha512-pf8+Kn2GLrFKLcb8JSLM6Z147Af6L9GQODpnOHM4gvXQv6E/GwQg47/o+7f1XCfzib3fdzOTJlDPvvO1rnXOTA==} + cpu: [x64] + os: [linux] + + '@oven/bun-windows-x64-baseline@1.2.13': + resolution: {integrity: sha512-Aiezu99fOUJJpzGuylOJryd6w9Syg2TBigHeXV2+RJsouBzvAnIEYIBA94ZspRq1ulD26Wmkk8Ae+jZ4edk9GA==} + cpu: [x64] + os: [win32] + + '@oven/bun-windows-x64@1.2.13': + resolution: {integrity: sha512-sArgbRmT7V3mUdNFaAdUcuJsuS+oeMDZLPWFSg0gtQZpRrURs9nPzEnZMmVCFo4+kPF9Tb5ujQT9uDySh6/qVg==} + cpu: [x64] + os: [win32] + '@pdfless/pdfless-js@1.0.510': resolution: {integrity: sha512-RmbzdGQcWy/OSuPF/eaT0wQsi9ELu465/r/8DsgajbHumStHrkzRqsm330g1PgBgQipZMi9ZTTHfP4i/Sbs+pw==} @@ -21415,6 +21484,12 @@ packages: builtins@1.0.3: resolution: {integrity: sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ==} + bun@1.2.13: + resolution: {integrity: sha512-EhP1MhFbicqtaRSFCbEZdkcFco8Ov47cNJcB9QmKS8U4cojKHfLU+dQR14lCvLYmtBvGgwv/Lp+9SSver2OPzQ==} + cpu: [arm64, x64, aarch64] + os: [darwin, linux, win32] + hasBin: true + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -34977,6 +35052,39 @@ snapshots: '@opentelemetry/semantic-conventions@1.3.1': {} + '@oven/bun-darwin-aarch64@1.2.13': + optional: true + + '@oven/bun-darwin-x64-baseline@1.2.13': + optional: true + + '@oven/bun-darwin-x64@1.2.13': + optional: true + + '@oven/bun-linux-aarch64-musl@1.2.13': + optional: true + + '@oven/bun-linux-aarch64@1.2.13': + optional: true + + '@oven/bun-linux-x64-baseline@1.2.13': + optional: true + + '@oven/bun-linux-x64-musl-baseline@1.2.13': + optional: true + + '@oven/bun-linux-x64-musl@1.2.13': + optional: true + + '@oven/bun-linux-x64@1.2.13': + optional: true + + '@oven/bun-windows-x64-baseline@1.2.13': + optional: true + + '@oven/bun-windows-x64@1.2.13': + optional: true + '@pdfless/pdfless-js@1.0.510': dependencies: '@microsoft/kiota-abstractions': 1.0.0-preview.77 @@ -35649,6 +35757,8 @@ snapshots: '@putout/operator-filesystem': 5.0.0(putout@36.13.1(eslint@8.57.1)(typescript@5.6.3)) '@putout/operator-json': 2.2.0 putout: 36.13.1(eslint@8.57.1)(typescript@5.6.3) + transitivePeerDependencies: + - supports-color '@putout/operator-regexp@1.0.0(putout@36.13.1(eslint@8.57.1)(typescript@5.6.3))': dependencies: @@ -38870,6 +38980,20 @@ snapshots: builtins@1.0.3: {} + bun@1.2.13: + optionalDependencies: + '@oven/bun-darwin-aarch64': 1.2.13 + '@oven/bun-darwin-x64': 1.2.13 + '@oven/bun-darwin-x64-baseline': 1.2.13 + '@oven/bun-linux-aarch64': 1.2.13 + '@oven/bun-linux-aarch64-musl': 1.2.13 + '@oven/bun-linux-x64': 1.2.13 + '@oven/bun-linux-x64-baseline': 1.2.13 + '@oven/bun-linux-x64-musl': 1.2.13 + '@oven/bun-linux-x64-musl-baseline': 1.2.13 + '@oven/bun-windows-x64': 1.2.13 + '@oven/bun-windows-x64-baseline': 1.2.13 + bundle-require@5.1.0(esbuild@0.24.2): dependencies: esbuild: 0.24.2