Skip to content

Commit c5466d0

Browse files
authored
feat: add credential management (#65)
Signed-off-by: Grant Linville <grant@acorn.io>
1 parent a649fff commit c5466d0

9 files changed

+224
-2
lines changed

Diff for: credentials.go

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package gptscript
2+
3+
import "time"
4+
5+
type CredentialType string
6+
7+
const (
8+
CredentialTypeTool CredentialType = "tool"
9+
CredentialTypeModelProvider CredentialType = "modelProvider"
10+
)
11+
12+
type Credential struct {
13+
Context string `json:"context"`
14+
ToolName string `json:"toolName"`
15+
Type CredentialType `json:"type"`
16+
Env map[string]string `json:"env"`
17+
Ephemeral bool `json:"ephemeral,omitempty"`
18+
ExpiresAt *time.Time `json:"expiresAt"`
19+
RefreshToken string `json:"refreshToken"`
20+
}
21+
22+
type CredentialRequest struct {
23+
Content string `json:"content"`
24+
AllContexts bool `json:"allContexts"`
25+
Context []string `json:"context"`
26+
Name string `json:"name"`
27+
}

Diff for: go.mod

+6-1
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,20 @@ module github.com/gptscript-ai/go-gptscript
22

33
go 1.23.0
44

5-
require github.com/getkin/kin-openapi v0.124.0
5+
require (
6+
github.com/getkin/kin-openapi v0.124.0
7+
github.com/stretchr/testify v1.8.4
8+
)
69

710
require (
11+
github.com/davecgh/go-spew v1.1.1 // indirect
812
github.com/go-openapi/jsonpointer v0.20.2 // indirect
913
github.com/go-openapi/swag v0.22.8 // indirect
1014
github.com/invopop/yaml v0.2.0 // indirect
1115
github.com/josharian/intern v1.0.0 // indirect
1216
github.com/mailru/easyjson v0.7.7 // indirect
1317
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
1418
github.com/perimeterx/marshmallow v1.1.5 // indirect
19+
github.com/pmezard/go-difflib v1.0.0 // indirect
1520
gopkg.in/yaml.v3 v3.0.1 // indirect
1621
)

Diff for: gptscript.go

+61
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,67 @@ func (g *GPTScript) PromptResponse(ctx context.Context, resp PromptResponse) err
336336
return err
337337
}
338338

