diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..731490b --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,10 @@ +version: 2 +updates: + + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + timezone: "Europe/Kyiv" + day: "friday" + time: "18:00" diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..4e66c36 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,33 @@ +name: Build + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + strategy: + matrix: + os: [ ubuntu-latest, windows-latest, macos-latest ] + version: [ 1.22 ] + runs-on: ${{ matrix.os }} + steps: + + - name: Check out + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.version }} + check-latest: true + + - name: Run Make:lint + run: make lint + + - name: Run Make:test + run: make test diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..8735f1b --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,22 @@ +name: Publish + +on: + workflow_dispatch: + +jobs: + build: + strategy: + matrix: + os: [ ubuntu-latest ] + version: [ 1.22 ] + runs-on: ${{ matrix.os }} + steps: + + - name: Check out + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.version }} + check-latest: true diff --git a/.gitignore b/.gitignore index b3a6792..ff5780c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ # OS Thumbs.db .DS_Store -*.pdb # Editors .vs/ @@ -13,6 +12,8 @@ Thumbs.db vendor/ *.test go.work +go.sum +go.work.sum *.out # Output @@ -35,3 +36,6 @@ env/ logs/ *.log *.log* + +# Coverage +coverage.txt diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8a5d9de --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +lint: ## Lint the source code + @echo "🧹 Cleaning go.mod.." + @go mod tidy + @echo "🧹 Formatting files.." + @go fmt ./... + @echo "🧹 Vetting go.mod.." + @go vet ./... + +test: ## Run tests + @echo "⏱️ Running tests.." + @go test -v -count=1 -race -shuffle=on -coverprofile=coverage.txt ./... diff --git a/README.md b/README.md index 037f298..5423a1e 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,11 @@ Discord Glide Docs ArtifactHub +
+ + Github Action + + Go Reference --- @@ -25,4 +30,31 @@ go get github.com/einstack/glide-go ## Usage -... +For a full example take a look at [`hello.go`](examples/hello.go). + +```go +package main + +import ( + "context" + "log" + + "github.com/einstack/glide-go" + "github.com/einstack/glide-go/lang" +) + +func main() { + client, err := glide.NewClient() + if err != nil { + log.Fatal(err) + } + + req := lang.NewChatRequest("Hello") + resp, err := client.Lang.Chat(ctx, "myrouter", req) + if err != nil { + log.Fatal(err) + } + + log.Println("response: ", resp.Content()) +} +``` diff --git a/client.go b/client.go new file mode 100644 index 0000000..d1fc0be --- /dev/null +++ b/client.go @@ -0,0 +1,132 @@ +package glide + +import ( + "context" + "net/http" + "net/url" + + "github.com/einstack/glide-go/config" + "github.com/einstack/glide-go/lang" +) + +// Client is an Einstack Glide client. +type Client struct { + config *config.Config + Lang lang.Language +} + +// ClientOption is a functional option type for Client. +// Also see NewClient. +type ClientOption func(*Client) error + +// NewClient instantiates a new Client. +func NewClient(options ...ClientOption) (*Client, error) { + client := &Client{config: config.NewConfig()} + client.Lang = lang.NewLanguage(client.config) + + for _, option := range options { + if err := option(client); err != nil { + return nil, err + } + } + + return client, nil +} + +// NewClientFromConfig instantiates a new Client. +func NewClientFromConfig(config *config.Config) (*Client, error) { + client := &Client{config: config} + client.Lang = lang.NewLanguage(client.config) + return client, nil +} + +// WithApiKey attaches the api key. +// Use environment variable 'GLIDE_API_KEY' to override. +func WithApiKey(apiKey string) ClientOption { + return func(client *Client) error { + client.config.ApiKey = apiKey + return nil + } +} + +// WithUserAgent replaces the 'User-Agent' header. +// Default value: 'Glide/0.1.0 (Go; Ver. 1.22.2)'. +// Use environment variable 'GLIDE_USER_AGENT' to override. +func WithUserAgent(userAgent string) ClientOption { + return func(client *Client) error { + client.config.UserAgent = userAgent + return nil + } +} + +// WithRawBaseURL parses and replaces the base URL. +// Default value: 'http://127.0.0.1:9099/'. +// Use environment variable 'GLIDE_BASE_URL' to override. +func WithRawBaseURL(rawBaseURL string) ClientOption { + return func(client *Client) error { + baseURL, err := url.Parse(rawBaseURL) + if err != nil { + return err + } + + client.config.BaseURL = baseURL + return nil + } +} + +// WithBaseURL replaces the base URL. +// Also see WithRawBaseURL. +func WithBaseURL(baseURL url.URL) ClientOption { + return func(client *Client) error { + client.config.BaseURL = &baseURL + return nil + } +} + +// WithHttpClient replaces the 'HTTP' client. +// Default value: 'http.DefaultClient'. +func WithHttpClient(httpClient *http.Client) ClientOption { + return func(client *Client) error { + client.config.HttpClient = httpClient + return nil + } +} + +// ApiKey returns the provided API key, empty string otherwise. +func (c *Client) ApiKey() string { + return c.config.ApiKey +} + +// UserAgent returns the used 'User-Agent' header value. +func (c *Client) UserAgent() string { + return c.config.UserAgent +} + +// BaseURL returns the used 'base url.URL'. +func (c *Client) BaseURL() url.URL { + return *c.config.BaseURL +} + +// HttpClient returns the underlying http.Client. +func (c *Client) HttpClient() *http.Client { + return c.config.HttpClient +} + +// Health returns true if the service is healthy. +func (c *Client) Health(ctx context.Context) (*bool, error) { + type Health struct { + Healthy bool `json:"healthy"` + } + + req, err := c.config.Build(ctx, http.MethodGet, "/v1/health/", nil) + if err != nil { + return nil, err + } + + var resp *Health + if _, err := c.config.Send(req, resp); err != nil { + return nil, err + } + + return &resp.Healthy, nil +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..f8a3d5a --- /dev/null +++ b/client_test.go @@ -0,0 +1,38 @@ +package glide_test + +import ( + "context" + "testing" + + "github.com/einstack/glide-go" + "github.com/einstack/glide-go/config" +) + +func TestNewClient(t *testing.T) { + if _, err := glide.NewClient( + glide.WithApiKey("testing"), + glide.WithRawBaseURL("http://127.0.0.1:9098/"), + glide.WithUserAgent("Einstack/1.0"), + ); err != nil { + t.Fatal(err) + } +} + +func TestNewClientFromConfig(t *testing.T) { + if _, err := glide.NewClientFromConfig( + config.NewConfig(), + ); err != nil { + t.Fatal(err) + } +} + +func TestClient_Health(t *testing.T) { + client, _ := glide.NewClient( + glide.WithApiKey("testing"), + ) + + ctx := context.Background() + if _, err := client.Health(ctx); err != nil { + t.Fatal(err) + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..be326bf --- /dev/null +++ b/config/config.go @@ -0,0 +1,121 @@ +package config + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + + "github.com/gorilla/websocket" +) + +// Config is an http.Client wrapper. +type Config struct { + ApiKey string + UserAgent string + BaseURL *url.URL + HttpClient *http.Client +} + +// NewConfig instantiates a new Config. +func NewConfig() *Config { + parseBaseURL, _ := url.Parse(envBaseUrl) + return &Config{ + ApiKey: envApiKey, + UserAgent: envUserAgent, + BaseURL: parseBaseURL, + HttpClient: http.DefaultClient, + } +} + +// Build instantiates a new http.Request. +func (c *Config) Build(ctx context.Context, method, path string, data any) (*http.Request, error) { + abs, err := c.BaseURL.Parse(path) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, method, abs.String(), nil) + if err != nil { + return nil, err + } + + if data != nil { + buf := new(bytes.Buffer) + if err := json.NewEncoder(buf).Encode(data); err != nil { + return nil, err + } + + req.Body = io.NopCloser(buf) + req.Header.Set("Content-Type", "application/json") + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", c.UserAgent) + + if len(c.ApiKey) > 0 { + req.Header.Set("Authorization", "Bearer "+c.ApiKey) + } + + return req, nil +} + +// Send sends an http.Request and decodes http.Response into ret. +func (c *Config) Send(r *http.Request, ret any) (*http.Response, error) { + resp, err := c.HttpClient.Do(r) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= http.StatusBadRequest { + if resp.Body == nil { + return nil, NewError() + } + + var errorResp *Error + if err := json.NewDecoder(resp.Body).Decode(errorResp); err != nil { + return nil, err + } + + errorResp.Status = resp.StatusCode + return nil, errorResp + } + + if resp.StatusCode != http.StatusNoContent && ret != nil && resp.Body != nil { + if err = json.NewDecoder(resp.Body).Decode(ret); err != nil { + return nil, err + } + } + + return resp, nil +} + +// Upgrade establishes the WebSocket connection. +func (c *Config) Upgrade(ctx context.Context, path string) (*websocket.Conn, error) { + wsBaseURL := c.BaseURL + if c.BaseURL.Scheme == "https" { + wsBaseURL.Scheme = "wss" + } else if c.BaseURL.Scheme == "http" { + wsBaseURL.Scheme = "ws" + } + + abs, err := wsBaseURL.Parse(path) + if err != nil { + return nil, err + } + + header := http.Header{} + if len(c.ApiKey) > 0 { + header.Set("Authorization", "Bearer "+c.ApiKey) + } + + conn, _, err := websocket.DefaultDialer.DialContext(ctx, abs.String(), header) + if err != nil { + return nil, err + } + + return conn, nil +} diff --git a/config/env.go b/config/env.go new file mode 100644 index 0000000..a171a60 --- /dev/null +++ b/config/env.go @@ -0,0 +1,34 @@ +package config + +import ( + "fmt" + "os" + "runtime" + "strings" +) + +// ClientVersion is the current version of this client. +var clientVersion = "0.1.0" + +// GoVersion is the required version of the Go runtime. +var goVersion = getVersion() + +// userAgent is a default User-Agent header value. +var userAgent = fmt.Sprintf("Glide/%s (Go; Ver. %s)", clientVersion, goVersion) + +func getVersion() string { + version := runtime.Version() + after, _ := strings.CutPrefix(version, "go") + return after +} + +var envApiKey = getEnv("GLIDE_API_KEY", "") +var envUserAgent = getEnv("GLIDE_USER_AGENT", userAgent) +var envBaseUrl = getEnv("GLIDE_BASE_URL", "http://127.0.0.1:9099/") + +func getEnv(key, df string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return df +} diff --git a/config/error.go b/config/error.go new file mode 100644 index 0000000..7935205 --- /dev/null +++ b/config/error.go @@ -0,0 +1,26 @@ +package config + +import ( + "fmt" + "net/http" +) + +// Error that may occur during the processing of API request. +type Error struct { + Name string `json:"name"` + Message string `json:"message"` + Status int `json:"status,omitempty"` +} + +// NewError instantiates a default Error. +func NewError() error { + return &Error{ + Name: "unrecognized_error", + Message: "There is no response body and the status code is in the range of 400-599.", + Status: http.StatusInternalServerError, + } +} + +func (e *Error) Error() string { + return fmt.Sprintf("%s: %s", e.Name, e.Message) +} diff --git a/examples/hello.go b/examples/hello.go new file mode 100644 index 0000000..d9e98be --- /dev/null +++ b/examples/hello.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "log" + + "github.com/einstack/glide-go" + "github.com/einstack/glide-go/lang" +) + +var router = "myrouter" + +func main() { + client, err := glide.NewClient() + if err != nil { + log.Fatal(err) + } + + ctx := context.Background() + log.Println(client.UserAgent()) + if _, err := client.Health(ctx); err != nil { + log.Fatal(err) + } + + req := lang.NewChatRequest("Hello") + resp, err := client.Lang.Chat(ctx, router, req) + if err != nil { + log.Fatal(err) + } + + log.Println("response: ", resp.Content()) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..26d9a29 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +// https://go.dev/ref/mod + +module github.com/einstack/glide-go + +go 1.22.2 + +require github.com/gorilla/websocket v1.5.3 diff --git a/lang/schema.go b/lang/schema.go new file mode 100644 index 0000000..1fb9d84 --- /dev/null +++ b/lang/schema.go @@ -0,0 +1,73 @@ +package lang + +// https://github.com/EinStack/glide/tree/develop/pkg/api/schemas + +// RouterList is a list of all router configurations. +type RouterList struct { + Routers []RouterConfig `json:"routers"` +} + +// RouterConfig is a single router configuration. +type RouterConfig map[string]any + +// ChatRequest is a unified chat request across all language models. +type ChatRequest struct { + Message ChatMessage `json:"message"` + MessageHistory *[]ChatMessage `json:"message_history,omitempty"` + OverrideParams *map[string]OverrideChatRequest `json:"override_params,omitempty"` +} + +// OverrideChatRequest is an override of a single chat request. +type OverrideChatRequest struct { + Message ChatMessage `json:"message"` +} + +// NewChatRequest instantiates a new ChatRequest. +func NewChatRequest(content string) ChatRequest { + message := ChatMessage{Content: content} + return ChatRequest{Message: message} +} + +// ChatResponse is a unified chat response across all language models. +type ChatResponse struct { + ID string `json:"id,omitempty"` + Created int `json:"created_at,omitempty"` + Provider string `json:"provider_id,omitempty"` + RouterID string `json:"router_id,omitempty"` + ModelID string `json:"model_id,omitempty"` + ModelName string `json:"model_name,omitempty"` + Cached bool `json:"cached,omitempty"` + ModelResponse ModelResponse `json:"model_response,omitempty"` +} + +// Content returns the content of the response. +func (r *ChatResponse) Content() string { + return r.ModelResponse.Message.Content +} + +// ModelResponse is unified response from the provider. +type ModelResponse struct { + Metadata map[string]string `json:"metadata,omitempty"` + Message ChatMessage `json:"message"` + TokenUsage TokenUsage `json:"token_usage"` +} + +// TokenUsage is a list of prompt, response and total token usage. +type TokenUsage struct { + PromptTokens int `json:"prompt_tokens"` + ResponseTokens int `json:"response_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// ChatMessage is content and role of the message. +type ChatMessage struct { + // The role of the author of this message. + // One of system, user, or assistant. + Role *string `json:"role"` + // The content of the message. + Content string `json:"content"` + // The name of the author of this message. + // May contain a-z, A-Z, 0-9, and underscores, + // with a maximum length of 64 characters. + Name string `json:"name,omitempty"` +} diff --git a/lang/service.go b/lang/service.go new file mode 100644 index 0000000..8f3bf05 --- /dev/null +++ b/lang/service.go @@ -0,0 +1,67 @@ +package lang + +import ( + "context" + "fmt" + "net/http" + + "github.com/einstack/glide-go/config" +) + +// Language implements APIs for '/v1/language' endpoints. +type Language interface { + // List retrieves a list of all router configs. + List(ctx context.Context) (*RouterList, error) + // Chat sends a single chat request to a specified router and retrieves the response. + Chat(ctx context.Context, router string, req ChatRequest) (*ChatResponse, error) + // ChatStream establishes a WebSocket connection for streaming chat messages from a specified router. + ChatStream(ctx context.Context, router string) (Chat, error) +} + +type languageSvc struct { + config *config.Config +} + +// NewLanguage instantiates a new Language service. +func NewLanguage(config *config.Config) Language { + return &languageSvc{config: config} +} + +func (svc *languageSvc) List(ctx context.Context) (*RouterList, error) { + httpReq, err := svc.config.Build(ctx, http.MethodGet, "/v1/list", nil) + if err != nil { + return nil, err + } + + var resp *RouterList + if _, err := svc.config.Send(httpReq, resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (svc *languageSvc) Chat(ctx context.Context, router string, req ChatRequest) (*ChatResponse, error) { + path := fmt.Sprintf("/v1/%s/chat", router) + httpReq, err := svc.config.Build(ctx, http.MethodPost, path, req) + if err != nil { + return nil, err + } + + var resp *ChatResponse + if _, err := svc.config.Send(httpReq, resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (svc *languageSvc) ChatStream(ctx context.Context, router string) (Chat, error) { + path := fmt.Sprintf("/v1/%s/chatStream", router) + conn, err := svc.config.Upgrade(ctx, path) + if err != nil { + return nil, err + } + + return newChatService(conn), nil +} diff --git a/lang/service_test.go b/lang/service_test.go new file mode 100644 index 0000000..e8026c0 --- /dev/null +++ b/lang/service_test.go @@ -0,0 +1,44 @@ +package lang_test + +import ( + "context" + "testing" + + "github.com/einstack/glide-go" + "github.com/einstack/glide-go/lang" +) + +var router = "myrouter" + +func TestLanguage_List(t *testing.T) { + client, _ := glide.NewClient() + ctx := context.Background() + + if _, err := client.Lang.List(ctx); err != nil { + t.Fatal(err) + } +} + +func TestLanguage_Chat(t *testing.T) { + client, _ := glide.NewClient() + ctx := context.Background() + + req := lang.NewChatRequest("Hello") + if _, err := client.Lang.Chat(ctx, router, req); err != nil { + t.Fatal(err) + } +} + +func TestLanguage_ChatStream(t *testing.T) { + client, _ := glide.NewClient() + ctx := context.Background() + + chat, err := client.Lang.ChatStream(ctx, router) + if err != nil { + t.Fatal(err) + } + + if err := chat.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/lang/stream.go b/lang/stream.go new file mode 100644 index 0000000..913c26b --- /dev/null +++ b/lang/stream.go @@ -0,0 +1,43 @@ +package lang + +import ( + "context" + "io" + + "github.com/gorilla/websocket" +) + +// Chat is a streaming (`WebSocket`) chat connection. +type Chat interface { + io.Closer + + // Send attempts to send the provided chat request. + Send(ctx context.Context) error + + // Recv attempts to receive the next chat response. + Recv(ctx context.Context) error +} + +type chatService struct { + conn *websocket.Conn +} + +// newChatService instantiates a new chatService. +func newChatService(conn *websocket.Conn) *chatService { + return &chatService{conn: conn} +} + +func (svc *chatService) Send(ctx context.Context) error { + // TODO. + panic("implement me") +} + +func (svc *chatService) Recv(ctx context.Context) error { + // TODO. + panic("implement me") +} + +// Close closes the underlying connection without sending or waiting for a close message. +func (svc *chatService) Close() error { + return svc.conn.Close() +}