Skip to content

Commit 0ef0f82

Browse files
authored
enhance: add support for args and aliases to credential tools (gptscript-ai#433)
Signed-off-by: Grant Linville <[email protected]>
1 parent 22e0293 commit 0ef0f82

File tree

8 files changed

+368
-39
lines changed

8 files changed

+368
-39
lines changed

.github/workflows/test.yaml

+3-4
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,11 @@ jobs:
2727
cache: false
2828
go-version: "1.21"
2929
- name: Build UI
30-
run: |
31-
if [ ${{ matrix.os }} != 'windows-latest' ]; then
32-
make build-ui
33-
fi
30+
if: matrix.os == 'ubuntu-22.04'
31+
run: make build-ui
3432
shell: bash
3533
- name: Validate
34+
if: matrix.os == 'ubuntu-22.04'
3635
run: make validate
3736
- name: Build
3837
run: make build

.golangci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ run:
33

44
output:
55
formats:
6-
- github-actions
6+
- format: colored-line-number
77

88
linters:
99
disable-all: true

Makefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ tidy:
2121
test:
2222
go test -v ./...
2323

24-
GOLANGCI_LINT_VERSION ?= v1.56.1
24+
GOLANGCI_LINT_VERSION ?= v1.59.0
2525
lint:
2626
if ! command -v golangci-lint &> /dev/null; then \
2727
echo "Could not find golangci-lint, installing version $(GOLANGCI_LINT_VERSION)."; \

docs/docs/03-tools/04-credentials.md

+53-11
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ directly from user input) and conveniently set them in the environment before ru
88
A credential provider tool looks just like any other GPTScript, with the following caveats:
99
- It cannot call the LLM and must run a command.
1010
- It must print contents to stdout in the format `{"env":{"ENV_VAR_1":"value1","ENV_VAR_2":"value2"}}`.
11-
- Any args defined on the tool will be ignored.
1211

1312
Here is a simple example of a credential provider tool that uses the builtin `sys.prompt` to ask the user for some input:
1413

@@ -42,14 +41,39 @@ LLM about it or even tell the LLM about the tool.
4241
If GPTScript has called the credential provider tool in the same context (more on that later), then it will use the stored
4342
credential instead of fetching it again.
4443

45-
You can also specify multiple credential tools for the same script:
44+
You can also specify multiple credential tools for the same script, but they must be on separate lines:
4645

4746
```yaml
48-
credentials: credential-tool-1.gpt, credential-tool-2.gpt
47+
credentials: credential-tool-1.gpt
48+
credentials: credential-tool-2.gpt
4949

5050
(tool stuff here)
5151
```
5252

53+
## Credential Tool Arguments
54+
55+
A credential tool may define arguments. Here is an example:
56+
57+
```yaml
58+
name: my-credential-tool
59+
args: env: the environment variable to set
60+
args: val: the value to set it to
61+
62+
#!/usr/bin/env bash
63+
64+
echo "{\"env\":{\"$ENV\":\"$VAL\"}}"
65+
```
66+
67+
When you reference this credential tool in another file, you can use syntax like this to set both arguments:
68+
69+
```yaml
70+
credential: my-credential-tool.gpt with MY_ENV_VAR as env and "my value" as val
71+
72+
(tool stuff here)
73+
```
74+
75+
In this example, the tool's output would be `{"env":{"MY_ENV_VAR":"my value"}}`
76+
5377
## Storing Credentials
5478

5579
By default, credentials are automatically stored in a config file at `$XDG_CONFIG_HOME/gptscript/config.json`.
@@ -67,16 +91,30 @@ is called `gptscript-credential-wincred.exe`.)
6791
There will likely be support added for other credential stores in the future.
6892

6993
:::note
70-
Credentials received from credential provider tools that are not on GitHub (such as a local file) will not be stored
71-
in the credentials store.
94+
Credentials received from credential provider tools that are not on GitHub (such as a local file) and do not have an alias
95+
will not be stored in the credentials store.
7296
:::
7397

