diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02818d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +test \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1d65b09 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# AI Proxy Service + +## Description + +The AI Proxy Service is a backend solution designed to securely manage and relay requests to various large language models (LLMs), including GPT-4 via the OpenAI API, while providing the flexibility to integrate other models that follow the OpenAI API standard format. This enables users to easily switch between different LLM providers, whether they are hosted externally or run locally, without major changes to client applications. + +The service is stateless and integrates with Single Sign-On (SSO) using Microsoft Entra ID and Google OAuth 2.0 for secure users authentication. It also supports server-side requests from other backend services through secret key-based authentication, making it easy to integrate AI capabilities securely into other systems. + +In addition to providing access to multiple AI models, the service enhances their functionality by addressing common limitations like context management, token efficiency, and cost control. Features such as token usage tracking and conversation summarization ensure that tokens are used optimally, reducing costs and improving performance. + +## Features +- **Stateless Architecture**: Ensures scalability and performance by maintaining no internal state between requests. +- **SSO Authentication**: Integrates with Microsoft Entra ID and Google OAuth 2.0 to authenticate users securely. +- **Secret Key-based Authentication**: Allows other backend services to securely access GPT-4 using secret key-based authentication. +- **Token Limitations**: Implements token consumption tracking over a specified period, ensuring cost control and preventing overuse. +- **Token Efficiency with Summarization**: The service periodically summarizes after every n conversations to maintain token efficiency. This allows for a streamlined conversation history, reducing the amount of context needed while still retaining relevant information. +- **Model Flexibility**: Supports multiple AI models that adhere to the OpenAI API standard format, providing flexibility to switch between different LLM providers. +- **Context Awareness**: Manages and maintains conversational context across multiple requests by storing relevant information in a separate datastore, simulating long-term memory for conversations. +- **Logging and Monitoring**: Logs requests, responses, and usage metrics, with integrations for observability platforms. +- **Error Handling**: Graceful error handling with clear error messages and status codes. +- **Extensible**: Designed for easy extension with new authentication providers or features. + +## Prerequisites + +- Go 1.20+ +- Git +- Docker + + +## Installation + +1. Clone the repository: + + ```sh + git clone + cd monitoring-app + ``` + +2. Install the dependencies: + + ```sh + go mod tidy + ``` + +## Configuration + +- **Main Configuration**: `config.yaml` + +### `config.yaml` + +This file contains the main configuration for the service, including logging levels, APM (Application Performance Monitoring), SSO, Datastore settings. + + +## AI Proxy SAD +![image.png](./docs/img/Architecture-Diagram.png) diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..8bfdea4 --- /dev/null +++ b/config.yaml @@ -0,0 +1,52 @@ +app: + name: "proxy-service" + port: 8080 + version: "1.0.0" + env: "dev" + tribe: "tribe" +ui: + host: "http://localhost:3000" +log: + level: "info" + format: "json" +redis: + host: "redis" + port: 6379 + password: "redis" + db: 0 +mongo: + host: "mongo" + port: 27017 + username: "admin" + password: "admin" + db: "proxy-service" +apm: + enabled: true + host: "jaeger" + port: 4317 + rate: 1 +microsoftOauth: + tenantID: "your-tenant-id" + clientID: "your-client-id" + clientSecret: "your-client-secret" + redirectURL: "your-redirect-url" +googleOauth: + clientID: "your-client-id" + clientSecret: "your-client-secret" + redirectURL: "your-redirect-url" +openAI: + host: "http://localhost:1234" + path: "/v1/chat/completions" + apiKey: "your-api-key" + tokenLifetime: 3600 + tokenLimit: 10000 +services: + - tribe: "tribeA" + name: "code-review" + username: "user" + password: "password" + - tribe: "tribeB" + name: "chatbot" + username: "user" + password: "password" + diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..ec57cf6 --- /dev/null +++ b/config/config.go @@ -0,0 +1,147 @@ +package config + +import ( + "fmt" + "reflect" + "strings" + + "github.com/go-playground/validator/v10" + "github.com/joho/godotenv" + "github.com/spf13/viper" +) + +type Config interface { + Init(configPath string) error + Get() *MainConfig +} + +type config struct { + Config *MainConfig +} + +func New() Config { + return &config{ + Config: &MainConfig{}, + } +} + +func (c *config) Init(configPath string) error { + // Load environment variables from .env file + if err := godotenv.Load(); err != nil { + // Log the error if needed, but continue to load other configurations + } + + if err := c.load(c.Config, ".", configPath); err != nil { + return err + } + err := validator.New().Struct(c.Config) + if err != nil { + return err + } + + return nil +} + +func (c *config) Get() *MainConfig { + return c.Config +} + +func (c *config) load(cfg *MainConfig, path string, configPath string) error { + // Set default values + viper.SetDefault("log.level", "info") + + viper.AddConfigPath(path) + if configPath != "" { + viper.SetConfigFile(configPath) + } + viper.SetConfigType("yaml") + viper.SetEnvKeyReplacer(strings.NewReplacer(`.`, `_`)) + viper.AutomaticEnv() + + // Read the config file + if err := viper.ReadInConfig(); err != nil && configPath != "" { + return err + } + + // Unmarshal the config into the struct + if err := viper.Unmarshal(cfg); err != nil { + return err + } + + // Populate struct from environment variables using reflection + if err := c.populateFromEnv(cfg); err != nil { + return err + } + + return nil +} + +// populateFromEnv populates struct fields from environment variables if the `env` tag is present +func (c *config) populateFromEnv(cfg any) error { + val := reflect.ValueOf(cfg) + + // Ensure that the value is a pointer to a struct + if val.Kind() != reflect.Ptr { + return fmt.Errorf("expected a pointer but got %v", val.Kind()) + } + + if val.Elem().Kind() != reflect.Struct { + return fmt.Errorf("expected a pointer to struct but got %v", val.Elem().Kind()) + } + + val = val.Elem() // Dereference the pointer to get to the struct + typ := val.Type() // Get the struct type + + return c.populate(val, typ) +} + +// populate recursively populates struct fields from environment variables if the `env` tag is present +func (c *config) populate(val reflect.Value, typ reflect.Type) error { + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + structField := typ.Field(i) + + // Get the 'env' tag + envTag := structField.Tag.Get("env") + if envTag != "" { + // Get the environment variable value from Viper + envValue := viper.GetString(envTag) + if envValue != "" { + // Set the field based on its type + switch field.Kind() { + case reflect.Ptr: + if field.Type().Elem().Kind() == reflect.String { + field.Set(reflect.ValueOf(&envValue)) // set *string + } else if field.Type().Elem().Kind() == reflect.Int { + // Convert string to int before setting if it's an integer field + var intVal int + fmt.Sscanf(envValue, "%d", &intVal) + field.Set(reflect.ValueOf(&intVal)) // set *int + } + case reflect.String: + field.SetString(envValue) // set string + case reflect.Int: + // Convert string to int before setting if it's an integer field + var intVal int + fmt.Sscanf(envValue, "%d", &intVal) + field.SetInt(int64(intVal)) // set int + case reflect.Bool: + // Convert string to bool before setting if it's a boolean field + var boolVal bool + fmt.Sscanf(envValue, "%t", &boolVal) + field.SetBool(boolVal) // set bool + } + } + } + + // Check if the field is a struct + if field.Kind() == reflect.Struct { + // Call populate recursively for nested structs + if err := c.populate(field, field.Type()); err != nil { + return err + } + } + } + + return nil +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..5008b98 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,49 @@ +package config_test + +import ( + "path/filepath" + "runtime" + "testing" + + config "github.com/abialemuel/AI-Proxy-Service/config" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type Suite struct { + cnf config.Config + suite.Suite +} + +func (c *Suite) SetupSuite() { + c.cnf = config.New() +} + +func (c *Suite) TestConfig() { + c.Run("Wrong init file path", func() { + err := c.cnf.Init("wrongPath") + assert.NotNil(c.T(), err) + }) + + c.Run("Failed config validation", func() { + _, filename, _, _ := runtime.Caller(0) + err := c.cnf.Init(filepath.Clean(filepath.Join(filepath.Dir(filename), "mocks/config.wrong.validate.yaml"))) + assert.NotNil(c.T(), err) + }) + + c.Run("Correct init file path", func() { + _, filename, _, _ := runtime.Caller(0) + err := c.cnf.Init(filepath.Clean(filepath.Join(filepath.Dir(filename), "mocks/config.yaml"))) + assert.Nil(c.T(), err) + }) + + c.Run("Get must be not nil", func() { + assert.NotNil(c.T(), c.cnf.Get()) + }) + +} + +func TestConfig(t *testing.T) { + suite.Run(t, &Suite{}) +} diff --git a/config/mocks/config.wrong.validate.yaml b/config/mocks/config.wrong.validate.yaml new file mode 100644 index 0000000..60d238b --- /dev/null +++ b/config/mocks/config.wrong.validate.yaml @@ -0,0 +1,6 @@ +# just sample config file for config testing +APM: + enabled: true + host: "otel-collector" + port: 4317 + rate: 1 \ No newline at end of file diff --git a/config/mocks/config.yaml b/config/mocks/config.yaml new file mode 100644 index 0000000..832f390 --- /dev/null +++ b/config/mocks/config.yaml @@ -0,0 +1,15 @@ +# just sample config file for config testing +App: + name: "proxy-service" + version: "1.0.0" + env: "dev" + tribe: "dpe" +log: + level: "info" + format: "json" + +APM: + enabled: true + host: "otel-collector" + port: 4317 + rate: 1 \ No newline at end of file diff --git a/config/types.go b/config/types.go new file mode 100644 index 0000000..adaa643 --- /dev/null +++ b/config/types.go @@ -0,0 +1,69 @@ +package config + +var Service = "proxy-service" +var Version = "v1.0.0" +var GitCommit string +var OSBuildName string +var BuildDate string + +type MainConfig struct { + Log struct { + Level string `yaml:"level" validate:"oneof=trace debug info warn error fatal panic"` + Format string `yaml:"format" validate:"oneof=text json"` + } `yaml:"log"` + APM struct { + Enabled bool `yaml:"enabled"` + Host string `yaml:"host"` + Port int `yaml:"port" validate:"required,min=1,max=65535"` + Rate *float64 `yaml:"rate" validate:"omitempty,min=0.1,max=1"` + } `yaml:"apm"` + App struct { + Name string `yaml:"name" validate:"required"` + Port int `yaml:"port" validate:"required,min=1,max=65535"` + Version string `yaml:"version" validate:"required"` + Env string `yaml:"env" validate:"required"` + Tribe string `yaml:"tribe" validate:"required"` + } + Redis struct { + Host string `yaml:"host" validate:"required"` + Port int `yaml:"port" validate:"required"` + Password string `yaml:"password" validate:"required"` + DB int `yaml:"db"` + } + Mongo struct { + Host string `yaml:"host" validate:"required"` + Port int `yaml:"port" validate:"required"` + Username string `yaml:"username" validate:"required"` + Password string `yaml:"password" validate:"required"` + DB string `yaml:"db" validate:"required"` + } + UI struct { + Host string `yaml:"host" validate:"required"` + } `yaml:"ui"` + MicrosoftOauth struct { + TenantID string `yaml:"tenantID" validate:"required"` + ClientID string `yaml:"clientID" validate:"required"` + ClientSecret string `yaml:"clientSecret" validate:"required"` + RedirectURL string `yaml:"redirectURL" validate:"required"` + } `yaml:"microsoftOauth"` + GoogleOauth struct { + ClientID string `yaml:"clientID" validate:"required"` + ClientSecret string `yaml:"clientSecret" validate:"required"` + RedirectURL string `yaml:"redirectURL" validate:"required"` + } `yaml:"googleOauth"` + OpenAI struct { + Host string `yaml:"host" validate:"required"` + Path string `yaml:"path" validate:"required"` + ApiKey string `yaml:"apiKey" validate:"required"` + TokenLifetime int `yaml:"tokenLifetime" validate:"required"` + TokenLimit int `yaml:"tokenLimit" validate:"required"` + } `yaml:"openAI"` + Services []BackendService `yaml:"services"` +} + +type BackendService struct { + Tribe string `yaml:"tribe"` + Name string `yaml:"name"` + Username string `yaml:"username"` + Password string `yaml:"password"` +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..01fc5cb --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,38 @@ + +version: '3' +services: + proxy-service: + build: + context: . + depends_on: + - redis + env_file: + - .env + ports: + - "8080:8080" + restart: always + redis: + image: docker.io/bitnami/redis + ports: + - "6379:6379" + restart: always + environment: + - REDIS_PASSWORD=redis + jaeger: + image: jaegertracing/all-in-one:latest + restart: always + ports: + - 6831:6831/udp + - 6832:6832/udp + - 16686:16686 + - 14268:14268 + - 4317:4317 + mongo: + image: mongo + ports: + - "27017:27017" + restart: always + environment: + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: admin + diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..e7bda80 --- /dev/null +++ b/dockerfile @@ -0,0 +1,41 @@ +# Build stage +FROM golang:1.22 AS builder +ENV CGO_ENABLED 0 +LABEL maintainer="DPE" + +WORKDIR /app + +# PAT config +ARG GITLAB_ID +ARG GITLAB_TOKEN + +RUN git config --global url."https://gitlab.playcourt.id".insteadOf "ssh://git@gitlab.playcourt.id" + +ENV GOPRIVATE=gitlab.playcourt.id/* +RUN echo "machine gitlab.playcourt.id login $GITLAB_ID password $GITLAB_TOKEN" > ~/.netrc + +# Copy everything from the current directory to the PWD(Present Working Directory) inside the container +COPY . . + +# Download all dependencies +RUN go mod tidy + +# Build the Go app +RUN go build -ldflags='-s -w' -o bin/app main.go + + +# Check if the binary was created +RUN if [ ! -f bin/app ]; then echo "Go build failed"; exit 1; fi + +# Stage 2: Create a minimal runtime image +FROM alpine:latest + +WORKDIR /app + +COPY --from=builder /app/bin/app /bin/app +COPY --from=builder /app/config.yaml ./config.yaml + +# Check if the binary was copied +RUN if [ ! -f /bin/app ]; then echo "Binary not copied"; exit 1; fi + +CMD ["/bin/app", "-c", "/etc/config.yaml"] \ No newline at end of file diff --git a/docs/img/Architecture-Diagram.png b/docs/img/Architecture-Diagram.png new file mode 100644 index 0000000..1976632 Binary files /dev/null and b/docs/img/Architecture-Diagram.png differ diff --git a/example.env b/example.env new file mode 100644 index 0000000..7e8791d --- /dev/null +++ b/example.env @@ -0,0 +1,8 @@ +MICROSOFT_OAUTH_TENANT_ID=your-tenant-id +MICROSOFT_OAUTH_CLIENT_ID=your-client-id +MICROSOFT_OAUTH_CLIENT_SECRET=your-client-secret +MICROSOFT_OAUTH_REDIRECT_URL=your-redirect-url + +GOOGLE_OAUTH_CLIENT_ID=your-client-id +GOOGLE_OAUTH_CLIENT_SECRET=your-client-secret +GOOGLE_OAUTH_REDIRECT_URL=your-redirect-url \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b1af508 --- /dev/null +++ b/go.mod @@ -0,0 +1,115 @@ +module github.com/abialemuel/AI-Proxy-Service + +go 1.22.0 + +require ( + github.com/ThreeDotsLabs/watermill v1.3.7 + github.com/abialemuel/poly-kit v0.0.2 + github.com/go-playground/locales v0.14.1 + github.com/go-playground/universal-translator v0.18.1 + github.com/go-playground/validator/v10 v10.22.1 + github.com/go-redis/redis/v8 v8.11.5 + github.com/golang-jwt/jwt/v4 v4.5.0 + github.com/joho/godotenv v1.5.1 + github.com/labstack/echo/v4 v4.12.0 + github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.9.0 + go.mongodb.org/mongo-driver v1.17.0 + go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.55.0 + go.opentelemetry.io/otel v1.30.0 + golang.org/x/oauth2 v0.20.0 + gopkg.in/DataDog/dd-trace-go.v1 v1.68.0 +) + +require ( + cloud.google.com/go/compute/metadata v0.3.0 // indirect + github.com/DataDog/appsec-internal-go v1.7.0 // indirect + github.com/DataDog/datadog-agent/pkg/obfuscate v0.48.0 // indirect + github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.48.1 // indirect + github.com/DataDog/datadog-go/v5 v5.3.0 // indirect + github.com/DataDog/go-libddwaf/v3 v3.3.0 // indirect + github.com/DataDog/go-tuf v1.0.2-0.5.2 // indirect + github.com/DataDog/sketches-go v1.4.5 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/cenkalti/backoff/v3 v3.2.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 // indirect + github.com/ebitengine/purego v0.6.0-alpha.5 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.2 // indirect + github.com/hashicorp/hcl v1.0.1-vault-5 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lithammer/shortuuid/v3 v3.0.7 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/outcaste-io/ristretto v0.2.3 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/philhofer/fwd v1.1.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/secure-systems-lab/go-securesystemslib v0.7.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sony/gobreaker v1.0.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tinylib/msgp v1.1.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 // indirect + go.opentelemetry.io/otel/metric v1.30.0 // indirect + go.opentelemetry.io/otel/sdk v1.28.0 // indirect + go.opentelemetry.io/otel/trace v1.30.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/mock v0.4.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/crypto v0.27.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/net v0.29.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect + golang.org/x/time v0.6.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect + google.golang.org/grpc v1.65.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6aad0cc --- /dev/null +++ b/go.sum @@ -0,0 +1,367 @@ +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/DataDog/appsec-internal-go v1.7.0 h1:iKRNLih83dJeVya3IoUfK+6HLD/hQsIbyBlfvLmAeb0= +github.com/DataDog/appsec-internal-go v1.7.0/go.mod h1:wW0cRfWBo4C044jHGwYiyh5moQV2x0AhnwqMuiX7O/g= +github.com/DataDog/datadog-agent/pkg/obfuscate v0.48.0 h1:bUMSNsw1iofWiju9yc1f+kBd33E3hMJtq9GuU602Iy8= +github.com/DataDog/datadog-agent/pkg/obfuscate v0.48.0/go.mod h1:HzySONXnAgSmIQfL6gOv9hWprKJkx8CicuXuUbmgWfo= +github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.48.1 h1:5nE6N3JSs2IG3xzMthNFhXfOaXlrsdgqmJ73lndFf8c= +github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.48.1/go.mod h1:Vc+snp0Bey4MrrJyiV2tVxxJb6BmLomPvN1RgAvjGaQ= +github.com/DataDog/datadog-go/v5 v5.3.0 h1:2q2qjFOb3RwAZNU+ez27ZVDwErJv5/VpbBPprz7Z+s8= +github.com/DataDog/datadog-go/v5 v5.3.0/go.mod h1:XRDJk1pTc00gm+ZDiBKsjh7oOOtJfYfglVCmFb8C2+Q= +github.com/DataDog/go-libddwaf/v3 v3.3.0 h1:jS72fuQpFgJZEdEJDmHJCPAgNTEMZoz1EUvimPUOiJ4= +github.com/DataDog/go-libddwaf/v3 v3.3.0/go.mod h1:Bz/0JkpGf689mzbUjKJeheJINqsyyhM8p9PDuHdK2Ec= +github.com/DataDog/go-tuf v1.0.2-0.5.2 h1:EeZr937eKAWPxJ26IykAdWA4A0jQXJgkhUjqEI/w7+I= +github.com/DataDog/go-tuf v1.0.2-0.5.2/go.mod h1:zBcq6f654iVqmkk8n2Cx81E1JnNTMOAx1UEO/wZR+P0= +github.com/DataDog/gostackparse v0.7.0 h1:i7dLkXHvYzHV308hnkvVGDL3BR4FWl7IsXNPz/IGQh4= +github.com/DataDog/gostackparse v0.7.0/go.mod h1:lTfqcJKqS9KnXQGnyQMCugq3u1FP6UZMfWR0aitKFMM= +github.com/DataDog/sketches-go v1.4.5 h1:ki7VfeNz7IcNafq7yI/j5U/YCkO3LJiMDtXz9OMQbyE= +github.com/DataDog/sketches-go v1.4.5/go.mod h1:7Y8GN8Jf66DLyDhc94zuWA3uHEt/7ttt8jHOBWWrSOg= +github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/ThreeDotsLabs/watermill v1.3.7 h1:NV0PSTmuACVEOV4dMxRnmGXrmbz8U83LENOvpHekN7o= +github.com/ThreeDotsLabs/watermill v1.3.7/go.mod h1:lBnrLbxOjeMRgcJbv+UiZr8Ylz8RkJ4m6i/VN/Nk+to= +github.com/abialemuel/poly-kit v0.0.2 h1:8CSdiu6Yjfvgr30d23vJtXMhTFS8XW/rOoDBd9tYgCI= +github.com/abialemuel/poly-kit v0.0.2/go.mod h1:UCXCqQ4G3TsZ5YCJ+uC011oVxm7G2RDs45Wb2x8gTwM= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= +github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 h1:8EXxF+tCLqaVk8AOC29zl2mnhQjwyLxxOTuhUazWRsg= +github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4/go.mod h1:I5sHm0Y0T1u5YjlyqC5GVArM7aNZRUYtTjmJ8mPJFds= +github.com/ebitengine/purego v0.6.0-alpha.5 h1:EYID3JOAdmQ4SNZYJHu9V6IqOeRQDBYxqKAg9PyoHFY= +github.com/ebitengine/purego v0.6.0-alpha.5/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +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/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= +github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b h1:h9U78+dx9a4BKdQkBBos92HalKpaGKHrp+3Uo6yTodo= +github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 h1:UpiO20jno/eV1eVZcxqWnUohyKRe1g8FPV/xH1s/2qs= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/hcl v1.0.1-vault-5 h1:kI3hhbbyzr4dldA8UdTb7ZlVVlI2DACdCfz31RPDgJM= +github.com/hashicorp/hcl v1.0.1-vault-5/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= +github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= +github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/outcaste-io/ristretto v0.2.3 h1:AK4zt/fJ76kjlYObOeNwh4T3asEuaCmp26pOvUOL9w0= +github.com/outcaste-io/ristretto v0.2.3/go.mod h1:W8HywhmtlopSB1jeMg3JtdIhf+DYkLAr0VN/s4+MHac= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= +github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3 h1:4+LEVOB87y175cLJC/mbsgKmoDOjrBldtXvioEy96WY= +github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3/go.mod h1:vl5+MqJ1nBINuSsUI2mGgH79UweUT/B5Fy8857PqyyI= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/secure-systems-lab/go-securesystemslib v0.7.0 h1:OwvJ5jQf9LnIAS83waAjPbcMsODrTQUpJ02eNLUoxBg= +github.com/secure-systems-lab/go-securesystemslib v0.7.0/go.mod h1:/2gYnlnHVQ6xeGtfIqFy7Do03K4cdCY0A/GlJLDKLHI= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= +github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= +github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.17.0 h1:Hp4q2MCjvY19ViwimTs00wHi7G4yzxh4/2+nTx8r40k= +go.mongodb.org/mongo-driver v1.17.0/go.mod h1:wwWm/+BuOddhcq3n68LKRmgk2wXzmF6s0SFOa0GINL4= +go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.55.0 h1:dJfUeXRQiU+7IhOeqXV7f1hJA47cCOBmCY8uyygIEZg= +go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.55.0/go.mod h1:Uk7Flfuk5HGTeggDwlwanunnSDcJydFRihfXT1Z5fEs= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 h1:9G6E0TXzGFVfTnawRzrPl83iHOAV7L8NJiR8RSGYV1g= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74= +go.opentelemetry.io/contrib/propagators/b3 v1.30.0 h1:vumy4r1KMyaoQRltX7cJ37p3nluzALX9nugCjNNefuY= +go.opentelemetry.io/contrib/propagators/b3 v1.30.0/go.mod h1:fRbvRsaeVZ82LIl3u0rIvusIel2UUf+JcaaIpy5taho= +go.opentelemetry.io/otel v1.30.0 h1:F2t8sK4qf1fAmY9ua4ohFS/K+FUuOPemHUIXHtktrts= +go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 h1:R3X6ZXmNPRR8ul6i3WgFURCHzaXjHdm0karRG/+dj3s= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0/go.mod h1:QWFXnDavXWwMx2EEcZsf3yxgEKAqsxQ+Syjp+seyInw= +go.opentelemetry.io/otel/metric v1.30.0 h1:4xNulvn9gjzo4hjg+wzIKG7iNFEaBMX00Qd4QIZs7+w= +go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ= +go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE= +go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg= +go.opentelemetry.io/otel/trace v1.30.0 h1:7UBkkYzeg3C7kQX8VAidWh2biiQbtAKjyIML8dQ9wmc= +go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= +golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 h1:0+ozOGcrp+Y8Aq8TLNN2Aliibms5LEzsq99ZZmAGYm0= +google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094/go.mod h1:fJ/e3If/Q67Mj99hin0hMhiNyCRmt6BQ2aWIJshUSJw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= +google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/DataDog/dd-trace-go.v1 v1.68.0 h1:8WPoOHJcMAtcxTVKM0DYnFweBjxxfNit3Sjo/rf+Hkw= +gopkg.in/DataDog/dd-trace-go.v1 v1.68.0/go.mod h1:mkZpWVLO/ERW5NqlW+w5d8waQKNvMSTUQLJfoI0vlvw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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= +lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo= +lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q= +modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= +modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0= +modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI= +modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw= +modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= +modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..fc4e2c5 --- /dev/null +++ b/main.go @@ -0,0 +1,180 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "time" + + "github.com/abialemuel/AI-Proxy-Service/config" + userAPIhttp "github.com/abialemuel/AI-Proxy-Service/pkg/user/api/http" + "go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho" + + mainCfg "github.com/abialemuel/AI-Proxy-Service/config" + "github.com/abialemuel/AI-Proxy-Service/pkg/common/cache" + "github.com/abialemuel/AI-Proxy-Service/pkg/common/cache/redis" + "github.com/abialemuel/AI-Proxy-Service/pkg/common/http/middleware/authguard" + "github.com/abialemuel/AI-Proxy-Service/pkg/common/mongodb" + oauthmanager "github.com/abialemuel/AI-Proxy-Service/pkg/common/oauth" + userBusiness "github.com/abialemuel/AI-Proxy-Service/pkg/user/business" + gpt4WebService "github.com/abialemuel/AI-Proxy-Service/pkg/user/modules/gpt4_webservice" + userRepository "github.com/abialemuel/AI-Proxy-Service/pkg/user/modules/repository" + "github.com/abialemuel/poly-kit/infrastructure/apm" + "github.com/abialemuel/poly-kit/infrastructure/logger" + "github.com/labstack/echo/v4" + mw "github.com/labstack/echo/v4/middleware" + dd "gopkg.in/DataDog/dd-trace-go.v1/contrib/labstack/echo.v4" +) + +var ( + APM *apm.APM + log logger.Logger +) + +func main() { + // config + cfg := initializeConfig("config.yaml") + log = initializeLogger(cfg) + + // initialize apm + if cfg.Get().APM.Enabled { + host := fmt.Sprintf("%s:%d", cfg.Get().APM.Host, cfg.Get().APM.Port) + apmPayload := apm.APMPayload{ + ServiceHost: &host, + ServiceName: cfg.Get().App.Name, + ServiceEnv: cfg.Get().App.Env, + ServiceTribe: cfg.Get().App.Tribe, + ServiceVersion: cfg.Get().App.Version, + SampleRate: cfg.Get().APM.Rate, + } + APM, err := apm.NewAPM(apm.DatadogAPMType, apmPayload) + if err != nil { + log.Get().Error(err) + panic(err) + } + fmt.Println("APM started...") + defer APM.EndAPM() + } + + // init oauthProvider + googleProvider, err := oauthmanager.NewOAuth2Provider(oauthmanager.GoogleProvider, + "google", + cfg.Get().GoogleOauth.ClientID, + cfg.Get().GoogleOauth.ClientSecret, + cfg.Get().GoogleOauth.RedirectURL) + if err != nil { + log.Get().Error(err) + panic(err) + } + + microsoftProvider, err := oauthmanager.NewOAuth2Provider(oauthmanager.MicrosoftProvider, + cfg.Get().MicrosoftOauth.TenantID, + cfg.Get().MicrosoftOauth.ClientID, + cfg.Get().MicrosoftOauth.ClientSecret, + cfg.Get().MicrosoftOauth.RedirectURL) + if err != nil { + log.Get().Error(err) + panic(err) + } + + // Init services + url := fmt.Sprintf("%s:%d", cfg.Get().Redis.Host, cfg.Get().Redis.Port) + redis.InitRedis(url, cfg.Get().Redis.Password, cfg.Get().Redis.DB) + // Example usage of redis + cache := cache.NewCache(&redis.Redis{}) + + // init gpt4 webservice + endpoint := fmt.Sprintf("%s%s", cfg.Get().OpenAI.Host, cfg.Get().OpenAI.Path) + gpt4Webservice := gpt4WebService.NewGPT4WebService(endpoint, cfg.Get().OpenAI.ApiKey) + + // init mongoDB + urlHost := fmt.Sprintf("%s:%d", cfg.Get().Mongo.Host, cfg.Get().Mongo.Port) + mongoDB, err := mongodb.NewMongoDB(urlHost, cfg.Get().Mongo.Username, cfg.Get().Mongo.Password, cfg.Get().Mongo.DB) + userRepo := userRepository.NewMongoDBRepository(mongoDB) + + // init userService + userService := newUserService(cache, cfg.Get(), gpt4Webservice, userRepo) + + // Init HTTP client + e := echo.New() + e.Use() + e.Use(mw.Recover()) + // opentelemetry echo middleware + e.Use(otelecho.Middleware(cfg.Get().App.Name)) + // datadog echo middleware + e.Use(dd.Middleware()) + e.Use(mw.CORSWithConfig( + mw.CORSConfig{ + AllowOrigins: []string{"*"}, + AllowHeaders: []string{echo.HeaderContentType, echo.HeaderAuthorization}, + AllowMethods: []string{http.MethodGet, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete}, + })) + + //health check + e.GET("/", func(c echo.Context) error { + return c.NoContent(200) + }) + e.GET("/health", func(c echo.Context) error { + return c.String(http.StatusOK, "OK") + }) + + // run server + go func() { + address := fmt.Sprintf(":%d", cfg.Get().App.Port) + + if err := e.Start(address); err != nil { + log.Get().Info("shutting down the server") + } + }() + + authGuard := authguard.NewAuthGuard(*cfg.Get()) + authGuard.AddService(cfg.Get().Services) + + // Register API + userHandler := userAPIhttp.NewHandler(userService, googleProvider, microsoftProvider, cfg.Get()) + userAPIhttp.RegisterPath(e, userHandler, authGuard) + + // Wait for interrupt signal to gracefully shutdown the server with + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt) + <-quit + + // a timeout of 10 seconds to shutdown the server + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := e.Shutdown(ctx); err != nil { + log.Get().Fatal(err) + } +} + +func initializeLogger(cfg mainCfg.Config) logger.Logger { + fmt.Printf("%s started...\n", cfg.Get().App.Name) + log := logger.New().Init(logger.Config{ + Level: cfg.Get().Log.Level, + Format: cfg.Get().Log.Format, + }) + return log +} + +func initializeConfig(path string) mainCfg.Config { + cfg := mainCfg.New() + err := cfg.Init(path) + if err != nil { + fmt.Errorf("failed to load config: %s", err.Error()) + panic(err) + } + return cfg +} + +func newUserService( + cache *cache.Cache, + cfg *config.MainConfig, + gpt4Webservice gpt4WebService.GPT4WebService, + userRepo *userRepository.MongoDBRepository, +) userBusiness.UserService { + userService := userBusiness.NewUserService(userRepo, cache, cfg, gpt4Webservice) + return userService +} diff --git a/pkg/common/amqp/publisher.go b/pkg/common/amqp/publisher.go new file mode 100644 index 0000000..8528432 --- /dev/null +++ b/pkg/common/amqp/publisher.go @@ -0,0 +1,28 @@ +package amqp + +import ( + "encoding/json" + + "github.com/ThreeDotsLabs/watermill" + "github.com/ThreeDotsLabs/watermill/message" + "github.com/ThreeDotsLabs/watermill/message/router/middleware" +) + +type Publisher struct { + pub message.Publisher + sub message.Subscriber +} + +func NewPublisherSubscriber(pub message.Publisher, sub message.Subscriber) Publisher { + return Publisher{pub: pub, sub: sub} +} + +func (p Publisher) Publish(topic string, payload interface{}) error { + marshaledPayload, _ := json.Marshal(payload) + msg := message.NewMessage(watermill.NewUUID(), marshaledPayload) + middleware.SetCorrelationID(watermill.NewUUID(), msg) + if err := p.pub.Publish(topic, msg); err != nil { + return err + } + return nil +} diff --git a/pkg/common/cache/cache.go b/pkg/common/cache/cache.go new file mode 100755 index 0000000..05cfeb1 --- /dev/null +++ b/pkg/common/cache/cache.go @@ -0,0 +1,22 @@ +package cache + +import ( + "context" + "time" +) + +type CacheInterface interface { + Set(ctx context.Context, key string, val interface{}, duration time.Duration) error + Get(ctx context.Context, key string) (val interface{}, found bool) + TTL(ctx context.Context, key string) (time.Duration, error) + Delete(ctx context.Context, key string) error +} + +type Cache struct { + CacheInterface +} + +// NewGoCache Initialize gocache +func NewCache(u CacheInterface) *Cache { + return &Cache{u} +} diff --git a/pkg/common/cache/redis/redis.go b/pkg/common/cache/redis/redis.go new file mode 100755 index 0000000..61cb0bb --- /dev/null +++ b/pkg/common/cache/redis/redis.go @@ -0,0 +1,77 @@ +package redis + +import ( + "context" + "time" + + "github.com/go-redis/redis/v8" +) + +var rdb *redis.Client + +type Redis struct{} + +// InitRedis initializes the Redis client +func InitRedis(addr, password string, db int) { + rdb = redis.NewClient(&redis.Options{ + Addr: addr, + Password: password, + DB: db, + }) +} + +// NewGoCache Initialize gocache +func NewRedis() *Redis { + return &Redis{} +} + +// Set sets a key and value in the cache, duration 0 means DefaultExpiration, duration -1 means NoExpiration, duration -2 means KeepTTL +func (gc Redis) Set(ctx context.Context, key string, val interface{}, duration time.Duration) error { + var err error + + if duration == 0 { + // No expiration + err = rdb.Set(ctx, key, val, 0).Err() + } else if duration == -1 { + // Keep existing TTL (check version compatibility) + ttl, err := rdb.TTL(ctx, key).Result() + if err != nil { + return err // Handle error retrieving TTL + } + if ttl < 0 { + // No existing key or no TTL set, treat like no expiration + err = rdb.Set(ctx, key, val, 0).Err() + } else { + // Set the new value with the existing TTL + err = rdb.Set(ctx, key, val, ttl).Err() + } + } else { + // Set with a specified expiration + err = rdb.Set(ctx, key, val, duration).Err() + } + + return err +} + +// Get retrieves a value from the cache using a key string +func (gc Redis) Get(ctx context.Context, key string) (interface{}, bool) { + val, err := rdb.Get(ctx, key).Result() + if err == redis.Nil { + return nil, false + } else if err != nil { + return nil, false + } + return val, true +} + +// retrieves the time to live of a key +func (gc Redis) TTL(ctx context.Context, key string) (time.Duration, error) { + ttl := rdb.TTL(ctx, key).Val() + return ttl, nil +} + +// deletes a key from the cache +func (gc Redis) Delete(ctx context.Context, key string) error { + err := rdb.Del(ctx, key).Err() + return err +} diff --git a/pkg/common/http/middleware/authguard/authguard.go b/pkg/common/http/middleware/authguard/authguard.go new file mode 100644 index 0000000..26f5e12 --- /dev/null +++ b/pkg/common/http/middleware/authguard/authguard.go @@ -0,0 +1,305 @@ +package authguard + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net/http" + "strings" + "sync" + + common "github.com/abialemuel/AI-Proxy-Service/pkg/common/http" + + "github.com/abialemuel/AI-Proxy-Service/config" + goJwt "github.com/golang-jwt/jwt/v4" + "github.com/labstack/echo/v4" +) + +// Constants for handling JWT and keys +var ( + UserAttr = "userAttr" + PrefixHeader = "Bearer " + PrefixHeaderBasic = "Basic " + googleIssuer = "https://accounts.google.com" + microsoftIssuer = "https://login.microsoftonline.com/%s/v2.0" + googleCertsURL = "https://www.googleapis.com/oauth2/v1/certs" + microsoftCertsURL = "https://login.microsoftonline.com/common/discovery/keys" +) + +type JWK struct { + Kty string `json:"kty"` + Use string `json:"use"` + Kid string `json:"kid"` + X5c []string `json:"x5c"` +} + +type JWKS struct { + Keys []JWK `json:"keys"` +} + +// JwtClaims represents the claims extracted from the JWT +type JwtClaims struct { + Picture string `json:"picture"` + Email string `json:"email"` + Name string `json:"name"` + goJwt.RegisteredClaims +} + +type BasicAuth struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// AuthGuard holds dependencies like API key and configuration +type AuthGuard struct { + cfg config.MainConfig + certs map[string]*rsa.PublicKey + certsLock sync.RWMutex + services map[string]BasicAuth +} + +// NewAuthGuard creates a new instance of AuthGuard +func NewAuthGuard(cfg config.MainConfig) *AuthGuard { + return &AuthGuard{ + cfg: cfg, + certs: make(map[string]*rsa.PublicKey), + } +} + +// Add AuthGuard.services +func (g *AuthGuard) AddService(services []config.BackendService) { + g.services = make(map[string]BasicAuth) + for _, service := range services { + g.services[service.Name] = BasicAuth{ + Username: service.Username, + Password: service.Password, + } + } +} + +// Bearer middleware validates JWT tokens, handling multiple OAuth2 providers +func (g *AuthGuard) Bearer(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + authHeader := c.Request().Header.Get("Authorization") + + if !strings.HasPrefix(authHeader, PrefixHeader) { + return c.JSON(http.StatusUnauthorized, common.NewUnauthorizedResponse("Authorization header missing/invalid")) + } + + token := strings.TrimPrefix(authHeader, PrefixHeader) + claims, err := g.ParseAndVerify(token) + if err != nil { + return c.JSON(http.StatusUnauthorized, common.NewUnauthorizedResponse(err.Error())) + } + + c.Set(UserAttr, claims) + + return next(c) + } +} + +// Basic middleware validates Basic Auth tokens +func (g *AuthGuard) Basic(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + authHeader := c.Request().Header.Get("Authorization") + serviceHeader := c.Request().Header.Get("X-Service") + + if !strings.HasPrefix(authHeader, PrefixHeaderBasic) { + return c.JSON(http.StatusUnauthorized, common.NewUnauthorizedResponse("Authorization header missing/invalid")) + } + + // validate if service is allowed + if _, ok := g.services[serviceHeader]; !ok { + return c.JSON(http.StatusUnauthorized, common.NewUnauthorizedResponse("Service not allowed")) + } + + username, password, ok := c.Request().BasicAuth() + if !ok { + return c.JSON(http.StatusUnauthorized, common.NewUnauthorizedResponse("Invalid token")) + } + + // compare with the stored credentials + if username != g.services[serviceHeader].Username || password != g.services[serviceHeader].Password { + return c.JSON(http.StatusUnauthorized, common.NewUnauthorizedResponse("Invalid Basic Auth token")) + } + + // set the service name to the context + c.Set("service", serviceHeader) + + return next(c) + } +} + +// ParseAndVerify handles JWT parsing and verification for multiple providers +func (g *AuthGuard) ParseAndVerify(accessToken string) (JwtClaims, error) { + // Check if the token is a JWT + if strings.Count(accessToken, ".") == 2 { + return g.verifyJWT(accessToken) + } else { + // unsupported token type, return error + return JwtClaims{}, errors.New("unsupported token type") + } +} + +// verifyJWT verifies the JWT token locally +func (g *AuthGuard) verifyJWT(accessToken string) (JwtClaims, error) { + token, err := goJwt.ParseWithClaims(accessToken, &JwtClaims{}, func(token *goJwt.Token) (interface{}, error) { + claims, ok := token.Claims.(*JwtClaims) + if !ok { + return nil, errors.New("invalid token claims") + } + + // Ensure the token is signed with RSA (RS256) + if _, ok := token.Method.(*goJwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + // Determine the provider by the `iss` claim + switch claims.Issuer { + case googleIssuer: + return g.getGooglePublicKey(token.Header["kid"].(string)) + case fmt.Sprintf(microsoftIssuer, g.cfg.MicrosoftOauth.TenantID): + return g.getMicrosoftPublicKey(token.Header["kid"].(string)) + default: + return nil, fmt.Errorf("issuer not recognized: %s", claims.Issuer) + } + }) + + if err != nil || !token.Valid { + return JwtClaims{}, fmt.Errorf("JWT verification failed: %v. Please re-login", err) + } + + claims, ok := token.Claims.(*JwtClaims) + if !ok { + return JwtClaims{}, errors.New("failed to extract claims") + } + + // Verify audience + if !g.verifyAudience(claims.Issuer, claims.Audience) { + return JwtClaims{}, errors.New("invalid audience") + } + + return *claims, nil +} + +// verifyAudience checks if the audience claim matches the expected audience +func (g *AuthGuard) verifyAudience(issuer string, aud []string) bool { + switch issuer { + case googleIssuer: + return aud[0] == g.cfg.GoogleOauth.ClientID + case fmt.Sprintf(microsoftIssuer, g.cfg.MicrosoftOauth.TenantID): + return aud[0] == g.cfg.MicrosoftOauth.ClientID + } + + return false +} + +// getMicrosoftPublicKey fetches the public key from Microsoft's JWKS URL +func (g *AuthGuard) getMicrosoftPublicKey(kid string) (*rsa.PublicKey, error) { + g.certsLock.RLock() + key, exists := g.certs[kid] + g.certsLock.RUnlock() + + if exists { + return key, nil + } + + resp, err := http.Get(microsoftCertsURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch Microsoft JWKS: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch Microsoft JWKS: %s", resp.Status) + } + + var jwks JWKS + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return nil, fmt.Errorf("failed to decode Microsoft JWKS: %v", err) + } + + g.certsLock.Lock() + defer g.certsLock.Unlock() + + for _, jwk := range jwks.Keys { + if jwk.Kid == kid { + certPEM := "-----BEGIN CERTIFICATE-----\n" + jwk.X5c[0] + "\n-----END CERTIFICATE-----" + pubKey, err := parseRSAPublicKeyFromPEM([]byte(certPEM)) + if err != nil { + return nil, fmt.Errorf("failed to parse RSA public key: %v", err) + } + g.certs[kid] = pubKey + return pubKey, nil + } + } + + return nil, fmt.Errorf("public key not found for kid: %s", kid) +} + +// getGooglePublicKey fetches the public key from Google's certs URL +func (g *AuthGuard) getGooglePublicKey(kid string) (*rsa.PublicKey, error) { + g.certsLock.RLock() + key, exists := g.certs[kid] + g.certsLock.RUnlock() + + if exists { + return key, nil + } + + resp, err := http.Get(googleCertsURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch Google certs: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch Google certs: %s", resp.Status) + } + + var certs map[string]string + if err := json.NewDecoder(resp.Body).Decode(&certs); err != nil { + return nil, fmt.Errorf("failed to decode Google certs: %v", err) + } + + g.certsLock.Lock() + defer g.certsLock.Unlock() + + for k, v := range certs { + pubKey, err := parseRSAPublicKeyFromPEM([]byte(v)) + if err != nil { + return nil, fmt.Errorf("failed to parse RSA public key: %v", err) + } + g.certs[k] = pubKey + } + + key, exists = g.certs[kid] + if !exists { + return nil, fmt.Errorf("public key not found for kid: %s", kid) + } + + return key, nil +} + +// parseRSAPublicKeyFromPEM parses an RSA public key from PEM encoded data +func parseRSAPublicKeyFromPEM(pemData []byte) (*rsa.PublicKey, error) { + block, _ := pem.Decode(pemData) + if block == nil || block.Type != "CERTIFICATE" { + return nil, errors.New("failed to decode PEM block containing certificate") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %v", err) + } + + rsaPub, ok := cert.PublicKey.(*rsa.PublicKey) + if !ok { + return nil, errors.New("not an RSA public key") + } + + return rsaPub, nil +} diff --git a/pkg/common/http/pagination/pagination.go b/pkg/common/http/pagination/pagination.go new file mode 100644 index 0000000..7041361 --- /dev/null +++ b/pkg/common/http/pagination/pagination.go @@ -0,0 +1,49 @@ +package pagination + +import ( + "errors" + "strconv" + "unicode" +) + +type Meta struct { + Page int `json:"page"` + Limit int `json:"limit"` + Offset int `json:"-"` + Count int `json:"count"` +} + +func Parse(queryLimit, queryPage string) (meta Meta, err error) { + meta.Count = 0 + + if !IsNumber(queryLimit) { + return meta, errors.New("Limit must be a valid numeric value") + } + + if !IsNumber(queryPage) { + return meta, errors.New("Page must be a valid numeric value") + } + + limit, err := strconv.Atoi(queryLimit) + if err != nil { + limit = 25 + } + meta.Limit = limit + + page, err := strconv.Atoi(queryPage) + if err != nil { + page = 1 + } + meta.Offset = (page - 1) * limit + meta.Page = page + return meta, nil +} + +func IsNumber(s string) bool { + for _, r := range s { + if !unicode.IsNumber(r) { + return false + } + } + return true +} diff --git a/pkg/common/http/response.go b/pkg/common/http/response.go new file mode 100644 index 0000000..4c0a161 --- /dev/null +++ b/pkg/common/http/response.go @@ -0,0 +1,62 @@ +package common + +// DefaultResponse default payload response +type DefaultResponse struct { + Code int `json:"code"` + Status string `json:"status,omitempty"` + Message string `json:"message"` + // Internal error `json:"-"` +} + +// NewValidationErrorResponse default validation error response +func NewValidationErrorResponse(message string) DefaultResponse { + return DefaultResponse{ + 400, + ValidationErrStatus, + message, + } +} + +// NewUnauthorizedResponse default unauthorized response +func NewUnauthorizedResponse(msg string) DefaultResponse { + return DefaultResponse{ + 401, + UnauthorizedStatus, + msg, + } +} + +// NewUnauthorizedResponse default unauthorized response +func NewForbiddenResponse(msg string) DefaultResponse { + return DefaultResponse{ + 403, + ForbiddenStatus, + msg, + } +} + +// NewDefaultSuccessResponse default validation error response +func NewDefaultSuccessResponse() DefaultResponse { + return DefaultResponse{ + 200, + SuccessStatus, + "Success", + } +} + +// NewDefaultSuccessResponse default validation error response +func NewDefaultCreatedResponse() DefaultResponse { + return DefaultResponse{ + 201, + SuccessStatus, + "Success", + } +} + +// ErrorResponse error response +type ErrorResponse struct { + Code int `json:"code"` + Status string `json:"status,omitempty"` + Message string `json:"message"` + Internal error `json:"-"` +} diff --git a/pkg/common/http/status.go b/pkg/common/http/status.go new file mode 100644 index 0000000..cc450e7 --- /dev/null +++ b/pkg/common/http/status.go @@ -0,0 +1,15 @@ +package common + +const ( + SuccessStatus = "SUCCESS" + UnauthorizedStatus = "UNAUTHORIZED" + ValidationErrStatus = "VALIDATION_ERROR" + BadRequestStatus = "BAD_REQUEST" + ForbiddenStatus = "FORBIDDEN" + InternalErrStatus = "SERVER_ERROR" + NotFoundStatus = "NOT_FOUND" + NotAcceptableStatus = "NOT_ACCEPTABLE" + TooEarlyStatus = "TOO_EARLY" + TooManyRequestStatus = "TOO_MANY_REQUEST" + DuplicateStatus = "DUPLICATE" +) diff --git a/pkg/common/http/validator/validator.go b/pkg/common/http/validator/validator.go new file mode 100644 index 0000000..7cf7024 --- /dev/null +++ b/pkg/common/http/validator/validator.go @@ -0,0 +1,61 @@ +package validator + +import ( + "fmt" + + "github.com/go-playground/locales/en" + ut "github.com/go-playground/universal-translator" + "github.com/go-playground/validator/v10" + enTranslations "github.com/go-playground/validator/v10/translations/en" + "github.com/labstack/echo/v4" +) + +var ( + validate *validator.Validate + uni *ut.UniversalTranslator + trans ut.Translator +) + +//GetValidator Initiatilize validator in singleton way +func GetValidator() *validator.Validate { + + if validate == nil { + validate = validator.New() + } + + return validate +} + +func getTrans() ut.Translator { + if trans == nil { + en := en.New() + uni = ut.New(en, en) + trans, _ = uni.GetTranslator("en") + + enTranslations.RegisterDefaultTranslations(validate, trans) + } + + return trans +} + +func CreateValidationErrorMessage(err error) string { + for _, e := range err.(validator.ValidationErrors) { + translatedErr := fmt.Errorf(e.Translate(getTrans())) + return translatedErr.Error() + } + return err.Error() +} + +func Validation(reg interface{}) (string, bool) { + var message string + var check bool = true + + err := GetValidator().Struct(reg) + if err != nil { + _, check = err.(*echo.HTTPError) + if !check { + message = CreateValidationErrorMessage(err) + } + } + return message, check +} diff --git a/pkg/common/mongodb/mongodb.go b/pkg/common/mongodb/mongodb.go new file mode 100644 index 0000000..6c1cbe1 --- /dev/null +++ b/pkg/common/mongodb/mongodb.go @@ -0,0 +1,25 @@ +package mongodb + +// new mongodb +import ( + "context" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func NewMongoDB(url string, username string, password string, dbName string) (*mongo.Database, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + uri := fmt.Sprintf("mongodb://%s:%s@%s", username, password, url) + client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) + if err != nil { + return nil, fmt.Errorf("failed to connect to mongodb: %w", err) + } + + db := client.Database(dbName) + return db, nil +} diff --git a/pkg/common/oauth/google/google.go b/pkg/common/oauth/google/google.go new file mode 100644 index 0000000..d551096 --- /dev/null +++ b/pkg/common/oauth/google/google.go @@ -0,0 +1,61 @@ +package google + +import ( + "context" + + "github.com/abialemuel/AI-Proxy-Service/pkg/common/oauth/model" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +// GoogleAdapter is an adapter for Google OAuth2. +type GoogleAdapter struct { + config *oauth2.Config +} + +// NewGoogleAdapter creates a new GoogleAdapter with the given client configuration. +func NewGoogleAdapter(clientID, clientSecret, redirectURL string) *GoogleAdapter { + config := &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: []string{"openid", "profile", "email"}, + Endpoint: google.Endpoint, + } + return &GoogleAdapter{config: config} +} + +// GetAuthURL generates the Google OAuth2 authorization URL. +func (g *GoogleAdapter) GetAuthURL(state string) string { + return g.config.AuthCodeURL(state) +} + +// Get Refresh Token +func (m *GoogleAdapter) GetRefreshToken(refreshToken string) (*model.TokenResponse, error) { + oldToken := &oauth2.Token{ + RefreshToken: refreshToken, + } + tokenSource := m.config.TokenSource(context.Background(), oldToken) + newToken, err := tokenSource.Token() + if err != nil { + return nil, err + } + idToken := newToken.Extra("id_token").(string) + + return &model.TokenResponse{AccessToken: newToken.AccessToken, RefreshToken: newToken.RefreshToken, IDToken: idToken}, nil +} + +// ExchangeCodeForToken exchanges an authorization code for tokens. +func (g *GoogleAdapter) ExchangeCodeForToken(code string) (*model.TokenResponse, error) { + token, err := g.config.Exchange(oauth2.NoContext, code) + if err != nil { + return nil, err + } + idToken := token.Extra("id_token").(string) + return &model.TokenResponse{AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, IDToken: idToken}, nil +} + +// GetRedirectURL returns the redirect URL configured for the Google OAuth2 provider. +func (g *GoogleAdapter) GetRedirectURL() string { + return g.config.RedirectURL +} diff --git a/pkg/common/oauth/manager.go b/pkg/common/oauth/manager.go new file mode 100644 index 0000000..db7a4e0 --- /dev/null +++ b/pkg/common/oauth/manager.go @@ -0,0 +1,37 @@ +package oauthmanager + +import ( + "errors" + + "github.com/abialemuel/AI-Proxy-Service/pkg/common/oauth/google" + "github.com/abialemuel/AI-Proxy-Service/pkg/common/oauth/microsoft" + "github.com/abialemuel/AI-Proxy-Service/pkg/common/oauth/model" +) + +// ProviderType represents the supported OAuth2 providers. +type ProviderType string + +const ( + GoogleProvider ProviderType = "google" + MicrosoftProvider ProviderType = "microsoft" +) + +// OAuth2Provider defines the interface for OAuth2 operations. +type OAuth2Provider interface { + GetAuthURL(state string) string + ExchangeCodeForToken(code string) (*model.TokenResponse, error) + GetRedirectURL() string + GetRefreshToken(refreshToken string) (*model.TokenResponse, error) +} + +// NewOAuth2Provider creates a new OAuth2 provider adapter based on the given provider type. +func NewOAuth2Provider(providerType ProviderType, tenantID, clientID, clientSecret, redirectURL string) (OAuth2Provider, error) { + switch providerType { + case GoogleProvider: + return google.NewGoogleAdapter(clientID, clientSecret, redirectURL), nil + case MicrosoftProvider: + return microsoft.NewMicrosoftAdapter(tenantID, clientID, clientSecret, redirectURL), nil + default: + return nil, errors.New("unsupported provider type") + } +} diff --git a/pkg/common/oauth/microsoft/microsoft.go b/pkg/common/oauth/microsoft/microsoft.go new file mode 100644 index 0000000..4f0f478 --- /dev/null +++ b/pkg/common/oauth/microsoft/microsoft.go @@ -0,0 +1,62 @@ +package microsoft + +import ( + "context" + + "github.com/abialemuel/AI-Proxy-Service/pkg/common/oauth/model" + "golang.org/x/oauth2" + "golang.org/x/oauth2/microsoft" +) + +// MicrosoftAdapter is an adapter for Microsoft OAuth2. +type MicrosoftAdapter struct { + config *oauth2.Config +} + +// NewMicrosoftAdapter creates a new MicrosoftAdapter with the given client configuration. +func NewMicrosoftAdapter(tenantID, clientID, clientSecret, redirectURL string) *MicrosoftAdapter { + config := &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: []string{"openid", "profile", "email", "offline_access"}, + Endpoint: microsoft.AzureADEndpoint(tenantID), + } + return &MicrosoftAdapter{config: config} +} + +// GetAuthURL generates the Microsoft OAuth2 authorization URL. +func (m *MicrosoftAdapter) GetAuthURL(state string) string { + return m.config.AuthCodeURL(state) +} + +// ExchangeCodeForToken exchanges an authorization code for tokens. +func (m *MicrosoftAdapter) ExchangeCodeForToken(code string) (*model.TokenResponse, error) { + token, err := m.config.Exchange(oauth2.NoContext, code) + if err != nil { + return nil, err + } + idToken := token.Extra("id_token").(string) + + return &model.TokenResponse{AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, IDToken: idToken}, nil +} + +// Get Refresh Token +func (m *MicrosoftAdapter) GetRefreshToken(refreshToken string) (*model.TokenResponse, error) { + oldToken := &oauth2.Token{ + RefreshToken: refreshToken, + } + tokenSource := m.config.TokenSource(context.Background(), oldToken) + newToken, err := tokenSource.Token() + if err != nil { + return nil, err + } + idToken := newToken.Extra("id_token").(string) + + return &model.TokenResponse{AccessToken: newToken.AccessToken, RefreshToken: newToken.RefreshToken, IDToken: idToken}, nil +} + +// GetRedirectURL returns the redirect URL configured for the Microsoft OAuth2 provider. +func (m *MicrosoftAdapter) GetRedirectURL() string { + return m.config.RedirectURL +} diff --git a/pkg/common/oauth/model/model.go b/pkg/common/oauth/model/model.go new file mode 100644 index 0000000..1667b49 --- /dev/null +++ b/pkg/common/oauth/model/model.go @@ -0,0 +1,11 @@ +package model + +import "time" + +// TokenResponse represents a general response structure for OAuth2 tokens. +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + Expiry time.Time `json:"expiry"` +} diff --git a/pkg/common/time/time.go b/pkg/common/time/time.go new file mode 100644 index 0000000..9ec9465 --- /dev/null +++ b/pkg/common/time/time.go @@ -0,0 +1,9 @@ +package time + +import "time" + +func GetOffsetTime() int { + t := time.Now() + _, offset := t.Zone() + return offset +} diff --git a/pkg/user/api/amqp/handler.go b/pkg/user/api/amqp/handler.go new file mode 100644 index 0000000..2faf0a6 --- /dev/null +++ b/pkg/user/api/amqp/handler.go @@ -0,0 +1,15 @@ +package amqp + +import ( + oauthmanager "github.com/abialemuel/AI-Proxy-Service/pkg/common/oauth" + "github.com/abialemuel/AI-Proxy-Service/pkg/user/business" +) + +type Handler struct { + service business.UserService + oauthManager oauthmanager.OAuth2Provider +} + +func NewHandler(s business.UserService) *Handler { + return &Handler{service: s} +} diff --git a/pkg/user/api/amqp/router.go b/pkg/user/api/amqp/router.go new file mode 100644 index 0000000..d9ecc2a --- /dev/null +++ b/pkg/user/api/amqp/router.go @@ -0,0 +1,8 @@ +package amqp + +import ( + "github.com/ThreeDotsLabs/watermill/message" +) + +func RegisterPath(r *message.Router, pub message.Publisher, sub message.Subscriber, h *Handler) { +} diff --git a/pkg/user/api/http/auth_handler.go b/pkg/user/api/http/auth_handler.go new file mode 100644 index 0000000..d53f081 --- /dev/null +++ b/pkg/user/api/http/auth_handler.go @@ -0,0 +1,115 @@ +package http + +import ( + "fmt" + "net/http" + + common "github.com/abialemuel/AI-Proxy-Service/pkg/common/http" + "github.com/abialemuel/AI-Proxy-Service/pkg/common/http/validator" + "github.com/abialemuel/AI-Proxy-Service/pkg/common/oauth/model" + "github.com/abialemuel/AI-Proxy-Service/pkg/user/api/http/request" + "github.com/abialemuel/AI-Proxy-Service/pkg/user/api/http/response" + + "github.com/abialemuel/poly-kit/infrastructure/apm" + "github.com/labstack/echo/v4" +) + +const ( + UIPath = "/gpt4/prompts" +) + +func (h *Handler) AuthHandler(c echo.Context) (err error) { + _, span := apm.StartTransaction(c.Request().Context(), "Handler::AuthUser") + defer apm.EndTransaction(span) + + req := new(request.AuthLogin) + if err := c.Bind(req); err != nil { + return c.JSON(http.StatusBadRequest, common.NewValidationErrorResponse("Invalid Body")) + } + if msg, check := validator.Validation(req); !check { + return c.JSON(http.StatusBadRequest, common.NewValidationErrorResponse(msg)) + } + + authUrl := "" + if req.Provider == "google" { + authUrl = h.googleOauth.GetAuthURL("google") + } else if req.Provider == "microsoft" { + authUrl = h.microsoftOauth.GetAuthURL("microsoft") + } + + // return json + return c.JSON(http.StatusOK, response.NewAuthResponse(authUrl)) +} + +// GoogleAuthCallback Receive Callback +func (h *Handler) GoogleAuthCallback(c echo.Context) error { + _, span := apm.StartTransaction(c.Request().Context(), "Handler::GoogleCallback") + defer apm.EndTransaction(span) + + req := new(request.AuthCallback) + if err := c.Bind(req); err != nil { + return c.JSON(http.StatusBadRequest, common.NewValidationErrorResponse("Invalid Body")) + } + if msg, check := validator.Validation(req); !check { + fmt.Println(msg) + return c.JSON(http.StatusBadRequest, common.NewValidationErrorResponse(msg)) + } + + token, err := h.googleOauth.ExchangeCodeForToken(req.Code) + if err != nil { + return c.JSON(http.StatusBadRequest, common.NewValidationErrorResponse(err.Error())) + } + + // redirect to url + return c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s?access_token=%s&refresh_token=%s", h.config.UI.Host+UIPath, token.IDToken, token.RefreshToken)) +} + +// MicrosoftAuthCallback Receive Callback +func (h *Handler) MicrosoftAuthCallback(c echo.Context) error { + _, span := apm.StartTransaction(c.Request().Context(), "Handler::MicrosoftCallback") + defer apm.EndTransaction(span) + + req := new(request.AuthCallback) + if err := c.Bind(req); err != nil { + return c.JSON(http.StatusBadRequest, common.NewValidationErrorResponse("Invalid Body")) + } + if msg, check := validator.Validation(req); !check { + return c.JSON(http.StatusBadRequest, common.NewValidationErrorResponse(msg)) + } + + token, err := h.microsoftOauth.ExchangeCodeForToken(req.Code) + if err != nil { + return c.JSON(http.StatusBadRequest, common.NewValidationErrorResponse(err.Error())) + } + + // redirect to url with token + return c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s?access_token=%s&refresh_token=%s", h.config.UI.Host+UIPath, token.IDToken, token.RefreshToken)) +} + +// RefreshTokenHandler Refresh Token +func (h *Handler) RefreshTokenHandler(c echo.Context) error { + _, span := apm.StartTransaction(c.Request().Context(), "Handler::RefreshToken") + defer apm.EndTransaction(span) + + req := new(request.AuthRefresh) + if err := c.Bind(req); err != nil { + return c.JSON(http.StatusBadRequest, common.NewValidationErrorResponse("Invalid Body")) + } + if msg, check := validator.Validation(req); !check { + return c.JSON(http.StatusBadRequest, common.NewValidationErrorResponse(msg)) + } + + token := new(model.TokenResponse) + var err error + if req.Provider == "google" { + token, err = h.googleOauth.GetRefreshToken(req.RefreshToken) + } else if req.Provider == "microsoft" { + token, err = h.microsoftOauth.GetRefreshToken(req.RefreshToken) + } + if err != nil { + return c.JSON(http.StatusBadRequest, common.NewValidationErrorResponse(err.Error())) + } + + // return json + return c.JSON(http.StatusOK, response.NewTokenResponse(token.IDToken, token.RefreshToken)) +} diff --git a/pkg/user/api/http/handler.go b/pkg/user/api/http/handler.go new file mode 100644 index 0000000..b39ee99 --- /dev/null +++ b/pkg/user/api/http/handler.go @@ -0,0 +1,133 @@ +package http + +import ( + "context" + "net/http" + "time" + + "github.com/abialemuel/AI-Proxy-Service/config" + common "github.com/abialemuel/AI-Proxy-Service/pkg/common/http" + "github.com/abialemuel/AI-Proxy-Service/pkg/common/http/middleware/authguard" + "github.com/abialemuel/AI-Proxy-Service/pkg/common/http/validator" + oauthmanager "github.com/abialemuel/AI-Proxy-Service/pkg/common/oauth" + "github.com/abialemuel/AI-Proxy-Service/pkg/user/api/http/request" + "github.com/abialemuel/AI-Proxy-Service/pkg/user/api/http/response" + "github.com/abialemuel/AI-Proxy-Service/pkg/user/business" + "github.com/abialemuel/AI-Proxy-Service/pkg/user/business/core" + + "github.com/abialemuel/poly-kit/infrastructure/apm" + "github.com/labstack/echo/v4" +) + +type Handler struct { + service business.UserService + googleOauth oauthmanager.OAuth2Provider + microsoftOauth oauthmanager.OAuth2Provider + config *config.MainConfig +} + +// NewHandler Construct user API handler +func NewHandler(service business.UserService, google oauthmanager.OAuth2Provider, microsoft oauthmanager.OAuth2Provider, cfg *config.MainConfig) *Handler { + return &Handler{ + service, + google, + microsoft, + cfg, + } +} + +func (h *Handler) GetUser(c echo.Context) error { + ctx, span := apm.StartTransaction(c.Request().Context(), "Handler::GetUser") + defer apm.EndTransaction(span) + // get user id from token + jwtAtrr := c.Get(authguard.UserAttr).(authguard.JwtClaims) + + tokenInfo := h.service.GetUserTokenUsage(ctx, jwtAtrr.Email) + + // return 200 with user data from jwt + return c.JSON(http.StatusOK, response.NewUserMeResponse(jwtAtrr, tokenInfo)) +} + +// GPT4Handler handler for GPT4 +func (h *Handler) UserGPT4Handler(c echo.Context) error { + ctx, span := apm.StartTransaction(c.Request().Context(), "Handler::GPT4UserPrompt") + defer apm.EndTransaction(span) + + jwtAtrr := c.Get(authguard.UserAttr).(authguard.JwtClaims) + userID := jwtAtrr.Email + + req := new(request.UserPromptGPTRequest) + if err := c.Bind(req); err != nil { + return c.JSON(http.StatusBadRequest, common.NewValidationErrorResponse("Invalid Body")) + } + if msg, check := validator.Validation(req); !check { + return c.JSON(http.StatusBadRequest, common.NewValidationErrorResponse(msg)) + } + + // Create a new context with a longer timeout or no timeout + longCtx, cancel := context.WithTimeout(ctx, 1*time.Minute) + defer cancel() + + res, err := h.service.UserPromtGPT(longCtx, core.UserPromtGPTRequest{ + UserID: userID, + Content: request.ToCoreUserPromptGPTRequest(req.Content), + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, common.NewValidationErrorResponse(err.Error())) + } + + return c.JSON(http.StatusOK, response.NewUserPromGPTResponse(res)) +} + +// ServiceGPT4Handler +func (h *Handler) ServiceGPT4Handler(c echo.Context) error { + ctx, span := apm.StartTransaction(c.Request().Context(), "Handler::GPT4ServicePrompt") + defer apm.EndTransaction(span) + + req := new(request.ServicePromptGPTRequest) + if err := c.Bind(req); err != nil { + return c.JSON(http.StatusBadRequest, common.NewValidationErrorResponse("Invalid Body")) + } + if msg, check := validator.Validation(req); !check { + return c.JSON(http.StatusBadRequest, common.NewValidationErrorResponse(msg)) + } + + // get service from context + serviceName := c.Get("service").(string) + + // Create a new context with a longer timeout or no timeout + longCtx, cancel := context.WithTimeout(ctx, 1*time.Minute) + defer cancel() + + res, err := h.service.ServicePrompt(longCtx, core.ServicePromptRequest{ + ServiceName: serviceName, + Model: req.Model, + Temperature: req.Temperature, + MaxTokens: req.MaxTokens, + TopP: req.TopP, + Messages: request.ToCoreMessage(req.Messages), + }) + + if err != nil { + return c.JSON(http.StatusInternalServerError, common.NewValidationErrorResponse(err.Error())) + } + + return c.JSON(http.StatusOK, response.NewServicePromGPTResponse(res)) +} + +// UserClearContextHandler handler for clearing context +func (h *Handler) UserClearContextHandler(c echo.Context) error { + ctx, span := apm.StartTransaction(c.Request().Context(), "Handler::ClearContext") + defer apm.EndTransaction(span) + + jwtAtrr := c.Get(authguard.UserAttr).(authguard.JwtClaims) + userID := jwtAtrr.Email + + // clear context + err := h.service.UserClearContext(ctx, userID) + if err != nil { + return c.JSON(http.StatusInternalServerError, common.NewValidationErrorResponse(err.Error())) + } + + return c.JSON(http.StatusOK, common.DefaultResponse{Code: 200, Message: "Context Cleared"}) +} diff --git a/pkg/user/api/http/request/auth.go b/pkg/user/api/http/request/auth.go new file mode 100644 index 0000000..0d13ead --- /dev/null +++ b/pkg/user/api/http/request/auth.go @@ -0,0 +1,15 @@ +package request + +type AuthCallback struct { + State string `query:"state" validate:"required"` + Code string `query:"code" validate:"required"` +} + +type AuthLogin struct { + Provider string `query:"provider" validate:"required"` +} + +type AuthRefresh struct { + Provider string `query:"provider" validate:"required"` + RefreshToken string `query:"refresh_token" validate:"required"` +} diff --git a/pkg/user/api/http/request/service_gpt4.go b/pkg/user/api/http/request/service_gpt4.go new file mode 100644 index 0000000..64d63fb --- /dev/null +++ b/pkg/user/api/http/request/service_gpt4.go @@ -0,0 +1,43 @@ +package request + +import "github.com/abialemuel/AI-Proxy-Service/pkg/user/business/core" + +type ServicePromptGPTRequest struct { + Model string `json:"model" validate:"required"` + Temperature float64 `json:"temperature" validate:"required"` + MaxTokens int `json:"max_tokens" validate:"required"` + TopP float64 `json:"top_p" validate:"required"` + Messages []Message `json:"messages" validate:"required"` +} + +type Message struct { + Role string `json:"role" validate:"required"` + Content []Content `json:"content" validate:"required"` +} + +func ToCoreMessage(req []Message) (res []core.MessageRequest) { + for _, v := range req { + var message core.MessageRequest + message.Role = v.Role + message.Content = ToCoreContent(v.Content) + res = append(res, message) + } + return res +} + +func ToCoreContent(req []Content) (res []core.Content) { + for _, v := range req { + var content core.Content + content.Type = v.Type + if v.Text != nil { + content.Text = v.Text + } + if v.ImageURL != nil { + content.ImageURL = &core.ImageURL{ + URL: v.ImageURL.URL, + } + } + res = append(res, content) + } + return res +} diff --git a/pkg/user/api/http/request/user_gpt4.go b/pkg/user/api/http/request/user_gpt4.go new file mode 100644 index 0000000..a6ddcdf --- /dev/null +++ b/pkg/user/api/http/request/user_gpt4.go @@ -0,0 +1,46 @@ +package request + +import "github.com/abialemuel/AI-Proxy-Service/pkg/user/business/core" + +// "content": [ +// { +// "type": "text", +// "text": "What’s in this image?", +// "image_url": {} +// }, +// { +// "type": "image_url", +// "image_url": { +// "url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYS" +// }} +type UserPromptGPTRequest struct { + Content []Content `json:"content" validate:"required"` +} + +type Content struct { + Type string `json:"type" validate:"required"` + Text *string `json:"text,omitempty"` + ImageURL *ImageURL `json:"image_url,omitempty"` +} + +type ImageURL struct { + URL string `json:"url"` +} + +func ToCoreUserPromptGPTRequest(req []Content) (res []core.Content) { + for _, v := range req { + var content core.Content + content.Type = v.Type + if v.Text != nil { + content.Text = v.Text + } + if v.ImageURL != nil { + content.ImageURL = &core.ImageURL{ + URL: v.ImageURL.URL, + } + } + res = append(res, content) + } + return res + +} diff --git a/pkg/user/api/http/response/service_gpt4.go b/pkg/user/api/http/response/service_gpt4.go new file mode 100644 index 0000000..18a4e32 --- /dev/null +++ b/pkg/user/api/http/response/service_gpt4.go @@ -0,0 +1,39 @@ +package response + +import "github.com/abialemuel/AI-Proxy-Service/pkg/user/business/core" + +type ServicePromGPT struct { + Model string `json:"model"` + Temperature float64 `json:"temperature"` + Usage Usage `json:"usage"` + Content string `json:"content"` +} + +type Usage struct { + CompletionTokens int `json:"completion_tokens"` + PromptTokens int `json:"prompt_tokens"` + TotalTokens int `json:"total_tokens"` +} +type ServicePromGPTResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Payload ServicePromGPT `json:"payload"` +} + +func NewServicePromGPTResponse(v core.ServicePromGPTResponse) *ServicePromGPTResponse { + var ResultResponse ServicePromGPTResponse + payload := ServicePromGPT{ + Model: v.GPT4PromptResponse.Model, + Content: v.GPT4PromptResponse.Choices[0].Message.Content, + Usage: Usage{ + CompletionTokens: v.GPT4PromptResponse.Usage.CompletionTokens, + PromptTokens: v.GPT4PromptResponse.Usage.PromptTokens, + TotalTokens: v.GPT4PromptResponse.Usage.TotalTokens, + }, + } + + ResultResponse.Code = 200 + ResultResponse.Message = "Success" + ResultResponse.Payload = payload + return &ResultResponse +} diff --git a/pkg/user/api/http/response/user_gpt4.go b/pkg/user/api/http/response/user_gpt4.go new file mode 100644 index 0000000..9db37f2 --- /dev/null +++ b/pkg/user/api/http/response/user_gpt4.go @@ -0,0 +1,114 @@ +package response + +import ( + "time" + + "github.com/abialemuel/AI-Proxy-Service/pkg/common/http/middleware/authguard" + "github.com/abialemuel/AI-Proxy-Service/pkg/user/business/core" +) + +type UserPromGPT struct { + Content string `json:"content"` +} + +type Token struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +type TokenResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Payload Token +} + +type UserAuth struct { + AuthURL string `json:"auth_url"` +} + +type UserAuthResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Payload UserAuth `json:"payload"` +} + +type UserPromGPTResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Payload UserPromGPT `json:"payload"` +} + +type UserMeResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Payload UserMe `json:"payload"` +} + +type UserMe struct { + Issuer string `json:"issuer"` + UserID string `json:"user_id"` + Email string `json:"email"` + Name string `json:"name"` + Picture string `json:"picture"` + ExpiresAt time.Time `json:"expires_at"` + TokenLimit int `json:"token_limit"` + TokenUsage int `json:"token_usage"` + Warning bool `json:"warning"` +} + +func NewUserMeResponse(v authguard.JwtClaims, tokenInfo core.UserTokenUsage) *UserMeResponse { + var ResultResponse UserMeResponse + payload := UserMe{ + Issuer: v.Issuer, + UserID: v.Subject, + Email: v.Email, + Name: v.Name, + Picture: v.Picture, + ExpiresAt: time.Unix(v.ExpiresAt.Unix(), 0), + TokenLimit: tokenInfo.TokenLimit, + TokenUsage: tokenInfo.TokenUsage, + Warning: tokenInfo.Warning, + } + + ResultResponse.Code = 200 + ResultResponse.Message = "Success" + ResultResponse.Payload = payload + return &ResultResponse +} + +func NewUserPromGPTResponse(v core.UserPromGPTResponse) *UserPromGPTResponse { + var ResultResponse UserPromGPTResponse + payload := UserPromGPT{ + Content: v.GPT4PromptResponse.Choices[0].Message.Content, + } + + ResultResponse.Code = 200 + ResultResponse.Message = "Success" + ResultResponse.Payload = payload + return &ResultResponse +} + +func NewAuthResponse(authUrl string) *UserAuthResponse { + var ResultResponse UserAuthResponse + payload := UserAuth{ + AuthURL: authUrl, + } + + ResultResponse.Code = 200 + ResultResponse.Message = "Success" + ResultResponse.Payload = payload + return &ResultResponse +} + +func NewTokenResponse(token string, refreshToken string) *TokenResponse { + var ResultResponse TokenResponse + payload := Token{ + AccessToken: token, + RefreshToken: refreshToken, + } + + ResultResponse.Code = 200 + ResultResponse.Message = "Success" + ResultResponse.Payload = payload + return &ResultResponse +} diff --git a/pkg/user/api/http/router.go b/pkg/user/api/http/router.go new file mode 100644 index 0000000..21d0d1e --- /dev/null +++ b/pkg/user/api/http/router.go @@ -0,0 +1,27 @@ +package http + +import ( + "github.com/abialemuel/AI-Proxy-Service/pkg/common/http/middleware/authguard" + + "github.com/labstack/echo/v4" +) + +// RegisterPath Register V1 API path +func RegisterPath(e *echo.Echo, h *Handler, authGuard *authguard.AuthGuard) { + if h == nil { + panic("item controller cannot be nil") + } + + // Auth implementation + e.GET("v1/auth/login", h.AuthHandler) + e.GET("v1/auth/google/callback", h.GoogleAuthCallback) + e.GET("v1/auth/microsoft/callback", h.MicrosoftAuthCallback) + e.GET("v1/auth/refresh", h.RefreshTokenHandler) + + e.GET("v1/users/me", h.GetUser, authGuard.Bearer) + e.POST("v1/prompt", h.UserGPT4Handler, authGuard.Bearer) + e.POST("v1/prompt/new", h.UserClearContextHandler, authGuard.Bearer) + + // Internal service for GPT4 + e.POST("v1/prompt/internal", h.ServiceGPT4Handler, authGuard.Basic) +} diff --git a/pkg/user/business/auth.go b/pkg/user/business/auth.go new file mode 100644 index 0000000..3528432 --- /dev/null +++ b/pkg/user/business/auth.go @@ -0,0 +1 @@ +package business diff --git a/pkg/user/business/contract/cache.go b/pkg/user/business/contract/cache.go new file mode 100644 index 0000000..1394899 --- /dev/null +++ b/pkg/user/business/contract/cache.go @@ -0,0 +1,13 @@ +package contract + +import ( + "context" + "time" +) + +type Cache interface { + Set(ctx context.Context, key string, val interface{}, duration time.Duration) error + Get(ctx context.Context, key string) (val interface{}, found bool) + TTL(ctx context.Context, key string) (time.Duration, error) + Delete(ctx context.Context, key string) error +} diff --git a/pkg/user/business/contract/gpt4_webservice.go b/pkg/user/business/contract/gpt4_webservice.go new file mode 100644 index 0000000..7b21f12 --- /dev/null +++ b/pkg/user/business/contract/gpt4_webservice.go @@ -0,0 +1,11 @@ +package contract + +import ( + "context" + + "github.com/abialemuel/AI-Proxy-Service/pkg/user/modules/gpt4_webservice" +) + +type GPT4WebService interface { + Prompt(ctx context.Context, payload gpt4_webservice.GPT4PromptRequestDao) (gpt4_webservice.GPT4PromptResponseDao, error) +} diff --git a/pkg/user/business/contract/payload.go b/pkg/user/business/contract/payload.go new file mode 100644 index 0000000..c976a50 --- /dev/null +++ b/pkg/user/business/contract/payload.go @@ -0,0 +1,9 @@ +package contract + +import ( + oauthmanager "github.com/abialemuel/AI-Proxy-Service/pkg/common/oauth" +) + +type AuthPayload struct { + AuthType *oauthmanager.ProviderType +} diff --git a/pkg/user/business/contract/publisher.go b/pkg/user/business/contract/publisher.go new file mode 100644 index 0000000..e781527 --- /dev/null +++ b/pkg/user/business/contract/publisher.go @@ -0,0 +1,5 @@ +package contract + +type Publisher interface { + Publish(topic string, msg interface{}) error +} diff --git a/pkg/user/business/contract/repository.go b/pkg/user/business/contract/repository.go new file mode 100644 index 0000000..757f246 --- /dev/null +++ b/pkg/user/business/contract/repository.go @@ -0,0 +1,12 @@ +package contract + +import ( + "context" + + "github.com/abialemuel/AI-Proxy-Service/pkg/user/modules/repository" +) + +type Repository interface { + // Conversations Repository + UpsertConversation(ctx context.Context, userID string, message []repository.Message, summary *repository.Summary) error +} diff --git a/pkg/user/business/core/gpt4.go b/pkg/user/business/core/gpt4.go new file mode 100644 index 0000000..6a6cb51 --- /dev/null +++ b/pkg/user/business/core/gpt4.go @@ -0,0 +1,69 @@ +package core + +import "github.com/abialemuel/AI-Proxy-Service/pkg/user/modules/gpt4_webservice" + +// type GPT4PromptRequest struct { +// Model string `json:"model"` +// Messages []Message `json:"messages"` +// Temperature float64 `json:"temperature"` +// MaxTokens int `json:"max_tokens"` +// TopP float64 `json:"top_p"` +// } + +type GPT4PromptResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created float64 `json:"created"` + Model string `json:"model"` + Choices []Choices `json:"choices"` + Usage Usage `json:"usage"` +} + +type Message struct { + Content string `json:"content"` + Role string `json:"role"` +} + +type Choices struct { + Index int `json:"index"` + Message Message `json:"message"` +} + +type Usage struct { + CompletionTokens int `json:"completion_tokens"` + PromptTokens int `json:"prompt_tokens"` + TotalTokens int `json:"total_tokens"` +} + +func ToCoreGPT4PromptResponse(p gpt4_webservice.GPT4PromptResponseDao) GPT4PromptResponse { + return GPT4PromptResponse{ + ID: p.ID, + Object: p.Object, + Created: p.Created, + Model: p.Model, + Choices: ToCoreChoices(p.Choices), + Usage: ToCoreUsage(p.Usage), + } +} + +func ToCoreChoices(c []gpt4_webservice.Choices) []Choices { + var coreChoices []Choices + for _, choice := range c { + coreChoices = append(coreChoices, Choices{ + Index: choice.Index, + Message: Message{ + Content: choice.Message.Content, + Role: choice.Message.Role, + }, + }) + } + return coreChoices +} + +func ToCoreUsage(u gpt4_webservice.Usage) Usage { + return Usage{ + CompletionTokens: u.CompletionTokens, + PromptTokens: u.PromptTokens, + TotalTokens: u.TotalTokens, + } +} diff --git a/pkg/user/business/core/user.go b/pkg/user/business/core/user.go new file mode 100644 index 0000000..12a67f0 --- /dev/null +++ b/pkg/user/business/core/user.go @@ -0,0 +1,64 @@ +package core + +import ( + "github.com/abialemuel/AI-Proxy-Service/pkg/user/modules/gpt4_webservice" + "github.com/abialemuel/AI-Proxy-Service/pkg/user/modules/repository" +) + +type UserPromtGPTRequest struct { + Content []Content `json:"content"` + UserID string `json:"user_id"` +} + +type UserPromGPTResponse struct { + GPT4PromptResponse + UserID string `json:"user_id"` +} + +type UserTokenUsage struct { + TokenLimit int `json:"token_limit"` + TokenUsage int `json:"token_usage"` + Warning bool `json:"warning"` +} + +type Content struct { + Type string `json:"type" validate:"required"` + Text *string `json:"text,omitempty"` + ImageURL *ImageURL `json:"image_url,omitempty"` +} + +type ImageURL struct { + URL string `json:"url"` +} + +func ToWebServiceUserPromtGPTContentRequest(req []Content) (res []gpt4_webservice.Content) { + for _, v := range req { + var content gpt4_webservice.Content + content.Type = v.Type + if v.Text != nil { + content.Text = v.Text + } + if v.ImageURL != nil { + content.ImageURL = &gpt4_webservice.ImageURL{ + URL: v.ImageURL.URL, + } + } + res = append(res, content) + } + return res +} + +func ToContentRepo(req []Content) (res []repository.Content) { + for _, v := range req { + var content repository.Content + content.Type = v.Type + if v.Text != nil { + content.Text = v.Text + } + if v.ImageURL != nil { + content.ImageURL = v.ImageURL.URL + } + res = append(res, content) + } + return res +} diff --git a/pkg/user/business/core/user_service.go b/pkg/user/business/core/user_service.go new file mode 100644 index 0000000..af4c059 --- /dev/null +++ b/pkg/user/business/core/user_service.go @@ -0,0 +1,32 @@ +package core + +import "github.com/abialemuel/AI-Proxy-Service/pkg/user/modules/gpt4_webservice" + +type ServicePromptRequest struct { + Model string `json:"model"` + ServiceName string `json:"service_name"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` + TopP float64 `json:"top_p"` + Messages []MessageRequest `json:"messages"` +} + +type MessageRequest struct { + Role string `json:"role"` + Content []Content `json:"content"` +} + +func ToWebServicePromtGPTMsgRequest(req []MessageRequest) (res []gpt4_webservice.MessageReq) { + for _, v := range req { + var message gpt4_webservice.MessageReq + message.Role = v.Role + message.Content = ToWebServiceUserPromtGPTContentRequest(v.Content) + res = append(res, message) + } + return res +} + +type ServicePromGPTResponse struct { + GPT4PromptResponse + UserID string `json:"user_id"` +} diff --git a/pkg/user/business/user.go b/pkg/user/business/user.go new file mode 100644 index 0000000..a1c44b7 --- /dev/null +++ b/pkg/user/business/user.go @@ -0,0 +1,362 @@ +package business + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "time" + + "github.com/abialemuel/AI-Proxy-Service/config" + "github.com/abialemuel/AI-Proxy-Service/pkg/user/business/contract" + "github.com/abialemuel/AI-Proxy-Service/pkg/user/business/core" + "github.com/abialemuel/AI-Proxy-Service/pkg/user/modules/gpt4_webservice" + "github.com/abialemuel/AI-Proxy-Service/pkg/user/modules/repository" + "github.com/abialemuel/poly-kit/infrastructure/apm" + "go.opentelemetry.io/otel/attribute" +) + +const ( + userDefaultModel = "gpt-4o-mini" + userDefaultTemperature = 0.7 + userDefaultTop = 0.95 + userDefaultMaxTokens = 4096 + userDefaultRole = "user" + redisKeyContext = "context-%s" + redisKeySummary = "summary-%s" + redisKeyTokenUsage = "token-usage-%s" + summaryDefaultTemperature = 0.4 // Lower temperature for more focused summaries + summaryDefaultTop = 0.65 // Slightly lower for more predictable responses + summaryDefaultMaxTokens = 100 // A shorter max token count for concise summaries + systemBrief = "Summarize this conversation into key points, keeping essential details without repeating previously given information." +) + +// UserService Business Logic of user domain +type UserService struct { + repo contract.Repository + cache contract.Cache + cfg *config.MainConfig + gpt4Webservice contract.GPT4WebService +} + +// NewUserService creates a new instance of UserService +func NewUserService( + repo contract.Repository, + cache contract.Cache, + cfg *config.MainConfig, + gpt4Webservice contract.GPT4WebService, +) UserService { + return UserService{repo: repo, cache: cache, cfg: cfg, gpt4Webservice: gpt4Webservice} +} + +// UserPromtGPT handles the GPT prompt request for a user +func (u UserService) UserPromtGPT(ctx context.Context, payload core.UserPromtGPTRequest) (res core.UserPromGPTResponse, err error) { + ctx, span := apm.StartTransaction(ctx, "Service::UserPromtGPT") + // defer add metadata error to span + defer func() { + if err != nil { + apm.AddEvent(ctx, "Error", + attribute.String("error", err.Error()), + ) + } + }() + defer apm.EndTransaction(span) + + // Validate token usage + token, valid, expiredDuration, tokenExist := u.validateTokenUsage(ctx, payload.UserID) + if !valid { + return core.UserPromGPTResponse{}, fmt.Errorf("token usage limit reached: %d token, Your limit resets after %s", token, expiredDuration) + } + + var existingMsgs, existingSummary []gpt4_webservice.MessageReq + var newSummary gpt4_webservice.MessageReq + contextKey := fmt.Sprintf(redisKeyContext, payload.UserID) + summaryKey := fmt.Sprintf(redisKeySummary, payload.UserID) + newContent := core.ToWebServiceUserPromtGPTContentRequest(payload.Content) + + // Retrieve existing messages from cache + existingData, success := u.cache.Get(ctx, contextKey) + if success { + strData := existingData.(string) + err = json.Unmarshal([]byte(strData), &existingMsgs) + if err != nil { + return core.UserPromGPTResponse{}, fmt.Errorf("error in GPT4 prompt: Unmarshal: %v", err) + } + existingMsgs = append(existingMsgs, gpt4_webservice.MessageReq{ + Content: newContent, + Role: userDefaultRole, + }) + } else { + existingMsgs = []gpt4_webservice.MessageReq{{ + Content: newContent, + Role: userDefaultRole, + }} + } + + // Retrieve existing summary from cache + promptPayload := []gpt4_webservice.MessageReq{{}} + existingSummaryData, success := u.cache.Get(ctx, summaryKey) + if success { + summary := existingSummaryData.(string) + err = json.Unmarshal([]byte(summary), &existingSummary) + if err != nil { + return core.UserPromGPTResponse{}, fmt.Errorf("error in GPT4 prompt: Unmarshal: %v", err) + } + promptPayload = existingSummary + promptPayload = append(promptPayload, existingMsgs...) + } else { + promptPayload = existingMsgs + } + + // Prepare OpenAI prompt request + gpt4Payload := gpt4_webservice.GPT4PromptRequestDao{ + Message: promptPayload, + Temperature: userDefaultTemperature, + MaxTokens: userDefaultMaxTokens, + TopP: userDefaultTop, + } + gpt4Response, err := u.gpt4Webservice.Prompt(ctx, gpt4Payload) + if err != nil { + return core.UserPromGPTResponse{}, fmt.Errorf("error in GPT4 prompt: %v", err) + } + res.GPT4PromptResponse = core.ToCoreGPT4PromptResponse(gpt4Response) + res.UserID = payload.UserID + + // Append assistant's response to existing messages + assistantResp := gpt4_webservice.MessageReq{ + Content: []gpt4_webservice.Content{{ + Type: "text", + Text: &res.GPT4PromptResponse.Choices[0].Message.Content, + }}, + Role: "assistant", + } + existingMsgs = append(existingMsgs, assistantResp) + + // Summarize conversation if message count exceeds threshold + if len(existingMsgs) >= 10 { + newSummary, err = u.userSummaryGPT(ctx, existingMsgs, &token) + if err != nil { + return core.UserPromGPTResponse{}, fmt.Errorf("error in GPT4 summary: %v", err) + } + + existingSummary = append(existingSummary, newSummary) + jsonData, err := json.Marshal(existingSummary) + if err != nil { + return core.UserPromGPTResponse{}, fmt.Errorf("error in GPT4 summary: %v", err) + } + + err = u.cache.Set(ctx, summaryKey, jsonData, 0) + if err != nil { + return core.UserPromGPTResponse{}, err + } + + u.cache.Delete(ctx, contextKey) + } else { + jsonData, err := json.Marshal(existingMsgs) + if err != nil { + return core.UserPromGPTResponse{}, fmt.Errorf("error in GPT4 prompt: %v", err) + } + + err = u.cache.Set(ctx, contextKey, jsonData, 0) + if err != nil { + return core.UserPromGPTResponse{}, err + } + } + + // add metadata token usage to span + apm.AddEvent(ctx, "TokenUsage", + attribute.String("user_id", payload.UserID), + attribute.Int("token_usage", res.Usage.TotalTokens), + ) + + // Update token usage + token += res.Usage.TotalTokens + duration := time.Second * time.Duration(u.cfg.OpenAI.TokenLifetime) + if tokenExist { + duration = -1 + err = u.cache.Set(ctx, fmt.Sprintf(redisKeyTokenUsage, payload.UserID), token, duration) + } else { + err = u.cache.Set(ctx, fmt.Sprintf(redisKeyTokenUsage, payload.UserID), token, duration) + } + + if err != nil { + return core.UserPromGPTResponse{}, err + } + + // upsert to mongo + mongoContent := core.ToContentRepo(payload.Content) + mongoMessage := []repository.Message{ + { + Content: mongoContent, + Role: userDefaultRole, + }, + { + Role: assistantResp.Role, + Content: []repository.Content{{ + Type: "text", + Text: assistantResp.Content[0].Text, + }}, + }, + } + + // newSummary to repo summary if newSummary not nil or empty + mongoSummary := repository.Summary{} + if len(newSummary.Content) > 0 { + mongoSummary = repository.Summary{ + Content: []repository.Content{{ + Type: "text", + Text: newSummary.Content[0].Text, + }}, + Role: newSummary.Role, + } + } + go u.upsertConversation(context.Background(), payload.UserID, mongoMessage, mongoSummary) + + return res, nil +} + +// ServicePrompt (another backend service) +func (u UserService) ServicePrompt(ctx context.Context, payload core.ServicePromptRequest) (res core.ServicePromGPTResponse, err error) { + ctx, span := apm.StartTransaction(ctx, "Service::ServicePrompt") + // defer add metadata error to span + defer func() { + if err != nil { + apm.AddEvent(ctx, "Error", + attribute.String("error", err.Error()), + ) + } + }() + defer apm.EndTransaction(span) + + // Prepare OpenAI prompt request + gpt4Payload := gpt4_webservice.GPT4PromptRequestDao{ + Message: core.ToWebServicePromtGPTMsgRequest(payload.Messages), + Temperature: payload.Temperature, + MaxTokens: payload.MaxTokens, + TopP: payload.TopP, + } + gpt4Response, err := u.gpt4Webservice.Prompt(ctx, gpt4Payload) + if err != nil { + return core.ServicePromGPTResponse{}, fmt.Errorf("error in GPT4 prompt: %v", err) + } + res.GPT4PromptResponse = core.ToCoreGPT4PromptResponse(gpt4Response) + res.UserID = payload.ServiceName + + // add metadata token usage to span + apm.AddEvent(ctx, "TokenUsage", + attribute.String("user_id", payload.ServiceName), + attribute.Int("token_usage", res.Usage.TotalTokens), + ) + + return res, nil +} + +// get user token information from cache +func (u UserService) GetUserTokenUsage(ctx context.Context, userID string) core.UserTokenUsage { + // get token usage from cache + var tokenCount int + tokenData, success := u.cache.Get(ctx, fmt.Sprintf(redisKeyTokenUsage, userID)) + if success { + tokenCount, _ = strconv.Atoi(tokenData.(string)) + } else { + tokenCount = 0 + } + + warn := false + if tokenCount > u.cfg.OpenAI.TokenLimit/2 { + warn = true + } + + return core.UserTokenUsage{ + TokenLimit: u.cfg.OpenAI.TokenLimit, + TokenUsage: tokenCount, + Warning: warn, + } +} + +// userSummaryGPT generates a summary of the conversation +func (u UserService) userSummaryGPT(ctx context.Context, payload []gpt4_webservice.MessageReq, token *int) (res gpt4_webservice.MessageReq, err error) { + text := fmt.Sprintf("%s conversation: %s", systemBrief, formatConversation(payload)) + msg := []gpt4_webservice.MessageReq{{ + Content: []gpt4_webservice.Content{{ + Type: "text", + Text: &text, + }}, + Role: "system", + }} + gpt4Payload := gpt4_webservice.GPT4PromptRequestDao{ + Message: msg, + Temperature: summaryDefaultTemperature, + MaxTokens: summaryDefaultMaxTokens, + TopP: summaryDefaultTop, + } + gpt4Response, err := u.gpt4Webservice.Prompt(ctx, gpt4Payload) + if err != nil { + return gpt4_webservice.MessageReq{}, err + } + + *token += gpt4Response.Usage.TotalTokens + + return gpt4_webservice.MessageReq{ + Content: []gpt4_webservice.Content{{ + Type: "text", + Text: &gpt4Response.Choices[0].Message.Content, + }}, + Role: "system", + }, nil +} + +// formatConversation formats the conversation for summarization +func formatConversation(conversation []gpt4_webservice.MessageReq) string { + var formattedText string + + for _, entry := range conversation { + role := entry.Role + text := entry.Content[0].Text + formattedText += role + ": " + *text + "\n" + } + + return formattedText +} + +// validateTokenUsage checks if the user has exceeded their token usage limit +func (u UserService) validateTokenUsage(ctx context.Context, userID string) (token int, valid bool, expired *time.Duration, exist bool) { + exist = false + redisKey := fmt.Sprintf(redisKeyTokenUsage, userID) + var tokenCount int + tokenData, success := u.cache.Get(ctx, redisKey) + if success { + exist = true + tokenCount, _ = strconv.Atoi(tokenData.(string)) + } else { + tokenCount = 0 + return tokenCount, true, nil, exist + } + + if tokenCount > u.cfg.OpenAI.TokenLimit { + expiredData, _ := u.cache.TTL(ctx, redisKey) + u.cache.Delete(ctx, fmt.Sprintf(redisKeyContext, userID)) + return tokenCount, false, &expiredData, exist + } + return tokenCount, true, nil, exist +} + +func (u UserService) upsertConversation(ctx context.Context, userID string, messages []repository.Message, summary repository.Summary) error { + err := u.repo.UpsertConversation(ctx, userID, messages, &summary) + if err != nil { + fmt.Println("Error upserting conversation:", err) + return err + } + return nil +} + +// UserClearContext +func (u UserService) UserClearContext(ctx context.Context, userID string) error { + contextKey := fmt.Sprintf(redisKeyContext, userID) + summaryKey := fmt.Sprintf(redisKeySummary, userID) + + u.cache.Delete(ctx, contextKey) + u.cache.Delete(ctx, summaryKey) + + return nil +} diff --git a/pkg/user/modules/gpt4_webservice/gpt4_model.go b/pkg/user/modules/gpt4_webservice/gpt4_model.go new file mode 100644 index 0000000..4e04b42 --- /dev/null +++ b/pkg/user/modules/gpt4_webservice/gpt4_model.go @@ -0,0 +1,51 @@ +package gpt4_webservice + +// GPT4PromptRequestDao is the request to GPT4 prompt +type GPT4PromptRequestDao struct { + Model string `json:"model"` + Message []MessageReq `json:"messages"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` + TopP float64 `json:"top_p"` +} + +// GPT4PromptResponse is the response from GPT4 prompt +type GPT4PromptResponseDao struct { + ID string `json:"id"` + Object string `json:"object"` + Created float64 `json:"created"` + Model string `json:"model"` + Choices []Choices `json:"choices"` + Usage Usage `json:"usage"` +} + +type MessageReq struct { + Content []Content `json:"content"` + Role string `json:"role"` +} + +type Content struct { + Type string `json:"type"` + Text *string `json:"text,omitempty"` + ImageURL *ImageURL `json:"image_url,omitempty"` +} + +type ImageURL struct { + URL string `json:"url"` +} + +type Choices struct { + Index int `json:"index"` + Message Message `json:"message"` +} + +type Message struct { + Content string `json:"content"` + Role string `json:"role"` +} + +type Usage struct { + CompletionTokens int `json:"completion_tokens"` + PromptTokens int `json:"prompt_tokens"` + TotalTokens int `json:"total_tokens"` +} diff --git a/pkg/user/modules/gpt4_webservice/gpt4_webservice.go b/pkg/user/modules/gpt4_webservice/gpt4_webservice.go new file mode 100644 index 0000000..6784b83 --- /dev/null +++ b/pkg/user/modules/gpt4_webservice/gpt4_webservice.go @@ -0,0 +1,70 @@ +package gpt4_webservice + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +type GPT4WebService struct { + client *http.Client + url string + apiKey string +} + +func NewGPT4WebService(url, apiKey string) GPT4WebService { + client := &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 10, + MaxIdleConnsPerHost: 10, + }, + } + + return GPT4WebService{ + client: client, + url: url, + apiKey: apiKey, + } +} + +func (ws GPT4WebService) Prompt(ctx context.Context, payload GPT4PromptRequestDao) (result GPT4PromptResponseDao, err error) { + jsonBody, _ := json.Marshal(payload) + reqBody := bytes.NewBuffer(jsonBody) + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, ws.url, reqBody) + if err != nil { + return result, err + } + + request.Header.Add("Content-Type", "application/json") + request.Header.Add("Api-Key", ws.apiKey) + + response, err := ws.client.Do(request) + if err != nil { + return result, err + } + defer response.Body.Close() + + // validate response status code + if response.StatusCode != http.StatusOK { + // print response.Body + buf := new(bytes.Buffer) + buf.ReadFrom(response.Body) + // print response.Body + fmt.Println(buf.String()) + // if err this {"error":{"code":"429","message": "Requests to the ChatCompletions_Create Operation under Azure OpenAI API version 2024-02-15-preview have exceeded token rate limit of your current OpenAI S0 pricing tier. Please retry after 8 seconds. Please go here: https://aka.ms/oai/quotaincrease if you would like to further increase the default rate limit."}}, wrap message and give information limitation from Azure OpenAI + if response.StatusCode == 429 { + return result, fmt.Errorf("Requests to the ChatCompletions_Create Operation under Azure OpenAI API have exceeded token rate limit of your current OpenAI S0 pricing tier. Please retry later.") + } + return result, fmt.Errorf("unexpected status code: %d", response.StatusCode) + } + + err = json.NewDecoder(response.Body).Decode(&result) + if err != nil { + return result, err + } + + return result, nil +} diff --git a/pkg/user/modules/repository/conversation.go b/pkg/user/modules/repository/conversation.go new file mode 100644 index 0000000..c8b6a8f --- /dev/null +++ b/pkg/user/modules/repository/conversation.go @@ -0,0 +1,90 @@ +package repository + +import ( + "context" + "time" + + "github.com/abialemuel/poly-kit/infrastructure/apm" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// MongoDBRepository The implementation of user.Repository object +type MongoDBRepository struct { + db *mongo.Database +} + +// NewPgDBRepository Generate pg user repository +func NewMongoDBRepository(db *mongo.Database) *MongoDBRepository { + repo := MongoDBRepository{db: db} + + return &repo +} + +// UpsertConversation inserts new messages into the messages collection and updates the conversation +func (r *MongoDBRepository) UpsertConversation(ctx context.Context, userID string, messages []Message, summary *Summary) error { + ctx, span := apm.StartTransaction(ctx, "Repository::UpsertConversation") + defer apm.EndTransaction(span) + + conversationsCollection := r.db.Collection("conversations") + messagesCollection := r.db.Collection("messages") + + // Create filter to find the last conversation by UserID + filter := bson.M{"user_id": userID} + + // Find options to sort by updated_at in descending order and limit to 1 + findOptions := options.FindOne().SetSort(bson.D{{"updated_at", -1}}) + + var conversation Conversation + err := conversationsCollection.FindOne(ctx, filter, findOptions).Decode(&conversation) + if err != nil { + if err == mongo.ErrNoDocuments { + // No existing conversation found, create a new one + conversation = Conversation{ + ID: primitive.NewObjectID(), + UserID: userID, + Summaries: []Summary{}, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _, err = conversationsCollection.InsertOne(ctx, conversation) + if err != nil { + return err + } + } else { + return err + } + } + + // Prepare messages for bulk insert + var messageDocs []interface{} + for _, message := range messages { + message.ConversationID = conversation.ID + message.Timestamp = time.Now() + messageDocs = append(messageDocs, message) + } + + // Insert new messages into the messages collection + if len(messageDocs) > 0 { + _, err = messagesCollection.InsertMany(ctx, messageDocs) + if err != nil { + return err + } + } + + // Update the conversation's updated_at timestamp + update := bson.M{ + "$set": bson.M{"updated_at": time.Now()}, + } + + if len(summary.Content) > 0 { + // Add the new summary to the conversation + update["$push"] = bson.M{"summaries": summary} + } + + // Perform the update operation on the conversation + _, err = conversationsCollection.UpdateOne(ctx, bson.M{"_id": conversation.ID}, update) + return err +} diff --git a/pkg/user/modules/repository/conversation_model.go b/pkg/user/modules/repository/conversation_model.go new file mode 100644 index 0000000..b4c8a27 --- /dev/null +++ b/pkg/user/modules/repository/conversation_model.go @@ -0,0 +1,38 @@ +package repository + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// Content represents the actual content of a message. +type Content struct { + Type string `bson:"type"` // Type of content, either text or image + Text *string `bson:"text"` // Text content of the message + ImageURL string `bson:"image_url"` // URL of the image, if applicable +} + +// Message represents a single message in a conversation. +type Message struct { + ID primitive.ObjectID `bson:"_id,omitempty"` // Unique identifier for the message + ConversationID primitive.ObjectID `bson:"conversation_id"` // Foreign key to link with a conversation + Role string `bson:"role"` // Either user, system, or assistant + Content []Content `bson:"content"` // The actual message content + Timestamp time.Time `bson:"timestamp"` // Time when the message was sent +} + +// Summary represents a summarized form of a message in a conversation (without ID and timestamps). +type Summary struct { + Role string `bson:"role"` // Role in the conversation (e.g., user, system, assistant) + Content []Content `bson:"content"` // The summarized content of the conversation +} + +// Conversation represents a conversation tied to a user session. +type Conversation struct { + ID primitive.ObjectID `bson:"_id,omitempty"` // Unique identifier for the conversation + UserID string `bson:"user_id"` // ID of the user associated with the conversation + Summaries []Summary `bson:"summaries"` // Array of summaries for the conversation + CreatedAt time.Time `bson:"created_at"` // Timestamp when the conversation was created + UpdatedAt time.Time `bson:"updated_at"` // Timestamp when the conversation was last updated +}