339+
type ListCredentialsOptions struct {
340+
CredentialContexts []string
341+
AllContexts bool
342+
}
343+
344+
func (g *GPTScript) ListCredentials(ctx context.Context, opts ListCredentialsOptions) ([]Credential, error) {
345+
req := CredentialRequest{}
346+
if opts.AllContexts {
347+
req.AllContexts = true
348+
} else if len(opts.CredentialContexts) > 0 {
349+
req.Context = opts.CredentialContexts
350+
} else {
351+
req.Context = []string{"default"}
352+
}
353+
354+
out, err := g.runBasicCommand(ctx, "credentials", req)
355+
if err != nil {
356+
return nil, err
357+
}
358+
359+
var creds []Credential
360+
if err = json.Unmarshal([]byte(out), &creds); err != nil {
361+
return nil, err
362+
}
363+
return creds, nil
364+
}
365+
366+
func (g *GPTScript) CreateCredential(ctx context.Context, cred Credential) error {
367+
credJSON, err := json.Marshal(cred)
368+
if err != nil {
369+
return fmt.Errorf("failed to marshal credential: %w", err)
370+
}
371+
372+
_, err = g.runBasicCommand(ctx, "credentials/create", CredentialRequest{Content: string(credJSON)})
373+
return err
374+
}
375+
376+
func (g *GPTScript) RevealCredential(ctx context.Context, credCtxs []string, name string) (Credential, error) {
377+
out, err := g.runBasicCommand(ctx, "credentials/reveal", CredentialRequest{
378+
Context: credCtxs,
379+
Name: name,
380+
})
381+
if err != nil {
382+
return Credential{}, err
383+
}
384+
385+
var cred Credential
386+
if err = json.Unmarshal([]byte(out), &cred); err != nil {
387+
return Credential{}, err
388+
}
389+
return cred, nil
390+
}
391+
392+
func (g *GPTScript) DeleteCredential(ctx context.Context, credCtx, name string) error {
393+
_, err := g.runBasicCommand(ctx, "credentials/delete", CredentialRequest{
394+
Context: []string{credCtx}, // Only one context can be specified for delete operations
395+
Name: name,
396+
})
397+
return err
398+
}
399+
339400
func (g *GPTScript) runBasicCommand(ctx context.Context, requestPath string, body any) (string, error) {
340401
run := &Run{
341402
url: g.globalOpts.URL,

Diff for: gptscript_test.go

+45
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@ package gptscript
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
7+
"math/rand"
68
"os"
79
"path/filepath"
810
"runtime"
11+
"strconv"
912
"strings"
1013
"testing"
1114

1215
"github.com/getkin/kin-openapi/openapi3"
16+
"github.com/stretchr/testify/require"
1317
)
1418

1519
var g *GPTScript
@@ -1448,3 +1452,44 @@ func TestLoadTools(t *testing.T) {
14481452
t.Errorf("Unexpected name: %s", prg.Name)
14491453
}
14501454
}
1455+
1456+
func TestCredentials(t *testing.T) {
1457+
// We will test in the following order of create, list, reveal, delete.
1458+
name := "test-" + strconv.Itoa(rand.Int())
1459+
if len(name) > 20 {
1460+
name = name[:20]
1461+
}
1462+
1463+
// Create
1464+
err := g.CreateCredential(context.Background(), Credential{
1465+
Context: "testing",
1466+
ToolName: name,
1467+
Type: CredentialTypeTool,
1468+
Env: map[string]string{"ENV": "testing"},
1469+
RefreshToken: "my-refresh-token",
1470+
})
1471+
require.NoError(t, err)
1472+
1473+
// List
1474+
creds, err := g.ListCredentials(context.Background(), ListCredentialsOptions{
1475+
CredentialContexts: []string{"testing"},
1476+
})
1477+
require.NoError(t, err)
1478+
require.GreaterOrEqual(t, len(creds), 1)
1479+
1480+
// Reveal
1481+
cred, err := g.RevealCredential(context.Background(), []string{"testing"}, name)
1482+
require.NoError(t, err)
1483+
require.Contains(t, cred.Env, "ENV")
1484+
require.Equal(t, cred.Env["ENV"], "testing")
1485+
require.Equal(t, cred.RefreshToken, "my-refresh-token")
1486+
1487+
// Delete
1488+
err = g.DeleteCredential(context.Background(), "testing", name)
1489+
require.NoError(t, err)
1490+
1491+
// Delete again and make sure we get a NotFoundError
1492+
err = g.DeleteCredential(context.Background(), "testing", name)
1493+
require.Error(t, err)
1494+
require.True(t, errors.As(err, &ErrNotFound{}))
1495+
}

Diff for: opts.go

+1
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ type Options struct {
7070
IncludeEvents bool `json:"includeEvents"`
7171
Prompt bool `json:"prompt"`
7272
CredentialOverrides []string `json:"credentialOverrides"`
73+
CredentialContexts []string `json:"credentialContext"` // json tag is left singular to match SDKServer
7374
Location string `json:"location"`
7475
ForceSequential bool `json:"forceSequential"`
7576
}

Diff for: run.go

