diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4c1c22bf..f710e953 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -12,13 +12,6 @@ jobs: release-tag: runs-on: ubuntu-22.04 steps: - - name: trigger ui repo tag workflow - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ secrets.DISPATCH_PAT }} - repository: gptscript-ai/ui - event-type: release - client-payload: '{"tag": "${{ github.ref_name }}"}' - name: Checkout uses: actions/checkout@v4 with: diff --git a/Makefile b/Makefile index e0d93a1d..5b1b6309 100644 --- a/Makefile +++ b/Makefile @@ -52,12 +52,14 @@ init-docs: docker run --rm --workdir=/docs -v $${PWD}/docs:/docs node:18-buster yarn install # Ensure docs build without errors. Makes sure generated docs are in-sync with CLI. -validate-docs: +validate-docs: gen-docs docker run --rm --workdir=/docs -v $${PWD}/docs:/docs node:18-buster yarn build - go run tools/gendocs/main.go if [ -n "$$(git status --porcelain --untracked-files=no)" ]; then \ git status --porcelain --untracked-files=no; \ echo "Encountered dirty repo!"; \ git diff; \ exit 1 \ ;fi + +gen-docs: + go run tools/gendocs/main.go \ No newline at end of file diff --git a/docs/docs/03-tools/04-credential-tools.md b/docs/docs/03-tools/04-credential-tools.md index 3e6a678a..1911dc34 100644 --- a/docs/docs/03-tools/04-credential-tools.md +++ b/docs/docs/03-tools/04-credential-tools.md @@ -204,3 +204,21 @@ that environment variable, and if it is set, get the refresh token from the exis typically without user interaction. For an example of a tool that uses the refresh feature, see the [Gateway OAuth2 tool](https://github.com/gptscript-ai/gateway-oauth2). + +### GPTSCRIPT_CREDENTIAL_EXPIRATION environment variable + +When a tool references a credential tool, GPTScript will add the environment variables from the credential to the tool's +environment before executing the tool. If at least one of the credentials has an `expiresAt` field, GPTScript will also +set the environment variable `GPTSCRIPT_CREDENTIAL_EXPIRATION`, which contains the nearest expiration time out of all +credentials referenced by the tool, in RFC 3339 format. That way, it can be referenced in the tool body if needed. +Here is an example: + +``` +Credential: my-credential-tool.gpt as myCred + +#!python3 + +import os + +print("myCred expires at " + os.getenv("GPTSCRIPT_CREDENTIAL_EXPIRATION", "")) +``` diff --git a/docs/docs/04-command-line-reference/gptscript.md b/docs/docs/04-command-line-reference/gptscript.md index de29a97f..b7de5e86 100644 --- a/docs/docs/04-command-line-reference/gptscript.md +++ b/docs/docs/04-command-line-reference/gptscript.md @@ -12,38 +12,39 @@ gptscript [flags] PROGRAM_FILE [INPUT...] ### Options ``` - --cache-dir string Directory to store cache (default: $XDG_CACHE_HOME/gptscript) ($GPTSCRIPT_CACHE_DIR) - --chat-state string The chat state to continue, or null to start a new chat and return the state ($GPTSCRIPT_CHAT_STATE) - -C, --chdir string Change current working directory ($GPTSCRIPT_CHDIR) - --color Use color in output (default true) ($GPTSCRIPT_COLOR) - --config string Path to GPTScript config file ($GPTSCRIPT_CONFIG) - --confirm Prompt before running potentially dangerous commands ($GPTSCRIPT_CONFIRM) - --credential-context string Context name in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) (default "default") - --credential-override strings Credentials to override (ex: --credential-override github.com/example/cred-tool:API_TOKEN=1234) ($GPTSCRIPT_CREDENTIAL_OVERRIDE) - --debug Enable debug logging ($GPTSCRIPT_DEBUG) - --debug-messages Enable logging of chat completion calls ($GPTSCRIPT_DEBUG_MESSAGES) - --default-model string Default LLM model to use ($GPTSCRIPT_DEFAULT_MODEL) (default "gpt-4o") - --default-model-provider string Default LLM model provider to use, this will override OpenAI settings ($GPTSCRIPT_DEFAULT_MODEL_PROVIDER) - --disable-cache Disable caching of LLM API responses ($GPTSCRIPT_DISABLE_CACHE) - --disable-tui Don't use chat TUI but instead verbose output ($GPTSCRIPT_DISABLE_TUI) - --dump-state string Dump the internal execution state to a file ($GPTSCRIPT_DUMP_STATE) - --events-stream-to string Stream events to this location, could be a file descriptor/handle (e.g. fd://2), filename, or named pipe (e.g. \\.\pipe\my-pipe) ($GPTSCRIPT_EVENTS_STREAM_TO) - --force-chat Force an interactive chat session if even the top level tool is not a chat tool ($GPTSCRIPT_FORCE_CHAT) - --force-sequential Force parallel calls to run sequentially ($GPTSCRIPT_FORCE_SEQUENTIAL) - -h, --help help for gptscript - -f, --input string Read input from a file ("-" for stdin) ($GPTSCRIPT_INPUT_FILE) - --list-models List the models available and exit ($GPTSCRIPT_LIST_MODELS) - --list-tools List built-in tools and exit ($GPTSCRIPT_LIST_TOOLS) - --no-trunc Do not truncate long log messages ($GPTSCRIPT_NO_TRUNC) - --openai-api-key string OpenAI API KEY ($OPENAI_API_KEY) - --openai-base-url string OpenAI base URL ($OPENAI_BASE_URL) - --openai-org-id string OpenAI organization ID ($OPENAI_ORG_ID) - -o, --output string Save output to a file, or - for stdout ($GPTSCRIPT_OUTPUT) - -q, --quiet No output logging (set --quiet=false to force on even when there is no TTY) ($GPTSCRIPT_QUIET) - --save-chat-state-file string A file to save the chat state to so that a conversation can be resumed with --chat-state ($GPTSCRIPT_SAVE_CHAT_STATE_FILE) - --sub-tool string Use tool of this name, not the first tool in file ($GPTSCRIPT_SUB_TOOL) - --ui Launch the UI ($GPTSCRIPT_UI) - --workspace string Directory to use for the workspace, if specified it will not be deleted on exit ($GPTSCRIPT_WORKSPACE) + --cache-dir string Directory to store cache (default: $XDG_CACHE_HOME/gptscript) ($GPTSCRIPT_CACHE_DIR) + --chat-state string The chat state to continue, or null to start a new chat and return the state ($GPTSCRIPT_CHAT_STATE) + -C, --chdir string Change current working directory ($GPTSCRIPT_CHDIR) + --color Use color in output (default true) ($GPTSCRIPT_COLOR) + --config string Path to GPTScript config file ($GPTSCRIPT_CONFIG) + --confirm Prompt before running potentially dangerous commands ($GPTSCRIPT_CONFIRM) + --credential-context string Context name in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) (default "default") + --credential-override strings Credentials to override (ex: --credential-override github.com/example/cred-tool:API_TOKEN=1234) ($GPTSCRIPT_CREDENTIAL_OVERRIDE) + --debug Enable debug logging ($GPTSCRIPT_DEBUG) + --debug-messages Enable logging of chat completion calls ($GPTSCRIPT_DEBUG_MESSAGES) + --default-model string Default LLM model to use ($GPTSCRIPT_DEFAULT_MODEL) (default "gpt-4o") + --default-model-provider string Default LLM model provider to use, this will override OpenAI settings ($GPTSCRIPT_DEFAULT_MODEL_PROVIDER) + --disable-cache Disable caching of LLM API responses ($GPTSCRIPT_DISABLE_CACHE) + --disable-tui Don't use chat TUI but instead verbose output ($GPTSCRIPT_DISABLE_TUI) + --dump-state string Dump the internal execution state to a file ($GPTSCRIPT_DUMP_STATE) + --events-stream-to string Stream events to this location, could be a file descriptor/handle (e.g. fd://2), filename, or named pipe (e.g. \\.\pipe\my-pipe) ($GPTSCRIPT_EVENTS_STREAM_TO) + --force-chat Force an interactive chat session if even the top level tool is not a chat tool ($GPTSCRIPT_FORCE_CHAT) + --force-sequential Force parallel calls to run sequentially ($GPTSCRIPT_FORCE_SEQUENTIAL) + --github-enterprise-hostname string The host name for a Github Enterprise instance to enable for remote loading ($GPTSCRIPT_GITHUB_ENTERPRISE_HOSTNAME) + -h, --help help for gptscript + -f, --input string Read input from a file ("-" for stdin) ($GPTSCRIPT_INPUT_FILE) + --list-models List the models available and exit ($GPTSCRIPT_LIST_MODELS) + --list-tools List built-in tools and exit ($GPTSCRIPT_LIST_TOOLS) + --no-trunc Do not truncate long log messages ($GPTSCRIPT_NO_TRUNC) + --openai-api-key string OpenAI API KEY ($OPENAI_API_KEY) + --openai-base-url string OpenAI base URL ($OPENAI_BASE_URL) + --openai-org-id string OpenAI organization ID ($OPENAI_ORG_ID) + -o, --output string Save output to a file, or - for stdout ($GPTSCRIPT_OUTPUT) + -q, --quiet No output logging (set --quiet=false to force on even when there is no TTY) ($GPTSCRIPT_QUIET) + --save-chat-state-file string A file to save the chat state to so that a conversation can be resumed with --chat-state ($GPTSCRIPT_SAVE_CHAT_STATE_FILE) + --sub-tool string Use tool of this name, not the first tool in file ($GPTSCRIPT_SUB_TOOL) + --ui Launch the UI ($GPTSCRIPT_UI) + --workspace string Directory to use for the workspace, if specified it will not be deleted on exit ($GPTSCRIPT_WORKSPACE) ``` ### SEE ALSO diff --git a/go.mod b/go.mod index 783e638b..c84dae97 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( github.com/xeipuuv/gojsonschema v1.2.0 golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc golang.org/x/sync v0.7.0 - golang.org/x/term v0.20.0 + golang.org/x/term v0.22.0 gopkg.in/yaml.v3 v3.0.1 gotest.tools/v3 v3.5.1 sigs.k8s.io/yaml v1.4.0 @@ -108,10 +108,10 @@ require ( github.com/yuin/goldmark v1.5.4 // indirect github.com/yuin/goldmark-emoji v1.0.2 // indirect go4.org v0.0.0-20200411211856-f5505b9728dd // indirect - golang.org/x/mod v0.17.0 // indirect - golang.org/x/net v0.24.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect - golang.org/x/tools v0.20.0 // indirect + golang.org/x/mod v0.19.0 // indirect + golang.org/x/net v0.27.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/tools v0.23.0 // indirect mvdan.cc/gofumpt v0.6.0 // indirect ) diff --git a/go.sum b/go.sum index fd165d3f..7e2d7b75 100644 --- a/go.sum +++ b/go.sum @@ -396,8 +396,8 @@ golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= +golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -419,8 +419,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= -golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -474,8 +474,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -485,8 +485,8 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= -golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -498,8 +498,8 @@ golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -531,8 +531,8 @@ golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= -golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= -golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= +golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= +golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/integration/cred_test.go b/integration/cred_test.go index 67298ef8..d77f096c 100644 --- a/integration/cred_test.go +++ b/integration/cred_test.go @@ -1,7 +1,9 @@ package integration import ( + "strings" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -15,15 +17,31 @@ func TestGPTScriptCredential(t *testing.T) { // TestCredentialScopes makes sure that environment variables set by credential tools and shared credential tools // are only available to the correct tools. See scripts/credscopes.gpt for more details. func TestCredentialScopes(t *testing.T) { - out, err := RunScript("scripts/credscopes.gpt", "--sub-tool", "oneOne") + out, err := RunScript("scripts/cred_scopes.gpt", "--sub-tool", "oneOne") require.NoError(t, err) require.Contains(t, out, "good") - out, err = RunScript("scripts/credscopes.gpt", "--sub-tool", "twoOne") + out, err = RunScript("scripts/cred_scopes.gpt", "--sub-tool", "twoOne") require.NoError(t, err) require.Contains(t, out, "good") - out, err = RunScript("scripts/credscopes.gpt", "--sub-tool", "twoTwo") + out, err = RunScript("scripts/cred_scopes.gpt", "--sub-tool", "twoTwo") require.NoError(t, err) require.Contains(t, out, "good") } + +// TestCredentialExpirationEnv tests a GPTScript with two credentials that expire at different times. +// One expires after two hours, and the other expires after one hour. +// This test makes sure that the GPTSCRIPT_CREDENTIAL_EXPIRATION environment variable is set to the nearer expiration time (1h). +func TestCredentialExpirationEnv(t *testing.T) { + out, err := RunScript("scripts/cred_expiration.gpt") + require.NoError(t, err) + + for _, line := range strings.Split(out, "\n") { + if timestamp, found := strings.CutPrefix(line, "Expires: "); found { + expiresTime, err := time.Parse(time.RFC3339, timestamp) + require.NoError(t, err) + require.True(t, time.Until(expiresTime) < time.Hour) + } + } +} diff --git a/integration/scripts/cred_expiration.gpt b/integration/scripts/cred_expiration.gpt new file mode 100644 index 00000000..da535df0 --- /dev/null +++ b/integration/scripts/cred_expiration.gpt @@ -0,0 +1,46 @@ +cred: credentialTool with 2 as hours +cred: credentialTool with 1 as hours + +#!python3 + +import os + +print("Expires: " + os.getenv("GPTSCRIPT_CREDENTIAL_EXPIRATION", ""), end="") + +--- +name: credentialTool +args: hours: the number of hours from now to expire + +#!python3 + +import os +import json +from datetime import datetime, timedelta, timezone + +class Output: + def __init__(self, env, expires_at): + self.env = env + self.expiresAt = expires_at + + def to_dict(self): + return { + "env": self.env, + "expiresAt": self.expiresAt.isoformat() + } + +hours_str = os.getenv("HOURS") +if hours_str is None: + print("HOURS environment variable is not set") + os._exit(1) + +try: + hours = int(hours_str) +except ValueError: + print("failed to parse HOURS") + os._exit(1) + +expires_at = datetime.now(timezone.utc) + timedelta(hours=hours) +out = Output(env={"yeet": "yote"}, expires_at=expires_at) +out_json = json.dumps(out.to_dict()) + +print(out_json) diff --git a/integration/scripts/credscopes.gpt b/integration/scripts/cred_scopes.gpt similarity index 98% rename from integration/scripts/credscopes.gpt rename to integration/scripts/cred_scopes.gpt index 7319f163..dc8e24e7 100644 --- a/integration/scripts/credscopes.gpt +++ b/integration/scripts/cred_scopes.gpt @@ -149,8 +149,8 @@ name: getcred import os import json -var = os.getenv('var') -val = os.getenv('val') +var = os.getenv('VAR') +val = os.getenv('VAL') output = { "env": { diff --git a/pkg/cli/gptscript.go b/pkg/cli/gptscript.go index 4458d87b..2d7e90d9 100644 --- a/pkg/cli/gptscript.go +++ b/pkg/cli/gptscript.go @@ -23,6 +23,7 @@ import ( "github.com/gptscript-ai/gptscript/pkg/gptscript" "github.com/gptscript-ai/gptscript/pkg/input" "github.com/gptscript-ai/gptscript/pkg/loader" + "github.com/gptscript-ai/gptscript/pkg/loader/github" "github.com/gptscript-ai/gptscript/pkg/monitor" "github.com/gptscript-ai/gptscript/pkg/mvl" "github.com/gptscript-ai/gptscript/pkg/openai" @@ -54,25 +55,26 @@ type GPTScript struct { Output string `usage:"Save output to a file, or - for stdout" short:"o"` EventsStreamTo string `usage:"Stream events to this location, could be a file descriptor/handle (e.g. fd://2), filename, or named pipe (e.g. \\\\.\\pipe\\my-pipe)" name:"events-stream-to"` // Input should not be using GPTSCRIPT_INPUT env var because that is the same value that is set in tool executions - Input string `usage:"Read input from a file (\"-\" for stdin)" short:"f" env:"GPTSCRIPT_INPUT_FILE"` - SubTool string `usage:"Use tool of this name, not the first tool in file" local:"true"` - Assemble bool `usage:"Assemble tool to a single artifact, saved to --output" hidden:"true" local:"true"` - ListModels bool `usage:"List the models available and exit" local:"true"` - ListTools bool `usage:"List built-in tools and exit" local:"true"` - ListenAddress string `usage:"Server listen address" default:"127.0.0.1:0" hidden:"true"` - Chdir string `usage:"Change current working directory" short:"C"` - Daemon bool `usage:"Run tool as a daemon" local:"true" hidden:"true"` - Ports string `usage:"The port range to use for ephemeral daemon ports (ex: 11000-12000)" hidden:"true"` - CredentialContext string `usage:"Context name in which to store credentials" default:"default"` - CredentialOverride []string `usage:"Credentials to override (ex: --credential-override github.com/example/cred-tool:API_TOKEN=1234)"` - ChatState string `usage:"The chat state to continue, or null to start a new chat and return the state" local:"true"` - ForceChat bool `usage:"Force an interactive chat session if even the top level tool is not a chat tool" local:"true"` - ForceSequential bool `usage:"Force parallel calls to run sequentially" local:"true"` - Workspace string `usage:"Directory to use for the workspace, if specified it will not be deleted on exit"` - UI bool `usage:"Launch the UI" local:"true" name:"ui"` - DisableTUI bool `usage:"Don't use chat TUI but instead verbose output" local:"true" name:"disable-tui"` - SaveChatStateFile string `usage:"A file to save the chat state to so that a conversation can be resumed with --chat-state" local:"true"` - DefaultModelProvider string `usage:"Default LLM model provider to use, this will override OpenAI settings"` + Input string `usage:"Read input from a file (\"-\" for stdin)" short:"f" env:"GPTSCRIPT_INPUT_FILE"` + SubTool string `usage:"Use tool of this name, not the first tool in file" local:"true"` + Assemble bool `usage:"Assemble tool to a single artifact, saved to --output" hidden:"true" local:"true"` + ListModels bool `usage:"List the models available and exit" local:"true"` + ListTools bool `usage:"List built-in tools and exit" local:"true"` + ListenAddress string `usage:"Server listen address" default:"127.0.0.1:0" hidden:"true"` + Chdir string `usage:"Change current working directory" short:"C"` + Daemon bool `usage:"Run tool as a daemon" local:"true" hidden:"true"` + Ports string `usage:"The port range to use for ephemeral daemon ports (ex: 11000-12000)" hidden:"true"` + CredentialContext string `usage:"Context name in which to store credentials" default:"default"` + CredentialOverride []string `usage:"Credentials to override (ex: --credential-override github.com/example/cred-tool:API_TOKEN=1234)"` + ChatState string `usage:"The chat state to continue, or null to start a new chat and return the state" local:"true"` + ForceChat bool `usage:"Force an interactive chat session if even the top level tool is not a chat tool" local:"true"` + ForceSequential bool `usage:"Force parallel calls to run sequentially" local:"true"` + Workspace string `usage:"Directory to use for the workspace, if specified it will not be deleted on exit"` + UI bool `usage:"Launch the UI" local:"true" name:"ui"` + DisableTUI bool `usage:"Don't use chat TUI but instead verbose output" local:"true" name:"disable-tui"` + SaveChatStateFile string `usage:"A file to save the chat state to so that a conversation can be resumed with --chat-state" local:"true"` + DefaultModelProvider string `usage:"Default LLM model provider to use, this will override OpenAI settings"` + GithubEnterpriseHostname string `usage:"The host name for a Github Enterprise instance to enable for remote loading" local:"true"` readData []byte } @@ -83,7 +85,7 @@ func New() *cobra.Command { root, &Eval{gptscript: root}, &Credential{root: root}, - &Parse{}, + &Parse{gptscript: root}, &Fmt{}, &Getenv{}, &SDKServer{ @@ -334,6 +336,10 @@ func (r *GPTScript) Run(cmd *cobra.Command, args []string) (retErr error) { return err } + if r.GithubEnterpriseHostname != "" { + loader.AddVSC(github.LoaderForPrefix(r.GithubEnterpriseHostname)) + } + // If the user is trying to launch the chat-builder UI, then set up the tool and options here. if r.UI { if os.Getenv(system.BinEnvVar) == "" { @@ -386,7 +392,9 @@ func (r *GPTScript) Run(cmd *cobra.Command, args []string) (retErr error) { // The UI must run in daemon mode. r.Daemon = true // Use the UI tool as the first argument. - args = append([]string{uiTool()}, args...) + args = append([]string{ + env.VarOrDefault("GPTSCRIPT_CHAT_UI_TOOL", "github.com/gptscript-ai/ui@v0.9.4"), + }, args...) } ctx := cmd.Context() @@ -398,6 +406,9 @@ func (r *GPTScript) Run(cmd *cobra.Command, args []string) (retErr error) { defer gptScript.Close(true) if r.ListModels { + if r.DefaultModelProvider != "" { + args = append(args, r.DefaultModelProvider) + } return r.listModels(ctx, gptScript, args) } @@ -503,15 +514,3 @@ func (r *GPTScript) Run(cmd *cobra.Command, args []string) (retErr error) { return r.PrintOutput(toolInput, s) } - -// uiTool returns the versioned UI tool reference for the current GPTScript version. -// For release versions, a reference with a matching release tag is returned. -// For all other versions, a reference to main is returned. -func uiTool() string { - ref := "github.com/gptscript-ai/ui" - if tag := version.Tag; !strings.Contains(tag, "v0.0.0-dev") { - ref = fmt.Sprintf("%s@%s", ref, tag) - } - - return env.VarOrDefault("GPTSCRIPT_CHAT_UI_TOOL", ref) -} diff --git a/pkg/cli/parse.go b/pkg/cli/parse.go index 8599bd97..081116c1 100644 --- a/pkg/cli/parse.go +++ b/pkg/cli/parse.go @@ -12,6 +12,7 @@ import ( type Parse struct { PrettyPrint bool `usage:"Indent the json output" short:"p"` + gptscript *GPTScript } func (e *Parse) Customize(cmd *cobra.Command) { @@ -26,7 +27,7 @@ func locationName(l string) string { } func (e *Parse) Run(_ *cobra.Command, args []string) error { - content, err := input.FromLocation(args[0]) + content, err := input.FromLocation(args[0], e.gptscript.DisableCache) if err != nil { return err } diff --git a/pkg/config/cliconfig.go b/pkg/config/cliconfig.go index e4aa49ab..7970415f 100644 --- a/pkg/config/cliconfig.go +++ b/pkg/config/cliconfig.go @@ -55,6 +55,8 @@ type CLIConfig struct { Auths map[string]AuthConfig `json:"auths,omitempty"` CredentialsStore string `json:"credsStore,omitempty"` GPTScriptConfigFile string `json:"gptscriptConfig,omitempty"` + GatewayURL string `json:"gatewayURL,omitempty"` + Integrations map[string]string `json:"integrations,omitempty"` auths map[string]types.AuthConfig authsLock *sync.Mutex diff --git a/pkg/credentials/credential.go b/pkg/credentials/credential.go index 605208a0..3d1e2192 100644 --- a/pkg/credentials/credential.go +++ b/pkg/credentials/credential.go @@ -16,6 +16,7 @@ const ( CredentialTypeTool CredentialType = "tool" CredentialTypeModelProvider CredentialType = "modelProvider" ExistingCredential = "GPTSCRIPT_EXISTING_CREDENTIAL" + CredentialExpiration = "GPTSCRIPT_CREDENTIAL_EXPIRATION" ) type Credential struct { diff --git a/pkg/engine/cmd.go b/pkg/engine/cmd.go index d62aad2e..14b41183 100644 --- a/pkg/engine/cmd.go +++ b/pkg/engine/cmd.go @@ -10,7 +10,7 @@ import ( "io" "os" "os/exec" - "path/filepath" + "path" "runtime" "sort" "strings" @@ -120,8 +120,11 @@ func (e *Engine) runCommand(ctx Context, tool types.Tool, input string, toolCate var extraEnv = []string{ strings.TrimSpace("GPTSCRIPT_CONTEXT=" + strings.Join(instructions, "\n")), } - cmd, stop, err := e.newCommand(ctx.Ctx, extraEnv, tool, input) + cmd, stop, err := e.newCommand(ctx.Ctx, extraEnv, tool, input, true) if err != nil { + if toolCategory == NoCategory { + return fmt.Sprintf("ERROR: got (%v) while parsing command", err), nil + } return "", err } defer stop() @@ -203,10 +206,8 @@ var ignoreENV = map[string]struct{}{ } func appendEnv(envs []string, k, v string) []string { - for _, k := range []string{k, env.ToEnvLike(k)} { - if _, ignore := ignoreENV[k]; !ignore { - envs = append(envs, k+"="+v) - } + if _, ignore := ignoreENV[k]; !ignore { + envs = append(envs, strings.ToUpper(env.ToEnvLike(k))+"="+v) } return envs } @@ -242,7 +243,11 @@ func appendInputAsEnv(env []string, input string) []string { return env } -func (e *Engine) newCommand(ctx context.Context, extraEnv []string, tool types.Tool, input string) (*exec.Cmd, func(), error) { +func (e *Engine) newCommand(ctx context.Context, extraEnv []string, tool types.Tool, input string, useShell bool) (*exec.Cmd, func(), error) { + if runtime.GOOS == "windows" { + useShell = false + } + envvars := append(e.Env[:], extraEnv...) envvars = appendInputAsEnv(envvars, input) if log.IsDebug() { @@ -252,9 +257,17 @@ func (e *Engine) newCommand(ctx context.Context, extraEnv []string, tool types.T interpreter, rest, _ := strings.Cut(tool.Instructions, "\n") interpreter = strings.TrimSpace(interpreter)[2:] - args, err := shlex.Split(interpreter) - if err != nil { - return nil, nil, err + var ( + args []string + err error + ) + if useShell { + args = strings.Fields(interpreter) + } else { + args, err = shlex.Split(interpreter) + if err != nil { + return nil, nil, err + } } envvars, err = e.getRuntimeEnv(ctx, tool, args, envvars) @@ -263,10 +276,9 @@ func (e *Engine) newCommand(ctx context.Context, extraEnv []string, tool types.T } envvars, envMap := envAsMapAndDeDup(envvars) - for i, arg := range args { - args[i] = os.Expand(arg, func(s string) string { - return envMap[s] - }) + + if runtime.GOOS == "windows" && (args[0] == "/bin/bash" || args[0] == "/bin/sh") { + args[0] = path.Base(args[0]) } if runtime.GOOS == "windows" && (args[0] == "/usr/bin/env" || args[0] == "/bin/env") { @@ -274,12 +286,11 @@ func (e *Engine) newCommand(ctx context.Context, extraEnv []string, tool types.T } var ( - cmdArgs = args[1:] - stop = func() {} + stop = func() {} ) if strings.TrimSpace(rest) != "" { - f, err := os.CreateTemp("", version.ProgramName+requiredFileExtensions[args[0]]) + f, err := os.CreateTemp(env.Getenv("GPTSCRIPT_TMPDIR", envvars), version.ProgramName+requiredFileExtensions[args[0]]) if err != nil { return nil, nil, err } @@ -293,21 +304,33 @@ func (e *Engine) newCommand(ctx context.Context, extraEnv []string, tool types.T stop() return nil, nil, err } - cmdArgs = append(cmdArgs, f.Name()) + args = append(args, f.Name()) + } + + // Expand and/or normalize env references + for i, arg := range args { + args[i] = os.Expand(arg, func(s string) string { + if strings.HasPrefix(s, "!") { + return envMap[s[1:]] + } + if !useShell { + return envMap[s] + } + return "${" + s + "}" + }) } - // This is a workaround for Windows, where the command interpreter is constructed with unix style paths - // It converts unix style paths to windows style paths if runtime.GOOS == "windows" { - parts := strings.Split(args[0], "/") - if parts[len(parts)-1] == "gptscript-go-tool" { - parts[len(parts)-1] = "gptscript-go-tool.exe" - } + args[0] = strings.ReplaceAll(args[0], "/", "\\") + } - args[0] = filepath.Join(parts...) + if useShell { + args = append([]string{"/bin/sh", "-c"}, strings.Join(args, " ")) + } else { + args[0] = env.Lookup(envvars, args[0]) } - cmd := exec.CommandContext(ctx, env.Lookup(envvars, args[0]), cmdArgs...) + cmd := exec.CommandContext(ctx, args[0], args[1:]...) cmd.Env = compressEnv(envvars) return cmd, stop, nil } diff --git a/pkg/engine/daemon.go b/pkg/engine/daemon.go index 4cdab995..113aa1ba 100644 --- a/pkg/engine/daemon.go +++ b/pkg/engine/daemon.go @@ -133,6 +133,7 @@ func (e *Engine) startDaemon(tool types.Tool) (string, error) { }, tool, "{}", + false, ) if err != nil { return url, err diff --git a/pkg/env/env.go b/pkg/env/env.go index bedd5f9d..2994825b 100644 --- a/pkg/env/env.go +++ b/pkg/env/env.go @@ -26,6 +26,15 @@ func ToEnvLike(v string) string { return strings.ToUpper(v) } +func Getenv(key string, envs []string) string { + for i := len(envs) - 1; i >= 0; i-- { + if k, v, ok := strings.Cut(envs[i], "="); ok && k == key { + return v + } + } + return "" +} + func Matches(cmd []string, bin string) bool { switch len(cmd) { case 0: diff --git a/pkg/input/input.go b/pkg/input/input.go index 3d480431..f930753f 100644 --- a/pkg/input/input.go +++ b/pkg/input/input.go @@ -55,13 +55,13 @@ func FromFile(file string) (string, error) { } // FromLocation takes a string that can be a file path or a URL to a file and returns the content of that file. -func FromLocation(s string) (string, error) { +func FromLocation(s string, disableCache bool) (string, error) { // Attempt to read the file first, if that fails, try to load the URL. Finally, // return an error if both fail. content, err := FromFile(s) if err != nil { log.Debugf("failed to read file %s (due to %v) attempting to load the URL...", s, err) - content, err = loader.ContentFromURL(s) + content, err = loader.ContentFromURL(s, disableCache) if err != nil { return "", err } diff --git a/pkg/loader/github/github.go b/pkg/loader/github/github.go index 2fb01c3d..7b6e79ec 100644 --- a/pkg/loader/github/github.go +++ b/pkg/loader/github/github.go @@ -2,6 +2,7 @@ package github import ( "context" + "crypto/tls" "encoding/json" "fmt" "io" @@ -18,52 +19,63 @@ import ( "github.com/gptscript-ai/gptscript/pkg/types" ) -const ( - GithubPrefix = "github.com/" - githubRepoURL = "https://github.com/%s/%s.git" - githubDownloadURL = "https://raw.githubusercontent.com/%s/%s/%s/%s" - githubCommitURL = "https://api.github.com/repos/%s/%s/commits/%s" -) +type Config struct { + Prefix string + RepoURL string + DownloadURL string + CommitURL string + AuthToken string +} var ( - githubAuthToken = os.Getenv("GITHUB_AUTH_TOKEN") - log = mvl.Package() + log = mvl.Package() + defaultGithubConfig = &Config{ + Prefix: "github.com/", + RepoURL: "https://github.com/%s/%s.git", + DownloadURL: "https://raw.githubusercontent.com/%s/%s/%s/%s", + CommitURL: "https://api.github.com/repos/%s/%s/commits/%s", + AuthToken: os.Getenv("GITHUB_AUTH_TOKEN"), + } ) func init() { loader.AddVSC(Load) } -func getCommitLsRemote(ctx context.Context, account, repo, ref string) (string, error) { - url := fmt.Sprintf(githubRepoURL, account, repo) +func getCommitLsRemote(ctx context.Context, account, repo, ref string, config *Config) (string, error) { + url := fmt.Sprintf(config.RepoURL, account, repo) return git.LsRemote(ctx, url, ref) } // regexp to match a git commit id var commitRegexp = regexp.MustCompile("^[a-f0-9]{40}$") -func getCommit(ctx context.Context, account, repo, ref string) (string, error) { +func getCommit(ctx context.Context, account, repo, ref string, config *Config) (string, error) { if commitRegexp.MatchString(ref) { return ref, nil } - url := fmt.Sprintf(githubCommitURL, account, repo, ref) + url := fmt.Sprintf(config.CommitURL, account, repo, ref) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", fmt.Errorf("failed to create request of %s/%s at %s: %w", account, repo, url, err) } - if githubAuthToken != "" { - req.Header.Add("Authorization", "Bearer "+githubAuthToken) + if config.AuthToken != "" { + req.Header.Add("Authorization", "Bearer "+config.AuthToken) } - resp, err := http.DefaultClient.Do(req) + client := http.DefaultClient + if req.Host == config.Prefix && strings.ToLower(os.Getenv("GH_ENTERPRISE_SKIP_VERIFY")) == "true" { + client = &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} + } + resp, err := client.Do(req) if err != nil { return "", err } else if resp.StatusCode != http.StatusOK { c, _ := io.ReadAll(resp.Body) resp.Body.Close() - commit, fallBackErr := getCommitLsRemote(ctx, account, repo, ref) + commit, fallBackErr := getCommitLsRemote(ctx, account, repo, ref, config) if fallBackErr == nil { return commit, nil } @@ -88,8 +100,28 @@ func getCommit(ctx context.Context, account, repo, ref string) (string, error) { return commit.SHA, nil } -func Load(ctx context.Context, _ *cache.Client, urlName string) (string, string, *types.Repo, bool, error) { - if !strings.HasPrefix(urlName, GithubPrefix) { +func LoaderForPrefix(prefix string) func(context.Context, *cache.Client, string) (string, string, *types.Repo, bool, error) { + return func(ctx context.Context, c *cache.Client, urlName string) (string, string, *types.Repo, bool, error) { + return LoadWithConfig(ctx, c, urlName, NewGithubEnterpriseConfig(prefix)) + } +} + +func Load(ctx context.Context, c *cache.Client, urlName string) (string, string, *types.Repo, bool, error) { + return LoadWithConfig(ctx, c, urlName, defaultGithubConfig) +} + +func NewGithubEnterpriseConfig(prefix string) *Config { + return &Config{ + Prefix: prefix, + RepoURL: fmt.Sprintf("https://%s/%%s/%%s.git", prefix), + DownloadURL: fmt.Sprintf("https://raw.%s/%%s/%%s/%%s/%%s", prefix), + CommitURL: fmt.Sprintf("https://%s/api/v3/repos/%%s/%%s/commits/%%s", prefix), + AuthToken: os.Getenv("GH_ENTERPRISE_TOKEN"), + } +} + +func LoadWithConfig(ctx context.Context, _ *cache.Client, urlName string, config *Config) (string, string, *types.Repo, bool, error) { + if !strings.HasPrefix(urlName, config.Prefix) { return "", "", nil, false, nil } @@ -107,12 +139,12 @@ func Load(ctx context.Context, _ *cache.Client, urlName string) (string, string, account, repo := parts[1], parts[2] path := strings.Join(parts[3:], "/") - ref, err := getCommit(ctx, account, repo, ref) + ref, err := getCommit(ctx, account, repo, ref, config) if err != nil { return "", "", nil, false, err } - downloadURL := fmt.Sprintf(githubDownloadURL, account, repo, ref, path) + downloadURL := fmt.Sprintf(config.DownloadURL, account, repo, ref, path) if path == "" || path == "/" || !strings.Contains(parts[len(parts)-1], ".") { var ( testPath string @@ -124,13 +156,20 @@ func Load(ctx context.Context, _ *cache.Client, urlName string) (string, string, } else { testPath = path + "/" + ext } - testURL = fmt.Sprintf(githubDownloadURL, account, repo, ref, testPath) + testURL = fmt.Sprintf(config.DownloadURL, account, repo, ref, testPath) if i == len(types.DefaultFiles)-1 { // no reason to test the last one, we are just going to use it. Being that the default list is only // two elements this loop could have been one check, but hey over-engineered code ftw. break } - if resp, err := http.Head(testURL); err == nil { + headReq, err := http.NewRequest("HEAD", testURL, nil) + if err != nil { + break + } + if config.AuthToken != "" { + headReq.Header.Add("Authorization", "Bearer "+config.AuthToken) + } + if resp, err := http.DefaultClient.Do(headReq); err == nil { _ = resp.Body.Close() if resp.StatusCode == 200 { break @@ -141,9 +180,9 @@ func Load(ctx context.Context, _ *cache.Client, urlName string) (string, string, path = testPath } - return downloadURL, githubAuthToken, &types.Repo{ + return downloadURL, config.AuthToken, &types.Repo{ VCS: "git", - Root: fmt.Sprintf(githubRepoURL, account, repo), + Root: fmt.Sprintf(config.RepoURL, account, repo), Path: gpath.Dir(path), Name: gpath.Base(path), Revision: ref, diff --git a/pkg/loader/github/github_test.go b/pkg/loader/github/github_test.go index d627ee5e..483722bc 100644 --- a/pkg/loader/github/github_test.go +++ b/pkg/loader/github/github_test.go @@ -2,6 +2,10 @@ package github import ( "context" + "fmt" + "net/http" + "net/http/httptest" + "os" "testing" "github.com/gptscript-ai/gptscript/pkg/types" @@ -44,3 +48,56 @@ func TestLoad(t *testing.T) { Revision: "172dfb00b48c6adbbaa7e99270933f95887d1b91", }).Equal(t, repo) } + +func TestLoad_GithubEnterprise(t *testing.T) { + gheToken := "mytoken" + os.Setenv("GH_ENTERPRISE_SKIP_VERIFY", "true") + os.Setenv("GH_ENTERPRISE_TOKEN", gheToken) + s := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/repos/gptscript-ai/gptscript/commits/172dfb0": + _, _ = w.Write([]byte(`{"sha": "172dfb00b48c6adbbaa7e99270933f95887d1b91"}`)) + default: + w.WriteHeader(404) + } + })) + defer s.Close() + + serverAddr := s.Listener.Addr().String() + + url, token, repo, ok, err := LoadWithConfig(context.Background(), nil, fmt.Sprintf("%s/gptscript-ai/gptscript/pkg/loader/testdata/tool@172dfb0", serverAddr), NewGithubEnterpriseConfig(serverAddr)) + require.NoError(t, err) + assert.True(t, ok) + autogold.Expect(fmt.Sprintf("https://raw.%s/gptscript-ai/gptscript/172dfb00b48c6adbbaa7e99270933f95887d1b91/pkg/loader/testdata/tool/tool.gpt", serverAddr)).Equal(t, url) + autogold.Expect(&types.Repo{ + VCS: "git", Root: fmt.Sprintf("https://%s/gptscript-ai/gptscript.git", serverAddr), + Path: "pkg/loader/testdata/tool", + Name: "tool.gpt", + Revision: "172dfb00b48c6adbbaa7e99270933f95887d1b91", + }).Equal(t, repo) + autogold.Expect(gheToken).Equal(t, token) + + url, token, repo, ok, err = Load(context.Background(), nil, "github.com/gptscript-ai/gptscript/pkg/loader/testdata/agent@172dfb0") + require.NoError(t, err) + assert.True(t, ok) + autogold.Expect("https://raw.githubusercontent.com/gptscript-ai/gptscript/172dfb00b48c6adbbaa7e99270933f95887d1b91/pkg/loader/testdata/agent/agent.gpt").Equal(t, url) + autogold.Expect(&types.Repo{ + VCS: "git", Root: "https://github.com/gptscript-ai/gptscript.git", + Path: "pkg/loader/testdata/agent", + Name: "agent.gpt", + Revision: "172dfb00b48c6adbbaa7e99270933f95887d1b91", + }).Equal(t, repo) + autogold.Expect("").Equal(t, token) + + url, token, repo, ok, err = Load(context.Background(), nil, "github.com/gptscript-ai/gptscript/pkg/loader/testdata/bothtoolagent@172dfb0") + require.NoError(t, err) + assert.True(t, ok) + autogold.Expect("https://raw.githubusercontent.com/gptscript-ai/gptscript/172dfb00b48c6adbbaa7e99270933f95887d1b91/pkg/loader/testdata/bothtoolagent/agent.gpt").Equal(t, url) + autogold.Expect(&types.Repo{ + VCS: "git", Root: "https://github.com/gptscript-ai/gptscript.git", + Path: "pkg/loader/testdata/bothtoolagent", + Name: "agent.gpt", + Revision: "172dfb00b48c6adbbaa7e99270933f95887d1b91", + }).Equal(t, repo) + autogold.Expect("").Equal(t, token) +} diff --git a/pkg/loader/loader.go b/pkg/loader/loader.go index 3d2ae8ed..80342f2b 100644 --- a/pkg/loader/loader.go +++ b/pkg/loader/loader.go @@ -68,10 +68,10 @@ func openFile(path string) (io.ReadCloser, bool, error) { } func loadLocal(base *source, name string) (*source, bool, error) { - // We want to keep all strings in / format, and only convert to platform specific when reading - // This is why we use path instead of filepath. filePath := name - if !path.IsAbs(name) { + if !filepath.IsAbs(name) { + // We want to keep all strings in / format, and only convert to platform specific when reading + // This is why we use path instead of filepath. filePath = path.Join(base.Path, name) } diff --git a/pkg/loader/url.go b/pkg/loader/url.go index 41400790..72970546 100644 --- a/pkg/loader/url.go +++ b/pkg/loader/url.go @@ -207,8 +207,10 @@ func getWithDefaults(req *http.Request) ([]byte, string, error) { panic("unreachable") } -func ContentFromURL(url string) (string, error) { - cache, err := cache.New() +func ContentFromURL(url string, disableCache bool) (string, error) { + cache, err := cache.New(cache.Options{ + DisableCache: disableCache, + }) if err != nil { return "", fmt.Errorf("failed to create cache: %w", err) } diff --git a/pkg/openapi/run.go b/pkg/openapi/run.go index 17199851..2efc2309 100644 --- a/pkg/openapi/run.go +++ b/pkg/openapi/run.go @@ -177,6 +177,7 @@ func HandleAuths(req *http.Request, envMap map[string]string, infoSets [][]Secur // We're using this info set, because no environment variables were missing. // Set up the request as needed. + v := url.Values{} for _, info := range infoSet { envNames := maps.Values(info.getCredentialNamesAndEnvVars(req.URL.Hostname())) switch info.Type { @@ -185,9 +186,7 @@ func HandleAuths(req *http.Request, envMap map[string]string, infoSets [][]Secur case "header": req.Header.Set(info.APIKeyName, envMap[envNames[0]]) case "query": - v := url.Values{} v.Add(info.APIKeyName, envMap[envNames[0]]) - req.URL.RawQuery = v.Encode() case "cookie": req.AddCookie(&http.Cookie{ Name: info.APIKeyName, @@ -203,6 +202,9 @@ func HandleAuths(req *http.Request, envMap map[string]string, infoSets [][]Secur } } } + if len(v) > 0 { + req.URL.RawQuery = v.Encode() + } return nil } diff --git a/pkg/parser/parser.go b/pkg/parser/parser.go index ff5d1374..b57cb658 100644 --- a/pkg/parser/parser.go +++ b/pkg/parser/parser.go @@ -16,7 +16,7 @@ import ( var ( sepRegex = regexp.MustCompile(`^\s*---+\s*$`) strictSepRegex = regexp.MustCompile(`^---\n$`) - skipRegex = regexp.MustCompile(`^![-\w]+\s*$`) + skipRegex = regexp.MustCompile(`^![-.:\w]+\s*$`) ) func normalize(key string) string { @@ -308,6 +308,8 @@ func Parse(input io.Reader, opts ...Options) (Document, error) { } } + nodes = assignMetadata(nodes) + if !opt.AssignGlobals { return Document{ Nodes: nodes, @@ -359,6 +361,42 @@ func Parse(input io.Reader, opts ...Options) (Document, error) { }, nil } +func assignMetadata(nodes []Node) (result []Node) { + metadata := map[string]map[string]string{} + result = make([]Node, 0, len(nodes)) + for _, node := range nodes { + if node.TextNode != nil { + body, ok := strings.CutPrefix(node.TextNode.Text, "!metadata:") + if ok { + line, rest, ok := strings.Cut(body, "\n") + if ok { + toolName, metaKey, ok := strings.Cut(strings.TrimSpace(line), ":") + if ok { + d, ok := metadata[toolName] + if !ok { + d = map[string]string{} + metadata[toolName] = d + } + d[metaKey] = strings.TrimSpace(rest) + } + } + } + } + } + if len(metadata) == 0 { + return nodes + } + + for _, node := range nodes { + if node.ToolNode != nil { + node.ToolNode.Tool.MetaData = metadata[node.ToolNode.Tool.Name] + } + result = append(result, node) + } + + return +} + func isGPTScriptHashBang(line string) bool { if !strings.HasPrefix(line, "#!") { return false diff --git a/pkg/parser/parser_test.go b/pkg/parser/parser_test.go index 9f682efa..3967ebd5 100644 --- a/pkg/parser/parser_test.go +++ b/pkg/parser/parser_test.go @@ -6,6 +6,7 @@ import ( "github.com/gptscript-ai/gptscript/pkg/types" "github.com/hexops/autogold/v2" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -239,3 +240,31 @@ share output filters: shared }}, }}).Equal(t, out) } + +func TestParseMetaData(t *testing.T) { + input := ` +name: first + +body +--- +!metadata:first:package.json +foo=base +f + +--- +!metadata:first2:requirements.txt +asdf2 + +--- +!metadata:first:requirements.txt +asdf +` + tools, err := ParseTools(strings.NewReader(input)) + require.NoError(t, err) + + assert.Len(t, tools, 1) + autogold.Expect(map[string]string{ + "package.json": "foo=base\nf", + "requirements.txt": "asdf", + }).Equal(t, tools[0].MetaData) +} diff --git a/pkg/remote/remote.go b/pkg/remote/remote.go index 6d83e6cc..89863529 100644 --- a/pkg/remote/remote.go +++ b/pkg/remote/remote.go @@ -22,10 +22,9 @@ import ( ) type Client struct { - clientsLock sync.Mutex + modelsLock sync.Mutex cache *cache.Client - clients map[string]*openai.Client - models map[string]*openai.Client + modelToProvider map[string]string runner *runner.Runner envs []string credStore credentials.CredentialStore @@ -43,14 +42,19 @@ func New(r *runner.Runner, envs []string, cache *cache.Client, credStore credent } func (c *Client) Call(ctx context.Context, messageRequest types.CompletionRequest, status chan<- types.CompletionStatus) (*types.CompletionMessage, error) { - c.clientsLock.Lock() - client, ok := c.models[messageRequest.Model] - c.clientsLock.Unlock() + c.modelsLock.Lock() + provider, ok := c.modelToProvider[messageRequest.Model] + c.modelsLock.Unlock() if !ok { return nil, fmt.Errorf("failed to find remote model %s", messageRequest.Model) } + client, err := c.load(ctx, provider) + if err != nil { + return nil, err + } + toolName, modelName := types.SplitToolRef(messageRequest.Model) if modelName == "" { // modelName is empty, then the messageRequest.Model is not of the form 'modelName from provider' @@ -96,19 +100,19 @@ func (c *Client) Supports(ctx context.Context, modelString string) (bool, error) return false, nil } - client, err := c.load(ctx, providerName) + _, err := c.load(ctx, providerName) if err != nil { return false, err } - c.clientsLock.Lock() - defer c.clientsLock.Unlock() + c.modelsLock.Lock() + defer c.modelsLock.Unlock() - if c.models == nil { - c.models = map[string]*openai.Client{} + if c.modelToProvider == nil { + c.modelToProvider = map[string]string{} } - c.models[modelString] = client + c.modelToProvider[modelString] = providerName return true, nil } @@ -141,24 +145,11 @@ func (c *Client) clientFromURL(ctx context.Context, apiURL string) (*openai.Clie } func (c *Client) load(ctx context.Context, toolName string) (*openai.Client, error) { - c.clientsLock.Lock() - defer c.clientsLock.Unlock() - - client, ok := c.clients[toolName] - if ok { - return client, nil - } - - if c.clients == nil { - c.clients = make(map[string]*openai.Client) - } - if isHTTPURL(toolName) { remoteClient, err := c.clientFromURL(ctx, toolName) if err != nil { return nil, err } - c.clients[toolName] = remoteClient return remoteClient, nil } @@ -174,14 +165,8 @@ func (c *Client) load(ctx context.Context, toolName string) (*openai.Client, err return nil, err } - if strings.HasSuffix(url, "/") { - url += "v1" - } else { - url += "/v1" - } - - client, err = openai.NewClient(ctx, c.credStore, openai.Options{ - BaseURL: url, + client, err := openai.NewClient(ctx, c.credStore, openai.Options{ + BaseURL: strings.TrimSuffix(url, "/") + "/v1", Cache: c.cache, CacheKey: prg.EntryToolID, }) @@ -189,7 +174,6 @@ func (c *Client) load(ctx context.Context, toolName string) (*openai.Client, err return nil, err } - c.clients[toolName] = client return client, nil } diff --git a/pkg/repos/download/extract.go b/pkg/repos/download/extract.go index 95a82d74..4cf09f0c 100644 --- a/pkg/repos/download/extract.go +++ b/pkg/repos/download/extract.go @@ -9,7 +9,9 @@ import ( "net/http" "net/url" "os" + "path" "path/filepath" + "strings" "time" "github.com/mholt/archiver/v4" @@ -60,6 +62,18 @@ func Extract(ctx context.Context, downloadURL, digest, targetDir string) error { return err } + bin := path.Base(parsedURL.Path) + if strings.HasSuffix(bin, ".exe") { + dst, err := os.Create(filepath.Join(targetDir, bin)) + if err != nil { + return err + } + defer dst.Close() + + _, err = io.Copy(dst, tmpFile) + return err + } + format, input, err := archiver.Identify(filepath.Base(parsedURL.Path), tmpFile) if err != nil { return err diff --git a/pkg/repos/get.go b/pkg/repos/get.go index 2f96d8b3..b43bc63b 100644 --- a/pkg/repos/get.go +++ b/pkg/repos/get.go @@ -15,6 +15,7 @@ import ( "github.com/BurntSushi/locker" "github.com/gptscript-ai/gptscript/pkg/config" "github.com/gptscript-ai/gptscript/pkg/credentials" + "github.com/gptscript-ai/gptscript/pkg/hash" "github.com/gptscript-ai/gptscript/pkg/loader/github" "github.com/gptscript-ai/gptscript/pkg/repos/git" "github.com/gptscript-ai/gptscript/pkg/repos/runtimes/golang" @@ -25,8 +26,9 @@ const credentialHelpersRepo = "github.com/gptscript-ai/gptscript-credential-help type Runtime interface { ID() string - Supports(cmd []string) bool - Setup(ctx context.Context, dataRoot, toolSource string, env []string) ([]string, error) + Supports(tool types.Tool, cmd []string) bool + Setup(ctx context.Context, tool types.Tool, dataRoot, toolSource string, env []string) ([]string, error) + GetHash(tool types.Tool) (string, error) } type noopRuntime struct { @@ -36,11 +38,15 @@ func (n noopRuntime) ID() string { return "none" } -func (n noopRuntime) Supports(_ []string) bool { +func (n noopRuntime) GetHash(_ types.Tool) (string, error) { + return "", nil +} + +func (n noopRuntime) Supports(_ types.Tool, _ []string) bool { return false } -func (n noopRuntime) Setup(_ context.Context, _, _ string, _ []string) ([]string, error) { +func (n noopRuntime) Setup(_ context.Context, _ types.Tool, _, _ string, _ []string) ([]string, error) { return nil, nil } @@ -182,8 +188,13 @@ func (m *Manager) setup(ctx context.Context, runtime Runtime, tool types.Tool, e locker.Lock(tool.ID) defer locker.Unlock(tool.ID) + runtimeHash, err := runtime.GetHash(tool) + if err != nil { + return "", nil, err + } + target := filepath.Join(m.storageDir, tool.Source.Repo.Revision, tool.Source.Repo.Path, tool.Source.Repo.Name, runtime.ID()) - targetFinal := filepath.Join(target, tool.Source.Repo.Path) + targetFinal := filepath.Join(target, tool.Source.Repo.Path+runtimeHash) doneFile := targetFinal + ".done" envData, err := os.ReadFile(doneFile) if err == nil { @@ -200,11 +211,17 @@ func (m *Manager) setup(ctx context.Context, runtime Runtime, tool types.Tool, e _ = os.RemoveAll(doneFile) _ = os.RemoveAll(target) - if err := git.Checkout(ctx, m.gitDir, tool.Source.Repo.Root, tool.Source.Repo.Revision, target); err != nil { - return "", nil, err + if tool.Source.Repo.VCS == "git" { + if err := git.Checkout(ctx, m.gitDir, tool.Source.Repo.Root, tool.Source.Repo.Revision, target); err != nil { + return "", nil, err + } + } else { + if err := os.MkdirAll(target, 0755); err != nil { + return "", nil, err + } } - newEnv, err := runtime.Setup(ctx, m.runtimeDir, targetFinal, env) + newEnv, err := runtime.Setup(ctx, tool, m.runtimeDir, targetFinal, env) if err != nil { return "", nil, err } @@ -227,20 +244,34 @@ func (m *Manager) setup(ctx context.Context, runtime Runtime, tool types.Tool, e } func (m *Manager) GetContext(ctx context.Context, tool types.Tool, cmd, env []string) (string, []string, error) { + var isLocal bool if tool.Source.Repo == nil { - return tool.WorkingDir, env, nil - } - - if tool.Source.Repo.VCS != "git" { - return "", nil, fmt.Errorf("only git is supported, found VCS %s for %s", tool.Source.Repo.VCS, tool.ID) + isLocal = true + d, _ := json.Marshal(tool) + id := hash.Digest(d)[:12] + tool.Source.Repo = &types.Repo{ + VCS: "", + Root: id, + Path: "/", + Name: id, + Revision: id, + } } for _, runtime := range m.runtimes { - if runtime.Supports(cmd) { + if runtime.Supports(tool, cmd) { log.Debugf("Runtime %s supports %v", runtime.ID(), cmd) - return m.setup(ctx, runtime, tool, env) + wd, env, err := m.setup(ctx, runtime, tool, env) + if isLocal { + wd = tool.WorkingDir + } + return wd, env, err } } + if isLocal { + return tool.WorkingDir, env, nil + } + return m.setup(ctx, &noopRuntime{}, tool, env) } diff --git a/pkg/repos/runtimes/busybox/SHASUMS256.txt b/pkg/repos/runtimes/busybox/SHASUMS256.txt new file mode 100644 index 00000000..7da1aff6 --- /dev/null +++ b/pkg/repos/runtimes/busybox/SHASUMS256.txt @@ -0,0 +1 @@ +6d2dfd1c1412c3550a89071a1b36a6f6073844320e687343d1dfc72719ecb8d9 FRP-5301-gda71f7c57/busybox-w64-FRP-5301-gda71f7c57.exe \ No newline at end of file diff --git a/pkg/repos/runtimes/busybox/busybox.go b/pkg/repos/runtimes/busybox/busybox.go new file mode 100644 index 00000000..481ed1fe --- /dev/null +++ b/pkg/repos/runtimes/busybox/busybox.go @@ -0,0 +1,112 @@ +package busybox + +import ( + "bufio" + "bytes" + "context" + _ "embed" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "strings" + + runtimeEnv "github.com/gptscript-ai/gptscript/pkg/env" + "github.com/gptscript-ai/gptscript/pkg/hash" + "github.com/gptscript-ai/gptscript/pkg/repos/download" + "github.com/gptscript-ai/gptscript/pkg/types" +) + +//go:embed SHASUMS256.txt +var releasesData []byte + +const downloadURL = "https://github.com/gptscript-ai/busybox-w32/releases/download/%s" + +type Runtime struct { +} + +func (r *Runtime) ID() string { + return "busybox" +} + +func (r *Runtime) GetHash(_ types.Tool) (string, error) { + return "", nil +} + +func (r *Runtime) Supports(_ types.Tool, cmd []string) bool { + if runtime.GOOS != "windows" { + return false + } + for _, bin := range []string{"bash", "sh", "/bin/sh", "/bin/bash"} { + if runtimeEnv.Matches(cmd, bin) { + return true + } + } + return false +} + +func (r *Runtime) Setup(ctx context.Context, _ types.Tool, dataRoot, _ string, env []string) ([]string, error) { + binPath, err := r.getRuntime(ctx, dataRoot) + if err != nil { + return nil, err + } + + newEnv := runtimeEnv.AppendPath(env, binPath) + return newEnv, nil +} + +func (r *Runtime) getReleaseAndDigest() (string, string, error) { + scanner := bufio.NewScanner(bytes.NewReader(releasesData)) + for scanner.Scan() { + line := scanner.Text() + fields := strings.Fields(line) + return fmt.Sprintf(downloadURL, fields[1]), fields[0], nil + } + + return "", "", fmt.Errorf("failed to find %s release", r.ID()) +} + +func (r *Runtime) getRuntime(ctx context.Context, cwd string) (string, error) { + url, sha, err := r.getReleaseAndDigest() + if err != nil { + return "", err + } + + target := filepath.Join(cwd, "busybox", hash.ID(url, sha)) + if _, err := os.Stat(target); err == nil { + return target, nil + } else if !errors.Is(err, fs.ErrNotExist) { + return "", err + } + + log.Infof("Downloading Busybox") + tmp := target + ".download" + defer os.RemoveAll(tmp) + + if err := os.MkdirAll(tmp, 0755); err != nil { + return "", err + } + + if err := download.Extract(ctx, url, sha, tmp); err != nil { + return "", err + } + + bbExe := filepath.Join(tmp, path.Base(url)) + + cmd := exec.Command(bbExe, "--install", ".") + cmd.Dir = filepath.Dir(bbExe) + + if err := cmd.Run(); err != nil { + return "", err + } + + if err := os.Rename(tmp, target); err != nil { + return "", err + } + + return target, nil +} diff --git a/pkg/repos/runtimes/busybox/busybox_test.go b/pkg/repos/runtimes/busybox/busybox_test.go new file mode 100644 index 00000000..77bfae59 --- /dev/null +++ b/pkg/repos/runtimes/busybox/busybox_test.go @@ -0,0 +1,42 @@ +package busybox + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/adrg/xdg" + "github.com/gptscript-ai/gptscript/pkg/types" + "github.com/samber/lo" + "github.com/stretchr/testify/require" +) + +var ( + testCacheHome = lo.Must(xdg.CacheFile("gptscript-test-cache/runtime")) +) + +func firstPath(s []string) string { + _, p, _ := strings.Cut(s[0], "=") + return strings.Split(p, string(os.PathListSeparator))[0] +} + +func TestRuntime(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip() + } + + r := Runtime{} + + s, err := r.Setup(context.Background(), types.Tool{}, testCacheHome, "testdata", os.Environ()) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(firstPath(s), "busybox.exe")) + if errors.Is(err, fs.ErrNotExist) { + _, err = os.Stat(filepath.Join(firstPath(s), "busybox")) + } + require.NoError(t, err) +} diff --git a/pkg/repos/runtimes/busybox/log.go b/pkg/repos/runtimes/busybox/log.go new file mode 100644 index 00000000..b7e486f1 --- /dev/null +++ b/pkg/repos/runtimes/busybox/log.go @@ -0,0 +1,5 @@ +package busybox + +import "github.com/gptscript-ai/gptscript/pkg/mvl" + +var log = mvl.Package() diff --git a/pkg/repos/runtimes/default.go b/pkg/repos/runtimes/default.go index d37cca8f..3782e26e 100644 --- a/pkg/repos/runtimes/default.go +++ b/pkg/repos/runtimes/default.go @@ -3,12 +3,14 @@ package runtimes import ( "github.com/gptscript-ai/gptscript/pkg/engine" "github.com/gptscript-ai/gptscript/pkg/repos" + "github.com/gptscript-ai/gptscript/pkg/repos/runtimes/busybox" "github.com/gptscript-ai/gptscript/pkg/repos/runtimes/golang" "github.com/gptscript-ai/gptscript/pkg/repos/runtimes/node" "github.com/gptscript-ai/gptscript/pkg/repos/runtimes/python" ) var Runtimes = []repos.Runtime{ + &busybox.Runtime{}, &python.Runtime{ Version: "3.12", Default: true, diff --git a/pkg/repos/runtimes/golang/golang.go b/pkg/repos/runtimes/golang/golang.go index 28300439..882e8a0b 100644 --- a/pkg/repos/runtimes/golang/golang.go +++ b/pkg/repos/runtimes/golang/golang.go @@ -18,6 +18,7 @@ import ( runtimeEnv "github.com/gptscript-ai/gptscript/pkg/env" "github.com/gptscript-ai/gptscript/pkg/hash" "github.com/gptscript-ai/gptscript/pkg/repos/download" + "github.com/gptscript-ai/gptscript/pkg/types" ) //go:embed digests.txt @@ -34,11 +35,16 @@ func (r *Runtime) ID() string { return "go" + r.Version } -func (r *Runtime) Supports(cmd []string) bool { - return len(cmd) > 0 && cmd[0] == "${GPTSCRIPT_TOOL_DIR}/bin/gptscript-go-tool" +func (r *Runtime) GetHash(_ types.Tool) (string, error) { + return "", nil } -func (r *Runtime) Setup(ctx context.Context, dataRoot, toolSource string, env []string) ([]string, error) { +func (r *Runtime) Supports(tool types.Tool, cmd []string) bool { + return tool.Source.IsGit() && + len(cmd) > 0 && cmd[0] == "${GPTSCRIPT_TOOL_DIR}/bin/gptscript-go-tool" +} + +func (r *Runtime) Setup(ctx context.Context, _ types.Tool, dataRoot, toolSource string, env []string) ([]string, error) { binPath, err := r.getRuntime(ctx, dataRoot) if err != nil { return nil, err diff --git a/pkg/repos/runtimes/golang/golang_test.go b/pkg/repos/runtimes/golang/golang_test.go index 5f71fb50..56098a51 100644 --- a/pkg/repos/runtimes/golang/golang_test.go +++ b/pkg/repos/runtimes/golang/golang_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/adrg/xdg" + "github.com/gptscript-ai/gptscript/pkg/types" "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -27,7 +28,7 @@ func TestRuntime(t *testing.T) { Version: "1.22.1", } - s, err := r.Setup(context.Background(), testCacheHome, "testdata", os.Environ()) + s, err := r.Setup(context.Background(), types.Tool{}, testCacheHome, "testdata", os.Environ()) require.NoError(t, err) p, v, _ := strings.Cut(s[0], "=") v, _, _ = strings.Cut(v, string(filepath.ListSeparator)) diff --git a/pkg/repos/runtimes/node/node.go b/pkg/repos/runtimes/node/node.go index 575e3b23..d0a9d8cb 100644 --- a/pkg/repos/runtimes/node/node.go +++ b/pkg/repos/runtimes/node/node.go @@ -17,12 +17,16 @@ import ( runtimeEnv "github.com/gptscript-ai/gptscript/pkg/env" "github.com/gptscript-ai/gptscript/pkg/hash" "github.com/gptscript-ai/gptscript/pkg/repos/download" + "github.com/gptscript-ai/gptscript/pkg/types" ) //go:embed SHASUMS256.txt.asc var releasesData []byte -const downloadURL = "https://nodejs.org/dist/%s/" +const ( + downloadURL = "https://nodejs.org/dist/%s/" + packageJSON = "package.json" +) type Runtime struct { // version something like "3.12" @@ -35,7 +39,7 @@ func (r *Runtime) ID() string { return "node" + r.Version } -func (r *Runtime) Supports(cmd []string) bool { +func (r *Runtime) Supports(_ types.Tool, cmd []string) bool { for _, testCmd := range []string{"node", "npx", "npm"} { if r.supports(testCmd, cmd) { return true @@ -54,17 +58,32 @@ func (r *Runtime) supports(testCmd string, cmd []string) bool { return runtimeEnv.Matches(cmd, testCmd) } -func (r *Runtime) Setup(ctx context.Context, dataRoot, toolSource string, env []string) ([]string, error) { +func (r *Runtime) GetHash(tool types.Tool) (string, error) { + if !tool.Source.IsGit() && tool.WorkingDir != "" { + if s, err := os.Stat(filepath.Join(tool.WorkingDir, packageJSON)); err == nil { + return hash.Digest(tool.WorkingDir + s.ModTime().String())[:7], nil + } + } + return "", nil +} + +func (r *Runtime) Setup(ctx context.Context, tool types.Tool, dataRoot, toolSource string, env []string) ([]string, error) { binPath, err := r.getRuntime(ctx, dataRoot) if err != nil { return nil, err } newEnv := runtimeEnv.AppendPath(env, binPath) - if err := r.runNPM(ctx, toolSource, binPath, append(env, newEnv...)); err != nil { + if err := r.runNPM(ctx, tool, toolSource, binPath, append(env, newEnv...)); err != nil { return nil, err } + if _, ok := tool.MetaData[packageJSON]; ok { + newEnv = append(newEnv, "GPTSCRIPT_TMPDIR="+toolSource) + } else if !tool.Source.IsGit() && tool.WorkingDir != "" { + newEnv = append(newEnv, "GPTSCRIPT_TMPDIR="+tool.WorkingDir, "GPTSCRIPT_RUNTIME_DEV=true") + } + return newEnv, nil } @@ -100,11 +119,26 @@ func (r *Runtime) getReleaseAndDigest() (string, string, error) { return "", "", fmt.Errorf("failed to find %s release for os=%s arch=%s", r.ID(), osName(), arch()) } -func (r *Runtime) runNPM(ctx context.Context, toolSource, binDir string, env []string) error { +func (r *Runtime) runNPM(ctx context.Context, tool types.Tool, toolSource, binDir string, env []string) error { log.InfofCtx(ctx, "Running npm in %s", toolSource) cmd := debugcmd.New(ctx, filepath.Join(binDir, "npm"), "install") cmd.Env = env cmd.Dir = toolSource + if contents, ok := tool.MetaData[packageJSON]; ok { + if err := os.WriteFile(filepath.Join(toolSource, packageJSON), []byte(contents+"\n"), 0644); err != nil { + return err + } + } else if !tool.Source.IsGit() { + if tool.WorkingDir == "" { + return nil + } + if _, err := os.Stat(filepath.Join(tool.WorkingDir, packageJSON)); errors.Is(fs.ErrNotExist, err) { + return nil + } else if err != nil { + return err + } + cmd.Dir = tool.WorkingDir + } return cmd.Run() } diff --git a/pkg/repos/runtimes/node/node_test.go b/pkg/repos/runtimes/node/node_test.go index 50ef1e0a..014619c8 100644 --- a/pkg/repos/runtimes/node/node_test.go +++ b/pkg/repos/runtimes/node/node_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/adrg/xdg" + "github.com/gptscript-ai/gptscript/pkg/types" "github.com/samber/lo" "github.com/stretchr/testify/require" ) @@ -28,7 +29,7 @@ func TestRuntime(t *testing.T) { Version: "20", } - s, err := r.Setup(context.Background(), testCacheHome, "testdata", os.Environ()) + s, err := r.Setup(context.Background(), types.Tool{}, testCacheHome, "testdata", os.Environ()) require.NoError(t, err) _, err = os.Stat(filepath.Join(firstPath(s), "node.exe")) if errors.Is(err, fs.ErrNotExist) { @@ -42,7 +43,7 @@ func TestRuntime21(t *testing.T) { Version: "21", } - s, err := r.Setup(context.Background(), testCacheHome, "testdata", os.Environ()) + s, err := r.Setup(context.Background(), types.Tool{}, testCacheHome, "testdata", os.Environ()) require.NoError(t, err) _, err = os.Stat(filepath.Join(firstPath(s), "node.exe")) if errors.Is(err, fs.ErrNotExist) { diff --git a/pkg/repos/runtimes/python/python.go b/pkg/repos/runtimes/python/python.go index c031cb16..87b072e5 100644 --- a/pkg/repos/runtimes/python/python.go +++ b/pkg/repos/runtimes/python/python.go @@ -17,12 +17,17 @@ import ( runtimeEnv "github.com/gptscript-ai/gptscript/pkg/env" "github.com/gptscript-ai/gptscript/pkg/hash" "github.com/gptscript-ai/gptscript/pkg/repos/download" + "github.com/gptscript-ai/gptscript/pkg/types" ) //go:embed python.json var releasesData []byte -const uvVersion = "uv==0.2.27" +const ( + uvVersion = "uv==0.2.33" + requirementsTxt = "requirements.txt" + gptscriptRequirementsTxt = "requirements-gptscript.txt" +) type Release struct { OS string `json:"os,omitempty"` @@ -43,7 +48,7 @@ func (r *Runtime) ID() string { return "python" + r.Version } -func (r *Runtime) Supports(cmd []string) bool { +func (r *Runtime) Supports(_ types.Tool, cmd []string) bool { if runtimeEnv.Matches(cmd, r.ID()) { return true } @@ -112,7 +117,7 @@ func (r *Runtime) copyPythonForWindows(binDir string) error { return nil } -func (r *Runtime) Setup(ctx context.Context, dataRoot, toolSource string, env []string) ([]string, error) { +func (r *Runtime) Setup(ctx context.Context, tool types.Tool, dataRoot, toolSource string, env []string) ([]string, error) { binPath, err := r.getRuntime(ctx, dataRoot) if err != nil { return nil, err @@ -145,7 +150,7 @@ func (r *Runtime) Setup(ctx context.Context, dataRoot, toolSource string, env [] } } - if err := r.runPip(ctx, toolSource, binPath, append(env, newEnv...)); err != nil { + if err := r.runPip(ctx, tool, toolSource, binPath, append(env, newEnv...)); err != nil { return nil, err } @@ -170,10 +175,44 @@ func (r *Runtime) getReleaseAndDigest() (string, string, error) { return "", "", fmt.Errorf("failed to find an python runtime for %s", r.Version) } -func (r *Runtime) runPip(ctx context.Context, toolSource, binDir string, env []string) error { +func (r *Runtime) GetHash(tool types.Tool) (string, error) { + if !tool.Source.IsGit() && tool.WorkingDir != "" { + if _, ok := tool.MetaData[requirementsTxt]; ok { + return "", nil + } + for _, req := range []string{gptscriptRequirementsTxt, requirementsTxt} { + reqFile := filepath.Join(tool.WorkingDir, req) + if s, err := os.Stat(reqFile); err == nil && !s.IsDir() { + return hash.Digest(tool.WorkingDir + s.ModTime().String())[:7], nil + } + } + } + + return "", nil +} + +func (r *Runtime) runPip(ctx context.Context, tool types.Tool, toolSource, binDir string, env []string) error { log.InfofCtx(ctx, "Running pip in %s", toolSource) - for _, req := range []string{"requirements-gptscript.txt", "requirements.txt"} { - reqFile := filepath.Join(toolSource, req) + if content, ok := tool.MetaData[requirementsTxt]; ok { + reqFile := filepath.Join(toolSource, requirementsTxt) + if err := os.WriteFile(reqFile, []byte(content+"\n"), 0644); err != nil { + return err + } + cmd := debugcmd.New(ctx, uvBin(binDir), "pip", "install", "-r", reqFile) + cmd.Env = env + return cmd.Run() + } + + reqPath := toolSource + if !tool.Source.IsGit() { + if tool.WorkingDir == "" { + return nil + } + reqPath = tool.WorkingDir + } + + for _, req := range []string{gptscriptRequirementsTxt, requirementsTxt} { + reqFile := filepath.Join(reqPath, req) if s, err := os.Stat(reqFile); err == nil && !s.IsDir() { cmd := debugcmd.New(ctx, uvBin(binDir), "pip", "install", "-r", reqFile) cmd.Env = env diff --git a/pkg/repos/runtimes/python/python_test.go b/pkg/repos/runtimes/python/python_test.go index fc2ededc..0e483305 100644 --- a/pkg/repos/runtimes/python/python_test.go +++ b/pkg/repos/runtimes/python/python_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/adrg/xdg" + "github.com/gptscript-ai/gptscript/pkg/types" "github.com/samber/lo" "github.com/stretchr/testify/require" ) @@ -27,7 +28,7 @@ func TestRuntime(t *testing.T) { Version: "3.12", } - s, err := r.Setup(context.Background(), testCacheHome, "testdata", os.Environ()) + s, err := r.Setup(context.Background(), types.Tool{}, testCacheHome, "testdata", os.Environ()) require.NoError(t, err) _, err = os.Stat(filepath.Join(firstPath(s), "python.exe")) if errors.Is(err, os.ErrNotExist) { diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index 3a33c720..a8d88fee 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -865,6 +865,7 @@ func (r *Runner) handleCredentials(callCtx engine.Context, monitor Monitor, env } } + var nearestExpiration *time.Time for _, ref := range credToolRefs { toolName, credentialAlias, args, err := types.ParseCredentialArgs(ref.Reference, callCtx.Input) if err != nil { @@ -969,11 +970,19 @@ func (r *Runner) handleCredentials(callCtx engine.Context, monitor Monitor, env } } + if c.ExpiresAt != nil && (nearestExpiration == nil || nearestExpiration.After(*c.ExpiresAt)) { + nearestExpiration = c.ExpiresAt + } + for k, v := range c.Env { env = append(env, fmt.Sprintf("%s=%s", k, v)) } } + if nearestExpiration != nil { + env = append(env, fmt.Sprintf("%s=%s", credentials.CredentialExpiration, nearestExpiration.Format(time.RFC3339))) + } + return env, nil } diff --git a/pkg/sdkserver/routes.go b/pkg/sdkserver/routes.go index 2e709e3f..98957624 100644 --- a/pkg/sdkserver/routes.go +++ b/pkg/sdkserver/routes.go @@ -127,6 +127,10 @@ func (s *server) listModels(w http.ResponseWriter, r *http.Request) { providers = reqObject.Providers } + if s.gptscriptOpts.DefaultModelProvider != "" { + providers = append(providers, s.gptscriptOpts.DefaultModelProvider) + } + out, err := s.client.ListModels(r.Context(), providers...) if err != nil { writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to list models: %w", err)) @@ -227,7 +231,7 @@ func (s *server) parse(w http.ResponseWriter, r *http.Request) { if reqObject.Content != "" { out, err = parser.Parse(strings.NewReader(reqObject.Content), reqObject.Options) } else { - content, loadErr := input.FromLocation(reqObject.File) + content, loadErr := input.FromLocation(reqObject.File, reqObject.DisableCache) if loadErr != nil { logger.Errorf(loadErr.Error()) writeError(logger, w, http.StatusInternalServerError, loadErr) diff --git a/pkg/sdkserver/types.go b/pkg/sdkserver/types.go index 9736f045..ade035b2 100644 --- a/pkg/sdkserver/types.go +++ b/pkg/sdkserver/types.go @@ -86,7 +86,8 @@ type parseRequest struct { parser.Options `json:",inline"` content `json:",inline"` - File string `json:"file"` + DisableCache bool `json:"disableCache"` + File string `json:"file"` } type modelsRequest struct { diff --git a/pkg/server/server.go b/pkg/server/server.go index d1c57100..00734c38 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -24,5 +24,6 @@ func ContextWithNewRunID(ctx context.Context) context.Context { } func RunIDFromContext(ctx context.Context) string { - return ctx.Value(execKey{}).(string) + runID, _ := ctx.Value(execKey{}).(string) + return runID } diff --git a/pkg/tests/runner_test.go b/pkg/tests/runner_test.go index 12eff23a..141e6aff 100644 --- a/pkg/tests/runner_test.go +++ b/pkg/tests/runner_test.go @@ -751,9 +751,6 @@ func TestGlobalErr(t *testing.T) { } func TestContextArg(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip() - } runner := tester.NewRunner(t) x, err := runner.Run("", `{ "file": "foo.db" @@ -1000,3 +997,47 @@ func TestToolRefAll(t *testing.T) { r := tester.NewRunner(t) r.RunDefault() } + +func TestRuntimes(t *testing.T) { + r := tester.NewRunner(t) + r.RespondWith(tester.Result{ + Func: types.CompletionFunctionCall{ + Name: "py", + Arguments: "{}", + }, + }, tester.Result{ + Func: types.CompletionFunctionCall{ + Name: "node", + Arguments: "{}", + }, + }, tester.Result{ + Func: types.CompletionFunctionCall{ + Name: "bash", + Arguments: "{}", + }, + }) + r.RunDefault() +} + +func TestRuntimesLocalDev(t *testing.T) { + r := tester.NewRunner(t) + r.RespondWith(tester.Result{ + Func: types.CompletionFunctionCall{ + Name: "py", + Arguments: "{}", + }, + }, tester.Result{ + Func: types.CompletionFunctionCall{ + Name: "node", + Arguments: "{}", + }, + }, tester.Result{ + Func: types.CompletionFunctionCall{ + Name: "bash", + Arguments: "{}", + }, + }) + r.RunDefault() + _ = os.RemoveAll("testdata/TestRuntimesLocalDev/node_modules") + _ = os.RemoveAll("testdata/TestRuntimesLocalDev/package-lock.json") +} diff --git a/pkg/tests/smoke/smoke_test.go b/pkg/tests/smoke/smoke_test.go index 66374ef1..a6d0ab2c 100644 --- a/pkg/tests/smoke/smoke_test.go +++ b/pkg/tests/smoke/smoke_test.go @@ -83,7 +83,7 @@ func TestSmoke(t *testing.T) { actualEvents, ` - disregard differences in timestamps, generated IDs, natural language verbiage, and event order -- omit callProgress events from the comparision +- omit callProgress events from the comparison - the overall stream of events and set of tools called should roughly match - arguments passed in tool calls should be roughly the same - the final callFinish event should be semantically similar diff --git a/pkg/tests/smoke/testdata/Bob/test.gpt b/pkg/tests/smoke/testdata/Bob/test.gpt index fe8ffb62..20f533e2 100644 --- a/pkg/tests/smoke/testdata/Bob/test.gpt +++ b/pkg/tests/smoke/testdata/Bob/test.gpt @@ -7,4 +7,4 @@ name: bob description: I'm Bob, a friendly guy. args: question: The question to ask Bob. -When asked how I am doing, respond with exactly "Thanks for asking "${question}", I'm doing great fellow friendly AI tool!" +When asked how I am doing, respond with exactly "Thanks for asking "${QUESTION}", I'm doing great fellow friendly AI tool!" diff --git a/pkg/tests/smoke/testdata/BobAsShell/test.gpt b/pkg/tests/smoke/testdata/BobAsShell/test.gpt index f04920bf..a0edb9c4 100644 --- a/pkg/tests/smoke/testdata/BobAsShell/test.gpt +++ b/pkg/tests/smoke/testdata/BobAsShell/test.gpt @@ -10,4 +10,4 @@ args: question: The question to ask Bob. #!/bin/bash -echo "Thanks for asking ${question}, I'm doing great fellow friendly AI tool!" +echo "Thanks for asking ${QUESTION}, I'm doing great fellow friendly AI tool!" diff --git a/pkg/tests/testdata/TestContextArg/other.gpt b/pkg/tests/testdata/TestContextArg/other.gpt index b1acd66a..f97b4ba6 100644 --- a/pkg/tests/testdata/TestContextArg/other.gpt +++ b/pkg/tests/testdata/TestContextArg/other.gpt @@ -2,5 +2,5 @@ name: fromcontext args: first: an arg args: second: an arg -#!/bin/bash -echo this is from other context ${first} and then ${second} \ No newline at end of file +#!/usr/bin/env bash +echo this is from other context ${FIRST} and then ${SECOND} \ No newline at end of file diff --git a/pkg/tests/testdata/TestContextArg/test.gpt b/pkg/tests/testdata/TestContextArg/test.gpt index 9569aaf9..50d2ccf2 100644 --- a/pkg/tests/testdata/TestContextArg/test.gpt +++ b/pkg/tests/testdata/TestContextArg/test.gpt @@ -9,4 +9,4 @@ name: fromcontext args: first: an arg #!/bin/bash -echo this is from context -- ${first} \ No newline at end of file +echo this is from context -- ${FIRST} \ No newline at end of file diff --git a/pkg/tests/testdata/TestRuntimes/call1-resp.golden b/pkg/tests/testdata/TestRuntimes/call1-resp.golden new file mode 100644 index 00000000..1d53670a --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/call1-resp.golden @@ -0,0 +1,16 @@ +`{ + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + } + } + ], + "usage": {} +}` diff --git a/pkg/tests/testdata/TestRuntimes/call1.golden b/pkg/tests/testdata/TestRuntimes/call1.golden new file mode 100644 index 00000000..67c7d9f7 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/call1.golden @@ -0,0 +1,37 @@ +`{ + "model": "gpt-4o", + "tools": [ + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:py", + "name": "py", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:node", + "name": "node", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:bash", + "name": "bash", + "parameters": null + } + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "text": "Dummy" + } + ], + "usage": {} + } + ] +}` diff --git a/pkg/tests/testdata/TestRuntimes/call2-resp.golden b/pkg/tests/testdata/TestRuntimes/call2-resp.golden new file mode 100644 index 00000000..4806793c --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/call2-resp.golden @@ -0,0 +1,16 @@ +`{ + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + } + } + ], + "usage": {} +}` diff --git a/pkg/tests/testdata/TestRuntimes/call2.golden b/pkg/tests/testdata/TestRuntimes/call2.golden new file mode 100644 index 00000000..da456a5d --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/call2.golden @@ -0,0 +1,70 @@ +`{ + "model": "gpt-4o", + "tools": [ + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:py", + "name": "py", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:node", + "name": "node", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:bash", + "name": "bash", + "parameters": null + } + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "text": "Dummy" + } + ], + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "py worked\r\n" + } + ], + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + }, + "usage": {} + } + ] +}` diff --git a/pkg/tests/testdata/TestRuntimes/call3-resp.golden b/pkg/tests/testdata/TestRuntimes/call3-resp.golden new file mode 100644 index 00000000..1103f824 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/call3-resp.golden @@ -0,0 +1,16 @@ +`{ + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 2, + "id": "call_3", + "function": { + "name": "bash", + "arguments": "{}" + } + } + } + ], + "usage": {} +}` diff --git a/pkg/tests/testdata/TestRuntimes/call3.golden b/pkg/tests/testdata/TestRuntimes/call3.golden new file mode 100644 index 00000000..f0792540 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/call3.golden @@ -0,0 +1,103 @@ +`{ + "model": "gpt-4o", + "tools": [ + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:py", + "name": "py", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:node", + "name": "node", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:bash", + "name": "bash", + "parameters": null + } + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "text": "Dummy" + } + ], + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "py worked\r\n" + } + ], + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + }, + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "node worked\n" + } + ], + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + }, + "usage": {} + } + ] +}` diff --git a/pkg/tests/testdata/TestRuntimes/call4-resp.golden b/pkg/tests/testdata/TestRuntimes/call4-resp.golden new file mode 100644 index 00000000..8135a8c9 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/call4-resp.golden @@ -0,0 +1,9 @@ +`{ + "role": "assistant", + "content": [ + { + "text": "TEST RESULT CALL: 4" + } + ], + "usage": {} +}` diff --git a/pkg/tests/testdata/TestRuntimes/call4.golden b/pkg/tests/testdata/TestRuntimes/call4.golden new file mode 100644 index 00000000..04ac31f5 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/call4.golden @@ -0,0 +1,136 @@ +`{ + "model": "gpt-4o", + "tools": [ + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:py", + "name": "py", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:node", + "name": "node", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:bash", + "name": "bash", + "parameters": null + } + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "text": "Dummy" + } + ], + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "py worked\r\n" + } + ], + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + }, + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "node worked\n" + } + ], + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + }, + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 2, + "id": "call_3", + "function": { + "name": "bash", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "bash works\n" + } + ], + "toolCall": { + "index": 2, + "id": "call_3", + "function": { + "name": "bash", + "arguments": "{}" + } + }, + "usage": {} + } + ] +}` diff --git a/pkg/tests/testdata/TestRuntimes/test.gpt b/pkg/tests/testdata/TestRuntimes/test.gpt new file mode 100644 index 00000000..db3ede64 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/test.gpt @@ -0,0 +1,58 @@ +name: first +tools: py, node, bash + +Dummy + +--- +name: py + +#!/usr/bin/env python3 + +import requests +import platform + +# this is dumb hack to get the line endings to always be \r\n so the golden files match +# on both linux and windows +if platform.system() == 'Windows': + print('py worked') +else: + print('py worked\r') + +--- +!metadata:py:requirements.txt + +requests + +--- +name: node + +#!/usr/bin/env node + +import chalk from 'chalk'; +console.log("node worked") + +--- +!metadata:node:package.json + +{ + "name": "chalk-example", + "version": "1.0.0", + "type": "module", + "description": "A simple example project to demonstrate the use of chalk", + "main": "example.js", + "scripts": { + "start": "node example.js" + }, + "author": "Your Name", + "license": "MIT", + "dependencies": { + "chalk": "^5.0.0" + } + } + +--- +name: bash + +#!/bin/bash + +echo bash works \ No newline at end of file diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/call1-resp.golden b/pkg/tests/testdata/TestRuntimesLocalDev/call1-resp.golden new file mode 100644 index 00000000..1d53670a --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/call1-resp.golden @@ -0,0 +1,16 @@ +`{ + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + } + } + ], + "usage": {} +}` diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/call1.golden b/pkg/tests/testdata/TestRuntimesLocalDev/call1.golden new file mode 100644 index 00000000..7e775029 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/call1.golden @@ -0,0 +1,37 @@ +`{ + "model": "gpt-4o", + "tools": [ + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:py", + "name": "py", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:node", + "name": "node", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:bash", + "name": "bash", + "parameters": null + } + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "text": "Dummy" + } + ], + "usage": {} + } + ] +}` diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/call2-resp.golden b/pkg/tests/testdata/TestRuntimesLocalDev/call2-resp.golden new file mode 100644 index 00000000..4806793c --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/call2-resp.golden @@ -0,0 +1,16 @@ +`{ + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + } + } + ], + "usage": {} +}` diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/call2.golden b/pkg/tests/testdata/TestRuntimesLocalDev/call2.golden new file mode 100644 index 00000000..cc1fd1b7 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/call2.golden @@ -0,0 +1,70 @@ +`{ + "model": "gpt-4o", + "tools": [ + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:py", + "name": "py", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:node", + "name": "node", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:bash", + "name": "bash", + "parameters": null + } + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "text": "Dummy" + } + ], + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "py worked\r\n" + } + ], + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + }, + "usage": {} + } + ] +}` diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/call3-resp.golden b/pkg/tests/testdata/TestRuntimesLocalDev/call3-resp.golden new file mode 100644 index 00000000..1103f824 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/call3-resp.golden @@ -0,0 +1,16 @@ +`{ + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 2, + "id": "call_3", + "function": { + "name": "bash", + "arguments": "{}" + } + } + } + ], + "usage": {} +}` diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/call3.golden b/pkg/tests/testdata/TestRuntimesLocalDev/call3.golden new file mode 100644 index 00000000..7c928c07 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/call3.golden @@ -0,0 +1,103 @@ +`{ + "model": "gpt-4o", + "tools": [ + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:py", + "name": "py", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:node", + "name": "node", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:bash", + "name": "bash", + "parameters": null + } + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "text": "Dummy" + } + ], + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "py worked\r\n" + } + ], + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + }, + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "node worked\n" + } + ], + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + }, + "usage": {} + } + ] +}` diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/call4-resp.golden b/pkg/tests/testdata/TestRuntimesLocalDev/call4-resp.golden new file mode 100644 index 00000000..8135a8c9 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/call4-resp.golden @@ -0,0 +1,9 @@ +`{ + "role": "assistant", + "content": [ + { + "text": "TEST RESULT CALL: 4" + } + ], + "usage": {} +}` diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/call4.golden b/pkg/tests/testdata/TestRuntimesLocalDev/call4.golden new file mode 100644 index 00000000..b95b880d --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/call4.golden @@ -0,0 +1,136 @@ +`{ + "model": "gpt-4o", + "tools": [ + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:py", + "name": "py", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:node", + "name": "node", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:bash", + "name": "bash", + "parameters": null + } + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "text": "Dummy" + } + ], + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "py worked\r\n" + } + ], + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + }, + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "node worked\n" + } + ], + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + }, + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 2, + "id": "call_3", + "function": { + "name": "bash", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "bash works\n" + } + ], + "toolCall": { + "index": 2, + "id": "call_3", + "function": { + "name": "bash", + "arguments": "{}" + } + }, + "usage": {} + } + ] +}` diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/package.json b/pkg/tests/testdata/TestRuntimesLocalDev/package.json new file mode 100644 index 00000000..d5f400a1 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/package.json @@ -0,0 +1,15 @@ +{ + "name": "chalk-example", + "version": "1.0.0", + "type": "module", + "description": "A simple example project to demonstrate the use of chalk", + "main": "example.js", + "scripts": { + "start": "node example.js" + }, + "author": "Your Name", + "license": "MIT", + "dependencies": { + "chalk": "^5.0.0" + } +} diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/requirements.txt b/pkg/tests/testdata/TestRuntimesLocalDev/requirements.txt new file mode 100644 index 00000000..f2293605 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/requirements.txt @@ -0,0 +1 @@ +requests diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/test.gpt b/pkg/tests/testdata/TestRuntimesLocalDev/test.gpt new file mode 100644 index 00000000..454ffce0 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/test.gpt @@ -0,0 +1,34 @@ +name: first +tools: py, node, bash + +Dummy + +--- +name: py + +#!/usr/bin/env python3 + +import requests +import platform + +# this is dumb hack to get the line endings to always be \r\n so the golden files match +# on both linux and windows +if platform.system() == 'Windows': + print('py worked') +else: + print('py worked\r') + +--- +name: node + +#!/usr/bin/env node + +import chalk from 'chalk'; +console.log("node worked") + +--- +name: bash + +#!/bin/bash + +echo bash works \ No newline at end of file diff --git a/pkg/tests/tester/runner.go b/pkg/tests/tester/runner.go index 775f0248..a36c5e91 100644 --- a/pkg/tests/tester/runner.go +++ b/pkg/tests/tester/runner.go @@ -8,8 +8,10 @@ import ( "path/filepath" "testing" + "github.com/adrg/xdg" "github.com/gptscript-ai/gptscript/pkg/credentials" "github.com/gptscript-ai/gptscript/pkg/loader" + "github.com/gptscript-ai/gptscript/pkg/repos/runtimes" "github.com/gptscript-ai/gptscript/pkg/runner" "github.com/gptscript-ai/gptscript/pkg/types" "github.com/hexops/autogold/v2" @@ -171,8 +173,14 @@ func NewRunner(t *testing.T) *Runner { t: t, } + cacheDir, err := xdg.CacheFile("gptscript-test-cache/runtime") + require.NoError(t, err) + + rm := runtimes.Default(cacheDir) + run, err := runner.New(c, credentials.NoopStore{}, runner.Options{ - Sequential: true, + Sequential: true, + RuntimeManager: rm, }) require.NoError(t, err) diff --git a/pkg/types/tool.go b/pkg/types/tool.go index 54d5d817..bb49e6f1 100644 --- a/pkg/types/tool.go +++ b/pkg/types/tool.go @@ -157,8 +157,9 @@ func (p Parameters) ToolRefNames() []string { type ToolDef struct { Parameters `json:",inline"` - Instructions string `json:"instructions,omitempty"` - BuiltinFunc BuiltinFunc `json:"-"` + Instructions string `json:"instructions,omitempty"` + BuiltinFunc BuiltinFunc `json:"-"` + MetaData map[string]string `json:"metaData,omitempty"` } type Tool struct { @@ -488,6 +489,21 @@ func (t ToolDef) String() string { _, _ = fmt.Fprintln(buf, t.Instructions) } + if t.Name != "" { + keys := maps.Keys(t.MetaData) + sort.Strings(keys) + for _, key := range keys { + buf.WriteString("---\n") + buf.WriteString("!metadata:") + buf.WriteString(t.Name) + buf.WriteString(":") + buf.WriteString(key) + buf.WriteString("\n") + buf.WriteString(t.MetaData[key]) + buf.WriteString("\n") + } + } + return buf.String() } @@ -793,6 +809,10 @@ type ToolSource struct { Repo *Repo `json:"repo,omitempty"` } +func (t ToolSource) IsGit() bool { + return t.Repo != nil && t.Repo.VCS == "git" +} + func (t ToolSource) String() string { return fmt.Sprintf("%s:%d", t.Location, t.LineNo) } diff --git a/pkg/types/tool_test.go b/pkg/types/tool_test.go index a47014a1..e95c2248 100644 --- a/pkg/types/tool_test.go +++ b/pkg/types/tool_test.go @@ -36,6 +36,13 @@ func TestToolDef_String(t *testing.T) { ExportCredentials: []string{"ExportCredential1", "ExportCredential2"}, Type: ToolTypeContext, }, + MetaData: map[string]string{ + "package.json": `{ +// blah blah some ugly JSON +} +`, + "requirements.txt": `requests=5`, + }, Instructions: "This is a sample instruction", } @@ -68,6 +75,15 @@ Share Credential: ExportCredential2 Chat: true This is a sample instruction +--- +!metadata:Tool Sample:package.json +{ +// blah blah some ugly JSON +} + +--- +!metadata:Tool Sample:requirements.txt +requests=5 `).Equal(t, tool.String()) }