├── .gitignore ├── assets └── demo.gif ├── tsconfig.json ├── .github └── workflows │ ├── publish.yaml │ ├── export-repo-secrets.yml │ └── test.yaml ├── package.json ├── src ├── cli.ts └── index.ts ├── README.md └── tests └── index.test.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | yarn.lock 4 | out.txt 5 | scripts.txt -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pulumi/pulumi-ai/HEAD/assets/demo.gif -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./lib", 4 | "target": "es2016", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "declaration": true, 8 | "sourceMap": true, 9 | }, 10 | "exclude": [ 11 | "tests", 12 | "lib", 13 | "node_modules" 14 | ] 15 | } -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | on: 3 | push: 4 | tags: 5 | - v*.*.* 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | - name: Setup Node 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: '19.x' 16 | registry-url: https://registry.npmjs.org 17 | - name: Install dependencies and build 🔧 18 | run: npm ci 19 | - name: Publish package on NPM 📦 20 | run: npm publish 21 | env: 22 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pulumi-ai", 3 | "version": "0.0.5", 4 | "main": "./lib/index.js", 5 | "repository": "https://github.com/pulumi/pulumi-ai", 6 | "author": "Luke Hoban", 7 | "license": "Apache-2.0", 8 | "dependencies": { 9 | "@pulumi/aws": "^4.38.1", 10 | "@pulumi/aws-apigateway": "^1.0.1", 11 | "@pulumi/awsx": "^0.32.0", 12 | "@pulumi/eks": "^1.0.1", 13 | "@pulumi/pulumi": "^3.73.0", 14 | "chalk": "^4.1.2", 15 | "open": "^8.4.2", 16 | "openai": "^3.2.1" 17 | }, 18 | "devDependencies": { 19 | "@types/chai": "^4.3.4", 20 | "@types/mocha": "^10.0.1", 21 | "@types/node": "^14.11.7", 22 | "chai": "^4.3.7", 23 | "mocha": "^10.2.0", 24 | "ts-mocha": "^10.0.0", 25 | "typescript": "^4.3.5" 26 | }, 27 | "scripts": { 28 | "prepublish": "tsc", 29 | "build": "tsc", 30 | "start": "tsc && node ./lib/cli.js", 31 | "test": "ts-mocha tests/**/*.test.ts" 32 | }, 33 | "bin": { 34 | "pulumi-ai": "./lib/cli.js" 35 | }, 36 | "files": [ 37 | "lib" 38 | ], 39 | "types": "lib/index.d.ts" 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/export-repo-secrets.yml: -------------------------------------------------------------------------------- 1 | permissions: write-all # Equivalent to default permissions plus id-token: write 2 | name: Export secrets to ESC 3 | on: [ workflow_dispatch ] 4 | jobs: 5 | export-to-esc: 6 | runs-on: ubuntu-latest 7 | name: export GitHub secrets to ESC 8 | steps: 9 | - name: Generate a GitHub token 10 | id: generate-token 11 | uses: actions/create-github-app-token@v1 12 | with: 13 | app-id: 1256780 # Export Secrets GitHub App 14 | private-key: ${{ secrets.EXPORT_SECRETS_PRIVATE_KEY }} 15 | - name: Export secrets to ESC 16 | uses: pulumi/esc-export-secrets-action@v1 17 | with: 18 | organization: pulumi 19 | org-environment: github-secrets/pulumi-pulumi-ai 20 | exclude-secrets: EXPORT_SECRETS_PRIVATE_KEY 21 | github-token: ${{ steps.generate-token.outputs.token }} 22 | oidc-auth: true 23 | oidc-requested-token-type: urn:pulumi:token-type:access_token:organization 24 | env: 25 | GITHUB_SECRETS: ${{ toJSON(secrets) }} 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test Package 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | env: 8 | PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN_PRODUCTION }} 9 | OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [19.x] 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | - name: Setup Node 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | registry-url: https://registry.npmjs.org 24 | - name: Setup Pulumi 25 | uses: pulumi/actions@v4 26 | - name: Install dependencies and build 🔧 27 | run: npm ci 28 | - name: Configure AWS Credentials 🔑 29 | uses: aws-actions/configure-aws-credentials@v1 30 | with: 31 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 32 | aws-region: us-west-2 33 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 34 | role-duration-seconds: 3600 35 | role-session-name: pulumiAI@githubActions 36 | role-to-assume: ${{ secrets.AWS_CI_ROLE_ARN }} 37 | - name: Test 🧪 38 | run: npm test -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { PulumiAI, InteractResponse } from "."; 3 | import * as readline from "readline"; 4 | import * as chalk from "chalk"; 5 | 6 | async function handleCommand(request: string, ai: PulumiAI) { 7 | const stack = await ai.stack; 8 | const parts = request.split(" "); 9 | switch (parts[0]) { 10 | case "!quit": 11 | console.log("destroying stack..."); 12 | await stack.destroy(); 13 | console.log("done. Goodbye!"); 14 | process.exit(0); 15 | case "!program": 16 | console.log(ai.program); 17 | break; 18 | case "!stack": 19 | const s = await stack.exportStack(); 20 | console.log(s.deployment.resources); 21 | break; 22 | case "!verbose": 23 | if (parts[1] == "off") { 24 | ai.verbose = false; 25 | console.warn("Verbose mode off.") 26 | } else { 27 | ai.verbose = true; 28 | console.warn("Verbose mode on.") 29 | } 30 | break; 31 | case "!open": 32 | if (parts.length <= 1) { 33 | console.warn("Usage: !open ") 34 | break; 35 | } 36 | let url: string; 37 | if (parts[1].startsWith("http")) { 38 | url = parts[1]; 39 | } else { 40 | const outputs = await stack.outputs(); 41 | url = outputs[parts[1]].value; 42 | } 43 | await open(url); 44 | break; 45 | default: 46 | console.log("Unknown command: " + request); 47 | break; 48 | } 49 | } 50 | 51 | async function run() { 52 | const openaiApiKey = process.env.OPENAI_API_KEY; 53 | if (!openaiApiKey) { 54 | console.error("Error: OPENAI_API_KEY must be set"); 55 | process.exit(2); 56 | } 57 | const openaiModel = process.env.OPENAI_MODEL ?? "gpt-4"; 58 | const openaiTemperature = +process.env.OPENAI_TEMPERATURE ?? 0.01; 59 | const ai = new PulumiAI({ 60 | openaiApiKey, 61 | openaiModel, 62 | openaiTemperature, 63 | autoDeploy: true, 64 | }); 65 | 66 | const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false }); 67 | 68 | console.log(chalk.magenta.bold("Welcome to Pulumi AI.")); 69 | console.log(); 70 | const stack = await ai.stack; 71 | const summary = await stack.workspace.stack(); 72 | const url = `${summary.url}/resources`; 73 | console.log(`Your stack: ${chalk.blue.underline(url)}`); 74 | console.log(); 75 | console.log(chalk.italic("What cloud infrastructure do you want to build today?")); 76 | 77 | while (true) { 78 | try { 79 | const request = await new Promise(resolve => { 80 | rl.question(`\n> ${chalk.reset()}`, resolve); 81 | }); 82 | if (request.length > 0 && request[0] == "!") { 83 | await handleCommand(request, ai); 84 | continue; 85 | } 86 | 87 | let text = ""; 88 | let i = 0; 89 | 90 | 91 | const resp = await ai.interact(request, (chunk) => { 92 | chunk = chunk.replace(/[\n\t\r]/g, " "); 93 | text = (text + chunk).slice(-60); 94 | const progress = [". ", ".. ", "...", " "][Math.floor((i++) / 3) % 4]; 95 | process.stdout.write(`\rThinking${progress} ${chalk.dim(text)}`); 96 | }, () => { 97 | readline.clearLine(process.stdout, -1) 98 | process.stdout.write(`\r`); 99 | }); 100 | 101 | process.stdout.write("\r"); 102 | if (resp.failed == true) { 103 | ai.errors.forEach(e => console.warn(`error: ${e.message}`)); 104 | console.warn(`The infrastructure update failed, try asking me to "fix the error".`); 105 | } else if (resp.program) { 106 | const outputs = resp.outputs || {}; 107 | if (Object.keys(outputs).length > 0) { 108 | console.log("Stack Outputs:"); 109 | } 110 | for (const [k, v] of Object.entries(outputs)) { 111 | console.log(` ${k}: ${v.value}`); 112 | } 113 | } else { 114 | // We couldn't find a program. 115 | console.warn(`error: ${resp.text}`); 116 | } 117 | } catch (err) { 118 | console.error(`error: ${err}`); 119 | continue; 120 | } 121 | } 122 | } 123 | 124 | run().catch(err => { 125 | console.error(`Error: ${err}`); 126 | process.exit(1); 127 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pulumi AI 2 | 3 | Create cloud infrastructure with Pulumi Automation API ☁️ and OpenAI GPT 🤖. Try out Pulumi AI online at https://pulumi.com/ai, or locally with `npx pulumi-ai`. 4 | 5 | > _Note_: This is an experimental AI experience for interactively building cloud infrastructure using GPT. It will likely do surprising and interesting things, and will make mistakes! You have the option to provide access to deploy infrastructure directly into your cloud account(s), which puts incredible power in the hands of the AI, be sure to use with approporiate caution. 6 | 7 | ![Demo of Pulumi AI](assets/demo.gif) 8 | 9 | ## Running 10 | 11 | To use the CLI tool, you must configure the following: 12 | * Download and install [`pulumi`](https://www.pulumi.com/docs/get-started/install/). 13 | * Get an [OpenAI API Key](https://platform.openai.com/account/api-keys) and make it available as `OPENAI_API_KEY`. 14 | * Login to Pulumi via `pulumi login`, or else by exporting a Pulumi Access Token as `PULUMI_ACCESS_TOKEN`. 15 | * Configure your cloud credentials for your target Cloud environment ([AWS](https://www.pulumi.com/registry/packages/aws/installation-configuration/), [Azure](https://www.pulumi.com/registry/packages/azure-native/installation-configuration/), [Google Cloud](https://www.pulumi.com/registry/packages/gcp/installation-configuration/), [Kubernetes](https://www.pulumi.com/registry/packages/kubernetes/installation-configuration/), etc.). 16 | 17 | > _Note_: The `pulumi-ai` CLI works best with AWS today, but can be used with any cloud. 18 | 19 | Then run: 20 | 21 | ```bash 22 | npx pulumi-ai 23 | ``` 24 | 25 | You can ask for any infrastructure you want, then use these commands to take actions outside of interacting with the AI: 26 | * `!quit`: Exit the AI and cleanup the temporary stack. 27 | * `!program`: See the Pulumi program that has been developed so far. 28 | * `!stack`: See details of the Pulumi stack that has been deployed so far. 29 | * `!verbose`: Turn verbose mode on for debugging interactions with GPT and Pulumi. 30 | * `!open `: Open a URL from the given stack output name in the system browser. 31 | 32 | The following environment variables are also available to configure the GPT AI used: 33 | * `OPENAI_MODEL`: Select one of the valid [OpenAI Models](https://platform.openai.com/docs/models), suchas as `gpt-4` (default, and most accurate but slow) or `gpt-3.5-turbo` (not as accurate but much faster). 34 | * `OPENAI_TEMPERATURE`: Configure the temperature to tune the AI to be more predicatable (lower values) or more creative (higher values). 35 | 36 | ## Examples 37 | 38 | ``` 39 | Welcome to Pulumi AI. 40 | 41 | Your stack: https://app.pulumi.com/luke/pulumi-ai/dev/resources 42 | 43 | What cloud infrastructure do you want to build today? 44 | 45 | > An AWS VPC 46 | create aws:ec2/vpc:Vpc my-vpc ... 47 | created my-vpc 48 | vpcCidrBlock: 10.0.0.0/16 49 | vpcId: vpc-016f35c7f078ab9e8 50 | 51 | > Add three private subnets 52 | create aws:ec2/subnet:Subnet private-subnet-2-2 ... 53 | create aws:ec2/subnet:Subnet private-subnet-1-1 ... 54 | create aws:ec2/subnet:Subnet private-subnet-3-3 ... 55 | created private-subnet-2-2 56 | created private-subnet-3-3 57 | created private-subnet-1-1 58 | privateSubnetIds: subnet-08add1c8ae97e3cfb,subnet-0cb4ea675f7ac64fe,subnet-0f9a22c87e766fa17 59 | vpcId: vpc-016f35c7f078ab9e8 60 | 61 | > Remove one of the subnets 62 | delete aws:ec2/subnet:Subnet private-subnet-3-3 ... 63 | deleted private-subnet-3-3 64 | privateSubnetIds: subnet-08add1c8ae97e3cfb,subnet-0cb4ea675f7ac64fe 65 | vpcId: vpc-016f35c7f078ab9e8 66 | 67 | > 68 | ``` 69 | 70 | ``` 71 | Welcome to Pulumi AI. 72 | 73 | Your stack: https://app.pulumi.com/luke-pulumi-corp/pulumi-ai/dev/resources 74 | 75 | What cloud infrastructure do you want to build today? 76 | 77 | > an s3 bucket 78 | create aws:s3/bucket:Bucket my-bucket ... 79 | created my-bucket 80 | Stack Outputs: 81 | bucketName: my-bucket-cc63555 82 | 83 | > add an index.html file that says "Hello, world!" in three languages 84 | update aws:s3/bucket:Bucket my-bucket ... 85 | updated my-bucket 86 | create aws:s3/bucketObject:BucketObject index ... 87 | created index 88 | Stack Outputs: 89 | bucketName: my-bucket-cc63555 90 | 91 | > give me a url for that index.html file 92 | update aws:s3/bucket:Bucket my-bucket ... 93 | updated my-bucket 94 | create aws:s3/bucketPolicy:BucketPolicy bucketPolicy ... 95 | created bucketPolicy 96 | Stack Outputs: 97 | bucketName: my-bucket-cc63555 98 | indexUrl: http://undefined/index.html 99 | 100 | > That gave an undefined url. Can you fix it? 101 | update aws:s3/bucket:Bucket my-bucket ... 102 | updated my-bucket 103 | Stack Outputs: 104 | bucketName: my-bucket-cc63555 105 | indexUrl: http://my-bucket-cc63555.s3-website-us-west-2.amazonaws.com/index.html 106 | 107 | > Great - that worked! Now make it fancier, and add some color :-) 108 | update aws:s3/bucket:Bucket my-bucket ... 109 | updated my-bucket 110 | update aws:s3/bucketObject:BucketObject index ... 111 | updated index 112 | Stack Outputs: 113 | bucketName: my-bucket-cc63555 114 | indexUrl: http://my-bucket-cc63555.s3-website-us-west-2.amazonaws.com/index.html 115 | 116 | > !program 117 | 118 | 119 | > !quit 120 | ``` -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import { PulumiAI } from "../src/index"; 2 | import { expect } from "chai"; 3 | import axios from "axios"; 4 | import { OutputMap } from "@pulumi/pulumi/automation"; 5 | 6 | const mockProgram = `import * as pulumi from "@pulumi/pulumi"; 7 | import * as aws from "@pulumi/aws"; 8 | 9 | // Create an AWS S3 bucket for website hosting 10 | const myBucket = new aws.s3.Bucket("myBucket", { 11 | website: { 12 | indexDocument: "index.html", 13 | errorDocument: "error.html", 14 | }, 15 | }); 16 | 17 | // Export the bucket name 18 | export const bucketName = myBucket.id;`; 19 | 20 | function randomHex(): string { 21 | return Math.floor(Math.random() * 0xffffffff).toString(16); 22 | } 23 | 24 | describe("pulumiai", (): void => { 25 | it("generates a title for a program", async () => { 26 | const p = new PulumiAI({ 27 | openaiApiKey: process.env.OPENAI_API_KEY!, 28 | stackName: `test-stack-${randomHex()}` 29 | }); 30 | 31 | const title = await p.generateTitleForProgram(mockProgram); 32 | expect(title).to.equal("S3 Website Hosting Setup"); 33 | }).timeout(100000); 34 | 35 | it("construct PulumiAI stack", async () => { 36 | const p = new PulumiAI({ 37 | openaiApiKey: process.env.OPENAI_API_KEY!, 38 | stackName: `test-stack-${randomHex()}` 39 | }); 40 | const stack = await p.stack; 41 | const summary = await stack.workspace.stack(); 42 | expect(summary!.resourceCount).to.equal(1); 43 | }).timeout(100000); 44 | 45 | it("builds a simple vpc", async () => { 46 | const commands = [ 47 | `An AWS VPC`, 48 | `add three private subnets`, 49 | `remove one of the subnets`, 50 | ]; 51 | await runTest(commands, {}, async (p, outputs) => { 52 | let subnets = 0; 53 | let vpcs = 0; 54 | for (const [k, v] of Object.entries(outputs)) { 55 | if (typeof v.value == "string" && v.value.startsWith("subnet-")) { 56 | subnets++; 57 | } else if (typeof v.value == "string" && v.value.startsWith("vpc-")) { 58 | vpcs++; 59 | } 60 | } 61 | expect(subnets).to.be.equal(2); 62 | expect(vpcs).to.be.equal(1); 63 | }); 64 | }).timeout(1000000); 65 | 66 | 67 | it("builds a static website deploy", async () => { 68 | const commands = [ 69 | `give me an s3 bucket`, 70 | `add an index.html file that says "Hello, world!" in three languages`, 71 | `give me the website url for the index.html file`, 72 | `That gave me "AccessDenied", can you fix it?`, 73 | ]; 74 | await runTest(commands, {}, async (p, outputs) => { 75 | let checked = 0; 76 | for (const [k, v] of Object.entries(outputs)) { 77 | if (typeof v.value == "string" && v.value.indexOf(".com") != -1) { 78 | const resp = await axios.get(v.value); 79 | expect(resp.data).to.contain("Hello"); 80 | checked++; 81 | } 82 | } 83 | expect(checked).to.be.equal(1); 84 | }); 85 | }).timeout(1000000); 86 | 87 | it("builds a static website no-deploy", async () => { 88 | const commands = [ 89 | `give me an s3 bucket`, 90 | `add an index.html file that says "Hello, world!" in three languages`, 91 | `give me the website url for the index.html file`, 92 | `That gave me "AccessDenied", can you fix it?`, 93 | ]; 94 | await runTest(commands, { autoDeploy: false }, async (p) => { 95 | expect(p.program).to.contain("Hello"); 96 | expect(p.program).to.contain.oneOf(["websiteEndpoint", "domainName"]); 97 | },); 98 | }).timeout(1000000); 99 | 100 | }); 101 | 102 | interface Options { 103 | autoDeploy?: boolean; 104 | } 105 | 106 | async function runTest(commands: string[], opts: Options, validate: (p: PulumiAI, outputs: OutputMap) => Promise) { 107 | const p = new PulumiAI({ 108 | openaiApiKey: process.env.OPENAI_API_KEY!, 109 | openaiTemperature: 0.01, // For test stability 110 | stackName: `test-stack-${randomHex()}`, 111 | ...opts, 112 | }); 113 | let i = 0; 114 | const stack = await p.stack; 115 | for (const command of commands) { 116 | console.log(`step ${++i}/${commands.length}: '${command}'`); 117 | await p.interact(command); 118 | while (p.errors.length != 0) { 119 | await p.interact("Fix the errors"); 120 | } 121 | if (p.autoDeploy) { 122 | const outputs = await stack.outputs(); 123 | console.log(`outputs:\n${JSON.stringify(outputs, null, 2)}`); 124 | } 125 | } 126 | console.log(`Program:\n${p.program}`); 127 | if (p.autoDeploy) { 128 | const outputs = await stack.outputs(); 129 | console.log(`Validating outputs:\n${JSON.stringify(outputs, null, 2)}`); 130 | await validate(p, outputs); 131 | } else { 132 | await validate(p, {}); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { LocalWorkspace, Stack, EngineEvent, OutputMap, DiagnosticEvent } from "@pulumi/pulumi/automation"; 2 | import { IncomingMessage } from "http"; 3 | import { AxiosResponse } from "axios"; 4 | import * as openai from "openai"; 5 | 6 | interface PromptArgs { 7 | lang: string; 8 | langcode: string; 9 | cloud: string; 10 | region: string; 11 | program: string; 12 | errors: string[]; 13 | outputs: Record; 14 | instructions: string; 15 | } 16 | 17 | const titlePrompt = (program: string, wordLimit: number) => `You are PulumiAI, an AI agent that builds and deploys Cloud Infrastructure. 18 | In response to the Program I provide, please respond with a title for the program. 19 | Your response should be at most ${wordLimit} words. 20 | 21 | Program: 22 | \`\`\` 23 | ${program} 24 | \`\`\` 25 | `; 26 | 27 | const basePrompt = (lang: string) => `You are PulumiAI, an AI agent that builds and deploys Cloud Infrastructure written in Pulumi ${lang}. 28 | Generate a description of the Pulumi program you will define, followed by a single Pulumi ${lang} program in response to each of my Instructions. 29 | I will then deploy that program for you and let you know if there were errors. 30 | You should modify the current program based on my instructions. 31 | You should not start from scratch unless asked.`; 32 | 33 | const textPrompt = (args: Pick) => `${basePrompt(args.lang)} 34 | Please output in markdown syntax. 35 | 36 | Current Program: 37 | \`\`\`${args.langcode} 38 | ${args.program} 39 | \`\`\` 40 | 41 | Instructions: 42 | ${args.instructions} 43 | `; 44 | 45 | const prompt = (args: PromptArgs) => `${basePrompt(args.lang)} 46 | Always include stack exports in the program. 47 | Do not use the local filesystem. Do not use Pulumi config. 48 | Do not generate code in languages other than ${args.lang}. 49 | If you can: 50 | * Use "@pulumi/awsx" for ECS, and Fargate and API Gateway 51 | * Use "@pulumi/eks" for EKS. 52 | * Use aws.lambda.CallbackFunction for lambdas and serverless functions. 53 | 54 | Current Program: 55 | \`\`\`${args.langcode} 56 | ${args.program} 57 | \`\`\` 58 | 59 | Errors: 60 | ${args.errors.join("\n")} 61 | 62 | Stack Outputs: 63 | ${Object.entries(args.outputs).map(([k, v]) => `${k}: ${v}`).join("\n")} 64 | 65 | Instructions: 66 | ${args.instructions} 67 | `; 68 | 69 | function requireFromString(src: string): Record { 70 | var exports = {}; 71 | try { 72 | eval(src); 73 | } catch (err) { 74 | console.error(`The generated program failed with an error: 75 | * Run "!program" to inspect the generated code, or 76 | * Include the error message in your prompt and ask "Can you fix this?" 77 | 78 | ${err.stack || err}`); 79 | } 80 | return exports; 81 | } 82 | 83 | export interface Options { 84 | /** 85 | * The OpenAI API key to use. 86 | */ 87 | openaiApiKey: string; 88 | /** 89 | * The OpenAI model to use. Defaults to "gpt-4". 90 | */ 91 | openaiModel?: string; 92 | /** 93 | * The OpenAI temperature to use. Defaults to 0. 94 | */ 95 | openaiTemperature?: number; 96 | /** 97 | * Whether to automatically deploy the stack. Defaults to true. 98 | */ 99 | autoDeploy?: boolean; 100 | /** 101 | * The name of the project to create. Defaults to "pulumi-ai". 102 | */ 103 | projectName?: string; 104 | /** 105 | * The name of the stack to create. Defaults to "dev". 106 | */ 107 | stackName?: string; 108 | } 109 | 110 | class ProgramResponse { 111 | program: string; 112 | text: string; 113 | } 114 | 115 | export class InteractResponse { 116 | text: string; 117 | outputs?: OutputMap; 118 | program?: string; 119 | failed?: boolean; 120 | } 121 | 122 | export class PulumiAI { 123 | public program: string; 124 | public errors: DiagnosticEvent[]; 125 | public stack: Promise; 126 | public verbose: boolean; 127 | public autoDeploy: boolean; 128 | 129 | private openaiApi: openai.OpenAIApi; 130 | private model: string; 131 | private temperature: number; 132 | 133 | constructor(options: Options) { 134 | const configuration = new openai.Configuration({ 135 | apiKey: options.openaiApiKey, 136 | }); 137 | this.openaiApi = new openai.OpenAIApi(configuration); 138 | this.program = "const pulumi = require('@pulumi/pulumi');" 139 | this.errors = []; 140 | this.verbose = false; 141 | this.autoDeploy = options.autoDeploy ?? true; 142 | this.model = options.openaiModel ?? "gpt-4"; 143 | this.temperature = options.openaiTemperature ?? 0; 144 | if (this.autoDeploy) { 145 | this.stack = this.initializeStack(options.stackName ?? "dev", options.projectName ?? "pulumi-ai"); 146 | } 147 | } 148 | 149 | public async generateTitleForProgram(program: string, wordLimit = 4): Promise { 150 | const resp = await this.openaiApi.createChatCompletion({ 151 | model: this.model, 152 | messages: [{ role: "user", content: titlePrompt(program, wordLimit) }], 153 | temperature: this.temperature, 154 | }); 155 | 156 | const completion = resp.data.choices[0]; 157 | if (!completion) { 158 | throw new Error("no title found"); 159 | } 160 | 161 | return completion.message.content; 162 | } 163 | 164 | public async generateProgramFromPrompt(language: string, instructions: string, program: string, onEvent?: (chunk: string) => void) { 165 | const markdownLangMap: Record = { 166 | "TypeScript": "typescript", 167 | "Go": "go", 168 | "Python": "python", 169 | "C#": "csharp", 170 | }; 171 | 172 | const content = textPrompt({ 173 | lang: language, 174 | langcode: markdownLangMap[language] ?? "typescript", 175 | program, 176 | instructions, 177 | }) 178 | 179 | return await this.generateProgramFor(content, onEvent); 180 | } 181 | 182 | public async interact(input: string, onEvent?: (chunk: string) => void, predeploy?: (resp: InteractResponse) => void): Promise { 183 | const resp = await this.getProgramFor(input, onEvent); 184 | this.program = resp.program; 185 | const response = { 186 | text: resp.text, 187 | program: resp.program, 188 | outputs: undefined, 189 | failed: undefined, 190 | }; 191 | if (this.autoDeploy) { 192 | if (predeploy) { predeploy(response); } 193 | try { 194 | response.outputs = await this.deploy(); 195 | this.errors = []; 196 | } catch (err) { 197 | this.errors = err.errors; 198 | response.failed = true; 199 | } 200 | } 201 | return response; 202 | } 203 | 204 | private async initializeStack(stackName: string, projectName: string): Promise { 205 | const stack = await LocalWorkspace.createOrSelectStack({ 206 | stackName: stackName, 207 | projectName: projectName, 208 | program: async () => requireFromString(""), 209 | }); 210 | await stack.setConfig("aws:region", { value: "us-west-2" }); 211 | // Cancel and ignore any errors to ensure we clean up after previous failed deployments 212 | try { await stack.cancel(); } catch (err) { } 213 | try { 214 | const res = await stack.up(); 215 | } catch (err) { 216 | if (err.commandResult) { 217 | throw new Error(err.commandResult.stderr); 218 | } 219 | throw err; 220 | } 221 | return stack; 222 | } 223 | 224 | private async getProgramFor(request: string, onEvent?: (chunk: string) => void): Promise { 225 | const content = prompt({ 226 | lang: "JavaScript", 227 | langcode: "javascript", 228 | cloud: "AWS", 229 | region: "us-west-2", 230 | program: this.program, 231 | errors: this.errors.map(e => JSON.stringify(e)), 232 | // TODO: Pass outputs from previous deployment 233 | outputs: {}, 234 | instructions: request, 235 | }) 236 | 237 | return this.generateProgramFor(content, onEvent); 238 | } 239 | 240 | private async generateProgramFor(content: string, onEvent?: (chunk: string) => void): Promise { 241 | this.log("prompt: " + content); 242 | let resp: AxiosResponse; 243 | try { 244 | resp = await this.openaiApi.createChatCompletion({ 245 | model: this.model, 246 | messages: [{ role: "user", content }], 247 | temperature: this.temperature, 248 | stream: true, 249 | }, { responseType: "stream" }); 250 | } catch (err) { 251 | if (err.message == "Request failed with status code 404") { 252 | throw new Error(`Got a 404 response from OpenAI API. Confirm that the model you provided (via \`OPENAI_MODEL\` or default \`gpt-4\`) is one that your OpenAI account has access to: '${this.model}'`) 253 | } 254 | throw err; 255 | } 256 | 257 | const stream = resp.data as unknown as IncomingMessage; 258 | 259 | const allData = new Promise((resolve, reject) => { 260 | const textParts: string[] = []; 261 | stream.on("data", async (chunk: Buffer) => { 262 | try { 263 | const payloads = chunk.toString().split("\n\n"); 264 | for (const payload of payloads) { 265 | if (payload.includes('[DONE]')) { 266 | resolve(textParts.join("")); 267 | } else if (payload.startsWith("data:")) { 268 | const data = payload.replace(/(\n)?^data:\s*/g, ''); 269 | const parsed = JSON.parse(data.trim()); 270 | const content = parsed.choices[0].delta.content; 271 | if (content) { 272 | if (onEvent) { 273 | onEvent(content); 274 | } 275 | textParts.push(content); 276 | } 277 | } else if (payload == "") { 278 | // Ignore empty payloads 279 | } else { 280 | this.log("unknown openai payload: " + payload) 281 | } 282 | } 283 | } catch (err) { 284 | reject(err); 285 | } 286 | }); 287 | }); 288 | 289 | // Wait until we've gotten all the updates from the stream. 290 | // This might throw, and if it does, we bubble that up into our caller. 291 | const text = await allData; 292 | this.log("response: " + text); 293 | const response = { 294 | text: text, 295 | program: "", 296 | }; 297 | 298 | const codestart = text.indexOf("```"); 299 | if (codestart == -1) { 300 | return response; 301 | } 302 | const start = text.indexOf("\n", codestart) + 1; 303 | const end = text.indexOf("```", start); 304 | response.program = text.substring(start, end); 305 | return response; 306 | } 307 | 308 | private log(msg: string) { 309 | if (this.verbose) { 310 | console.warn(msg); 311 | } 312 | } 313 | 314 | private async deploy(): Promise { 315 | const stack = await this.stack; 316 | stack.workspace.program = async () => requireFromString(this.program); 317 | 318 | const errors: DiagnosticEvent[] = []; 319 | const onEvent = (event: EngineEvent) => { 320 | try { 321 | if (event.diagnosticEvent && (event.diagnosticEvent.severity == "error" || event.diagnosticEvent.severity == "info#err")) { 322 | if (!event.diagnosticEvent.message.startsWith("One or more errors occurred")) { 323 | errors.push(event.diagnosticEvent); 324 | } 325 | } else if (event.resourcePreEvent) { 326 | if (event.resourcePreEvent.metadata.op != "same") { 327 | const name = event.resourcePreEvent.metadata.urn.split("::")[3]; 328 | console.log(`${event.resourcePreEvent.metadata.op} ${event.resourcePreEvent.metadata.type} ${name} ...`); 329 | } 330 | } else if (event.resOutputsEvent) { 331 | if (event.resOutputsEvent.metadata.op != "same") { 332 | const name = event.resOutputsEvent.metadata.urn.split("::")[3]; 333 | console.log(`${event.resOutputsEvent.metadata.op}d ${name}`); 334 | } 335 | } else if (event.diagnosticEvent || event.preludeEvent || event.summaryEvent || event.cancelEvent) { 336 | // Ignore thse events 337 | } else { 338 | this.log("unhandled event: " + JSON.stringify(event, null, 4)); 339 | } 340 | } catch (err) { 341 | this.log(`couldn't handle event ${event}: ${err}`); 342 | } 343 | } 344 | 345 | try { 346 | const res = await stack.up({ onEvent }); 347 | return res.outputs 348 | } catch (err) { 349 | // Add the errors and rethrow 350 | err.errors = errors; 351 | throw err; 352 | } 353 | } 354 | 355 | } 356 | --------------------------------------------------------------------------------