diff --git a/credentials.go b/credentials.go new file mode 100644 index 0000000..4c7c11f --- /dev/null +++ b/credentials.go @@ -0,0 +1,27 @@ +package gptscript + +import "time" + +type CredentialType string + +const ( + CredentialTypeTool CredentialType = "tool" + CredentialTypeModelProvider CredentialType = "modelProvider" +) + +type Credential struct { + Context string `json:"context"` + ToolName string `json:"toolName"` + Type CredentialType `json:"type"` + Env map[string]string `json:"env"` + Ephemeral bool `json:"ephemeral,omitempty"` + ExpiresAt *time.Time `json:"expiresAt"` + RefreshToken string `json:"refreshToken"` +} + +type CredentialRequest struct { + Content string `json:"content"` + AllContexts bool `json:"allContexts"` + Context []string `json:"context"` + Name string `json:"name"` +} diff --git a/go.mod b/go.mod index 7094ab6..283f8d6 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,13 @@ module github.com/gptscript-ai/go-gptscript go 1.23.0 -require github.com/getkin/kin-openapi v0.124.0 +require ( + github.com/getkin/kin-openapi v0.124.0 + github.com/stretchr/testify v1.8.4 +) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-openapi/jsonpointer v0.20.2 // indirect github.com/go-openapi/swag v0.22.8 // indirect github.com/invopop/yaml v0.2.0 // indirect @@ -12,5 +16,6 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/gptscript.go b/gptscript.go index 1968144..0fc11aa 100644 --- a/gptscript.go +++ b/gptscript.go @@ -307,6 +307,67 @@ func (g *GPTScript) PromptResponse(ctx context.Context, resp PromptResponse) err return err } +type ListCredentialsOptions struct { + CredentialContexts []string + AllContexts bool +} + +func (g *GPTScript) ListCredentials(ctx context.Context, opts ListCredentialsOptions) ([]Credential, error) { + req := CredentialRequest{} + if opts.AllContexts { + req.AllContexts = true + } else if len(opts.CredentialContexts) > 0 { + req.Context = opts.CredentialContexts + } else { + req.Context = []string{"default"} + } + + out, err := g.runBasicCommand(ctx, "credentials", req) + if err != nil { + return nil, err + } + + var creds []Credential + if err = json.Unmarshal([]byte(out), &creds); err != nil { + return nil, err + } + return creds, nil +} + +func (g *GPTScript) CreateCredential(ctx context.Context, cred Credential) error { + credJSON, err := json.Marshal(cred) + if err != nil { + return fmt.Errorf("failed to marshal credential: %w", err) + } + + _, err = g.runBasicCommand(ctx, "credentials/create", CredentialRequest{Content: string(credJSON)}) + return err +} + +func (g *GPTScript) RevealCredential(ctx context.Context, credCtxs []string, name string) (Credential, error) { + out, err := g.runBasicCommand(ctx, "credentials/reveal", CredentialRequest{ + Context: credCtxs, + Name: name, + }) + if err != nil { + return Credential{}, err + } + + var cred Credential + if err = json.Unmarshal([]byte(out), &cred); err != nil { + return Credential{}, err + } + return cred, nil +} + +func (g *GPTScript) DeleteCredential(ctx context.Context, credCtx, name string) error { + _, err := g.runBasicCommand(ctx, "credentials/delete", CredentialRequest{ + Context: []string{credCtx}, // Only one context can be specified for delete operations + Name: name, + }) + return err +} + func (g *GPTScript) runBasicCommand(ctx context.Context, requestPath string, body any) (string, error) { run := &Run{ url: g.url, diff --git a/gptscript_test.go b/gptscript_test.go index 6d43e4f..b1a2456 100644 --- a/gptscript_test.go +++ b/gptscript_test.go @@ -2,14 +2,18 @@ package gptscript import ( "context" + "errors" "fmt" + "math/rand" "os" "path/filepath" "runtime" + "strconv" "strings" "testing" "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/require" ) var g *GPTScript @@ -1448,3 +1452,44 @@ func TestLoadTools(t *testing.T) { t.Errorf("Unexpected name: %s", prg.Name) } } + +func TestCredentials(t *testing.T) { + // We will test in the following order of create, list, reveal, delete. + name := "test-" + strconv.Itoa(rand.Int()) + if len(name) > 20 { + name = name[:20] + } + + // Create + err := g.CreateCredential(context.Background(), Credential{ + Context: "testing", + ToolName: name, + Type: CredentialTypeTool, + Env: map[string]string{"ENV": "testing"}, + RefreshToken: "my-refresh-token", + }) + require.NoError(t, err) + + // List + creds, err := g.ListCredentials(context.Background(), ListCredentialsOptions{ + CredentialContexts: []string{"testing"}, + }) + require.NoError(t, err) + require.GreaterOrEqual(t, len(creds), 1) + + // Reveal + cred, err := g.RevealCredential(context.Background(), []string{"testing"}, name) + require.NoError(t, err) + require.Contains(t, cred.Env, "ENV") + require.Equal(t, cred.Env["ENV"], "testing") + require.Equal(t, cred.RefreshToken, "my-refresh-token") + + // Delete + err = g.DeleteCredential(context.Background(), "testing", name) + require.NoError(t, err) + + // Delete again and make sure we get a NotFoundError + err = g.DeleteCredential(context.Background(), "testing", name) + require.Error(t, err) + require.True(t, errors.As(err, &ErrNotFound{})) +} diff --git a/opts.go b/opts.go index 191a8d0..b1eae28 100644 --- a/opts.go +++ b/opts.go @@ -66,6 +66,7 @@ type Options struct { IncludeEvents bool `json:"includeEvents"` Prompt bool `json:"prompt"` CredentialOverrides []string `json:"credentialOverrides"` + CredentialContexts []string `json:"credentialContext"` // json tag is left singular to match SDKServer Location string `json:"location"` ForceSequential bool `json:"forceSequential"` } diff --git a/run.go b/run.go index 5b716aa..1b376ab 100644 --- a/run.go +++ b/run.go @@ -17,6 +17,14 @@ import ( var errAbortRun = errors.New("run aborted") +type ErrNotFound struct { + Message string +} + +func (e ErrNotFound) Error() string { + return e.Message +} + type Run struct { url, requestPath, toolPath string tools []ToolDef @@ -36,6 +44,7 @@ type Run struct { output, errput string events chan Frame lock sync.Mutex + responseCode int } // Text returns the text output of the gptscript. It blocks until the output is ready. @@ -60,6 +69,11 @@ func (r *Run) State() RunState { // Err returns the error that caused the gptscript to fail, if any. func (r *Run) Err() error { if r.err != nil { + if r.responseCode == http.StatusNotFound { + return ErrNotFound{ + Message: fmt.Sprintf("run encountered an error: %s", r.errput), + } + } return fmt.Errorf("run encountered an error: %w with error output: %s", r.err, r.errput) } return nil @@ -235,6 +249,7 @@ func (r *Run) request(ctx context.Context, payload any) (err error) { return r.err } + r.responseCode = resp.StatusCode if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest { r.state = Error r.err = fmt.Errorf("run encountered an error") @@ -325,6 +340,15 @@ func (r *Run) request(ctx context.Context, payload any) (err error) { done, _ = out["done"].(bool) r.rawOutput = out + case []any: + b, err := json.Marshal(out) + if err != nil { + r.state = Error + r.err = fmt.Errorf("failed to process stdout: %w", err) + return + } + + r.output = string(b) default: r.state = Error r.err = fmt.Errorf("failed to process stdout, invalid type: %T", out) diff --git a/run_test.go b/run_test.go index f9014a1..ac1e1d8 100644 --- a/run_test.go +++ b/run_test.go @@ -2,8 +2,13 @@ package gptscript import ( "context" + "crypto/rand" + "encoding/hex" + "os" "runtime" "testing" + + "github.com/stretchr/testify/require" ) func TestRestartingErrorRun(t *testing.T) { @@ -42,3 +47,44 @@ func TestRestartingErrorRun(t *testing.T) { t.Errorf("executing run with input of 0 should not fail: %v", err) } } + +func TestStackedContexts(t *testing.T) { + const name = "testcred" + + wd, err := os.Getwd() + require.NoError(t, err) + + bytes := make([]byte, 32) + _, err = rand.Read(bytes) + require.NoError(t, err) + + context1 := hex.EncodeToString(bytes)[:16] + context2 := hex.EncodeToString(bytes)[16:] + + run, err := g.Run(context.Background(), wd+"/test/credential.gpt", Options{ + CredentialContexts: []string{context1, context2}, + }) + require.NoError(t, err) + + _, err = run.Text() + require.NoError(t, err) + + // The credential should exist in context1 now. + cred, err := g.RevealCredential(context.Background(), []string{context1, context2}, name) + require.NoError(t, err) + require.Equal(t, cred.Context, context1) + + // Now change the context order and run the script again. + run, err = g.Run(context.Background(), wd+"/test/credential.gpt", Options{ + CredentialContexts: []string{context2, context1}, + }) + require.NoError(t, err) + + _, err = run.Text() + require.NoError(t, err) + + // Now make sure the credential exists in context1 still. + cred, err = g.RevealCredential(context.Background(), []string{context2, context1}, name) + require.NoError(t, err) + require.Equal(t, cred.Context, context1) +} diff --git a/test/credential.gpt b/test/credential.gpt new file mode 100644 index 0000000..61e656f --- /dev/null +++ b/test/credential.gpt @@ -0,0 +1,13 @@ +name: echocred +credential: mycredentialtool as testcred + +#!/usr/bin/env bash + +echo $VALUE + +--- +name: mycredentialtool + +#!sys.echo + +{"env":{"VALUE":"hello"}} \ No newline at end of file diff --git a/test/global-tools.gpt b/test/global-tools.gpt index 4671fee..7e975be 100644 --- a/test/global-tools.gpt +++ b/test/global-tools.gpt @@ -4,7 +4,7 @@ Runbook 3 --- Name: tool_1 -Global Tools: 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: 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!"