diff --git a/docs/docs/03-tools/01-using.md b/docs/docs/03-tools/01-using.md index a2485dc7..075ef9d2 100644 --- a/docs/docs/03-tools/01-using.md +++ b/docs/docs/03-tools/01-using.md @@ -35,19 +35,34 @@ Select a number at random between 1 and 100 and return only the number. ``` ### External Tools +You can refer to GPTScript tool files that are served on the web or stored locally. This is useful for sharing tools across multiple scripts or for using tools that are not part of the core GPTScript distribution. -This is where the real power of GPTScript starts to become evident. For this example lets use the external [image-generation](https://github.com/gptscript-ai/image-generation) and [search](https://github.com/gptscript-ai/search) tools to generate an image and then search the web for similar images. +```yaml +tools: https://get.gptscript.ai/echo.gpt + +Echo the phrase "Hello, World!". +``` + +Or, if the file is stored locally in "echo.gpt": + +```yaml +tools: echo.gpt + +Echo the phrase "Hello, World!". +``` + +You can also refer to OpenAPI definition files as though they were GPTScript tool files. GPTScript will treat each operation in the file as a separate tool. For more details, see [OpenAPI Tools](03-openapi.md). -:::note -There will be better packaging and distribution for external tools in the future. For now, this assumes you have the tools cloned locally and are pointing to their repos directly. -::: +### Packaged Tools on GitHub +GPTScript tools can be packaged and shared on GitHub, and referred to by their GitHub URL. For example: ```yaml -tools: ./image-generation/tool.gpt, ./vision/tool.gpt, sys.read +tools: github.com/gptscript-ai/image-generation, github.com/gptscript-ai/vision, sys.read Generate an image of a city skyline at night and write the resulting image to a file called city_skyline.png. Take this image and write a description of it in the style of pirate. ``` -External tools are tools that are defined by a `tool.gpt` file in their root directory. They can be imported into a GPTScript by specifying the path to the tool's root directory. +When this script is run, GPTScript will locally clone the referenced GitHub repos and run the tools referenced inside them. +For more info on how this works, see [Authoring Tools](02-authoring.md). diff --git a/docs/docs/03-tools/03-openapi.md b/docs/docs/03-tools/03-openapi.md new file mode 100644 index 00000000..70f4a7fd --- /dev/null +++ b/docs/docs/03-tools/03-openapi.md @@ -0,0 +1,62 @@ +# OpenAPI Tools + +:::note +This is a new feature and might be buggy. +::: + +GPTScript can treat OpenAPI v3 definition files as though they were tool files. +Each operation (a path and HTTP method) in the file will become a simple tool that makes an HTTP request. +GPTScript will automatically and internally generate the necessary code to make the request and parse the response. + +Here is an example that uses the OpenAPI [Petstore Example](https://github.com/OAI/OpenAPI-Specification/blob/main/examples/v3.0/petstore.yaml): + +```yaml +Tools: https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.yaml + +List all the pets. After you get a response, create a new pet named Mark. He is a lizard. +``` + +You can also use a local file path instead of a URL. + +## Servers + +GPTScript will look at the top-level `servers` array in the file and choose the first HTTPS server it finds. +If no HTTPS server exists, it will choose the first HTTP server. Other protocols (such as WSS) are not yet supported. + +GPTScript will also handle path- and operation-level server overrides, following the same logic of choosing the first HTTPS server it finds, +or the first HTTP server if no HTTPS server exists in the array. + +Additionally, GPTScript can handle variables in the server name. For example, this: + +```yaml +servers: + - url: '{server}/v1' + variables: + server: + default: https://api.example.com +``` + +Will be resolved as `https://api.example.com/v1`. + +## Authentication + +GPTScript currently ignores any security schemes and authentication/authorization information in the OpenAPI definition file. This might change in the future. + +For now, the only supported type of authentication is bearer tokens. GPTScript will look for a special environment variable based +on the hostname of the server. It looks for the format `GPTSCRIPT__BEARER_TOKEN`, where `` is the hostname, but in all caps and +dots are replaced by underscores. For example, if the server is `https://api.example.com`, GPTScript will look for an environment variable +called `GPTSCRIPT_API_EXAMPLE_COM_BEARER_TOKEN`. If it finds one, it will use it as the bearer token for all requests to that server. + +:::note +GPTScript will not look for bearer tokens if the server uses HTTP instead of HTTPS. +::: + +## MIME Types and Request Bodies + +In OpenAPI definitions, request bodies are described with a MIME type. Currently, GPTScript supports these MIME types: +- `application/json` +- `text/plain` +- `multipart/form-data` + +GPTScript will return an error when parsing the OpenAPI definition if it finds a request body that does not specify +at least one of these supported MIME types. diff --git a/go.mod b/go.mod index d2ab7bed..debbeba0 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/acorn-io/cmd v0.0.0-20240203032901-e9e631185ddb github.com/adrg/xdg v0.4.0 github.com/fatih/color v1.16.0 + github.com/getkin/kin-openapi v0.123.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/hexops/autogold/v2 v2.1.0 github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 @@ -18,13 +19,15 @@ require ( github.com/olahol/melody v1.1.4 github.com/rs/cors v1.10.1 github.com/samber/lo v1.38.1 - github.com/sashabaranov/go-openai v1.20.1 + github.com/sashabaranov/go-openai v1.20.4 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.8.4 + github.com/tidwall/gjson v1.17.1 golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc golang.org/x/sync v0.6.0 golang.org/x/term v0.16.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -38,6 +41,8 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dsnet/compress v0.0.1 // indirect github.com/go-logr/logr v1.4.1 // indirect + github.com/go-openapi/jsonpointer v0.20.2 // indirect + github.com/go-openapi/swag v0.22.8 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-containerregistry v0.16.1 // indirect @@ -48,18 +53,23 @@ require ( github.com/hexops/gotextdiff v1.0.3 // indirect github.com/hexops/valast v1.4.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/yaml v0.2.0 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/compress v1.16.5 // indirect github.com/klauspost/pgzip v1.2.5 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.10 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/nightlyone/lockfile v1.0.0 // indirect github.com/nwaples/rardecode/v2 v2.0.0-beta.2 // indirect github.com/olekukonko/tablewriter v0.0.6-0.20230925090304-df64c4bbad77 // indirect github.com/onsi/ginkgo/v2 v2.13.0 // indirect github.com/onsi/gomega v1.29.0 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pierrec/lz4/v4 v4.1.15 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.1.0 // indirect @@ -67,6 +77,8 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/therootcompany/xz v1.0.1 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect github.com/ulikunitz/xz v0.5.10 // indirect go4.org v0.0.0-20200411211856-f5505b9728dd // indirect golang.org/x/mod v0.15.0 // indirect @@ -74,7 +86,6 @@ require ( golang.org/x/sys v0.16.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/tools v0.17.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.110.1 // indirect mvdan.cc/gofumpt v0.6.0 // indirect sigs.k8s.io/controller-runtime v0.16.3 // indirect diff --git a/go.sum b/go.sum index 24a9d14e..d5e0739c 100644 --- a/go.sum +++ b/go.sum @@ -66,13 +66,21 @@ github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4Nij github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/getkin/kin-openapi v0.123.0 h1:zIik0mRwFNLyvtXK274Q6ut+dPh6nlxBp0x7mNrPhs8= +github.com/getkin/kin-openapi v0.123.0/go.mod h1:wb1aSZA/iWmorQP9KTAS/phLj/t17B5jT7+fS8ed9NM= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= +github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= +github.com/go-openapi/swag v0.22.8 h1:/9RjDSQ0vbFR+NyjGMkFTsA1IA0fmhKSThmfGZjicbw= +github.com/go-openapi/swag v0.22.8/go.mod h1:6QT22icPLEqAM/z/TChgb4WAveCHF92+2gF0CNjHpPI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -138,8 +146,12 @@ github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4Dvx github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= +github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA= github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -159,6 +171,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -175,6 +189,8 @@ github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1f github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mholt/archiver/v4 v4.0.0-alpha.8 h1:tRGQuDVPh66WCOelqe6LIGh0gwmfwxUrSSDunscGsRM= github.com/mholt/archiver/v4 v4.0.0-alpha.8/go.mod h1:5f7FUYGXdJWUjESffJaYR4R60VhnHxb2X3T1teMyv5A= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/nightlyone/lockfile v1.0.0 h1:RHep2cFKK4PonZJDdEl4GmkabuhbsRMgk/k3uAmxBiA= github.com/nightlyone/lockfile v1.0.0/go.mod h1:rywoIealpdNse2r832aiD9jRk8ErCatROs6LzC841CI= github.com/nwaples/rardecode/v2 v2.0.0-beta.2 h1:e3mzJFJs4k83GXBEiTaQ5HgSc/kOK8q0rDaRO0MPaOk= @@ -187,6 +203,8 @@ github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4 github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -225,6 +243,14 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw= github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY= +github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= +github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= @@ -422,6 +448,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index 3a708d79..31788790 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -185,6 +185,8 @@ func (e *Engine) Start(ctx Context, input string) (*Return, error) { return e.runHTTP(ctx.Ctx, ctx.Program, tool, input) } else if tool.IsDaemon() { return e.runDaemon(ctx.Ctx, ctx.Program, tool, input) + } else if tool.IsOpenAPI() { + return e.runOpenAPI(tool, input) } s, err := e.runCommand(ctx.Ctx, tool, input) if err != nil { diff --git a/pkg/engine/openapi.go b/pkg/engine/openapi.go new file mode 100644 index 00000000..a12e1309 --- /dev/null +++ b/pkg/engine/openapi.go @@ -0,0 +1,384 @@ +package engine + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "strings" + + "github.com/gptscript-ai/gptscript/pkg/env" + "github.com/gptscript-ai/gptscript/pkg/types" + "github.com/tidwall/gjson" +) + +var SupportedMIMETypes = []string{"application/json", "text/plain", "multipart/form-data"} + +type Parameter struct { + Name string `json:"name"` + Style string `json:"style"` + Explode *bool `json:"explode"` +} + +type OpenAPIInstructions struct { + Server string `json:"server"` + Path string `json:"path"` + Method string `json:"method"` + BodyContentMIME string `json:"bodyContentMIME"` + QueryParameters []Parameter `json:"queryParameters"` + PathParameters []Parameter `json:"pathParameters"` + HeaderParameters []Parameter `json:"headerParameters"` + CookieParameters []Parameter `json:"cookieParameters"` +} + +// runOpenAPI runs a tool that was generated from an OpenAPI definition. +// The tool itself will have instructions regarding the HTTP request that needs to be made. +// The tools Instructions field will be in the format "#!sys.openapi '{Instructions JSON}'", +// where {Instructions JSON} is a JSON string of type OpenAPIInstructions. +func (e *Engine) runOpenAPI(tool types.Tool, input string) (*Return, error) { + envMap := map[string]string{} + + for _, env := range e.Env { + k, v, _ := strings.Cut(env, "=") + envMap[k] = v + } + + // Extract the instructions from the tool to determine server, path, method, etc. + var instructions OpenAPIInstructions + _, inst, _ := strings.Cut(tool.Instructions, types.OpenAPIPrefix+" ") + inst = strings.TrimPrefix(inst, "'") + inst = strings.TrimSuffix(inst, "'") + if err := json.Unmarshal([]byte(inst), &instructions); err != nil { + return nil, fmt.Errorf("failed to unmarshal tool instructions: %w", err) + } + + // Handle path parameters + instructions.Path = handlePathParameters(instructions.Path, instructions.PathParameters, input) + + // Parse the URL + u, err := url.Parse(instructions.Server + instructions.Path) + if err != nil { + return nil, fmt.Errorf("failed to parse server URL %s: %w", instructions.Server+instructions.Path, err) + } + + // Set up the request + req, err := http.NewRequest(instructions.Method, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Check for a bearer token (only if using HTTPS) + if u.Scheme == "https" { + // For "https://example.com" the bearer token env name would be GPTSCRIPT_EXAMPLE_COM_BEARER_TOKEN + bearerEnv := "GPTSCRIPT_" + env.ToEnvLike(u.Hostname()) + "_BEARER_TOKEN" + if bearerToken, ok := envMap[bearerEnv]; ok { + req.Header.Set("Authorization", "Bearer "+bearerToken) + } + } + + // Handle query parameters + req.URL.RawQuery = handleQueryParameters(req.URL.Query(), instructions.QueryParameters, input).Encode() + + // Handle header and cookie parameters + handleHeaderParameters(req, instructions.HeaderParameters, input) + handleCookieParameters(req, instructions.CookieParameters, input) + + // Handle request body + if instructions.BodyContentMIME != "" { + res := gjson.Get(input, "requestBodyContent") + if res.Exists() { + var body bytes.Buffer + switch instructions.BodyContentMIME { + case "application/json": + if err := json.NewEncoder(&body).Encode(res.Value()); err != nil { + return nil, fmt.Errorf("failed to encode JSON: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + case "text/plain": + body.WriteString(res.String()) + req.Header.Set("Content-Type", "text/plain") + + case "multipart/form-data": + multiPartWriter := multipart.NewWriter(&body) + req.Header.Set("Content-Type", multiPartWriter.FormDataContentType()) + if res.IsObject() { + for k, v := range res.Map() { + if err := multiPartWriter.WriteField(k, v.String()); err != nil { + return nil, fmt.Errorf("failed to write multipart field: %w", err) + } + } + } else { + return nil, fmt.Errorf("multipart/form-data requires an object as the requestBodyContent") + } + if err := multiPartWriter.Close(); err != nil { + return nil, fmt.Errorf("failed to close multipart writer: %w", err) + } + + default: + return nil, fmt.Errorf("unsupported MIME type: %s", instructions.BodyContentMIME) + } + req.Body = io.NopCloser(&body) + } + } + + // Make the request + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + result, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + resultStr := string(result) + + return &Return{ + Result: &resultStr, + }, nil +} + +// handleQueryParameters extracts each query parameter from the input JSON and adds it to the URL query. +func handleQueryParameters(q url.Values, params []Parameter, input string) url.Values { + for _, param := range params { + res := gjson.Get(input, param.Name) + if res.Exists() { + // If it's an array or object, handle the serialization style + if res.IsArray() { + switch param.Style { + case "form", "": // form is the default style for query parameters + if param.Explode == nil || *param.Explode { // default is to explode + for _, item := range res.Array() { + q.Add(param.Name, item.String()) + } + } else { + var strs []string + for _, item := range res.Array() { + strs = append(strs, item.String()) + } + q.Add(param.Name, strings.Join(strs, ",")) + } + case "spaceDelimited": + if param.Explode == nil || *param.Explode { + for _, item := range res.Array() { + q.Add(param.Name, item.String()) + } + } else { + var strs []string + for _, item := range res.Array() { + strs = append(strs, item.String()) + } + q.Add(param.Name, strings.Join(strs, " ")) + } + case "pipeDelimited": + if param.Explode == nil || *param.Explode { + for _, item := range res.Array() { + q.Add(param.Name, item.String()) + } + } else { + var strs []string + for _, item := range res.Array() { + strs = append(strs, item.String()) + } + q.Add(param.Name, strings.Join(strs, "|")) + } + } + } else if res.IsObject() { + switch param.Style { + case "form", "": // form is the default style for query parameters + if param.Explode == nil || *param.Explode { // default is to explode + for k, v := range res.Map() { + q.Add(k, v.String()) + } + } else { + var strs []string + for k, v := range res.Map() { + strs = append(strs, k, v.String()) + } + q.Add(param.Name, strings.Join(strs, ",")) + } + case "deepObject": + for k, v := range res.Map() { + q.Add(param.Name+"["+k+"]", v.String()) + } + } + } else { + q.Add(param.Name, res.String()) + } + } + } + return q +} + +// handlePathParameters extracts each path parameter from the input JSON and replaces its placeholder in the URL path. +func handlePathParameters(path string, params []Parameter, input string) string { + for _, param := range params { + res := gjson.Get(input, param.Name) + if res.Exists() { + // If it's an array or object, handle the serialization style + if res.IsArray() { + switch param.Style { + case "simple", "": // simple is the default style for path parameters + // simple looks the same regardless of whether explode is true + strs := make([]string, len(res.Array())) + for i, item := range res.Array() { + strs[i] = item.String() + } + path = strings.Replace(path, "{"+param.Name+"}", strings.Join(strs, ","), 1) + case "label": + strs := make([]string, len(res.Array())) + for i, item := range res.Array() { + strs[i] = item.String() + } + + if param.Explode == nil || !*param.Explode { // default is to not explode + path = strings.Replace(path, "{"+param.Name+"}", "."+strings.Join(strs, ","), 1) + } else { + path = strings.Replace(path, "{"+param.Name+"}", "."+strings.Join(strs, "."), 1) + } + case "matrix": + strs := make([]string, len(res.Array())) + for i, item := range res.Array() { + strs[i] = item.String() + } + + if param.Explode == nil || !*param.Explode { // default is to not explode + path = strings.Replace(path, "{"+param.Name+"}", ";"+param.Name+"="+strings.Join(strs, ","), 1) + } else { + s := "" + for _, str := range strs { + s += ";" + param.Name + "=" + str + } + path = strings.Replace(path, "{"+param.Name+"}", s, 1) + } + } + } else if res.IsObject() { + switch param.Style { + case "simple", "": + if param.Explode == nil || !*param.Explode { // default is to not explode + var strs []string + for k, v := range res.Map() { + strs = append(strs, k, v.String()) + } + path = strings.Replace(path, "{"+param.Name+"}", strings.Join(strs, ","), 1) + } else { + var strs []string + for k, v := range res.Map() { + strs = append(strs, k+"="+v.String()) + } + path = strings.Replace(path, "{"+param.Name+"}", strings.Join(strs, ","), 1) + } + case "label": + if param.Explode == nil || !*param.Explode { // default is to not explode + var strs []string + for k, v := range res.Map() { + strs = append(strs, k, v.String()) + } + path = strings.Replace(path, "{"+param.Name+"}", "."+strings.Join(strs, ","), 1) + } else { + s := "" + for k, v := range res.Map() { + s += "." + k + "=" + v.String() + } + path = strings.Replace(path, "{"+param.Name+"}", s, 1) + } + case "matrix": + if param.Explode == nil || !*param.Explode { // default is to not explode + var strs []string + for k, v := range res.Map() { + strs = append(strs, k, v.String()) + } + path = strings.Replace(path, "{"+param.Name+"}", ";"+param.Name+"="+strings.Join(strs, ","), 1) + } else { + s := "" + for k, v := range res.Map() { + s += ";" + k + "=" + v.String() + } + path = strings.Replace(path, "{"+param.Name+"}", s, 1) + } + } + } else { + // Serialization is handled slightly differently even for basic types. + // Explode doesn't do anything though. + switch param.Style { + case "simple", "": + path = strings.Replace(path, "{"+param.Name+"}", res.String(), 1) + case "label": + path = strings.Replace(path, "{"+param.Name+"}", "."+res.String(), 1) + case "matrix": + path = strings.Replace(path, "{"+param.Name+"}", ";"+param.Name+"="+res.String(), 1) + } + } + } + } + return path +} + +// handleHeaderParameters extracts each header parameter from the input JSON and adds it to the request headers. +func handleHeaderParameters(req *http.Request, params []Parameter, input string) { + for _, param := range params { + res := gjson.Get(input, param.Name) + if res.Exists() { + if res.IsArray() { + strs := make([]string, len(res.Array())) + for i, item := range res.Array() { + strs[i] = item.String() + } + req.Header.Add(param.Name, strings.Join(strs, ",")) + } else if res.IsObject() { + // Handle explosion + var strs []string + if param.Explode == nil || !*param.Explode { // default is to not explode + for k, v := range res.Map() { + strs = append(strs, k, v.String()) + } + } else { + for k, v := range res.Map() { + strs = append(strs, k+"="+v.String()) + } + } + req.Header.Add(param.Name, strings.Join(strs, ",")) + } else { // basic type + req.Header.Add(param.Name, res.String()) + } + } + } +} + +// handleCookieParameters extracts each cookie parameter from the input JSON and adds it to the request cookies. +func handleCookieParameters(req *http.Request, params []Parameter, input string) { + for _, param := range params { + res := gjson.Get(input, param.Name) + if res.Exists() { + if res.IsArray() { + strs := make([]string, len(res.Array())) + for i, item := range res.Array() { + strs[i] = item.String() + } + req.AddCookie(&http.Cookie{ + Name: param.Name, + Value: strings.Join(strs, ","), + }) + } else if res.IsObject() { + var strs []string + for k, v := range res.Map() { + strs = append(strs, k, v.String()) + } + req.AddCookie(&http.Cookie{ + Name: param.Name, + Value: strings.Join(strs, ","), + }) + } else { // basic type + req.AddCookie(&http.Cookie{ + Name: param.Name, + Value: res.String(), + }) + } + } + } +} diff --git a/pkg/engine/openapi_test.go b/pkg/engine/openapi_test.go new file mode 100644 index 00000000..df1e00fc --- /dev/null +++ b/pkg/engine/openapi_test.go @@ -0,0 +1,255 @@ +package engine + +import ( + "encoding/json" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPathParameterSerialization(t *testing.T) { + input := struct { + Value int `json:"v"` + Array []string `json:"a"` + Object map[string]string `json:"o"` + }{ + Value: 42, + Array: []string{"foo", "bar", "baz"}, + Object: map[string]string{"qux": "quux", "corge": "grault"}, + } + inputStr, err := json.Marshal(input) + require.NoError(t, err) + + path := "/mypath/{v}/{a}/{o}" + + tests := []struct { + name string + style string + explode bool + expectedPaths []string // We use multiple expected paths due to randomness in map iteration + }{ + { + name: "simple + no explode", + style: "simple", + explode: false, + expectedPaths: []string{ + "/mypath/42/foo,bar,baz/qux,quux,corge,grault", + "/mypath/42/foo,bar,baz/corge,grault,qux,quux", + }, + }, + { + name: "simple + explode", + style: "simple", + explode: true, + expectedPaths: []string{ + "/mypath/42/foo,bar,baz/qux=quux,corge=grault", + "/mypath/42/foo,bar,baz/corge=grault,qux=quux", + }, + }, + { + name: "label + no explode", + style: "label", + explode: false, + expectedPaths: []string{ + "/mypath/.42/.foo,bar,baz/.qux,quux,corge,grault", + "/mypath/.42/.foo,bar,baz/.corge,grault,qux,quux", + }, + }, + { + name: "label + explode", + style: "label", + explode: true, + expectedPaths: []string{ + "/mypath/.42/.foo.bar.baz/.qux=quux.corge=grault", + "/mypath/.42/.foo.bar.baz/.corge=grault.qux=quux", + }, + }, + { + name: "matrix + no explode", + style: "matrix", + explode: false, + expectedPaths: []string{ + "/mypath/;v=42/;a=foo,bar,baz/;o=qux,quux,corge,grault", + "/mypath/;v=42/;a=foo,bar,baz/;o=corge,grault,qux,quux", + }, + }, + { + name: "matrix + explode", + style: "matrix", + explode: true, + expectedPaths: []string{ + "/mypath/;v=42/;a=foo;a=bar;a=baz/;qux=quux;corge=grault", + "/mypath/;v=42/;a=foo;a=bar;a=baz/;corge=grault;qux=quux", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := path + params := getParameters(test.style, test.explode) + path = handlePathParameters(path, params, string(inputStr)) + require.Contains(t, test.expectedPaths, path) + }) + } +} + +func TestQueryParameterSerialization(t *testing.T) { + input := struct { + Value int `json:"v"` + Array []string `json:"a"` + Object map[string]string `json:"o"` + }{ + Value: 42, + Array: []string{"foo", "bar", "baz"}, + Object: map[string]string{"qux": "quux", "corge": "grault"}, + } + inputStr, err := json.Marshal(input) + require.NoError(t, err) + + tests := []struct { + name string + input string + param Parameter + expectedQueries []string // We use multiple expected queries due to randomness in map iteration + }{ + { + name: "value", + input: string(inputStr), + param: Parameter{ + Name: "v", + }, + expectedQueries: []string{"v=42"}, + }, + { + name: "array form + explode", + input: string(inputStr), + param: Parameter{ + Name: "a", + Style: "form", + Explode: boolPointer(true), + }, + expectedQueries: []string{"a=foo&a=bar&a=baz"}, + }, + { + name: "array form + no explode", + input: string(inputStr), + param: Parameter{ + Name: "a", + Style: "form", + Explode: boolPointer(false), + }, + expectedQueries: []string{"a=foo%2Cbar%2Cbaz"}, // %2C is a comma + }, + { + name: "array spaceDelimited + explode", + input: string(inputStr), + param: Parameter{ + Name: "a", + Style: "spaceDelimited", + Explode: boolPointer(true), + }, + expectedQueries: []string{"a=foo&a=bar&a=baz"}, + }, + { + name: "array spaceDelimited + no explode", + input: string(inputStr), + param: Parameter{ + Name: "a", + Style: "spaceDelimited", + Explode: boolPointer(false), + }, + expectedQueries: []string{"a=foo+bar+baz"}, + }, + { + name: "array pipeDelimited + explode", + input: string(inputStr), + param: Parameter{ + Name: "a", + Style: "pipeDelimited", + Explode: boolPointer(true), + }, + expectedQueries: []string{"a=foo&a=bar&a=baz"}, + }, + { + name: "array pipeDelimited + no explode", + input: string(inputStr), + param: Parameter{ + Name: "a", + Style: "pipeDelimited", + Explode: boolPointer(false), + }, + expectedQueries: []string{"a=foo%7Cbar%7Cbaz"}, // %7C is a pipe + }, + { + name: "object form + explode", + input: string(inputStr), + param: Parameter{ + Name: "o", + Style: "form", + Explode: boolPointer(true), + }, + expectedQueries: []string{ + "qux=quux&corge=grault", + "corge=grault&qux=quux", + }, + }, + { + name: "object form + no explode", + input: string(inputStr), + param: Parameter{ + Name: "o", + Style: "form", + Explode: boolPointer(false), + }, + expectedQueries: []string{ // %2C is a comma + "o=qux%2Cquux%2Ccorge%2Cgrault", + "o=corge%2Cgrault%2Cqux%2Cquux", + }, + }, + { + name: "object deepObject", + input: string(inputStr), + param: Parameter{ + Name: "o", + Style: "deepObject", + }, + expectedQueries: []string{ // %5B is a [ and %5D is a ] + "o%5Bqux%5D=quux&o%5Bcorge%5D=grault", + "o%5Bcorge%5D=grault&o%5Bqux%5D=quux", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + q := handleQueryParameters(url.Values{}, []Parameter{test.param}, test.input) + require.Contains(t, test.expectedQueries, q.Encode()) + }) + } +} + +func getParameters(style string, explode bool) []Parameter { + return []Parameter{ + { + Name: "v", + Style: style, + Explode: &explode, + }, + { + Name: "a", + Style: style, + Explode: &explode, + }, + { + Name: "o", + Style: style, + Explode: &explode, + }, + } +} + +func boolPointer(b bool) *bool { + return &b +} diff --git a/pkg/loader/loader.go b/pkg/loader/loader.go index 532caefb..85ec985f 100644 --- a/pkg/loader/loader.go +++ b/pkg/loader/loader.go @@ -14,11 +14,13 @@ import ( "path/filepath" "strings" + "github.com/getkin/kin-openapi/openapi3" "github.com/gptscript-ai/gptscript/pkg/assemble" "github.com/gptscript-ai/gptscript/pkg/builtin" "github.com/gptscript-ai/gptscript/pkg/engine" "github.com/gptscript-ai/gptscript/pkg/parser" "github.com/gptscript-ai/gptscript/pkg/types" + "gopkg.in/yaml.v3" ) type source struct { @@ -126,9 +128,22 @@ func readTool(ctx context.Context, prg *types.Program, base *source, targetToolN return loadProgram(data, prg, targetToolName) } - tools, err := parser.Parse(bytes.NewReader(data)) - if err != nil { - return types.Tool{}, err + var tools []types.Tool + if isOpenAPI(data) { + if t, err := openapi3.NewLoader().LoadFromData(data); err == nil { + tools, err = getOpenAPITools(t) + if err != nil { + return types.Tool{}, fmt.Errorf("error parsing OpenAPI definition: %w", err) + } + } + } + + // If we didn't get any tools from trying to parse it as OpenAPI, try to parse it as a GPTScript + if len(tools) == 0 { + tools, err = parser.Parse(bytes.NewReader(data)) + if err != nil { + return types.Tool{}, err + } } if len(tools) == 0 { @@ -156,7 +171,7 @@ func readTool(ctx context.Context, prg *types.Program, base *source, targetToolN return types.Tool{}, parser.NewErrLine(tool.Source.Location, tool.Source.LineNo, fmt.Errorf("only the first tool in a file can have no name")) } - if targetToolName != "" && tool.Parameters.Name == targetToolName { + if targetToolName != "" && strings.EqualFold(tool.Parameters.Name, targetToolName) { mainTool = tool } @@ -301,3 +316,16 @@ func SplitToolRef(targetToolName string) (toolName, subTool string) { } return } + +func isOpenAPI(data []byte) bool { + var version struct { + OpenAPI string `json:"openapi" yaml:"openapi"` + } + + if err := json.Unmarshal(data, &version); err != nil { + if err := yaml.Unmarshal(data, &version); err != nil { + return false + } + } + return strings.HasPrefix(version.OpenAPI, "3.") +} diff --git a/pkg/loader/loader_test.go b/pkg/loader/loader_test.go index e46aa823..fd0eda93 100644 --- a/pkg/loader/loader_test.go +++ b/pkg/loader/loader_test.go @@ -75,13 +75,13 @@ func TestHelloWorld(t *testing.T) { "modelName": "gpt-4-turbo-preview", "internalPrompt": null, "arguments": { - "type": "object", "properties": { "input": { "description": " Any string", "type": "string" } - } + }, + "type": "object" }, "instructions": "echo \"${input}\"", "id": "https://get.gptscript.ai/echo.gpt:1", diff --git a/pkg/loader/openapi.go b/pkg/loader/openapi.go new file mode 100644 index 00000000..315ec27f --- /dev/null +++ b/pkg/loader/openapi.go @@ -0,0 +1,214 @@ +package loader + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/gptscript-ai/gptscript/pkg/engine" + "github.com/gptscript-ai/gptscript/pkg/types" +) + +// getOpenAPITools parses an OpenAPI definition and generates a set of tools from it. +// Each operation will become a tool definition. +// The tool's Instructions will be in the format "#!sys.openapi '{JSON Instructions}'", +// where the JSON Instructions are a JSON-serialized engine.OpenAPIInstructions struct. +func getOpenAPITools(t *openapi3.T) ([]types.Tool, error) { + if len(t.Servers) == 0 { + return nil, fmt.Errorf("no servers found in OpenAPI spec") + } + defaultServer, err := parseServer(t.Servers[0]) + if err != nil { + return nil, err + } + + var ( + toolNames []string + tools []types.Tool + operationNum = 1 + ) + for pathString, pathObj := range t.Paths.Map() { + // Handle path-level server override, if one exists + pathServer := defaultServer + if pathObj.Servers != nil && len(pathObj.Servers) > 0 { + pathServer, err = parseServer(pathObj.Servers[0]) + if err != nil { + return nil, err + } + } + + for method, operation := range pathObj.Operations() { + // Handle operation-level server override, if one exists + operationServer := pathServer + if operation.Servers != nil && len(*operation.Servers) > 0 { + operationServer, err = parseServer((*operation.Servers)[0]) + if err != nil { + return nil, err + } + } + + // Each operation can have a description and a summary. Use the Description if one exists, + // otherwise us the summary. + toolDesc := operation.Description + if toolDesc == "" { + toolDesc = operation.Summary + } + + var ( + queryParameters []engine.Parameter + pathParameters []engine.Parameter + headerParameters []engine.Parameter + cookieParameters []engine.Parameter + bodyMIME string + ) + tool := types.Tool{ + Parameters: types.Parameters{ + Name: operation.OperationID, + Description: toolDesc, + Arguments: &openapi3.Schema{ + Type: "object", + Properties: openapi3.Schemas{}, + Required: []string{}, + }, + }, + Source: types.ToolSource{ + // We need some concept of a line number in order for tools to have different IDs + // So we basically just treat it as an "operation number" in this case + LineNo: operationNum, + }, + } + + toolNames = append(toolNames, tool.Parameters.Name) + + // Handle query, path, and header parameters + for _, param := range operation.Parameters { + arg := param.Value.Schema.Value + + if arg.Description == "" { + arg.Description = param.Value.Description + } + + // Add the new arg to the tool's arguments + tool.Parameters.Arguments.Properties[param.Value.Name] = &openapi3.SchemaRef{Value: arg} + + // Check whether it is required + if param.Value.Required { + tool.Parameters.Arguments.Required = append(tool.Parameters.Arguments.Required, param.Value.Name) + } + + // Add the parameter to the appropriate list for the tool's instructions + p := engine.Parameter{ + Name: param.Value.Name, + Style: param.Value.Style, + Explode: param.Value.Explode, + } + switch param.Value.In { + case "query": + queryParameters = append(queryParameters, p) + case "path": + pathParameters = append(pathParameters, p) + case "header": + headerParameters = append(headerParameters, p) + case "cookie": + cookieParameters = append(cookieParameters, p) + } + } + + // Handle the request body, if one exists + if operation.RequestBody != nil { + for mime, content := range operation.RequestBody.Value.Content { + // Each MIME type needs to be handled individually, so we + // keep a list of the ones we support. + if !slices.Contains(engine.SupportedMIMETypes, mime) { + continue + } + bodyMIME = mime + + arg := content.Schema.Value + if arg.Description == "" { + arg.Description = content.Schema.Value.Description + } + + // Unfortunately, the request body doesn't contain any good descriptor for it, + // so we just use "requestBodyContent" as the name of the arg. + tool.Parameters.Arguments.Properties["requestBodyContent"] = &openapi3.SchemaRef{Value: arg} + break + } + + if bodyMIME == "" { + return nil, fmt.Errorf("no supported MIME types found for request body in operation %s", operation.OperationID) + } + } + + // OpenAI will get upset if we have an object schema with no properties, + // so we just nil this out if there were no properties added. + if len(tool.Arguments.Properties) == 0 { + tool.Arguments = nil + } + + var err error + tool.Instructions, err = instructionString(operationServer, method, pathString, bodyMIME, queryParameters, pathParameters, headerParameters, cookieParameters) + if err != nil { + return nil, err + } + + tools = append(tools, tool) + operationNum++ + } + } + + // The first tool we generate is a special tool that just exports all the others. + exportTool := types.Tool{ + Parameters: types.Parameters{ + Description: fmt.Sprintf("This is a tool set for the %s OpenAPI spec", t.Info.Title), + Export: toolNames, + }, + Source: types.ToolSource{ + LineNo: 0, + }, + } + // Add it to the front of the slice. + tools = append([]types.Tool{exportTool}, tools...) + + return tools, nil +} + +func instructionString(server, method, path, bodyMIME string, queryParameters, pathParameters, headerParameters, cookieParameters []engine.Parameter) (string, error) { + inst := engine.OpenAPIInstructions{ + Server: server, + Path: path, + Method: method, + BodyContentMIME: bodyMIME, + QueryParameters: queryParameters, + PathParameters: pathParameters, + HeaderParameters: headerParameters, + CookieParameters: cookieParameters, + } + instBytes, err := json.Marshal(inst) + if err != nil { + return "", fmt.Errorf("failed to marshal tool instructions: %w", err) + } + + return fmt.Sprintf("%s '%s'", types.OpenAPIPrefix, string(instBytes)), nil +} + +func parseServer(server *openapi3.Server) (string, error) { + s := server.URL + for name, variable := range server.Variables { + if variable == nil { + continue + } + + if variable.Default != "" { + s = strings.Replace(s, "{"+name+"}", variable.Default, 1) + } else if len(variable.Enum) > 0 { + s = strings.Replace(s, "{"+name+"}", variable.Enum[0], 1) + } + } + if !strings.HasPrefix(s, "http") { + return "", fmt.Errorf("invalid server URL: %s (must use HTTP or HTTPS; relative URLs not supported)", s) + } + return s, nil +} diff --git a/pkg/openai/client.go b/pkg/openai/client.go index 8ee9e0d2..90b7d76c 100644 --- a/pkg/openai/client.go +++ b/pkg/openai/client.go @@ -332,9 +332,7 @@ func (c *Client) Call(ctx context.Context, messageRequest types.CompletionReques for _, tool := range messageRequest.Tools { params := tool.Function.Parameters - if params != nil && params.Type == "object" && params.Properties == nil { - params.Properties = map[string]types.Property{} - } + request.Tools = append(request.Tools, openai.Tool{ Type: openai.ToolTypeFunction, Function: &openai.FunctionDefinition{ diff --git a/pkg/parser/parser.go b/pkg/parser/parser.go index 003ddcf2..1c8bc68c 100644 --- a/pkg/parser/parser.go +++ b/pkg/parser/parser.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/getkin/kin-openapi/openapi3" "github.com/gptscript-ai/gptscript/pkg/types" ) @@ -46,11 +47,9 @@ func csv(line string) (result []string) { func addArg(line string, tool *types.Tool) error { if tool.Parameters.Arguments == nil { - tool.Parameters.Arguments = &types.JSONSchema{ - Property: types.Property{ - Type: "object", - }, - Properties: map[string]types.Property{}, + tool.Parameters.Arguments = &openapi3.Schema{ + Type: "object", + Properties: openapi3.Schemas{}, } } @@ -59,9 +58,11 @@ func addArg(line string, tool *types.Tool) error { return fmt.Errorf("invalid arg format: %s", line) } - tool.Parameters.Arguments.Properties[key] = types.Property{ - Description: value, - Type: "string", + tool.Parameters.Arguments.Properties[key] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: value, + Type: "string", + }, } return nil diff --git a/pkg/system/prompt.go b/pkg/system/prompt.go index 84e0a47e..620780e9 100644 --- a/pkg/system/prompt.go +++ b/pkg/system/prompt.go @@ -5,7 +5,7 @@ import ( "os" "strings" - "github.com/gptscript-ai/gptscript/pkg/types" + "github.com/getkin/kin-openapi/openapi3" ) // Suffix is default suffix of gptscript files @@ -26,14 +26,14 @@ You don't move to the next step until you have a result. // to just send pure text but the interface required JSON (as that is the fundamental interface of tools in OpenAI) var DefaultPromptParameter = "defaultPromptParameter" -var DefaultToolSchema = types.JSONSchema{ - Property: types.Property{ - Type: "object", - }, - Properties: map[string]types.Property{ - DefaultPromptParameter: { - Description: "Prompt to send to the tool or assistant. This may be instructions or question.", - Type: "string", +var DefaultToolSchema = openapi3.Schema{ + Type: "object", + Properties: openapi3.Schemas{ + DefaultPromptParameter: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: "Prompt to send to the tool or assistant. This may be instructions or question.", + Type: "string", + }, }, }, Required: []string{DefaultPromptParameter}, diff --git a/pkg/tests/testdata/TestExport/call1.golden b/pkg/tests/testdata/TestExport/call1.golden index b6083d9e..b0619ee2 100644 --- a/pkg/tests/testdata/TestExport/call1.golden +++ b/pkg/tests/testdata/TestExport/call1.golden @@ -7,7 +7,6 @@ "toolID": "testdata/TestExport/parent.gpt:5", "name": "frommain", "parameters": { - "type": "object", "properties": { "defaultPromptParameter": { "description": "Prompt to send to the tool or assistant. This may be instructions or question.", @@ -16,7 +15,8 @@ }, "required": [ "defaultPromptParameter" - ] + ], + "type": "object" } } }, @@ -25,7 +25,6 @@ "toolID": "testdata/TestExport/parent.gpt:11", "name": "parent-local", "parameters": { - "type": "object", "properties": { "defaultPromptParameter": { "description": "Prompt to send to the tool or assistant. This may be instructions or question.", @@ -34,7 +33,8 @@ }, "required": [ "defaultPromptParameter" - ] + ], + "type": "object" } } }, @@ -43,7 +43,6 @@ "toolID": "testdata/TestExport/sub/child.gpt:8", "name": "transient", "parameters": { - "type": "object", "properties": { "defaultPromptParameter": { "description": "Prompt to send to the tool or assistant. This may be instructions or question.", @@ -52,7 +51,8 @@ }, "required": [ "defaultPromptParameter" - ] + ], + "type": "object" } } } diff --git a/pkg/tests/testdata/TestExport/call3.golden b/pkg/tests/testdata/TestExport/call3.golden index f5f62eda..b4874230 100644 --- a/pkg/tests/testdata/TestExport/call3.golden +++ b/pkg/tests/testdata/TestExport/call3.golden @@ -7,7 +7,6 @@ "toolID": "testdata/TestExport/parent.gpt:5", "name": "frommain", "parameters": { - "type": "object", "properties": { "defaultPromptParameter": { "description": "Prompt to send to the tool or assistant. This may be instructions or question.", @@ -16,7 +15,8 @@ }, "required": [ "defaultPromptParameter" - ] + ], + "type": "object" } } }, @@ -25,7 +25,6 @@ "toolID": "testdata/TestExport/parent.gpt:11", "name": "parent-local", "parameters": { - "type": "object", "properties": { "defaultPromptParameter": { "description": "Prompt to send to the tool or assistant. This may be instructions or question.", @@ -34,7 +33,8 @@ }, "required": [ "defaultPromptParameter" - ] + ], + "type": "object" } } }, @@ -43,7 +43,6 @@ "toolID": "testdata/TestExport/sub/child.gpt:8", "name": "transient", "parameters": { - "type": "object", "properties": { "defaultPromptParameter": { "description": "Prompt to send to the tool or assistant. This may be instructions or question.", @@ -52,7 +51,8 @@ }, "required": [ "defaultPromptParameter" - ] + ], + "type": "object" } } } diff --git a/pkg/types/completion.go b/pkg/types/completion.go index e7a0d418..e370e9cb 100644 --- a/pkg/types/completion.go +++ b/pkg/types/completion.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/fatih/color" + "github.com/getkin/kin-openapi/openapi3" ) type CompletionRequest struct { @@ -24,11 +25,11 @@ type CompletionTool struct { } type CompletionFunctionDefinition struct { - ToolID string `json:"toolID,omitempty"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Domain string `json:"domain,omitempty"` - Parameters *JSONSchema `json:"parameters"` + ToolID string `json:"toolID,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Domain string `json:"domain,omitempty"` + Parameters *openapi3.Schema `json:"parameters"` } // Chat message role defined by the OpenAI API. diff --git a/pkg/types/jsonschema.go b/pkg/types/jsonschema.go index a89c59dd..b290b510 100644 --- a/pkg/types/jsonschema.go +++ b/pkg/types/jsonschema.go @@ -1,69 +1,23 @@ package types -import "encoding/json" - -type JSONSchema struct { - Property - - ID string `json:"$id,omitempty"` - Title string `json:"title,omitempty"` - Properties map[string]Property `json:"properties"` - Required []string `json:"required,omitempty"` - Defs map[string]JSONSchema `json:"defs,omitempty"` - - AdditionalProperties bool `json:"additionalProperties,omitempty"` -} - -func ObjectSchema(kv ...string) *JSONSchema { - s := &JSONSchema{ - Property: Property{ - Type: "object", - }, - Properties: map[string]Property{}, +import ( + "github.com/getkin/kin-openapi/openapi3" +) + +func ObjectSchema(kv ...string) *openapi3.Schema { + s := &openapi3.Schema{ + Type: "object", + Properties: openapi3.Schemas{}, } for i, v := range kv { if i%2 == 1 { - s.Properties[kv[i-1]] = Property{ - Description: v, - Type: "string", + s.Properties[kv[i-1]] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: v, + Type: "string", + }, } } } return s } - -type Property struct { - Description string `json:"description,omitempty"` - Type string `json:"type,omitempty"` - Ref string `json:"$ref,omitempty"` - Items []JSONSchema `json:"items,omitempty"` -} - -type Type []string - -func (t *Type) UnmarshalJSON(data []byte) error { - switch data[0] { - case '[': - return json.Unmarshal(data, (*[]string)(t)) - case 'n': - return json.Unmarshal(data, (*[]string)(t)) - default: - var s string - if err := json.Unmarshal(data, &s); err != nil { - return err - } - *t = []string{s} - } - return nil -} - -func (t *Type) MarshalJSON() ([]byte, error) { - switch len(*t) { - case 0: - return json.Marshal(nil) - case 1: - return json.Marshal((*t)[0]) - default: - return json.Marshal(*t) - } -} diff --git a/pkg/types/tool.go b/pkg/types/tool.go index 26e114b8..685f3674 100644 --- a/pkg/types/tool.go +++ b/pkg/types/tool.go @@ -6,11 +6,13 @@ import ( "sort" "strings" + "github.com/getkin/kin-openapi/openapi3" "golang.org/x/exp/maps" ) const ( DaemonPrefix = "#!sys.daemon" + OpenAPIPrefix = "#!sys.openapi" CommandPrefix = "#!" ) @@ -35,19 +37,19 @@ func (p Program) SetBlocking() Program { type BuiltinFunc func(ctx context.Context, env []string, input string) (string, error) type Parameters struct { - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - MaxTokens int `json:"maxTokens,omitempty"` - ModelName string `json:"modelName,omitempty"` - ModelProvider bool `json:"modelProvider,omitempty"` - JSONResponse bool `json:"jsonResponse,omitempty"` - Temperature *float32 `json:"temperature,omitempty"` - Cache *bool `json:"cache,omitempty"` - InternalPrompt *bool `json:"internalPrompt"` - Arguments *JSONSchema `json:"arguments,omitempty"` - Tools []string `json:"tools,omitempty"` - Export []string `json:"export,omitempty"` - Blocking bool `json:"-"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + MaxTokens int `json:"maxTokens,omitempty"` + ModelName string `json:"modelName,omitempty"` + ModelProvider bool `json:"modelProvider,omitempty"` + JSONResponse bool `json:"jsonResponse,omitempty"` + Temperature *float32 `json:"temperature,omitempty"` + Cache *bool `json:"cache,omitempty"` + InternalPrompt *bool `json:"internalPrompt"` + Arguments *openapi3.Schema `json:"arguments,omitempty"` + Tools []string `json:"tools,omitempty"` + Export []string `json:"export,omitempty"` + Blocking bool `json:"-"` } type Tool struct { @@ -102,7 +104,7 @@ func (t Tool) String() string { sort.Strings(keys) for _, key := range keys { prop := t.Parameters.Arguments.Properties[key] - _, _ = fmt.Fprintf(buf, "Args: %s: %s\n", key, prop.Description) + _, _ = fmt.Fprintf(buf, "Args: %s: %s\n", key, prop.Value.Description) } } if t.Parameters.InternalPrompt != nil { @@ -147,6 +149,10 @@ func (t Tool) IsDaemon() bool { return strings.HasPrefix(t.Instructions, DaemonPrefix) } +func (t Tool) IsOpenAPI() bool { + return strings.HasPrefix(t.Instructions, OpenAPIPrefix) +} + func (t Tool) IsHTTP() bool { return strings.HasPrefix(t.Instructions, "#!http://") || strings.HasPrefix(t.Instructions, "#!https://")