98+
## Credential Aliases
99+
100+
When you reference a credential tool in your script, you can give it an alias using the `as` keyword like this:
101+
102+
```yaml
103+
credentials: my-credential-tool.gpt as myAlias
104+
105+
(tool stuff here)
106+
```
107+
108+
This will store the resulting credential with the name `myAlias`.
109+
This is useful when you want to reference the same credential tool in scripts that need to handle different credentials,
110+
or when you want to store credentials that were provided by a tool that is not on GitHub.
111+
74112
## Credential Contexts
75113

76-
Each stored credential is uniquely identified by the name of its provider tool and the name of its context. A credential
77-
context is basically a namespace for credentials. If you have multiple credentials from the same provider tool, you can
78-
switch between them by defining them in different credential contexts. The default context is called `default`, and this
79-
is used if none is specified.
114+
Each stored credential is uniquely identified by the name of its provider tool (or alias, if one was specified) and the name of its context.
115+
A credential context is basically a namespace for credentials. If you have multiple credentials from the same provider tool,
116+
you can switch between them by defining them in different credential contexts. The default context is called `default`,
117+
and this is used if none is specified.
80118

81119
You can set the credential context to use with the `--credential-context` flag when running GPTScript. For
82120
example:
@@ -97,13 +135,17 @@ credentials in all contexts with `--all-contexts`.
97135
You can delete a credential by running the following command:
98136

99137
```bash
100-
gptscript credential delete --credential-context <credential context> <credential tool name>
138+
gptscript credential delete --credential-context <credential context> <credential name>
101139
```
102140

103141
The `--show-env-vars` argument will also display the names of the environment variables that are set by the credential.
104142
This is useful when working with credential overrides.
105143

106-
## Credential Overrides
144+
## Credential Overrides (Advanced)
145+
146+
:::note
147+
The syntax for this will change at some point in the future.
148+
:::
107149

108150
You can bypass credential tools and stored credentials by setting the `--credential-override` argument (or the
109151
`GPTSCRIPT_CREDENTIAL_OVERRIDE` environment variable) when running GPTScript. To set up a credential override, you

pkg/parser/parser.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ func isParam(line string, tool *types.Tool) (_ bool, err error) {
138138
return false, err
139139
}
140140
case "credentials", "creds", "credential", "cred":
141-
tool.Parameters.Credentials = append(tool.Parameters.Credentials, csv(strings.ToLower(value))...)
141+
tool.Parameters.Credentials = append(tool.Parameters.Credentials, value)
142142
default:
143143
return false, nil
144144
}

pkg/runner/runner.go

+38-15
Original file line numberDiff line numberDiff line change
@@ -816,17 +816,27 @@ func (r *Runner) handleCredentials(callCtx engine.Context, monitor Monitor, env
816816
continue
817817
}
818818

819+
toolName, credentialAlias, args, err := types.ParseCredentialArgs(credToolName, callCtx.Input)
820+
if err != nil {
821+
return nil, fmt.Errorf("failed to parse credential tool %q: %w", credToolName, err)
822+
}
823+
819824
var (
820825
cred *credentials.Credential
821826
exists bool
822-
err error
823827
)
824828

