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 @@
+
+
+
+
+
---
@@ -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()
+}