diff --git a/pkg/loader/loader_test.go b/pkg/loader/loader_test.go index a5c328b5..d70c45f5 100644 --- a/pkg/loader/loader_test.go +++ b/pkg/loader/loader_test.go @@ -10,7 +10,6 @@ import ( "path/filepath" "testing" - "github.com/gptscript-ai/gptscript/pkg/types" "github.com/hexops/autogold/v2" "github.com/stretchr/testify/require" ) @@ -68,47 +67,6 @@ func TestIsOpenAPI(t *testing.T) { require.Equal(t, 3, v, "(json) expected openapi v3") } -func TestLoadOpenAPI(t *testing.T) { - numOpenAPITools := func(set types.ToolSet) int { - num := 0 - for _, v := range set { - if v.IsOpenAPI() { - num++ - } - } - return num - } - - prgv3 := types.Program{ - ToolSet: types.ToolSet{}, - } - datav3, err := os.ReadFile("testdata/openapi_v3.yaml") - require.NoError(t, err) - _, err = readTool(context.Background(), nil, &prgv3, &source{Content: datav3}, "") - require.NoError(t, err, "failed to read openapi v3") - require.Equal(t, 3, numOpenAPITools(prgv3.ToolSet), "expected 3 openapi tools") - - prgv2json := types.Program{ - ToolSet: types.ToolSet{}, - } - datav2, err := os.ReadFile("testdata/openapi_v2.json") - require.NoError(t, err) - _, err = readTool(context.Background(), nil, &prgv2json, &source{Content: datav2}, "") - require.NoError(t, err, "failed to read openapi v2") - require.Equal(t, 3, numOpenAPITools(prgv2json.ToolSet), "expected 3 openapi tools") - - prgv2yaml := types.Program{ - ToolSet: types.ToolSet{}, - } - datav2, err = os.ReadFile("testdata/openapi_v2.yaml") - require.NoError(t, err) - _, err = readTool(context.Background(), nil, &prgv2yaml, &source{Content: datav2}, "") - require.NoError(t, err, "failed to read openapi v2 (yaml)") - require.Equal(t, 3, numOpenAPITools(prgv2yaml.ToolSet), "expected 3 openapi tools") - - require.EqualValuesf(t, prgv2json.ToolSet, prgv2yaml.ToolSet, "expected same toolset for openapi v2 json and yaml") -} - func TestHelloWorld(t *testing.T) { prg, err := Program(context.Background(), "https://raw.githubusercontent.com/ibuildthecloud/test/bafe5a62174e8a0ea162277dcfe3a2ddb7eea928/example/sub/tool.gpt", diff --git a/pkg/loader/openapi.go b/pkg/loader/openapi.go index 8790fbb7..45254c9d 100644 --- a/pkg/loader/openapi.go +++ b/pkg/loader/openapi.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/url" + "regexp" "slices" "sort" "strings" @@ -14,6 +15,8 @@ import ( "github.com/gptscript-ai/gptscript/pkg/types" ) +var toolNameRegex = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) + // 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}'", @@ -115,6 +118,13 @@ func getOpenAPITools(t *openapi3.T, defaultHost string) ([]types.Tool, error) { toolDesc = toolDesc[:1024] } + toolName := operation.OperationID + if toolName == "" { + // When there is no operation ID, we use the method + path as the tool name and remove all characters + // except letters, numbers, underscores, and hyphens. + toolName = toolNameRegex.ReplaceAllString(strings.ToLower(method)+strings.ReplaceAll(pathString, "/", "_"), "") + } + var ( // auths are represented as a list of maps, where each map contains the names of the required security schemes. // Items within the same map are a logical AND. The maps themselves are a logical OR. For example: @@ -133,7 +143,7 @@ func getOpenAPITools(t *openapi3.T, defaultHost string) ([]types.Tool, error) { tool := types.Tool{ ToolDef: types.ToolDef{ Parameters: types.Parameters{ - Name: operation.OperationID, + Name: toolName, Description: toolDesc, Arguments: &openapi3.Schema{ Type: &openapi3.Types{"object"}, diff --git a/pkg/loader/openapi_test.go b/pkg/loader/openapi_test.go new file mode 100644 index 00000000..d00ffcca --- /dev/null +++ b/pkg/loader/openapi_test.go @@ -0,0 +1,88 @@ +package loader + +import ( + "context" + "os" + "testing" + + "github.com/gptscript-ai/gptscript/pkg/types" + "github.com/hexops/autogold/v2" + "github.com/stretchr/testify/require" +) + +func TestLoadOpenAPI(t *testing.T) { + numOpenAPITools := func(set types.ToolSet) int { + num := 0 + for _, v := range set { + if v.IsOpenAPI() { + num++ + } + } + return num + } + + prgv3 := types.Program{ + ToolSet: types.ToolSet{}, + } + datav3, err := os.ReadFile("testdata/openapi_v3.yaml") + require.NoError(t, err) + _, err = readTool(context.Background(), nil, &prgv3, &source{Content: datav3}, "") + require.NoError(t, err, "failed to read openapi v3") + require.Equal(t, 3, numOpenAPITools(prgv3.ToolSet), "expected 3 openapi tools") + + prgv2json := types.Program{ + ToolSet: types.ToolSet{}, + } + datav2, err := os.ReadFile("testdata/openapi_v2.json") + require.NoError(t, err) + _, err = readTool(context.Background(), nil, &prgv2json, &source{Content: datav2}, "") + require.NoError(t, err, "failed to read openapi v2") + require.Equal(t, 3, numOpenAPITools(prgv2json.ToolSet), "expected 3 openapi tools") + + prgv2yaml := types.Program{ + ToolSet: types.ToolSet{}, + } + datav2, err = os.ReadFile("testdata/openapi_v2.yaml") + require.NoError(t, err) + _, err = readTool(context.Background(), nil, &prgv2yaml, &source{Content: datav2}, "") + require.NoError(t, err, "failed to read openapi v2 (yaml)") + require.Equal(t, 3, numOpenAPITools(prgv2yaml.ToolSet), "expected 3 openapi tools") + + require.EqualValuesf(t, prgv2json.ToolSet, prgv2yaml.ToolSet, "expected same toolset for openapi v2 json and yaml") +} + +func TestOpenAPIv3(t *testing.T) { + prgv3 := types.Program{ + ToolSet: types.ToolSet{}, + } + datav3, err := os.ReadFile("testdata/openapi_v3.yaml") + require.NoError(t, err) + _, err = readTool(context.Background(), nil, &prgv3, &source{Content: datav3}, "") + require.NoError(t, err) + + autogold.ExpectFile(t, prgv3.ToolSet, autogold.Dir("testdata/openapi")) +} + +func TestOpenAPIv3NoOperationIDs(t *testing.T) { + prgv3 := types.Program{ + ToolSet: types.ToolSet{}, + } + datav3, err := os.ReadFile("testdata/openapi_v3_no_operation_ids.yaml") + require.NoError(t, err) + _, err = readTool(context.Background(), nil, &prgv3, &source{Content: datav3}, "") + require.NoError(t, err) + + autogold.ExpectFile(t, prgv3.ToolSet, autogold.Dir("testdata/openapi")) +} + +func TestOpenAPIv2(t *testing.T) { + prgv2 := types.Program{ + ToolSet: types.ToolSet{}, + } + datav2, err := os.ReadFile("testdata/openapi_v2.yaml") + require.NoError(t, err) + _, err = readTool(context.Background(), nil, &prgv2, &source{Content: datav2}, "") + require.NoError(t, err) + + autogold.ExpectFile(t, prgv2.ToolSet, autogold.Dir("testdata/openapi")) +} diff --git a/pkg/loader/testdata/openapi/TestOpenAPIv2.golden b/pkg/loader/testdata/openapi/TestOpenAPIv2.golden new file mode 100644 index 00000000..90dd1967 --- /dev/null +++ b/pkg/loader/testdata/openapi/TestOpenAPIv2.golden @@ -0,0 +1,110 @@ +types.ToolSet{ + ":": types.Tool{ + ToolDef: types.ToolDef{Parameters: types.Parameters{ + Description: "This is a tool set for the Swagger Petstore OpenAPI spec", + ModelName: "gpt-4o", + Export: []string{ + "listPets", + "createPets", + "showPetById", + }, + }}, + ID: ":", + ToolMapping: map[string][]types.ToolReference{ + "createPets": {{ + Reference: "createPets", + ToolID: ":createPets", + }}, + "listPets": {{ + Reference: "listPets", + ToolID: ":listPets", + }}, + "showPetById": {{ + Reference: "showPetById", + ToolID: ":showPetById", + }}, + }, + LocalTools: map[string]string{ + "": ":", + "createpets": ":createPets", + "listpets": ":listPets", + "showpetbyid": ":showPetById", + }, + }, + ":createPets": types.Tool{ + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Name: "createPets", + Description: "Create a pet", + ModelName: "gpt-4o", + }, + Instructions: `#!sys.openapi '{"server":"http://petstore.swagger.io/v1","path":"/pets","method":"POST","bodyContentMIME":"","apiKeyInfos":null,"queryParameters":null,"pathParameters":null,"headerParameters":null,"cookieParameters":null}'`, + }, + ID: ":createPets", + ToolMapping: map[string][]types.ToolReference{}, + LocalTools: map[string]string{ + "": ":", + "createpets": ":createPets", + "listpets": ":listPets", + "showpetbyid": ":showPetById", + }, + Source: types.ToolSource{LineNo: 2}, + }, + ":listPets": types.Tool{ + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Name: "listPets", + Description: "List all pets", + ModelName: "gpt-4o", + Arguments: &openapi3.Schema{ + Type: &openapi3.Types{ + "object", + }, + Required: []string{}, + Properties: openapi3.Schemas{"limit": &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + Format: "int32", + Description: "How many items to return at one time (max 100)", + }}}, + }, + }, + Instructions: `#!sys.openapi '{"server":"http://petstore.swagger.io/v1","path":"/pets","method":"GET","bodyContentMIME":"","apiKeyInfos":null,"queryParameters":[{"name":"limit","style":"","explode":null}],"pathParameters":null,"headerParameters":null,"cookieParameters":null}'`, + }, + ID: ":listPets", + ToolMapping: map[string][]types.ToolReference{}, + LocalTools: map[string]string{ + "": ":", + "createpets": ":createPets", + "listpets": ":listPets", + "showpetbyid": ":showPetById", + }, + Source: types.ToolSource{LineNo: 1}, + }, + ":showPetById": types.Tool{ + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Name: "showPetById", + Description: "Info for a specific pet", + ModelName: "gpt-4o", + Arguments: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{"petId"}, + Properties: openapi3.Schemas{"petId": &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "The id of the pet to retrieve", + }}}, + }, + }, + Instructions: `#!sys.openapi '{"server":"http://petstore.swagger.io/v1","path":"/pets/{petId}","method":"GET","bodyContentMIME":"","apiKeyInfos":null,"queryParameters":null,"pathParameters":[{"name":"petId","style":"","explode":null}],"headerParameters":null,"cookieParameters":null}'`, + }, + ID: ":showPetById", + ToolMapping: map[string][]types.ToolReference{}, + LocalTools: map[string]string{ + "": ":", + "createpets": ":createPets", + "listpets": ":listPets", + "showpetbyid": ":showPetById", + }, + Source: types.ToolSource{LineNo: 3}, + }, +} diff --git a/pkg/loader/testdata/openapi/TestOpenAPIv3.golden b/pkg/loader/testdata/openapi/TestOpenAPIv3.golden new file mode 100644 index 00000000..72ccafae --- /dev/null +++ b/pkg/loader/testdata/openapi/TestOpenAPIv3.golden @@ -0,0 +1,134 @@ +types.ToolSet{ + ":": types.Tool{ + ToolDef: types.ToolDef{Parameters: types.Parameters{ + Description: "This is a tool set for the Swagger Petstore OpenAPI spec", + ModelName: "gpt-4o", + Export: []string{ + "listPets", + "createPets", + "showPetById", + }, + }}, + ID: ":", + ToolMapping: map[string][]types.ToolReference{ + "createPets": {{ + Reference: "createPets", + ToolID: ":createPets", + }}, + "listPets": {{ + Reference: "listPets", + ToolID: ":listPets", + }}, + "showPetById": {{ + Reference: "showPetById", + ToolID: ":showPetById", + }}, + }, + LocalTools: map[string]string{ + "": ":", + "createpets": ":createPets", + "listpets": ":listPets", + "showpetbyid": ":showPetById", + }, + }, + ":createPets": types.Tool{ + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Name: "createPets", + Description: "Create a pet", + ModelName: "gpt-4o", + Arguments: &openapi3.Schema{ + Type: &openapi3.Types{ + "object", + }, + Required: []string{}, + Properties: openapi3.Schemas{"requestBodyContent": &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{ + "id", + "name", + }, + Properties: openapi3.Schemas{ + "id": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{ + "integer", + }, + Format: "int64", + }, + }, + "name": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + "tag": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + }, + }}}, + }, + }, + Instructions: `#!sys.openapi '{"server":"http://petstore.swagger.io/v1","path":"/pets","method":"POST","bodyContentMIME":"application/json","apiKeyInfos":null,"queryParameters":null,"pathParameters":null,"headerParameters":null,"cookieParameters":null}'`, + }, + ID: ":createPets", + ToolMapping: map[string][]types.ToolReference{}, + LocalTools: map[string]string{ + "": ":", + "createpets": ":createPets", + "listpets": ":listPets", + "showpetbyid": ":showPetById", + }, + Source: types.ToolSource{LineNo: 2}, + }, + ":listPets": types.Tool{ + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Name: "listPets", + Description: "List all pets", + ModelName: "gpt-4o", + Arguments: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{}, + Properties: openapi3.Schemas{"limit": &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + Format: "int32", + Description: "How many items to return at one time (max 100)", + Max: valast.Ptr(float64(100)), + }}}, + }, + }, + Instructions: `#!sys.openapi '{"server":"http://petstore.swagger.io/v1","path":"/pets","method":"GET","bodyContentMIME":"","apiKeyInfos":null,"queryParameters":[{"name":"limit","style":"","explode":null}],"pathParameters":null,"headerParameters":null,"cookieParameters":null}'`, + }, + ID: ":listPets", + ToolMapping: map[string][]types.ToolReference{}, + LocalTools: map[string]string{ + "": ":", + "createpets": ":createPets", + "listpets": ":listPets", + "showpetbyid": ":showPetById", + }, + Source: types.ToolSource{LineNo: 1}, + }, + ":showPetById": types.Tool{ + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Name: "showPetById", + Description: "Info for a specific pet", + ModelName: "gpt-4o", + Arguments: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{"petId"}, + Properties: openapi3.Schemas{"petId": &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "The id of the pet to retrieve", + }}}, + }, + }, + Instructions: `#!sys.openapi '{"server":"http://petstore.swagger.io/v1","path":"/pets/{petId}","method":"GET","bodyContentMIME":"","apiKeyInfos":null,"queryParameters":null,"pathParameters":[{"name":"petId","style":"","explode":null}],"headerParameters":null,"cookieParameters":null}'`, + }, + ID: ":showPetById", + ToolMapping: map[string][]types.ToolReference{}, + LocalTools: map[string]string{ + "": ":", + "createpets": ":createPets", + "listpets": ":listPets", + "showpetbyid": ":showPetById", + }, + Source: types.ToolSource{LineNo: 3}, + }, +} diff --git a/pkg/loader/testdata/openapi/TestOpenAPIv3NoOperationIDs.golden b/pkg/loader/testdata/openapi/TestOpenAPIv3NoOperationIDs.golden new file mode 100644 index 00000000..3bcfd9e5 --- /dev/null +++ b/pkg/loader/testdata/openapi/TestOpenAPIv3NoOperationIDs.golden @@ -0,0 +1,134 @@ +types.ToolSet{ + ":": types.Tool{ + ToolDef: types.ToolDef{Parameters: types.Parameters{ + Description: "This is a tool set for the Swagger Petstore OpenAPI spec", + ModelName: "gpt-4o", + Export: []string{ + "get_pets", + "post_pets", + "get_pets_petId", + }, + }}, + ID: ":", + ToolMapping: map[string][]types.ToolReference{ + "get_pets": {{ + Reference: "get_pets", + ToolID: ":get_pets", + }}, + "get_pets_petId": {{ + Reference: "get_pets_petId", + ToolID: ":get_pets_petId", + }}, + "post_pets": {{ + Reference: "post_pets", + ToolID: ":post_pets", + }}, + }, + LocalTools: map[string]string{ + "": ":", + "get_pets": ":get_pets", + "get_pets_petid": ":get_pets_petId", + "post_pets": ":post_pets", + }, + }, + ":get_pets": types.Tool{ + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Name: "get_pets", + Description: "List all pets", + ModelName: "gpt-4o", + Arguments: &openapi3.Schema{ + Type: &openapi3.Types{ + "object", + }, + Required: []string{}, + Properties: openapi3.Schemas{"limit": &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + Format: "int32", + Description: "How many items to return at one time (max 100)", + Max: valast.Ptr(float64(100)), + }}}, + }, + }, + Instructions: `#!sys.openapi '{"server":"http://petstore.swagger.io/v1","path":"/pets","method":"GET","bodyContentMIME":"","apiKeyInfos":null,"queryParameters":[{"name":"limit","style":"","explode":null}],"pathParameters":null,"headerParameters":null,"cookieParameters":null}'`, + }, + ID: ":get_pets", + ToolMapping: map[string][]types.ToolReference{}, + LocalTools: map[string]string{ + "": ":", + "get_pets": ":get_pets", + "get_pets_petid": ":get_pets_petId", + "post_pets": ":post_pets", + }, + Source: types.ToolSource{LineNo: 1}, + }, + ":get_pets_petId": types.Tool{ + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Name: "get_pets_petId", + Description: "Info for a specific pet", + ModelName: "gpt-4o", + Arguments: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{"petId"}, + Properties: openapi3.Schemas{"petId": &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "The id of the pet to retrieve", + }}}, + }, + }, + Instructions: `#!sys.openapi '{"server":"http://petstore.swagger.io/v1","path":"/pets/{petId}","method":"GET","bodyContentMIME":"","apiKeyInfos":null,"queryParameters":null,"pathParameters":[{"name":"petId","style":"","explode":null}],"headerParameters":null,"cookieParameters":null}'`, + }, + ID: ":get_pets_petId", + ToolMapping: map[string][]types.ToolReference{}, + LocalTools: map[string]string{ + "": ":", + "get_pets": ":get_pets", + "get_pets_petid": ":get_pets_petId", + "post_pets": ":post_pets", + }, + Source: types.ToolSource{LineNo: 3}, + }, + ":post_pets": types.Tool{ + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Name: "post_pets", + Description: "Create a pet", + ModelName: "gpt-4o", + Arguments: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{}, + Properties: openapi3.Schemas{"requestBodyContent": &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{ + "id", + "name", + }, + Properties: openapi3.Schemas{ + "id": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{ + "integer", + }, + Format: "int64", + }, + }, + "name": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + "tag": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + }, + }}}, + }, + }, + Instructions: `#!sys.openapi '{"server":"http://petstore.swagger.io/v1","path":"/pets","method":"POST","bodyContentMIME":"application/json","apiKeyInfos":null,"queryParameters":null,"pathParameters":null,"headerParameters":null,"cookieParameters":null}'`, + }, + ID: ":post_pets", + ToolMapping: map[string][]types.ToolReference{}, + LocalTools: map[string]string{ + "": ":", + "get_pets": ":get_pets", + "get_pets_petid": ":get_pets_petId", + "post_pets": ":post_pets", + }, + Source: types.ToolSource{LineNo: 2}, + }, +} diff --git a/pkg/loader/testdata/openapi_v3_no_operation_ids.yaml b/pkg/loader/testdata/openapi_v3_no_operation_ids.yaml new file mode 100644 index 00000000..53e58b88 --- /dev/null +++ b/pkg/loader/testdata/openapi_v3_no_operation_ids.yaml @@ -0,0 +1,116 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT +servers: + - url: http://petstore.swagger.io/v1 +paths: + /pets: + get: + summary: List all pets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + maximum: 100 + format: int32 + responses: + '200': + description: A paged array of pets + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + summary: Create a pet + tags: + - pets + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + required: true + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /pets/{petId}: + get: + summary: Info for a specific pet + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/Pet" + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string \ No newline at end of file