Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add credential management #91

Merged
merged 7 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/gptscript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,70 @@ export class GPTScript {
return this._load({toolDefs, disableCache, subTool})
}

async listCredentials(context: Array<string>, allContexts: boolean): Promise<Array<Credential>> {
if (!this.ready) {
this.ready = await this.testGPTScriptURL(20)
}
const resp = await fetch(`${GPTScript.serverURL}/credentials`, {
method: "POST",
body: JSON.stringify({context, allContexts})
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does runSubcommand not work for these?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remember trying it and it not working for some reason, but I don't remember why...let me try that again and see.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking a look at the code again, RunSubcommand doesn't let me specify an arbitrary request body to use. I can only pass it a tool and some run options. But I need to be able to pass things like a credential (in the case of create), or name/context information for the other operations.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know if you want me to change this or leave it as is @thedadams

Copy link
Contributor

@thedadams thedadams Sep 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you try something like this?

const r: Run = new RunSubcommand("credentials", undefined, {}, GPTScript.serverURL)

r.request(payload)
await r.text()

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That worked, thanks. This is updated now


if (resp.status < 200 || resp.status >= 400) {
throw new Error(`Failed to list credentials: ${(await resp.json())["stderr"]}`)
}

const r = await resp.json()
return r["stdout"].map((c: any) => jsonToCredential(JSON.stringify(c)))
}

async createCredential(credential: Credential): Promise<void> {
if (!this.ready) {
this.ready = await this.testGPTScriptURL(20)
}
const resp = await fetch(`${GPTScript.serverURL}/credentials/create`, {
method: "POST",
body: JSON.stringify({
content: credentialToJSON(credential)
})
})

if (resp.status < 200 || resp.status >= 400) {
throw new Error(`Failed to create credential: ${(await resp.json())["stderr"]}`)
}
}

async revealCredential(context: Array<string>, name: string): Promise<Credential> {
if (!this.ready) {
this.ready = await this.testGPTScriptURL(20)
}
const resp = await fetch(`${GPTScript.serverURL}/credentials/reveal`, {
method: "POST",
body: JSON.stringify({context, name})
})

if (resp.status < 200 || resp.status >= 400) {
throw new Error(`Failed to reveal credential: ${(await resp.json())["stderr"]}`)
}

const r = await resp.json()
return jsonToCredential(JSON.stringify(r["stdout"]))
}

async deleteCredential(context: string, name: string): Promise<void> {
if (!this.ready) {
this.ready = await this.testGPTScriptURL(20)
}
const resp = await fetch(`${GPTScript.serverURL}/credentials/delete`, {
method: "POST",
body: JSON.stringify({context: [context], name})
})

if (resp.status < 200 || resp.status >= 400) {
throw new Error(`Failed to delete credential: ${(await resp.json())["stderr"]}`)
}
}

/**
* Helper method to handle the common logic for loading.
*
Expand Down Expand Up @@ -967,3 +1031,48 @@ function parseBlocksFromNodes(nodes: any[]): Block[] {
function randomId(prefix: string): string {
return prefix + Math.random().toString(36).substring(2, 12)
}

export enum CredentialType {
Tool = "tool",
ModelProvider = "modelProvider",
}

export type Credential = {
context: string
name: string
type: CredentialType
env: Record<string, string>
ephemeral: boolean
expiresAt?: Date | undefined
refreshToken?: string | undefined
}

// for internal use only
type cred = {
context: string
toolName: string
type: string
env: Record<string, string>
ephemeral: boolean
expiresAt: string | undefined
refreshToken: string | undefined
}

export function credentialToJSON(c: Credential): string {
const expiresAt = c.expiresAt ? c.expiresAt.toISOString() : undefined
const type = c.type === CredentialType.Tool ? "tool" : "modelProvider"
return JSON.stringify({context: c.context, toolName: c.name, type: type, env: c.env, ephemeral: c.ephemeral, expiresAt: expiresAt, refreshToken: c.refreshToken} as cred)
}

function jsonToCredential(cred: string): Credential {
const c = JSON.parse(cred) as cred
return {
context: c.context,
name: c.toolName,
type: c.type === "tool" ? CredentialType.Tool : CredentialType.ModelProvider,
env: c.env,
ephemeral: c.ephemeral,
expiresAt: c.expiresAt ? new Date(c.expiresAt) : undefined,
refreshToken: c.refreshToken
}
}
4 changes: 2 additions & 2 deletions tests/fixtures/global-tools.gpt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Runbook 3

---
Name: tool_1
Global Tools: sys.read, sys.write, github.com/gptscript-ai/knowledge, github.com/drpebcak/duckdb, github.com/gptscript-ai/browser, github.com/gptscript-ai/browser-search/google, github.com/gptscript-ai/browser-search/google-question-answerer
Global Tools: sys.read, sys.write, github.com/drpebcak/duckdb, github.com/gptscript-ai/browser, github.com/gptscript-ai/browser-search/google, github.com/gptscript-ai/browser-search/google-question-answerer

Say "Hello!"

Expand All @@ -16,4 +16,4 @@ What time is it?
---
Name: tool_3

Give me a paragraph of lorem ipsum
Give me a paragraph of lorem ipsum
69 changes: 67 additions & 2 deletions tests/gptscript.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import * as gptscript from "../src/gptscript"
import {ArgumentSchemaType, getEnv, PropertyType, RunEventType, TextType, ToolDef, ToolType} from "../src/gptscript"
import {
ArgumentSchemaType,
Credential, CredentialType,
getEnv,
PropertyType,
RunEventType,
TextType,
ToolDef,
ToolType
} from "../src/gptscript"
import path from "path"
import {fileURLToPath} from "url"
import * as fs from "node:fs"
import {randomBytes} from "node:crypto";

let gFirst: gptscript.GPTScript
let g: gptscript.GPTScript
Expand Down Expand Up @@ -791,4 +801,59 @@ describe("gptscript module", () => {
expect(err).toEqual(undefined)
expect(out).toEqual("200")
}, 20000)
})

test("credential operations", async () => {
const name = "test-" + randomBytes(10).toString("hex")
const value = randomBytes(10).toString("hex")

// Create
try {
await g.createCredential({
name: name,
context: "default",
env: {"TEST": value},
ephemeral: false,
expiresAt: new Date(Date.now() + 5000), // 5 seconds from now
type: CredentialType.Tool,
})
} catch (e) {
throw new Error("failed to create credential: " + e)
}

// Wait 5 seconds
await new Promise(resolve => setTimeout(resolve, 5000))

// Reveal
try {
const result = await g.revealCredential(["default"], name)
expect(result.env["TEST"]).toEqual(value)
expect(result.expiresAt!.valueOf()).toBeLessThan(new Date().valueOf())
} catch (e) {
throw new Error("failed to reveal credential: " + e)
}

// List
try {
const result = await g.listCredentials(["default"], false)
expect(result.length).toBeGreaterThan(0)
expect(result.map(c => c.name)).toContain(name)
} catch (e) {
throw new Error("failed to list credentials: " + e)
}

// Delete
try {
await g.deleteCredential("default", name)
} catch (e) {
throw new Error("failed to delete credential: " + e)
}

// Verify deletion
try {
const result = await g.listCredentials(["default"], false)
expect(result.map(c => c.name)).not.toContain(name)
} catch (e) {
throw new Error("failed to verify deletion: " + e)
}
}, 20000)
})