Skip to content

Commit

Permalink
feat: add LLM suggestions to CLI (#94)
Browse files Browse the repository at this point in the history
  • Loading branch information
ryan-timothy-albert committed May 27, 2023
1 parent e1f5918 commit d350da8
Show file tree
Hide file tree
Showing 7 changed files with 494 additions and 5 deletions.
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func Init(version, artifactArch string) {
usageInit()
mergeInit()
updateInit(version, artifactArch)
suggestInit()
}

func Execute(version, artifactArch string) {
Expand Down
43 changes: 43 additions & 0 deletions cmd/suggest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package cmd

import (
"fmt"
"github.com/manifoldco/promptui"
"github.com/speakeasy-api/speakeasy/internal/validation"
"github.com/spf13/cobra"
)

var suggestCmd = &cobra.Command{
Use: "suggest",
Short: "Validate an OpenAPI document and get fixes suggested by ChatGPT",
Long: `The "suggest" command validates an OpenAPI spec and uses OpenAI's ChatGPT to suggest fixes to your spec.
You will need to set your OpenAI API key in a OPENAI_API_KEY environment variable. You will also need to authenticate with the Speakeasy API,
you must first create an API key via https://app.speakeasyapi.dev and then set the SPEAKEASY_API_KEY environment variable to the value of the API key.`,
RunE: suggestFixesOpenAPI,
}

func suggestInit() {
suggestCmd.Flags().StringP("schema", "s", "", "path to the OpenAPI document")
_ = suggestCmd.MarkFlagRequired("schema")
rootCmd.AddCommand(suggestCmd)
}

func suggestFixesOpenAPI(cmd *cobra.Command, args []string) error {
// no authentication required for validating specs

schemaPath, err := cmd.Flags().GetString("schema")
if err != nil {
return err
}

if err := validation.ValidateOpenAPI(cmd.Context(), schemaPath, true); err != nil {
rootCmd.SilenceUsage = true

return err
}

uploadCommand := promptui.Styler(promptui.FGCyan, promptui.FGBold)("speakeasy api register-schema --schema=" + schemaPath)
fmt.Printf("\nYou can upload your schema to Speakeasy using the following command:\n%s\n", uploadCommand)

return nil
}
8 changes: 7 additions & 1 deletion cmd/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func validateInit() {

//nolint:errcheck
func validateOpenAPIInit() {
validateOpenAPICmd.Flags().BoolP("fix", "f", false, "fix openapi failures with llm suggestions")
validateOpenAPICmd.Flags().StringP("schema", "s", "", "path to the OpenAPI document")
_ = validateOpenAPICmd.MarkFlagRequired("schema")

Expand All @@ -59,7 +60,12 @@ func validateOpenAPI(cmd *cobra.Command, args []string) error {
return err
}

if err := validation.ValidateOpenAPI(cmd.Context(), schemaPath); err != nil {
fix, err := cmd.Flags().GetBool("fix")
if err != nil {
return err
}

if err := validation.ValidateOpenAPI(cmd.Context(), schemaPath, fix); err != nil {
rootCmd.SilenceUsage = true

return err
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ require (
go.uber.org/zap v1.24.0
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561
golang.org/x/term v0.7.0
gopkg.in/yaml.v2 v2.4.0
)

require (
Expand Down Expand Up @@ -117,6 +118,5 @@ require (
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
217 changes: 217 additions & 0 deletions internal/suggestions/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package suggestions

import (
"bytes"
"encoding/json"
"fmt"
"github.com/speakeasy-api/speakeasy/internal/config"
"io/ioutil"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)

const uploadTimeout = time.Minute * 2
const suggestionTimeout = time.Minute * 1

const ApiURL = "https://api.prod.speakeasyapi.dev"

var baseURL = ApiURL

type suggestionResponse struct {
Suggestion string `json:"suggestion"`
}

type suggestionRequest struct {
Error string `json:"error"`
LineNumber int `json:"line_number"`
}

func Upload(filePath string) (string, string, error) {
openAIKey, err := GetOpenAIKey()
if err != nil {
return "", "", err
}

apiKey, err := getSpeakeasyAPIKey()
if err != nil {
return "", "", err
}

fileData, err := os.ReadFile(filePath)
if err != nil {
return "", "", err
}

body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
part, err := writer.CreatePart(map[string][]string{
"Content-Disposition": {"form-data; name=\"file\"; filename=\"" + filepath.Base(filePath) + "\""},
"Content-Type": {detectFileType(filePath)}, // Set the MIME type here
})
if err != nil {
return "", "", err
}

_, err = part.Write(fileData)
if err != nil {
return "", "", err
}

err = writer.Close()
if err != nil {
return "", "", err
}

req, err := http.NewRequest("POST", baseURL+"/v1/llm/openapi", body)
if err != nil {
return "", "", fmt.Errorf("error creating request for upload: %v", err)
}

req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("x-openai-key", openAIKey)
req.Header.Set("x-api-key", apiKey)

client := &http.Client{
Timeout: uploadTimeout,
}
resp, err := client.Do(req)
if err != nil {
return "", "", fmt.Errorf("error making request for upload: %v", err)
}

defer resp.Body.Close()

if resp.StatusCode == http.StatusUnprocessableEntity {
return "", "", fmt.Errorf("OpenAPI document is larger than 50,000 line limit")
}

if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("%v error occured: %s", resp.StatusCode, resp.Status)
}

token := resp.Header.Get("x-session-token")
if token == "" {
return "", "", fmt.Errorf("session token is empty")
}

return token, strings.ToLower(filepath.Ext(filePath))[1:], nil
}

func Suggestion(token string, error string, lineNumber int, fileType string) (string, error) {
openAIKey, err := GetOpenAIKey()
if err != nil {
return "", err
}

apiKey, err := getSpeakeasyAPIKey()
if err != nil {
return "", err
}

reqBody := suggestionRequest{
Error: error,
LineNumber: lineNumber,
}

jsonPayload, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("error marshaling request payload: %v", err)
}

req, err := http.NewRequest("POST", baseURL+"/v1/llm/openapi/suggestion", bytes.NewBuffer(jsonPayload))
if err != nil {
return "", fmt.Errorf("error creating request for suggest: %v", err)
}

req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-session-token", token)
req.Header.Set("x-openai-key", openAIKey)
req.Header.Set("x-api-key", apiKey)
req.Header.Set("x-file-type", fileType)

client := &http.Client{
Timeout: suggestionTimeout,
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error making request for suggest: %v", err)
}

defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("%v error occured: %s", resp.StatusCode, resp.Status)
}

body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}

var response suggestionResponse
if err := json.Unmarshal(body, &response); err != nil {
return "", fmt.Errorf("error unmarshaling response body: %v", err)
}

return response.Suggestion, nil
}

func Clear(token string) error {
apiKey, err := getSpeakeasyAPIKey()
if err != nil {
return err
}

req, err := http.NewRequest("DELETE", baseURL+"/v1/llm/openapi", nil)
if err != nil {
return fmt.Errorf("error creating request for suggest: %v", err)
}

req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-session-token", token)
req.Header.Set("x-api-key", apiKey)

client := &http.Client{
Timeout: suggestionTimeout,
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error making request for suggest: %v", err)
}

defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %v", resp.StatusCode)
}

return nil
}

func GetOpenAIKey() (string, error) {
key := os.Getenv("OPENAI_API_KEY")
if key == "" {
return "", fmt.Errorf("OPENAI_API_KEY must be set to use LLM Suggestions")
}

return key, nil
}

func getSpeakeasyAPIKey() (string, error) {
key, _ := config.GetSpeakeasyAPIKey()
if key == "" {
return "", fmt.Errorf("no speakeasy api key available, please set SPEAKEASY_API_KEY or run 'speakeasy auth' to authenticate the CLI with the Speakeasy Platform")
}

return key, nil
}

func init() {
if url := os.Getenv("SPEAKEASY_SERVER_URL"); url != "" {
baseURL = url
}
}

0 comments on commit d350da8

Please sign in to comment.