825-
// Only try to look up the cred if the tool is on GitHub.
826-
if isGitHubTool(credToolName) {
827-
cred, exists, err = store.Get(credToolName)
829+
// Only try to look up the cred if the tool is on GitHub or has an alias.
830+
// If it is a GitHub tool and has an alias, the alias overrides the tool name, so we use it as the credential name.
831+
if isGitHubTool(toolName) && credentialAlias == "" {
832+
cred, exists, err = store.Get(toolName)
828833
if err != nil {
829-
return nil, fmt.Errorf("failed to get credentials for tool %s: %w", credToolName, err)
834+
return nil, fmt.Errorf("failed to get credentials for tool %s: %w", toolName, err)
835+
}
836+
} else if credentialAlias != "" {
837+
cred, exists, err = store.Get(credentialAlias)
838+
if err != nil {
839+
return nil, fmt.Errorf("failed to get credentials for tool %s: %w", credentialAlias, err)
830840
}
831841
}
832842

@@ -838,12 +848,21 @@ func (r *Runner) handleCredentials(callCtx engine.Context, monitor Monitor, env
838848
return nil, fmt.Errorf("failed to find ID for tool %s", credToolName)
839849
}
840850

841-
subCtx, err := callCtx.SubCall(callCtx.Ctx, "", credToolRefs[0].ToolID, "", engine.CredentialToolCategory) // leaving callID as "" will cause it to be set by the engine
851+
var input string
852+
if args != nil {
853+
inputBytes, err := json.Marshal(args)
854+
if err != nil {
855+
return nil, fmt.Errorf("failed to marshal args for tool %s: %w", credToolName, err)
856+
}
857+
input = string(inputBytes)
858+
}
859+
860+
subCtx, err := callCtx.SubCall(callCtx.Ctx, input, credToolRefs[0].ToolID, "", engine.CredentialToolCategory) // leaving callID as "" will cause it to be set by the engine
842861
if err != nil {
843862
return nil, fmt.Errorf("failed to create subcall context for tool %s: %w", credToolName, err)
844863
}
845864

846-
res, err := r.call(subCtx, monitor, env, "")
865+
res, err := r.call(subCtx, monitor, env, input)
847866
if err != nil {
848867
return nil, fmt.Errorf("failed to run credential tool %s: %w", credToolName, err)
849868
}
@@ -860,9 +879,13 @@ func (r *Runner) handleCredentials(callCtx engine.Context, monitor Monitor, env
860879
}
861880

862881
cred = &credentials.Credential{
863-
ToolName: credToolName,
864-
Type: credentials.CredentialTypeTool,
865-
Env: envMap.Env,
882+
Type: credentials.CredentialTypeTool,
883+
Env: envMap.Env,
884+
}
885+
if credentialAlias != "" {
886+
cred.ToolName = credentialAlias
887+
} else {
888+
cred.ToolName = toolName
866889
}
867890

868891
isEmpty := true
@@ -873,15 +896,15 @@ func (r *Runner) handleCredentials(callCtx engine.Context, monitor Monitor, env
873896
}
874897
}
875898

876-
// Only store the credential if the tool is on GitHub, and the credential is non-empty.
877-
if isGitHubTool(credToolName) && callCtx.Program.ToolSet[credToolRefs[0].ToolID].Source.Repo != nil {
899+
// Only store the credential if the tool is on GitHub or has an alias, and the credential is non-empty.
900+
if (isGitHubTool(toolName) && callCtx.Program.ToolSet[credToolRefs[0].ToolID].Source.Repo != nil) || credentialAlias != "" {
878901
if isEmpty {
879-
log.Warnf("Not saving empty credential for tool %s", credToolName)
902+
log.Warnf("Not saving empty credential for tool %s", toolName)
880903
} else if err := store.Add(*cred); err != nil {
881-
return nil, fmt.Errorf("failed to add credential for tool %s: %w", credToolName, err)
904+
return nil, fmt.Errorf("failed to add credential for tool %s: %w", toolName, err)
882905
}
883906
} else {
884-
log.Warnf("Not saving credential for local tool %s - credentials will only be saved for tools from GitHub.", credToolName)
907+
log.Warnf("Not saving credential for tool %s - credentials will only be saved for tools from GitHub, or tools that use aliases.", toolName)
885908
}
886909
}
887910

pkg/types/credential_test.go