+24
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ import (
1717

1818
var errAbortRun = errors.New("run aborted")
1919

20+
type ErrNotFound struct {
21+
Message string
22+
}
23+
24+
func (e ErrNotFound) Error() string {
25+
return e.Message
26+
}
27+
2028
type Run struct {
2129
url, token, requestPath, toolPath string
2230
tools []ToolDef
@@ -36,6 +44,7 @@ type Run struct {
3644
output, errput string
3745
events chan Frame
3846
lock sync.Mutex
47+
responseCode int
3948
}
4049

4150
// Text returns the text output of the gptscript. It blocks until the output is ready.
@@ -60,6 +69,11 @@ func (r *Run) State() RunState {
6069
// Err returns the error that caused the gptscript to fail, if any.
6170
func (r *Run) Err() error {
6271
if r.err != nil {
72+
if r.responseCode == http.StatusNotFound {
73+
return ErrNotFound{
74+
Message: fmt.Sprintf("run encountered an error: %s", r.errput),
75+
}
76+
}
6377
return fmt.Errorf("run encountered an error: %w with error output: %s", r.err, r.errput)
6478
}
6579
return nil
@@ -245,6 +259,7 @@ func (r *Run) request(ctx context.Context, payload any) (err error) {
245259
return r.err
246260
}
247261

262+
r.responseCode = resp.StatusCode
248263
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
249264
r.state = Error
250265
r.err = fmt.Errorf("run encountered an error")
@@ -335,6 +350,15 @@ func (r *Run) request(ctx context.Context, payload any) (err error) {
335350

336351
done, _ = out["done"].(bool)
337352
r.rawOutput = out
353+
case []any:
354+
b, err := json.Marshal(out)
355+
if err != nil {
356+
r.state = Error
357+
r.err = fmt.Errorf("failed to process stdout: %w", err)
358+
return
359+
}
360+
361+
r.output = string(b)
338362
default:
339363
r.state = Error
340364
r.err = fmt.Errorf("failed to process stdout, invalid type: %T", out)

Diff for: run_test.go

+46
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ package gptscript
22

33
import (
44
"context"
5+
"crypto/rand"
6+
"encoding/hex"
7+
"os"
58
"runtime"
69
"testing"
10+
11+
"github.com/stretchr/testify/require"
712
)
813

914
func TestRestartingErrorRun(t *testing.T) {
@@ -42,3 +47,44 @@ func TestRestartingErrorRun(t *testing.T) {
4247
t.Errorf("executing run with input of 0 should not fail: %v", err)
4348
}
4449
}
50+
51+
func TestStackedContexts(t *testing.T) {
52+
const name = "testcred"
53+
54+
wd, err := os.Getwd()
55+
require.NoError(t, err)
56+
57+
bytes := make([]byte, 32)
58+
_, err = rand.Read(bytes)
59+
require.NoError(t, err)
60+
61+
context1 := hex.EncodeToString(bytes)[:16]
62+
context2 := hex.EncodeToString(bytes)[16:]
63+
64+
run, err := g.Run(context.Background(), wd+"/test/credential.gpt", Options{
65+
CredentialContexts: []string{context1, context2},
66+
})
67+
require.NoError(t, err)
68+
69+
_, err = run.Text()
70+
require.NoError(t, err)
71+
72+
// The credential should exist in context1 now.
73+
cred, err := g.RevealCredential(context.Background(), []string{context1, context2}, name)
74+
require.NoError(t, err)
75+
require.Equal(t, cred.Context, context1)
76+
77+
// Now change the context order and run the script again.
78+
run, err = g.Run(context.Background(), wd+"/test/credential.gpt", Options{
79+
CredentialContexts: []string{context2, context1},
80+
})
81+
require.NoError(t, err)
82+
83+
_, err = run.Text()
84+
require.NoError(t, err)
85+
86+
// Now make sure the credential exists in context1 still.
87+
cred, err = g.RevealCredential(context.Background(), []string{context2, context1}, name)
88+
require.NoError(t, err)
89+
require.Equal(t, cred.Context, context1)
90+
}

Diff for: test/credential.gpt

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: echocred
2+
credential: mycredentialtool as testcred
3+
4+
#!/usr/bin/env bash
5+
6+
echo $VALUE
7+
8+
---
9+
name: mycredentialtool
10+
11+
#!sys.echo
12+
13+
{"env":{"VALUE":"hello"}}

Diff for: test/global-tools.gpt

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Runbook 3
44

55
---
66
Name: tool_1
7-
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
7+
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
88

99
Say "Hello!"
1010

0 commit comments

Comments
 (0)