+153
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package types
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestParseCredentialArgs(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
toolName string
14+
input string
15+
expectedName string
16+
expectedAlias string
17+
expectedArgs map[string]string
18+
wantErr bool
19+
}{
20+
{
21+
name: "empty",
22+
toolName: "",
23+
expectedName: "",
24+
expectedAlias: "",
25+
},
26+
{
27+
name: "tool name only",
28+
toolName: "myCredentialTool",
29+
expectedName: "myCredentialTool",
30+
expectedAlias: "",
31+
},
32+
{
33+
name: "tool name and alias",
34+
toolName: "myCredentialTool as myAlias",
35+
expectedName: "myCredentialTool",
36+
expectedAlias: "myAlias",
37+
},
38+
{
39+
name: "tool name with one arg",
40+
toolName: "myCredentialTool with value1 as arg1",
41+
expectedName: "myCredentialTool",
42+
expectedAlias: "",
43+
expectedArgs: map[string]string{
44+
"arg1": "value1",
45+
},
46+
},
47+
{
48+
name: "tool name with two args",
49+
toolName: "myCredentialTool with value1 as arg1 and value2 as arg2",
50+
expectedName: "myCredentialTool",
51+
expectedAlias: "",
52+
expectedArgs: map[string]string{
53+
"arg1": "value1",
54+
"arg2": "value2",
55+
},
56+
},
57+
{
58+
name: "tool name with alias and one arg",
59+
toolName: "myCredentialTool as myAlias with value1 as arg1",
60+
expectedName: "myCredentialTool",
61+
expectedAlias: "myAlias",
62+
expectedArgs: map[string]string{
63+
"arg1": "value1",
64+
},
65+
},
66+
{
67+
name: "tool name with alias and two args",
68+
toolName: "myCredentialTool as myAlias with value1 as arg1 and value2 as arg2",
69+
expectedName: "myCredentialTool",
70+
expectedAlias: "myAlias",
71+
expectedArgs: map[string]string{
72+
"arg1": "value1",
73+
"arg2": "value2",
74+
},
75+
},
76+
{
77+
name: "tool name with quoted args",
78+
toolName: `myCredentialTool with "value one" as arg1 and "value two" as arg2`,
79+
expectedName: "myCredentialTool",
80+
expectedAlias: "",
81+
expectedArgs: map[string]string{
82+
"arg1": "value one",
83+
"arg2": "value two",
84+
},
85+
},
86+
{
87+
name: "tool name with arg references",
88+
toolName: `myCredentialTool with ${var1} as arg1 and ${var2} as arg2`,
89+
input: `{"var1": "value1", "var2": "value2"}`,
90+
expectedName: "myCredentialTool",
91+
expectedAlias: "",
92+
expectedArgs: map[string]string{
93+
"arg1": "value1",
94+
"arg2": "value2",
95+
},
96+
},
97+
{
98+
name: "tool name with alias but no 'as' (invalid)",
99+
toolName: "myCredentialTool myAlias",
100+
wantErr: true,
101+
},
102+
{
103+
name: "tool name with 'as' but no alias (invalid)",
104+
toolName: "myCredentialTool as",
105+
wantErr: true,
106+
},
107+
{
108+
name: "tool with 'with' but no args (invalid)",
109+
toolName: "myCredentialTool with",
110+
wantErr: true,
111+
},
112+
{
113+
name: "tool with args but no 'with' (invalid)",
114+
toolName: "myCredentialTool value1 as arg1",
115+
wantErr: true,
116+
},
117+
{
118+
name: "tool with trailing 'and' (invalid)",
119+
toolName: "myCredentialTool with value1 as arg1 and",
120+
wantErr: true,
121+
},
122+
{
123+
name: "tool with quoted arg but the quote is unterminated (invalid)",
124+
toolName: `myCredentialTool with "value one" as arg1 and "value two as arg2`,
125+
wantErr: true,
126+
},
127+
{
128+
name: "invalid input",
129+
toolName: "myCredentialTool",
130+
input: `{"asdf":"asdf"`,
131+
wantErr: true,
132+
},
133+
}
134+
135+
for _, tt := range tests {
136+
t.Run(tt.name, func(t *testing.T) {
137+
originalName, alias, args, err := ParseCredentialArgs(tt.toolName, tt.input)
138+
if tt.wantErr {
139+
require.Error(t, err, "expected an error but got none")
140+
return
141+
}
142+
143+
require.NoError(t, err, "did not expect an error but got one")
144+
require.Equal(t, tt.expectedName, originalName, "unexpected original name")
145+
require.Equal(t, tt.expectedAlias, alias, "unexpected alias")
146+
require.Equal(t, len(tt.expectedArgs), len(args), "unexpected number of args")
147+
148+
for k, v := range tt.expectedArgs {
149+
assert.Equal(t, v, args[k], "unexpected value for args[%s]", k)
150+
}
151+
})
152+
}
153+
}

0 commit comments

Comments
 (0)