diff --git a/.github/workflows/services.yml b/.github/workflows/services.yml index da03a2badfe..07e7803171e 100644 --- a/.github/workflows/services.yml +++ b/.github/workflows/services.yml @@ -87,7 +87,7 @@ jobs: strategy: matrix: ## TODO: add more modules - module: [database, pay, account, minio, launchpad, exceptionmonitor] + module: [database, pay, account, minio, launchpad, exceptionmonitor, aiproxy] steps: - name: Checkout uses: actions/checkout@v3 @@ -182,7 +182,7 @@ jobs: strategy: matrix: ## TODO: add more modules - module: [database, pay, account, minio, launchpad, exceptionmonitor] + module: [database, pay, account, minio, launchpad, exceptionmonitor, aiproxy] steps: - name: Checkout uses: actions/checkout@v3 diff --git a/README.md b/README.md index cb2291c09b7..5b35844dfe1 100644 --- a/README.md +++ b/README.md @@ -54,30 +54,49 @@ Sealos['siːləs] is a cloud operating system distribution based on the Kubernet <img width="916" alt="image" src="https://github.com/labring/sealos/assets/8912557/9e8c1d76-718e-4910-a9ab-94f220a61a9c"> +## Create any development environment in sealos with one click -## 🚀 Deploy your app on Sealos +1. [Login in](https://cloud.sealos.run) , open sealos Devbox. + + <img width="656" alt="image" src="https://github.com/user-attachments/assets/79692f6c-315f-4363-9426-b41f541f9a55"> + +2. Create a development env, any language, any framework. + + <img width="1147" alt="image" src="https://github.com/user-attachments/assets/6075bbb0-4765-4786-9154-3adaa139900c"> + +3. Use vscode or cursor access to the Env. + + <img width="864" alt="image" src="https://github.com/user-attachments/assets/e5f9dcdc-5149-4e43-aa13-6c17507fbe9f"> + + <img width="1024" alt="image" src="https://github.com/user-attachments/assets/9a985280-6ff2-48dc-83b9-9abd8f93af17"> -[Quick Start](https://cloud.sealos.io) +## Create any database on sealos -* [Easily Deploy Nginx in 30 Seconds on Sealos](https://sealos.io/docs/quick-start/use-app-launchpad) -* [Start a mysql/pgsql/mongo highly available database in 30 seconds on Sealos](https://sealos.io/docs/quick-start/use-database) -* [Running WordPress on Sealos](https://sealos.io/docs/examples/blog-platform/install-wordpress) -* [Running the Uptime Kuma dial test system on Sealos](https://docs.sealos.io/docs/examples/dial-testing-system/install-uptime-kuma) -* [Running a low-code platform on Sealos](https://docs.sealos.io/docs/category/low-code-platform) +1. [Login in](https://cloud.sealos.run) , open sealos database. + + <img width="567" alt="image" src="https://github.com/user-attachments/assets/74ca3ce9-6ef8-4396-b9c2-7c1940bb7e0c"> - +2. Create Database. -🔍 Some Screen Shots of Sealos: + <img width="874" alt="image" src="https://github.com/user-attachments/assets/4cc88a54-70e6-458f-9766-4578774e7f81"> -<div align="center"> +3. Access your database. -| Templates | App Launchpad | -| :---: | :---: | -|  |  | -| Database | Serverless | -|  |  | + <img width="1430" alt="image" src="https://github.com/user-attachments/assets/bcf54218-f4f4-4c89-a107-0bbde6f92d67"> -</div> +## Deploy any docker image on sealos + +1. [Login in](https://cloud.sealos.run) , open sealos App launchpad. + + <img width="567" alt="image" src="https://github.com/user-attachments/assets/5f6481c0-05c6-4892-a096-94b613cee73c"> + +2. Deploy a docker image, ingress, deployment... + + <img width="971" alt="image" src="https://github.com/user-attachments/assets/a291571f-d9fe-42e5-812e-3d8f274a97ca"> + +3. Access your service. + + <img width="1016" alt="image" src="https://github.com/user-attachments/assets/a54884cf-a1e8-4178-88af-655234ec7eef"> ## Install diff --git a/controllers/account/api/v1/account_types.go b/controllers/account/api/v1/account_types.go index 9d3911168ec..7d3fc498fe6 100644 --- a/controllers/account/api/v1/account_types.go +++ b/controllers/account/api/v1/account_types.go @@ -34,8 +34,9 @@ type ( const ( // Consumption 消费 Consumption common.Type = iota - // Recharge 充值 - Recharge + //Subconsumption 子消费 + SubConsumption + TransferIn TransferOut ActivityGiving diff --git a/controllers/app/api/v1/template_types.go b/controllers/app/api/v1/template_types.go index a75f873a01f..55d038136bd 100644 --- a/controllers/app/api/v1/template_types.go +++ b/controllers/app/api/v1/template_types.go @@ -20,6 +20,15 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +type I18nData struct { + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + GitRepo string `json:"gitRepo,omitempty"` + Description string `json:"description,omitempty"` + Readme string `json:"readme,omitempty"` + Icon string `json:"icon,omitempty"` +} + type TemplateType string const ( @@ -57,18 +66,20 @@ type InputData struct { type Inputs map[string]InputData type TemplateData struct { - Title string `json:"title"` - URL string `json:"url,omitempty"` - GitRepo string `json:"gitRepo,omitempty"` - Author string `json:"author,omitempty"` - Description string `json:"description,omitempty"` - Readme string `json:"readme,omitempty"` - Icon string `json:"icon,omitempty"` - TemplateType TemplateType `json:"templateType"` - Draft bool `json:"draft,omitempty"` - Categories []string `json:"categories,omitempty"` - Defaults Defaults `json:"defaults,omitempty"` - Inputs Inputs `json:"inputs,omitempty"` + Title string `json:"title"` + URL string `json:"url,omitempty"` + GitRepo string `json:"gitRepo,omitempty"` + Author string `json:"author,omitempty"` + Description string `json:"description,omitempty"` + Readme string `json:"readme,omitempty"` + Icon string `json:"icon,omitempty"` + TemplateType TemplateType `json:"templateType"` + Locale string `json:"locale,omitempty"` + I18n map[string]I18nData `json:"i18n,omitempty"` + Draft bool `json:"draft,omitempty"` + Categories []string `json:"categories,omitempty"` + Defaults Defaults `json:"defaults,omitempty"` + Inputs Inputs `json:"inputs,omitempty"` } // TemplateSpec defines the desired state of Template diff --git a/controllers/app/api/v1/zz_generated.deepcopy.go b/controllers/app/api/v1/zz_generated.deepcopy.go index 25e21f6582a..57c6923da35 100644 --- a/controllers/app/api/v1/zz_generated.deepcopy.go +++ b/controllers/app/api/v1/zz_generated.deepcopy.go @@ -197,6 +197,21 @@ func (in Defaults) DeepCopy() Defaults { return *out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *I18nData) DeepCopyInto(out *I18nData) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new I18nData. +func (in *I18nData) DeepCopy() *I18nData { + if in == nil { + return nil + } + out := new(I18nData) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *InputData) DeepCopyInto(out *InputData) { *out = *in @@ -368,6 +383,13 @@ func (in *Template) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TemplateData) DeepCopyInto(out *TemplateData) { *out = *in + if in.I18n != nil { + in, out := &in.I18n, &out.I18n + *out = make(map[string]I18nData, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } if in.Categories != nil { in, out := &in.Categories, &out.Categories *out = make([]string, len(*in)) diff --git a/controllers/app/config/crd/bases/app.sealos.io_instances.yaml b/controllers/app/config/crd/bases/app.sealos.io_instances.yaml index 39995007720..f680b25f3ba 100644 --- a/controllers/app/config/crd/bases/app.sealos.io_instances.yaml +++ b/controllers/app/config/crd/bases/app.sealos.io_instances.yaml @@ -1,4 +1,4 @@ -# Copyright © 2023 sealos. +# Copyright © 2024 sealos. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -72,6 +72,23 @@ spec: type: boolean gitRepo: type: string + i18n: + additionalProperties: + properties: + description: + type: string + gitRepo: + type: string + icon: + type: string + readme: + type: string + title: + type: string + url: + type: string + type: object + type: object icon: type: string inputs: @@ -89,6 +106,8 @@ spec: - type type: object type: object + locale: + type: string readme: type: string templateType: diff --git a/controllers/app/config/crd/bases/app.sealos.io_templates.yaml b/controllers/app/config/crd/bases/app.sealos.io_templates.yaml index 9b97c702883..5db66f5fc56 100644 --- a/controllers/app/config/crd/bases/app.sealos.io_templates.yaml +++ b/controllers/app/config/crd/bases/app.sealos.io_templates.yaml @@ -1,4 +1,4 @@ -# Copyright © 2023 sealos. +# Copyright © 2024 sealos. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -72,6 +72,23 @@ spec: type: boolean gitRepo: type: string + i18n: + additionalProperties: + properties: + description: + type: string + gitRepo: + type: string + icon: + type: string + readme: + type: string + title: + type: string + url: + type: string + type: object + type: object icon: type: string inputs: @@ -89,6 +106,8 @@ spec: - type type: object type: object + locale: + type: string readme: type: string templateType: diff --git a/controllers/devbox/internal/controller/helper/devbox.go b/controllers/devbox/internal/controller/helper/devbox.go index c9791ded077..bd00faee4c8 100644 --- a/controllers/devbox/internal/controller/helper/devbox.go +++ b/controllers/devbox/internal/controller/helper/devbox.go @@ -92,7 +92,10 @@ func GenerateDevboxPhase(devbox *devboxv1alpha1.Devbox, podList corev1.PodList) case corev1.PodPending: return devboxv1alpha1.DevboxPhasePending case corev1.PodRunning: - return devboxv1alpha1.DevboxPhaseRunning + if podList.Items[0].Status.ContainerStatuses[0].Ready && podList.Items[0].Status.ContainerStatuses[0].ContainerID != "" { + return devboxv1alpha1.DevboxPhaseRunning + } + return devboxv1alpha1.DevboxPhasePending } case devboxv1alpha1.DevboxStateStopped: if len(podList.Items) == 0 { @@ -144,7 +147,7 @@ func GenerateSSHKeyPair() ([]byte, []byte, error) { func UpdatePredicatedCommitStatus(devbox *devboxv1alpha1.Devbox, pod *corev1.Pod) { for i, c := range devbox.Status.CommitHistory { if c.Pod == pod.Name { - devbox.Status.CommitHistory[i].PredicatedStatus = PodPhaseToCommitStatus(pod.Status.Phase) + devbox.Status.CommitHistory[i].PredicatedStatus = PredicateCommitStatus(pod) break } } @@ -178,7 +181,7 @@ func UpdateCommitHistory(devbox *devboxv1alpha1.Devbox, pod *corev1.Pod, updateS if !found { newCommitHistory := &devboxv1alpha1.CommitHistory{ Pod: pod.Name, - PredicatedStatus: PodPhaseToCommitStatus(pod.Status.Phase), + PredicatedStatus: PredicateCommitStatus(pod), } if len(pod.Status.ContainerStatuses) > 0 { newCommitHistory.ContainerID = pod.Status.ContainerStatuses[0].ContainerID @@ -191,14 +194,21 @@ func UpdateCommitHistory(devbox *devboxv1alpha1.Devbox, pod *corev1.Pod, updateS } } -func PodPhaseToCommitStatus(podPhase corev1.PodPhase) devboxv1alpha1.CommitStatus { - switch podPhase { - case corev1.PodPending: +func podContainerID(pod *corev1.Pod) string { + if len(pod.Status.ContainerStatuses) > 0 { + return pod.Status.ContainerStatuses[0].ContainerID + } + return "" +} + +// PredicateCommitStatus returns the commit status of the pod +// if the pod container id is empty, it means the pod is pending or has't started, we can assume the image has not been committed +// otherwise, it means the pod has been started, we can assume the image has been committed +func PredicateCommitStatus(pod *corev1.Pod) devboxv1alpha1.CommitStatus { + if podContainerID(pod) == "" { return devboxv1alpha1.CommitStatusPending - case corev1.PodRunning, corev1.PodFailed, corev1.PodSucceeded: - return devboxv1alpha1.CommitStatusSuccess } - return devboxv1alpha1.CommitStatusUnknown + return devboxv1alpha1.CommitStatusSuccess } func PodMatchExpectations(expectPod *corev1.Pod, pod *corev1.Pod) bool { diff --git a/controllers/objectstorage/config/rbac/role.yaml b/controllers/objectstorage/config/rbac/role.yaml index c0deac9fd3e..cc8805d1a4d 100644 --- a/controllers/objectstorage/config/rbac/role.yaml +++ b/controllers/objectstorage/config/rbac/role.yaml @@ -19,80 +19,92 @@ metadata: creationTimestamp: null name: manager-role rules: - - apiGroups: - - "" - resources: - - secrets - verbs: - - create - - delete - - get - - list - - patch - - update - - watch - - apiGroups: - - "" - resources: - - resourcequotas - verbs: - - create - - delete - - get - - list - - patch - - update - - watch - - apiGroups: - - objectstorage.sealos.io - resources: - - objectstoragebuckets - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch - - apiGroups: - - objectstorage.sealos.io - resources: - - objectstoragebuckets/finalizers - verbs: - - update - - apiGroups: - - objectstorage.sealos.io - resources: - - objectstoragebuckets/status - verbs: - - get - - patch - - update - - apiGroups: - - objectstorage.sealos.io - resources: - - objectstorageusers - verbs: - - create - - delete - - get - - list - - patch - - update - - watch - - apiGroups: - - objectstorage.sealos.io - resources: - - objectstorageusers/finalizers - verbs: - - update - - apiGroups: - - objectstorage.sealos.io - resources: - - objectstorageusers/status - verbs: - - get - - patch - - update +- apiGroups: + - "" + resources: + - secrets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - resourcequotas + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - resourcequotas/status + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - objectstorage.sealos.io + resources: + - objectstoragebuckets + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch +- apiGroups: + - objectstorage.sealos.io + resources: + - objectstoragebuckets/finalizers + verbs: + - update +- apiGroups: + - objectstorage.sealos.io + resources: + - objectstoragebuckets/status + verbs: + - get + - patch + - update +- apiGroups: + - objectstorage.sealos.io + resources: + - objectstorageusers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - objectstorage.sealos.io + resources: + - objectstorageusers/finalizers + verbs: + - update +- apiGroups: + - objectstorage.sealos.io + resources: + - objectstorageusers/status + verbs: + - get + - patch + - update diff --git a/controllers/objectstorage/controllers/objectstorageuser_controller.go b/controllers/objectstorage/controllers/objectstorageuser_controller.go index 3f37abe11c3..89012bc9aae 100644 --- a/controllers/objectstorage/controllers/objectstorageuser_controller.go +++ b/controllers/objectstorage/controllers/objectstorageuser_controller.go @@ -87,6 +87,7 @@ const ( //+kubebuilder:rbac:groups=objectstorage.sealos.io,resources=objectstorageusers/finalizers,verbs=update //+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=core,resources=resourcequotas,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=core,resources=resourcequotas/status,verbs=get;list;watch;create;update;patch;delete func (r *ObjectStorageUserReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { username := req.Name diff --git a/controllers/pkg/database/cockroach/accountv2.go b/controllers/pkg/database/cockroach/accountv2.go index 121046d5328..f331269e41d 100644 --- a/controllers/pkg/database/cockroach/accountv2.go +++ b/controllers/pkg/database/cockroach/accountv2.go @@ -222,40 +222,56 @@ func (c *Cockroach) getFirstRechargePayments(ops *types.UserQueryOpts) ([]types. } func (c *Cockroach) ProcessPendingTaskRewards() error { - userTasks, err := c.getPendingRewardUserTask() - if err != nil { - return fmt.Errorf("failed to get pending reward user task: %w", err) - } - tasks, err := c.getTask() - if err != nil { - return fmt.Errorf("failed to get tasks: %w", err) - } - for i := range userTasks { - err = c.DB.Transaction(func(tx *gorm.DB) error { - task := tasks[userTasks[i].TaskID] + for { + var userTask types.UserTask + err := c.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{ + Strength: "UPDATE", + Options: "SKIP LOCKED", + }).Where(&types.UserTask{ + Status: types.TaskStatusCompleted, + RewardStatus: types.TaskStatusNotCompleted, + }).First(&userTask).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + return fmt.Errorf("failed to get pending reward user task: %w", err) + } + tasks, err := c.getTask() + if err != nil { + return fmt.Errorf("failed to get tasks: %w", err) + } + + task := tasks[userTask.TaskID] if task.Reward == 0 { - fmt.Printf("usertask %v reward is 0, skip\n", userTasks[i]) + fmt.Printf("usertask %v reward is 0, skip\n", userTask) return nil } - if err = c.updateBalanceRaw(tx, &types.UserQueryOpts{UID: userTasks[i].UserUID}, task.Reward, false, true, true); err != nil { + if err = c.updateBalanceRaw(tx, &types.UserQueryOpts{UID: userTask.UserUID}, task.Reward, false, true, true); err != nil { return fmt.Errorf("failed to update balance: %w", err) } msg := fmt.Sprintf("task %s reward", task.Title) transaction := types.AccountTransaction{ Balance: task.Reward, Type: string(task.TaskType) + "_Reward", - UserUID: userTasks[i].UserUID, + UserUID: userTask.UserUID, ID: uuid.New(), Message: &msg, - BillingID: userTasks[i].ID, + BillingID: userTask.ID, } - if err = tx.Save(&transaction).Error; err != nil { + if err = tx.Create(&transaction).Error; err != nil { return fmt.Errorf("failed to save transaction: %w", err) } - return c.completeRewardUserTask(tx, &userTasks[i]) + if err = tx.Model(&userTask).Update("rewardStatus", types.TaskStatusCompleted).Error; err != nil { + return fmt.Errorf("failed to update user task status: %w", err) + } + return nil }) + if errors.Is(err, gorm.ErrRecordNotFound) { + break + } if err != nil { - return fmt.Errorf("failed to process reward pending user task %v rewards: %w", userTasks[i], err) + return err } } return nil @@ -276,17 +292,6 @@ func (c *Cockroach) getTask() (map[uuid.UUID]types.Task, error) { return c.tasks, nil } -func (c *Cockroach) getPendingRewardUserTask() ([]types.UserTask, error) { - var userTasks []types.UserTask - return userTasks, c.DB.Where(&types.UserTask{Status: types.TaskStatusCompleted, RewardStatus: types.TaskStatusNotCompleted}). - Find(&userTasks).Error -} - -func (c *Cockroach) completeRewardUserTask(tx *gorm.DB, userTask *types.UserTask) error { - userTask.RewardStatus = types.TaskStatusCompleted - return tx.Model(userTask).Update("rewardStatus", types.TaskStatusCompleted).Error -} - func (c *Cockroach) GetUserCr(ops *types.UserQueryOpts) (*types.RegionUserCr, error) { if ops.UID == uuid.Nil && ops.Owner == "" { if ops.ID == "" { @@ -311,6 +316,46 @@ func (c *Cockroach) GetUserCr(ops *types.UserQueryOpts) (*types.RegionUserCr, er return &userCr, nil } +func (c *Cockroach) GetAccountWithWorkspace(workspace string) (*types.Account, error) { + if workspace == "" { + return nil, fmt.Errorf("empty workspace") + } + var userUIDString string + err := c.Localdb.Table("Workspace"). + Select(`"UserCr"."userUid"`). + Joins(`JOIN "UserWorkspace" ON "Workspace".uid = "UserWorkspace"."workspaceUid"`). + Joins(`JOIN "UserCr" ON "UserWorkspace"."userCrUid" = "UserCr".uid`). + Where(`"Workspace".id = ?`, workspace). + Where(`"UserWorkspace".role = ?`, "OWNER"). + Limit(1). + Scan(&userUIDString).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("not found user uid with workspace %s", workspace) + } + return nil, fmt.Errorf("failed to get user uid with workspace %s: %v", workspace, err) + } + + userUID, err := uuid.Parse(userUIDString) + if err != nil { + return nil, fmt.Errorf("failed to parse user uid %s: %v", userUIDString, err) + } + if userUID == uuid.Nil { + return nil, fmt.Errorf("empty user uid") + } + + var account types.Account + err = c.DB.Where(&types.Account{UserUID: userUID}).First(&account).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("not found account with user uid %s", userUID) + } + return nil, fmt.Errorf("failed to get account with user uid %s: %v", userUID, err) + } + return &account, nil +} + func (c *Cockroach) GetUserUID(ops *types.UserQueryOpts) (uuid.UUID, error) { if ops.UID != uuid.Nil { return ops.UID, nil @@ -492,37 +537,33 @@ func (c *Cockroach) updateBalanceRaw(tx *gorm.DB, ops *types.UserQueryOpts, amou } ops.UID = user.UserUID } - var account = &types.Account{} - if err := tx.Where(&types.Account{UserUID: ops.UID}).First(account).Error; err != nil { - // if not found, create a new account and retry - if !errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("failed to get account: %w", err) - } - if account, err = c.NewAccount(ops); err != nil { - return fmt.Errorf("failed to create account: %v", err) - } + return c.updateWithAccount(ops.UID, isDeduction, add, isActive, amount, tx) +} + +func (c *Cockroach) updateWithAccount(userUID uuid.UUID, isDeduction, add, isActive bool, amount int64, db *gorm.DB) error { + exprs := map[string]interface{}{} + control := "-" + if add { + control = "+" } - if err := c.updateWithAccount(isDeduction, add, account, amount); err != nil { - return err + if isDeduction { + exprs["deduction_balance"] = gorm.Expr("deduction_balance "+control+" ?", amount) + } else { + exprs["balance"] = gorm.Expr("balance "+control+" ?", amount) } if isActive { - account.ActivityBonus = account.ActivityBonus + amount - } - if err := tx.Save(account).Error; err != nil { - return fmt.Errorf("failed to update account balance: %w", err) + exprs[`"activityBonus"`] = gorm.Expr(`"activityBonus" + ?`, amount) } - return nil + result := db.Model(&types.Account{}).Where(`"userUid" = ?`, userUID).Updates(exprs) + return HandleUpdateResult(result, types.Account{}.TableName()) } -func (c *Cockroach) updateWithAccount(isDeduction bool, add bool, account *types.Account, amount int64) error { - balancePtr := &account.Balance - if isDeduction { - balancePtr = &account.DeductionBalance +func HandleUpdateResult(result *gorm.DB, entityName string) error { + if result.Error != nil { + return fmt.Errorf("failed to update %s: %w", entityName, result.Error) } - if add { - *balancePtr += amount - } else { - *balancePtr -= amount + if result.RowsAffected == 0 { + return fmt.Errorf("no %s updated", entityName) } return nil } @@ -557,6 +598,10 @@ func (c *Cockroach) AddDeductionBalance(ops *types.UserQueryOpts, amount int64) }) } +func (c *Cockroach) AddDeductionBalanceWithDB(ops *types.UserQueryOpts, amount int64, tx *gorm.DB) error { + return c.updateBalance(tx, ops, amount, true, true) +} + func (c *Cockroach) AddDeductionBalanceWithFunc(ops *types.UserQueryOpts, amount int64, preDo, postDo func() error) error { return c.DB.Transaction(func(tx *gorm.DB) error { if err := preDo(); err != nil { @@ -996,14 +1041,24 @@ func NewCockRoach(globalURI, localURI string) (*Cockroach, error) { IgnoreRecordNotFoundError: true, Colorful: true, }) - db, err := gorm.Open(postgres.Open(globalURI), &gorm.Config{ - Logger: dbLogger, + db, err := gorm.Open(postgres.New(postgres.Config{ + DSN: globalURI, + PreferSimpleProtocol: true, + }), &gorm.Config{ + Logger: dbLogger, + PrepareStmt: true, + TranslateError: true, }) if err != nil { return nil, fmt.Errorf("failed to open global url %s : %v", globalURI, err) } - localdb, err := gorm.Open(postgres.Open(localURI), &gorm.Config{ - Logger: dbLogger, + localdb, err := gorm.Open(postgres.New(postgres.Config{ + DSN: localURI, + PreferSimpleProtocol: true, + }), &gorm.Config{ + Logger: dbLogger, + PrepareStmt: true, + TranslateError: true, }) if err != nil { return nil, fmt.Errorf("failed to open local url %s : %v", localURI, err) diff --git a/controllers/pkg/database/cockroach/accountv2_test.go b/controllers/pkg/database/cockroach/accountv2_test.go index 15abd30817f..751b584a01e 100644 --- a/controllers/pkg/database/cockroach/accountv2_test.go +++ b/controllers/pkg/database/cockroach/accountv2_test.go @@ -47,3 +47,20 @@ func TestCockroach_GetUserOauthProvider(t *testing.T) { } t.Logf("provider: %+v", provider) } + +func TestCockroach_GetAccountWithWorkspace(t *testing.T) { + ck, err := NewCockRoach(os.Getenv("GLOBAL_COCKROACH_URI"), os.Getenv("LOCAL_COCKROACH_URI")) + if err != nil { + t.Errorf("NewCockRoach() error = %v", err) + return + } + defer ck.Close() + + account, err := ck.GetAccountWithWorkspace("ns-1c6gn6e0") + + if err != nil { + t.Errorf("GetAccountWithWorkspace() error = %v", err) + return + } + t.Logf("account: %+v", account) +} diff --git a/controllers/pkg/database/interface.go b/controllers/pkg/database/interface.go index 53bd72d7284..36e474a41ea 100644 --- a/controllers/pkg/database/interface.go +++ b/controllers/pkg/database/interface.go @@ -18,6 +18,8 @@ import ( "context" "time" + "gorm.io/gorm" + "go.mongodb.org/mongo-driver/bson/primitive" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -109,6 +111,7 @@ type AccountV2 interface { TransferAccount(from, to *types.UserQueryOpts, amount int64) error TransferAccountAll(from, to *types.UserQueryOpts) error AddDeductionBalance(user *types.UserQueryOpts, balance int64) error + AddDeductionBalanceWithDB(ops *types.UserQueryOpts, amount int64, tx *gorm.DB) error AddDeductionBalanceWithFunc(ops *types.UserQueryOpts, amount int64, preDo, postDo func() error) error } diff --git a/controllers/pkg/resources/resources.go b/controllers/pkg/resources/resources.go index ffe94d3267d..4452acd08e9 100644 --- a/controllers/pkg/resources/resources.go +++ b/controllers/pkg/resources/resources.go @@ -18,6 +18,10 @@ import ( "strings" "time" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/google/uuid" + "github.com/labring/sealos/controllers/pkg/common" "github.com/labring/sealos/controllers/pkg/gpu" @@ -84,6 +88,29 @@ type Monitor struct { Property string `json:"property,omitempty" bson:"property,omitempty"` } +type ActiveBilling struct { + ID primitive.ObjectID `json:"_id,omitempty" bson:"_id,omitempty"` + Time time.Time `json:"time,omitempty" bson:"time"` + Namespace string `json:"namespace" bson:"namespace"` + AppType string `json:"app_type" bson:"app_type"` + AppName string `json:"app_name" bson:"app_name"` + Used UsedMap `json:"used,omitempty" bson:"used,omitempty"` + Amount int64 `json:"amount" bson:"amount,omitempty"` + Owner string `json:"owner" bson:"owner,omitempty"` + UserUID uuid.UUID `json:"user_uid" bson:"user_uid"` + Status ConsumptionStatus `json:"status" bson:"status"` + //Rule string `json:"rule" bson:"rule,omitempty"` +} + +type ConsumptionStatus string + +const ( + Consumed ConsumptionStatus = "consumed" + Processing ConsumptionStatus = "processing" + Unconsumed ConsumptionStatus = "unconsumed" + ErrorConsumed ConsumptionStatus = "error_consumed" +) + type BillingType int type Billing struct { @@ -102,12 +129,13 @@ type Billing struct { Amount int64 `json:"amount" bson:"amount,omitempty"` Owner string `json:"owner" bson:"owner,omitempty"` // 0: 未结算 1: 已结算 - Status BillingStatus `json:"status" bson:"status,omitempty"` + Status BillingStatus `json:"status" bson:"status"` // if type = Consumption, then payment is not nil Payment *Payment `json:"payment" bson:"payment,omitempty"` // if type = Transfer, then transfer is not nil Transfer *Transfer `json:"transfer" bson:"transfer,omitempty"` Detail string `json:"detail" bson:"detail,omitempty"` + UserUID uuid.UUID `json:"user_uid" bson:"user_uid,omitempty"` } type Payment struct { @@ -165,6 +193,7 @@ const ( appStore dbBackup devBox + llmToken ) const ( @@ -178,19 +207,22 @@ const ( AppStore = "APP-STORE" DBBackup = "DB-BACKUP" DevBox = "DEV-BOX" + LLMToken = "LLM-TOKEN" ) var AppType = map[string]uint8{ - DB: db, APP: app, TERMINAL: terminal, JOB: job, OTHER: other, ObjectStorage: objectStorage, CVM: cvm, AppStore: appStore, DBBackup: dbBackup, DevBox: devBox, + DB: db, APP: app, TERMINAL: terminal, JOB: job, OTHER: other, ObjectStorage: objectStorage, CVM: cvm, AppStore: appStore, DBBackup: dbBackup, DevBox: devBox, LLMToken: llmToken, } var AppTypeReverse = map[uint8]string{ - db: DB, app: APP, terminal: TERMINAL, job: JOB, other: OTHER, objectStorage: ObjectStorage, cvm: CVM, appStore: AppStore, dbBackup: DBBackup, devBox: DevBox, + db: DB, app: APP, terminal: TERMINAL, job: JOB, other: OTHER, objectStorage: ObjectStorage, cvm: CVM, appStore: AppStore, dbBackup: DBBackup, devBox: DevBox, llmToken: LLMToken, } // resource consumption type EnumUsedMap map[uint8]int64 +type UsedMap map[string]float64 + type PropertyType struct { // For the monitoring storage enumeration type, use uint 8 to save memory // 0 cpu, 1 memory, 2 storage, 3 network ... expandable diff --git a/controllers/pkg/types/dbquery.go b/controllers/pkg/types/dbquery.go index 9d057f48a06..61cf6dc9dae 100644 --- a/controllers/pkg/types/dbquery.go +++ b/controllers/pkg/types/dbquery.go @@ -23,6 +23,7 @@ import ( type UserQueryOpts struct { UID uuid.UUID ID string + Namespace string Owner string IgnoreEmpty bool } diff --git a/deploy/objectstorage/README.md b/deploy/objectstorage/README.md index 9e0fee22bcb..7e454c72f41 100644 --- a/deploy/objectstorage/README.md +++ b/deploy/objectstorage/README.md @@ -10,6 +10,10 @@ log: fix minioKubeblocksPassword env error log: update controller and frontend image +### date 2024.11.22 + +log: add resourcequota/status role to objectstorage-manager-role clusterrole + ## components 1. minio diff --git a/docs/5.0/docs/user-guide/ai-proxy/ai-proxy.md b/docs/5.0/docs/user-guide/ai-proxy/ai-proxy.md new file mode 100644 index 00000000000..792d7648ede --- /dev/null +++ b/docs/5.0/docs/user-guide/ai-proxy/ai-proxy.md @@ -0,0 +1,144 @@ +--- +title: AI Proxy - 一站式 AI 模型调用解决方案 | Sealos +description: AI Proxy 是 Sealos 平台提供的统一 AI 模型调用服务,支持多平台 API Key 管理、统一计费和监控,让开发者轻松接入各类 AI 模型。 +keywords: ["AI Proxy", "Sealos", "AI模型调用", "API管理", "统一计费", "开发者工具"] +--- + +# AI Proxy 使用指南 + +## 简介 + +AI Proxy 是 [Sealos 平台](/docs/5.0/introduction/what-is-sealos.md)提供的一站式 AI 模型调用解决方案,让开发者能够在统一的平台中轻松调用和管理各类 AI 模型。无论是通义千问、文心一言还是其他 AI 模型,都可以通过统一的接口进行调用。 + +### 为什么选择 AI Proxy? + +- 🔑 **一键获取多平台密钥** - 无需分别注册各个 AI 平台 +- 💰 **统一计费更省心** - 告别多平台充值的烦恼 +- 📊 **集中管理更高效** - 一站式监控所有调用情况 + +## 功能特点 + +### 统一的 API 访问 + +- 提供标准化的 API 接口,支持多家主流 AI 模型厂商 +- 兼容 OpenAI API 格式,便于快速迁移和集成 +- 持续扩充支持的模型类型 + +### 简化的密钥管理 + + + +- 一键获取多平台 API Key +- 无需分别注册各个 AI 平台 +- 统一的密钥管理界面 + +### 集中化计费与监控 + + + +- 使用 Sealos 平台统一结算 +- 透明的按量计费模式 +- 详细的费用明细和账单查询 +- 详细的调用日志记录 + +## 快速开始 + +### 1. 获取 API Key + +1. 访问 [Sealos Cloud](https://hzh.sealos.run) +2. 打开 AI Proxy 应用 +3. 点击【新建】按钮创建新的 API Key +4. 复制生成的 API Endpoint 和 API Key + +### 2. API 调用示例 + +#### 使用 JavaScript 调用 + +```javascript +async function main() { + const apiKey = 'your-api-key' + const apiUrl = 'https://aiproxy.hzh.sealos.run/v1/chat/completions' + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify({ + model: 'Doubao-lite-4k', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: '你好,请介绍一下你自己。' } + ], + max_tokens: 2048, + temperature: 0.7, + }), + }) + + const data = await response.json() + console.log(data.choices[0].message.content) +} +``` + +### 3. 请求参数说明 + +| 参数 | 类型 | 说明 | 示例值 | +|------|------|------|--------| +| model | string | 要使用的模型名称 | 'Doubao-lite-4k' | +| messages | array | 对话消息列表 | [{"role": "user"}] | +| max_tokens | number | 最大生成的 token 数量 | 2048 | +| temperature | number | 生成文本的随机性,范围 0-1 | 0.7 | + +## 费用管理 + +### 查看费用明细 + +1. 在 AI Proxy 界面中点击【费用明细】 +2. 可查看各模型的调用次数和费用统计 +3. 支持按时间范围筛选费用记录 + +### 余额充值 +1. 进入 Sealos 费用中心 +2. 选择充值金额 +3. 完成支付后即可使用充值金额调用模型 + +## 调用日志 + +### 日志查看 + +- 支持查看详细的 API 调用记录 +- 包含调用时间、模型名称、输入输出 token 数量等信息 + +### 日志筛选 + +- 按时间范围筛选 +- 按模型类型筛选 +- 按 Token 筛选 + +## 最佳实践 + +### 开发建议 + +1. 合理设置 `max_tokens` 参数,避免生成过长的无效内容 +2. 根据场景调整 `temperature` 参数,对话场景建议使用较高的值 +3. 在生产环境中做好异常处理和重试机制 + +### 成本优化 + +1. 选择适合业务场景的模型 +2. 合理设置上下文长度,避免无效的 token 消耗 + +## 常见问题 + +### API 调用失败 + +- 检查 API Key 是否正确 +- 确认账户余额是否充足 +- 查看具体的错误信息进行排查 + +### 费用相关 + +- 费用按实际调用量计费 +- 不同模型的计费标准不同 +- 支持查看详细的计费规则 diff --git a/docs/5.0/docs/user-guide/ai-proxy/images/ai-proxy-billing.png b/docs/5.0/docs/user-guide/ai-proxy/images/ai-proxy-billing.png new file mode 100644 index 00000000000..9d163b444c8 Binary files /dev/null and b/docs/5.0/docs/user-guide/ai-proxy/images/ai-proxy-billing.png differ diff --git a/docs/5.0/docs/user-guide/ai-proxy/images/ai-proxy-key-management.png b/docs/5.0/docs/user-guide/ai-proxy/images/ai-proxy-key-management.png new file mode 100644 index 00000000000..43c20d0366f Binary files /dev/null and b/docs/5.0/docs/user-guide/ai-proxy/images/ai-proxy-key-management.png differ diff --git a/docs/5.0/docs/user-guide/devbox/devbox.md b/docs/5.0/docs/user-guide/devbox/devbox.md new file mode 100644 index 00000000000..9410b058d26 --- /dev/null +++ b/docs/5.0/docs/user-guide/devbox/devbox.md @@ -0,0 +1,35 @@ +# Devbox + +## Overview + +> A platform for instant collaborative development, seamless deployment, and strict environment isolation. Streamline your workflow with our all-in-one solution. + +Sealos Devbox is an all-in-one platform designed for integrated online development, testing, and production. It offers a seamless solution for creating environments and database dependencies with just a single click. This innovative platform allows developers to work locally using their preferred IDEs while streamlining setup processes and enabling automatic application deployment. + + + +### Key Features and Advantages + +#### Instant collaborative environments + +Sealos Devbox provides quick and easy setup of development environments for a wide range of programming languages and frameworks, including less common ones. This feature enables teams to start collaborating instantly, regardless of the technology stack they're using. + +#### Cloud development environment + +One of the primary advantages of Sealos Devbox is its ability to eliminate environment inconsistencies. By offering a unified cloud platform, it allows teams to share code, configurations, and test data effortlessly. This streamlined approach accelerates development processes, enhances efficiency, and promotes seamless collaboration within a single, harmonious environment. + +#### Headless development experience + +Sealos Devbox simplifies the development process by unifying development, testing, and production environments. It automates environment creation and integrates smoothly with local IDEs, providing a hassle-free setup experience for developers. + +#### Effortless continuous delivery + +With Sealos Devbox, teams can deliver applications smoothly without requiring expertise in Docker or Kubernetes. Developers simply need to specify the version, and Devbox handles all the complex tasks, including building containers. + +#### Strict environment isolation + +Sealos Devbox offers isolated development environments, helping teams avoid dependency conflicts. Each project can have its own consistent and reproducible workspace, allowing developers to focus on relevant tasks without worrying about environmental inconsistencies. + +#### Access from any network + +Sealos Devbox provides access to applications from both internal networks and the Internet, with automatic TLS configuration. This feature ensures secure and flexible development capabilities, allowing teams to work from any network, anywhere in the world. \ No newline at end of file diff --git a/docs/5.0/docs/user-guide/devbox/faq.md b/docs/5.0/docs/user-guide/devbox/faq.md new file mode 100644 index 00000000000..00c4f4921a2 --- /dev/null +++ b/docs/5.0/docs/user-guide/devbox/faq.md @@ -0,0 +1,74 @@ +# FAQ + +## 1. Cursor connection problem but VSCode can connect + +Cursor Since the plugin version synchronization with VSCode is slow, outdated versions may cause connection problems. + +Solution: Manually install the Devbox plugin. Install Remote-SSH in the Cursor extension market. Note that the current version should be v0.113.x. (Note that this version must be installed in Cursor. If you download Remote-SSH from the webpage and then import it into Cursor, there is a high probability that the versions do not correspond and lead to incompatibility.) + +1. Download the vsix file of the [Devbox](https://marketplace.visualstudio.com/items?itemName=labring.devbox-aio) plugin from the VSCode plugin market. + + + +2. Open the Cursor's extension window. + +3. Drag the downloaded file into the extension window. + + + +4. Install Remote-SSH v0.113.x in Cursor. + + + +## 2. Cursor and VSCode cannot connect + +First, understand the principle of the Devbox plugin: add remote environment information by modifying the ssh config file, and connect to the remote environment through the Remote-SSH plugin. The plugin first writes the following line of code in `~/.ssh/config` (some older versions may write other similar content): + +```bash +Include ~/.ssh/sealos/devbox_config +``` + +This line of code imports the contents of the file `~/.ssh/sealos/devbox_config` into the current file. And `devbox_config` contains normal SSH configuration content, for example: + +```config +Host usw.sailos.io_ns-rqtny6y6_devbox1234 + HostName usw.sailos.io + User devbox + Port 40911 + IdentityFile ~/.ssh/sealos/usw.sailos.io_ns-rqtny6y6_devbox1234 + IdentitiesOnly yes + StrictHostKeyChecking no +``` + +So if there is a problem, it is most likely a plugin bug that causes errors in reading and writing files. You can feedback this to us or try to adjust the SSH file yourself. + +## 3. Always stuck in downloading vscode-server or keep retrying + +Cause: Due to some operation (such as restarting Devbox during this process), the download cursor is suspended, and re-downloading causes conflicts. + +Solution: + +1. Enter the web terminal and delete the `.cursor-server` folder. + 1. Click "Terminal" in the operation button on the right side of the Devbox webpage list item. + 2. Enter the terminal and go to the user directory first, `cd ..`, then use `ls -a ` to view all files and you can see `.cursor-server`. + 3. Remove `rm -rf .cursor-server`. + 4. Just retry the connection. +2. If there is no content in the newly created Devbox, you can directly delete it and rebuild it. + +## 4. Report the following error + +```bash +upstream connect error or disconnect/reset before headers. retried and the latest reset reason: remote connection failure, transport failure reason: delayed connect error: 111 +``` + +First of all, you should understand that your current environment is a development environment. The URL you are connecting to is a test URL, which is only used in the development environment. This URL corresponds to the port of the development environment. In other words, you must run the development environment, such as `npm run dev` to run your program first, before you can see the content through the URL, otherwise this error will be reported. + +Another possible situation is that you just need to wait for a while, maybe the network is slow. + +## 5. Click the link Cursor and enter the cursor interface, and an error message "Failed to fetch" is reported + +Try to open Cursor's extension market. If the extension market cannot be loaded normally and the error `Error while fetching extensions.Failed to fetch` is reported, it is a network problem that cannot load Cursor's plug-in market. Please refer to the manual installation tutorial above to manually install the Devbox plug-in or try to change your network environment. + +## 6. The local localhost can open the project but the public network address cannot be opened + +The exposed address in the code must be changed from `localhost` to `0.0.0.0` due to network reasons. \ No newline at end of file diff --git a/docs/5.0/docs/user-guide/devbox/images/faq-1.png b/docs/5.0/docs/user-guide/devbox/images/faq-1.png new file mode 100644 index 00000000000..a04895dd2df Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/faq-1.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/faq-2.png b/docs/5.0/docs/user-guide/devbox/images/faq-2.png new file mode 100644 index 00000000000..699e988dfd9 Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/faq-2.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/faq-3.png b/docs/5.0/docs/user-guide/devbox/images/faq-3.png new file mode 100644 index 00000000000..66a94f7d1a7 Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/faq-3.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/quick-start-1.png b/docs/5.0/docs/user-guide/devbox/images/quick-start-1.png new file mode 100644 index 00000000000..af6f4698767 Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/quick-start-1.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/quick-start-10.png b/docs/5.0/docs/user-guide/devbox/images/quick-start-10.png new file mode 100644 index 00000000000..e390f1fdca9 Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/quick-start-10.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/quick-start-2.png b/docs/5.0/docs/user-guide/devbox/images/quick-start-2.png new file mode 100644 index 00000000000..7da4e09c31d Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/quick-start-2.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/quick-start-3.png b/docs/5.0/docs/user-guide/devbox/images/quick-start-3.png new file mode 100644 index 00000000000..c0fdb9554e9 Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/quick-start-3.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/quick-start-4.png b/docs/5.0/docs/user-guide/devbox/images/quick-start-4.png new file mode 100644 index 00000000000..ab7e99b8095 Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/quick-start-4.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/quick-start-5.png b/docs/5.0/docs/user-guide/devbox/images/quick-start-5.png new file mode 100644 index 00000000000..da15be428b6 Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/quick-start-5.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/quick-start-6.png b/docs/5.0/docs/user-guide/devbox/images/quick-start-6.png new file mode 100644 index 00000000000..15847a6d53b Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/quick-start-6.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/quick-start-7.png b/docs/5.0/docs/user-guide/devbox/images/quick-start-7.png new file mode 100644 index 00000000000..c7860131e6d Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/quick-start-7.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/quick-start-8.png b/docs/5.0/docs/user-guide/devbox/images/quick-start-8.png new file mode 100644 index 00000000000..a58ebd3fc76 Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/quick-start-8.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/quick-start-9.png b/docs/5.0/docs/user-guide/devbox/images/quick-start-9.png new file mode 100644 index 00000000000..d26bcb08445 Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/quick-start-9.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-1.png b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-1.png new file mode 100644 index 00000000000..a2b8ad66059 Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-1.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-10.png b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-10.png new file mode 100644 index 00000000000..d6724dd2908 Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-10.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-11.png b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-11.png new file mode 100644 index 00000000000..1b6d1c13e69 Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-11.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-2.png b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-2.png new file mode 100644 index 00000000000..ced2f197b1e Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-2.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-3.png b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-3.png new file mode 100644 index 00000000000..9c6df1e7da2 Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-3.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-4.png b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-4.png new file mode 100644 index 00000000000..9cbc4361c4d Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-4.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-5.png b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-5.png new file mode 100644 index 00000000000..06aa6e9d866 Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-5.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-6.png b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-6.png new file mode 100644 index 00000000000..adee170669f Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-6.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-7.png b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-7.png new file mode 100644 index 00000000000..2e7cd1052c2 Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-7.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-8.png b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-8.png new file mode 100644 index 00000000000..6a4e0077e6b Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-8.png differ diff --git a/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-9.png b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-9.png new file mode 100644 index 00000000000..9ef04d95261 Binary files /dev/null and b/docs/5.0/docs/user-guide/devbox/images/use-jb-ide-9.png differ diff --git a/docs/5.0/docs/user-guide/devbox/quick-start.md b/docs/5.0/docs/user-guide/devbox/quick-start.md new file mode 100644 index 00000000000..106d1c53033 --- /dev/null +++ b/docs/5.0/docs/user-guide/devbox/quick-start.md @@ -0,0 +1,169 @@ +# Quick Start + +> Learn how to create, develop, and deploy a Next.js app using Sealos DevBox. This guide covers project setup, remote development with Cursor IDE, and cloud deployment. + +Sealos Devbox is an all-in-one platform designed for integrated online development, testing, and production. It offers a seamless solution for creating environments and database dependencies with just a single click, allows developers to work locally using their preferred IDEs while streamlining setup processes and enabling automatic application deployment. + +In this guide We'll demonstrate how to create a minimal Next.js demo project with Sealos Devbox. + +## Create a Devbox Project + +1. Click on the "Devbox" icon on Sealos Desktop, then click on the "Create New Project" button to create a new project. + +2. In the "Runtime" section, choose "Next.js" as the development framework. Use the sliders to set the CPU cores and memory for the project. + + + +3. After setting up the basic environment, you'll need to configure the network settings for your project: + +- Scroll down to the "Network" section of the configuration page. +- Container Port: + - Enter "3000" in the Container Port field. This is the default port that Next.js uses for development. + - If you need additional ports, click the "Add Port" button and specify them. +- Enable Internet Access: + - Toggle the switch to enable internet access for your Devbox. This allows external users to access your Next.js + application through the public internet using the provided domain. +- Domain: + - By default, Sealos provides a subdomain for your application. + - If you want to use a custom domain, click on "Custom Domain" and follow the instructions to set it up. + +Remember that the container port (3000) should match the port your Next.js application is configured to run on. If you change the port in your Next.js configuration, make sure to update it here as well. + + + +4. Click on the "Create" button to create your project. + + + +## Connect with Cursor IDE + +After creating your project, you'll see it listed in the Devbox List. Each project has an "Operation" column with various options. + + + +1. To connect to your project's Devbox runtime using Cursor IDE: + +- Locate your project in the Devbox List. +- In the "Operation" column, click on the dropdown arrow next to the VSCode icon. +- From the dropdown menu, select "Cursor". +- Click on the "Cursor" option that appears. + +2. When you click on "Cursor", it will launch the Cursor IDE application on your local machine. Within Cursor, a popup window will appear, prompting you to install the Devbox plugin for Cursor. This plugin enables SSH remote connection to the Devbox runtime. + +- Follow the instructions in the Cursor popup to install the Devbox plugin. +- Once installed, Cursor will establish a remote connection to your Devbox runtime. + +> You can switch between different IDE options (VSCode, Cursor, or VSCode Insiders) at any time by using the dropdown menu in the "Operation" column. + +## Develop + +1. After the connection is established, you'll be able to access and edit your project files directly within the Cursor IDE environment. + + + +This remote connection allows you to develop your Next.js application using Cursor IDE, with all the benefits of a cloud-based development environment: + +- Your code runs in the Devbox runtime, ensuring consistency across development and production environments. +- You can access your project from anywhere, on any device with Cursor installed. +- Collaboration becomes easier as team members can connect to the same Devbox runtime. + +2. You can start debugging your Next.js application: + +- Open the terminal within Cursor IDE. +- Navigate to your project directory if you're not already there. +- Run the following command to start the Next.js development serve: + +```bash +npm run dev +``` + +- This command will start your Next.js application in development mode. + +3. To access your running application: + +- Return to the Sealos Devbox List in your browser. +- Find the project you just created. +- Click on the "Detail" button on the right side of your project's row. + +4. In the project details page: + +- Look for the "Network" section. +- You'll see an "External Address" field. +- Click on this external address. + + + +5. This will open your Next.js application in a new browser tab, allowing you to view and interact with your running service. + + + +## Release + +After you've developed and tested your Next.js application, you can release it as an OCI (Open Container Initiative) image. This allows you to version your application and prepare it for deployment. + +1. In the Cursor IDE terminal, navigate to your project directory and run the build command: + +```bash +npm run build +``` + +This command creates a production-ready build of your Next.js application in the '.next' directory. + +2. Navigate to your project's details page: + +- Go to the Sealos Devbox List in your browser. +- Find your project and click on the "Detail" button on the right side of your project's row. + +3. On the project details page, look for the "Version" section. + +4. Click on the "Release" button located in the top right corner of the "Version" section. + +5. A "Release" dialog box will appear. Here, you need to provide the following information: + +- Image Name: This field is pre-filled with your project's image name. +- Tag: Enter a version tag for your release (e.g., v1.0). +- Description: Provide a brief description of this release (e.g., "Initial release" or "Bug fixes for login feature"). + + + +6. After filling in the required information, click the "Release" button at the bottom of the dialog box. + +7. The system will process your release. Once completed, you'll see a new entry in the "Version" section of your project + details page, showing the tag, status, creation time, and description of your release. + + + +By following these steps, you've successfully created an OCI image of your Next.js application. This image can now be used for deployment or shared with other team members. Each release creates a snapshot of your current code, allowing you to maintain different versions of your application and easily roll back if needed. + +> Remember to create new releases whenever you make significant changes or reach important milestones in your project. This practice helps in maintaining a clear history of your application's development and facilitates easier deployment and collaboration. + +## Deploy + +After releasing your Next.js application as an OCI image, you can deploy it to Sealos Cloud for production use. Here's how to do it: + +1. In your project's details page, locate the "Version" section. + +2. Find the release you want to deploy and click the "Deploy" button in the "Operation" column. + +3. This will redirect you to the App Launchpad application within Sealos. + +4. In the App Launchpad application, follow the deployment wizard to configure your application settings. This may include: + +- Selecting the appropriate environment +- Setting resource limits (CPU, memory) +- Configuring environment variables if needed +- Setting up any required volumes or persistent storage + + + +5. Once you've configured all necessary settings, click the "Deploy Application" button in the top right corner to start the deployment process. + +6. You'll be taken to the application details view within App Launchpad. + +7. Once the status is "Running", Click on the address provided under "Public Address". This will open your deployed Next.js application in a new browser tab. + +By following these steps, you've successfully deployed your Next.js application to Sealos Cloud using the App Launchpad application. Your application is now accessible via the public address, allowing users to interact with it from anywhere on the internet. + +> You can always update your application by creating a new release in Devbox and repeating this deployment process with the new version using App Launchpad. + +This workflow allows you to develop and debug your Next.js application in a cloud environment while still using your preferred local IDE. The external address makes it easy to share your work with team members or clients, as they can access your running application from anywhere with an internet connection. \ No newline at end of file diff --git a/docs/5.0/docs/user-guide/devbox/use-jetbrains-ide.md b/docs/5.0/docs/user-guide/devbox/use-jetbrains-ide.md new file mode 100644 index 00000000000..340b9f35cdd --- /dev/null +++ b/docs/5.0/docs/user-guide/devbox/use-jetbrains-ide.md @@ -0,0 +1,50 @@ +# Develop with JetBrains IDE + +> This guide describes how to use IntelliJ IDEA in the JetBrains IDE to develop a Java-based Devbox. + +## Prerequisites + +1. Download the [JetBrains Gateway](https://www.jetbrains.com/remote-development/gateway/) application. + +2. Start a Devbox project. + +3. Download the private key of the Devbox project to your local computer. View the SSH configuration in the Devbox project detail (Username: devbox, Host: hzh.sealos.run, Port: 30566). + + + +## Get Started + +1. Open Devbox, select JetBrains IDE and click on it. + + + +2. Automatically invoke the local JetBrains Gateway and click `New Connection`. + + + +3. Open JetBrains Gateway, fill in Username, Host and Port, check Specify private key, and select the path to the private key. Click `Check Connection and Continue` to test the SSH connection. + + + +4. Select `IntelliJ IDEA 2024.3.1 Preview` for the IDE version and `/home/devbox/project` for the project path. Click Download IDE and Connect to download the IDE and connect. + + + +You need to wait for the IDE to download. + + + +5. Automatically invoke the local IntelliJ IDEA, select English as the language, open the project file, and click the green arrow to run the Java service. + + + + + +6. Open the detail of the Devbox project and click the public address to access the Java service. + + + +7. Successfully accessed the Java service. + + + diff --git a/docs/5.0/i18n/zh-Hans/developer-guide/system-design/system-application.md b/docs/5.0/i18n/zh-Hans/developer-guide/system-design/system-application.md index fb13e936d62..45a286ef0f8 100644 --- a/docs/5.0/i18n/zh-Hans/developer-guide/system-design/system-application.md +++ b/docs/5.0/i18n/zh-Hans/developer-guide/system-design/system-application.md @@ -19,7 +19,7 @@ description: 了解Sealos系统应用的基本原理与实现,包括Kubernetes 组成:前端系统+Kubernetes API -用于管理用户自定义的应用,本质上是吧用户在 GUI 上的命令映射成yml文件(主要是deployment,service等文件),交给Kubernetes执行。 +用于管理用户自定义的应用,本质上是把用户在 GUI 上的命令映射成yml文件(主要是deployment,service等文件),交给Kubernetes执行。 ## 费用中心 @@ -94,4 +94,4 @@ websocket返回给前端,后续使用这个 websocket进行通信。 ## 私有云 -一个单独的链接,点击直接跳转到一个问卷调查。 \ No newline at end of file +一个单独的链接,点击直接跳转到一个问卷调查。 diff --git a/docs/5.0/i18n/zh-Hans/user-guide/ai-proxy/ai-proxy.md b/docs/5.0/i18n/zh-Hans/user-guide/ai-proxy/ai-proxy.md new file mode 100644 index 00000000000..df3dd70f422 --- /dev/null +++ b/docs/5.0/i18n/zh-Hans/user-guide/ai-proxy/ai-proxy.md @@ -0,0 +1,144 @@ +--- +title: AI Proxy +description: AI Proxy 是 Sealos 平台提供的统一 AI 模型调用服务,支持多平台 API Key 管理、统一计费和监控,让开发者轻松接入各类 AI 模型。 +keywords: ["AI Proxy", "Sealos", "AI模型调用", "API管理", "统一计费", "开发者工具"] +--- + +# AI Proxy 使用指南 + +## 简介 + +AI Proxy 是 [Sealos 平台](../../Intro.md)提供的一站式 AI 模型调用解决方案,让开发者能够在统一的平台中轻松调用和管理各类 AI 模型。无论是通义千问、文心一言还是其他 AI 模型,都可以通过统一的接口进行调用。 + +### 为什么选择 AI Proxy? + +- 🔑 **一键获取多平台密钥** - 无需分别注册各个 AI 平台 +- 💰 **统一计费更省心** - 告别多平台充值的烦恼 +- 📊 **集中管理更高效** - 一站式监控所有模型调用情况 + +## 功能特点 + +### 统一的 API 访问 + +- 提供标准化的 API 接口,支持多家主流 AI 模型厂商 +- 兼容 OpenAI API 格式,便于快速迁移和集成 +- 持续扩充支持的模型类型 + +### 简化的密钥管理 + + + +- 一键获取多平台 API Key +- 无需分别注册各个 AI 平台 +- 统一的密钥管理界面 + +### 集中化计费与监控 + + + +- 使用 Sealos 平台统一结算 +- 透明的按量计费模式 +- 详细的费用明细和账单查询 +- 详细的调用日志记录 + +## 快速开始 + +### 1. 获取 API Key + +1. 访问 [Sealos Cloud](https://hzh.sealos.run) +2. 打开 AI Proxy 应用 +3. 点击【新建】按钮创建新的 API Key +4. 复制生成的 API Endpoint 和 API Key + +### 2. API 调用示例 + +#### 使用 JavaScript 调用 + +```javascript +async function main() { + const apiKey = 'your-api-key' + const apiUrl = 'https://aiproxy.hzh.sealos.run/v1/chat/completions' + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify({ + model: 'Doubao-lite-4k', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: '你好,请介绍一下你自己。' } + ], + max_tokens: 2048, + temperature: 0.7, + }), + }) + + const data = await response.json() + console.log(data.choices[0].message.content) +} +``` + +### 3. 请求参数说明 + +| 参数 | 类型 | 说明 | 示例值 | +|------|------|------|--------| +| model | string | 要使用的模型名称 | 'Doubao-lite-4k' | +| messages | array | 对话消息列表 | [{"role": "user"}] | +| max_tokens | number | 最大生成的 token 数量 | 2048 | +| temperature | number | 生成文本的随机性,范围 0-1 | 0.7 | + +## 费用管理 + +### 查看费用明细 + +1. 在 AI Proxy 界面中点击【费用明细】 +2. 可查看各模型的调用次数和费用统计 +3. 支持按时间范围筛选费用记录 + +### 余额充值 +1. 进入 Sealos 费用中心 +2. 选择充值金额 +3. 完成支付后即可使用充值金额调用模型 + +## 调用日志 + +### 日志查看 + +- 支持查看详细的 API 调用记录 +- 包含调用时间、模型名称、输入输出 token 数量等信息 + +### 日志筛选 + +- 按时间范围筛选 +- 按模型类型筛选 +- 按 Token 筛选 + +## 最佳实践 + +### 开发建议 + +1. 合理设置 `max_tokens` 参数,避免生成过长的无效内容 +2. 根据场景调整 `temperature` 参数,对话场景建议使用较高的值 +3. 在生产环境中做好异常处理和重试机制 + +### 成本优化 + +1. 选择适合业务场景的模型 +2. 合理设置上下文长度,避免无效的 token 消耗 + +## 常见问题 + +### API 调用失败 + +- 检查 API Key 是否正确 +- 确认账户余额是否充足 +- 查看具体的错误信息进行排查 + +### 费用相关 + +- 费用按实际调用量计费 +- 不同模型的计费标准不同 +- 支持查看详细的计费规则 diff --git a/docs/5.0/i18n/zh-Hans/user-guide/ai-proxy/images/ai-proxy-billing.png b/docs/5.0/i18n/zh-Hans/user-guide/ai-proxy/images/ai-proxy-billing.png new file mode 100644 index 00000000000..9d163b444c8 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/ai-proxy/images/ai-proxy-billing.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/ai-proxy/images/ai-proxy-key-management.png b/docs/5.0/i18n/zh-Hans/user-guide/ai-proxy/images/ai-proxy-key-management.png new file mode 100644 index 00000000000..43c20d0366f Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/ai-proxy/images/ai-proxy-key-management.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/devbox.md b/docs/5.0/i18n/zh-Hans/user-guide/devbox/devbox.md new file mode 100644 index 00000000000..d4746167a67 --- /dev/null +++ b/docs/5.0/i18n/zh-Hans/user-guide/devbox/devbox.md @@ -0,0 +1,35 @@ +# Devbox + +## 简介 + +> Sealos Devbox 是一个用于即时协作开发、无缝部署和严格环境隔离的平台,帮助你简化工作流程。 + +Sealos DevBox是一个一站式平台,集成了在线开发、测试和生产环境。它允许你一键创建环境和设置数据库依赖,让开发者在本地使用自己喜欢的IDE工作,同时简化了设置流程,还能自动部署应用,简直是开发者的得力助手。 + + + +### 主要特点和优势 + +#### 即时协作环境 + +Sealos Devbox 提供多种编程语言和框架的快速开发环境设置,支持团队立即开始协作,无论使用哪种技术栈。 + +#### 云开发环境 + +Sealos Devbox 提供统一云平台,消除了环境不一致的问题,让团队可以轻松共享代码、配置和测试数据,从而加速开发过程并提升效率。 + +#### 无头开发经验 + +Sealos Devbox 简化了开发流程,自动创建环境并与本地 IDE 无缝集成,为开发人员提供轻松的设置体验。 + +#### 简化持续交付 + +无需 Docker 或 Kubernetes 专业知识,Sealos Devbox 使团队能够轻松交付应用程序。开发人员只需指定版本,Sealos Devbox 负责处理构建和容器化等复杂任务。 + +#### 严格环境隔离 + +Sealos Devbox 提供隔离的开发环境,帮助团队避免依赖冲突。每个项目都可以拥有自己一致且可重现的工作空间,让开发人员可以专注于相关任务,而不必担心环境不一致。 + +#### 灵活的网络访问 + +Sealos Devbox 提供从内部网络和互联网访问应用程序的功能,并自动配置 TLS,确保安全和灵活的开发能力,让团队可以在全球任何地方工作。 diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/faq.md b/docs/5.0/i18n/zh-Hans/user-guide/devbox/faq.md new file mode 100644 index 00000000000..16df14162b4 --- /dev/null +++ b/docs/5.0/i18n/zh-Hans/user-guide/devbox/faq.md @@ -0,0 +1,74 @@ +# FAQ + +## 1、Cursor 连接出现问题但是 VSCode 可以连接 + +Cursor 由于插件版本同步 VSCode 比较缓慢,比较落后的版本可能会导致连接出现问题。 + +解决措施:手动安装 Devbox 插件。在 Cursor 扩展市场里安装 Remote-SSH,注意版本暂时应该是 v0.113.x。(注意必须在 Cursor 里安装这个版本,在网页里下载 Remote-SSH 再导入到 Cursor 里大概率版本不对应导致不兼容。) + +1. 从 VSCode 插件市场下载 [Devbox](https://marketplace.visualstudio.com/items?itemName=labring.devbox-aio) 插件的 vsix 文件。 + + + +2. 打开 Cursor 的扩展窗口。 + +3. 将下载的文件拖拽到扩展窗口中。 + + + +4. 在 Cursor 里安装 Remote-SSH v0.113.x。 + + + +## 2、Cursor 和 VSCode 都无法连接 + +首先明白 Devbox 插件的原理:即通过改动 ssh config 文件来添加远程环境信息,并通过 Remote-SSH 插件进行远程环境的连接。插件首先在 `~/.ssh/config` 写入下面这行代码(一些老版本可能写入的其他类似的内容): + +```bash +Include ~/.ssh/sealos/devbox_config +``` + +这行代码的作用是将 `~/.ssh/sealos/devbox_config` 这个文件的内容导入到当前文件。而 `devbox_config` 里则是正常的 SSH 配置内容,例如: + +```config +Host usw.sailos.io_ns-rqtny6y6_devbox1234 + HostName usw.sailos.io + User devbox + Port 40911 + IdentityFile ~/.ssh/sealos/usw.sailos.io_ns-rqtny6y6_devbox1234 + IdentitiesOnly yes + StrictHostKeyChecking no +``` + +所以如果出现问题,大概率是插件 BUG 读写文件出错,可以反馈给我们,或者自己尝试调整 SSH 文件。 + +## 3、一直卡在下载 vscode-server 过程,或者是不断重试 + +原因:因为某种操作(在这个过程中重启 Devbox 等)导致下载 cursor 假死,重新下载产生冲突。 + +解决措施: + +1. 进入Web 终端删除 `.cursor-server` 文件夹。 + 1. 点击 Devbox 网页列表项右边操作按钮里的“终端”。 + 2. 进入终端先进入用户目录,`cd ..`,然后通过 `ls -a ` 查看所有文件可以看到 `.cursor-server`。 + 3. 删除 `rm -rf .cursor-server`。 + 4. 重试连接即可。 +2. 如果是刚创建里面没有内容的话,可以直接删除该 Devbox 重建。 + +## 4、报如下错误 + +```bash +upstream connect error or disconnect/reset before headers. retried and the latest reset reason: remote connection failure, transport failure reason: delayed connect error: 111 +``` + +首先明白一下你此时的环境是开发环境,你现在连接的网址是测试网址,只用于开发环境,这个网址对应的是开发环境的端口。也就是说你必须运行开发环境,例如 `npm run dev` 让你的程序先运行起来,才能通过网址看到内容,否则就会报这个错误。 + +另一种可能的情况是等待一段时间就可以了,可能网络比较卡顿。 + +## 5、点击链接 Cursor,进入 cursor 界面报错 “Failed to fetch” + +尝试打开 Cursor 的扩展市场,如果扩展市场无法正常加载,报错 `Error while fetching extensions.Failed to fetch`,则是网络问题无法加载 Cursor 的插件市场。请参考上面的手动安装教程手动安装 Devbox 插件或者尝试更换您的网络环境。 + +## 6、本地 localhost 能打开项目但是公网地址打不开 + +代码里暴露地址由于网络原因必须从 `localhost` 改为 `0.0.0.0`。 \ No newline at end of file diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/faq-1.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/faq-1.png new file mode 100644 index 00000000000..a04895dd2df Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/faq-1.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/faq-2.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/faq-2.png new file mode 100644 index 00000000000..699e988dfd9 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/faq-2.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/faq-3.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/faq-3.png new file mode 100644 index 00000000000..66a94f7d1a7 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/faq-3.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-1.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-1.png new file mode 100644 index 00000000000..b270802c709 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-1.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-10.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-10.png new file mode 100644 index 00000000000..48d338f8538 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-10.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-2.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-2.png new file mode 100644 index 00000000000..a9a50a7fb45 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-2.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-3.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-3.png new file mode 100644 index 00000000000..9b1a2b930b5 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-3.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-4.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-4.png new file mode 100644 index 00000000000..ab7e99b8095 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-4.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-5.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-5.png new file mode 100644 index 00000000000..83fb9f9d353 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-5.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-6.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-6.png new file mode 100644 index 00000000000..15847a6d53b Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-6.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-7.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-7.png new file mode 100644 index 00000000000..a06126fbabd Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-7.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-8.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-8.png new file mode 100644 index 00000000000..4b669720936 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-8.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-9.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-9.png new file mode 100644 index 00000000000..91dddc32141 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/quick-start-9.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-1.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-1.png new file mode 100644 index 00000000000..a2b8ad66059 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-1.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-10.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-10.png new file mode 100644 index 00000000000..ec718d9dadc Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-10.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-11.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-11.png new file mode 100644 index 00000000000..1b6d1c13e69 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-11.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-2.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-2.png new file mode 100644 index 00000000000..d4a43cde1a9 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-2.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-3.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-3.png new file mode 100644 index 00000000000..71c14cc5ff9 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-3.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-4.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-4.png new file mode 100644 index 00000000000..9cbc4361c4d Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-4.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-5.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-5.png new file mode 100644 index 00000000000..06aa6e9d866 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-5.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-6.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-6.png new file mode 100644 index 00000000000..adee170669f Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-6.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-7.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-7.png new file mode 100644 index 00000000000..2e7cd1052c2 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-7.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-8.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-8.png new file mode 100644 index 00000000000..6a4e0077e6b Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-8.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-9.png b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-9.png new file mode 100644 index 00000000000..9ef04d95261 Binary files /dev/null and b/docs/5.0/i18n/zh-Hans/user-guide/devbox/images/use-jb-ide-9.png differ diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/quick-start.md b/docs/5.0/i18n/zh-Hans/user-guide/devbox/quick-start.md new file mode 100644 index 00000000000..61dd38cdb32 --- /dev/null +++ b/docs/5.0/i18n/zh-Hans/user-guide/devbox/quick-start.md @@ -0,0 +1,140 @@ +# 快速开始 + +> 本指南介绍如何使用 Sealos Devbox 创建、开发和部署 Next.js 应用。内容涵盖项目设置、使用 Cursor IDE 进行远程开发以及云端部署。 + +## 创建 Devbox 项目 + +1. 点击 Sealos 桌面的 Devbox,然后点击新建项目按钮,创建一个新项目。 + +2. 在运行环境部分,选择 Next.js 作为开发框架。然后设置项目的CPU和内存。 + + + +3. 设置网络配置: + +- 容器暴露端口设置为 3000,这是 Next.js 开发环境的默认端口。(如果需要额外的端口,请点击添加端口) +- 开启公网访问,这会自动生成一个随机的公网域名。(如果想使用自定义域名,请点击自定义域名) + +> 请确保容器暴露端口与 Next.js 应用配置的端口一致。如果你修改了 Next.js 配置中的端口,记得在此处同步更新。 + + + +4. 点击右上角的创建来创建项目。 + +## 连接 Cursor IDE + +创建项目后,你可以在 Devbox 项目列表中找到它。每个项目都有一个操作列,可以在操作列的下拉框中切换不同的 IDE。 + + + +1. 启动本地 Cursor IDE: + +- 选择操作列的下拉框中的 Cursor 来启动本地 Cursor IDE。 + +2. 本地 Cursor IDE 连接 Devbox: + +- Cursor IDE 会弹出窗口提示你安装 Devbox 插件,安装后即可通过 SSH 与 Devbox 连接。 + +> 你可以随时切换不同的 IDE(VSCode、VSCode Insiders、Cursor 或 Windsurf)。 + +## 开发 + +1. 本地 Cursor IDE 成功连接 Devbox 后,你就可以在 Cursor IDE 中直接编辑项目文件。 + + + +> 通过远程连接,你可以在 Devbox 运行时运行代码,确保开发与生产环境一致,并在任何地方、任何安装了 Cursor 的设备上访问项目,方便团队协作。 + +2. 调试 Next.js 应用: + +- 打开 Cursor IDE 终端。 +- 导航到项目目录。 +- 运行以下命令以开发模式启动 Next.js 服务: + +```bash +npm run dev +``` + +3. 访问正在运行的应用: + +- 打开 Sealos 桌面的 Devbox。 +- 找到你的项目并点击详情按钮。 +- 点击外网地址。 + + + +4. 成功打开你的 Next.js 应用。 + + + +## 发布 + +开发并测试 Next.js 应用后,你可以将其打包为 OCI 镜像(即容器镜像),这样可以方便地进行版本控制并准备部署。 + +1. 在 Cursor IDE 终端中,导航到项目目录并运行构建命令: + +```bash +npm run build +``` + +此命令在 `.next` 目录中生成可用于生产的 Next.js 应用版本。 + +2. 转到项目详情页面: + +- 打开 Sealos 桌面的 Devbox。 +- 找到你的项目并点击详情按钮。 + +3. 在详情页面,找到“版本历史”部分。 + +4. 点击“版本历史”右上角的“发布版本”按钮。 + +5. 在弹出的“发布版本”对话框中,提供以下信息: + +- 镜像名:预填为项目名。 +- 版本号:输入版本号(如:v1.0)。 +- 版本描述:简要描述版本内容(如:“初始版本”或“修复登录问题”)。 + + + +6. 填写完毕后,点击“发版”按钮。 + +7. 系统会处理发布,完成后,你会在“版本历史”中看到新版本的记录,包括版本号、状态、创建时间和描述。 + + + +通过这些步骤,你已成功创建 Next.js 应用的 OCI 镜像。此镜像可以用于部署或与团队共享,每次发布都会创建一个代码快照,方便版本管理和回滚。 + +> 每当有重大更改或里程碑时,记得发布新版本。这能帮助保持开发历史清晰,并使部署和协作更加顺畅。 + +## 部署 + +将 Next.js 应用发布为 OCI 镜像后,你可以将其部署到 Sealos Cloud 以供生产使用。操作步骤如下: + +1. 在项目详情页面中,找到“版本历史”部分。 + +2. 找到需要部署的版本,点击“操作”栏中的“上线”按钮。 + +3. 系统会将你重定向到 Sealos 的应用管理界面。 + +4. 在应用管理中,按照部署向导配置应用设置。通常包括: + +- 设置应用名称 +- 设置资源限制(CPU和内存) +- 设置环境变量 +- 设置卷或持久存储 + + + +5. 配置完毕后,点击右上角的“部署应用”按钮开始部署。 + +6. 部署完成后,进入应用详情页面。 + +7. 当状态变为“running”时,点击“公网地址”,即可在新标签页中打开你的 Next.js 应用。 + + + +通过这些步骤,你已成功将 Next.js 应用部署到 Sealos Cloud。现在,用户可以通过公网地址访问应用。 + +> 你可以随时通过 Devbox 创建新版本,并重复此过程更新应用。 + +此工作流程使你能够在云环境中开发和调试 Next.js 应用,同时使用本地 IDE。外部地址使你可以轻松地与团队或客户共享应用,任何地方都能访问。 \ No newline at end of file diff --git a/docs/5.0/i18n/zh-Hans/user-guide/devbox/use-jetbrains-ide.md b/docs/5.0/i18n/zh-Hans/user-guide/devbox/use-jetbrains-ide.md new file mode 100644 index 00000000000..1a9f8fd2765 --- /dev/null +++ b/docs/5.0/i18n/zh-Hans/user-guide/devbox/use-jetbrains-ide.md @@ -0,0 +1,50 @@ +# 使用 JetBrains IDE 开发 + +> 本指南介绍如何使用 JetBrains IDE 中的 IntelliJ IDEA 来开发基础环境为 Java 的 Devbox。 + +## 前置准备 + +1. 需要提前下载好 [JetBrains Gateway](https://www.jetbrains.com/remote-development/gateway/) 应用。 + +2. 启动一个 Devbox 项目。 + +3. 下载 Devbox 项目的私钥到本地。查看 Devbox 项目详情中的 SSH 配置(Username:devbox,Host:hzh.sealos.run,Port:30566)。 + + + +## 开始使用 + +1. 打开 Devbox,选择 JetBrains IDE 并点击。 + + + +2. 自动唤起本地的 JetBrains Gateway,点击 `New Connection`。 + + + +3. 打开 JetBrains Gateway,填写 Username、Host 和 Port,勾选 Specify private key,选择私钥的所在路径。点击 `Check Connection and Continue`,即可测试 SSH 连接。 + + + +4. IDE 版本选择 `IntelliJ IDEA 2024.3.1 Preview`(因为 Devbox 的运行环境是 Java,所以选择 IDEA,根据具体的语言选择不同 IDE),项目路径选择 `/home/devbox/project`。点击 Download IDE and Connect,即可下载 IDE 和连接。 + + + +需要等待 IDE 下载完毕。 + + + +5. 自动唤起本地的 IntelliJ IDEA,语言选择 Chinese 简体中文,打开项目文件,点击绿箭头来运行 Java 服务。 + + + + + +6. 打开 Devbox 项目的详情,点击公网地址,即可跳转访问 Java 服务。 + + + +7. 成功访问 Java 服务。 + + + diff --git a/docs/5.0/sidebar.json b/docs/5.0/sidebar.json index 12768635160..994c44732f0 100644 --- a/docs/5.0/sidebar.json +++ b/docs/5.0/sidebar.json @@ -44,6 +44,7 @@ "user-guide/system-function/work-order/work-order" ] }, + "user-guide/ai-proxy/ai-proxy", "user-guide/app-store/app-store", { "type": "category", @@ -78,7 +79,19 @@ "user-guide/objectstorage/objectstorage", "user-guide/cronjob/cronjob", "user-guide/terminal/terminal", - "user-guide/kubepanel/kubepanel" + "user-guide/kubepanel/kubepanel", + { + "type": "category", + "label": "Devbox", + "link": { + "type": "doc", + "id": "user-guide/devbox/devbox" + }, + "items": [ + "user-guide/devbox/quick-start", + "user-guide/devbox/faq" + ] + } ] }, { diff --git a/docs/website/docusaurus.config.js b/docs/website/docusaurus.config.js index 61421a3dfb0..94d64fb3527 100644 --- a/docs/website/docusaurus.config.js +++ b/docs/website/docusaurus.config.js @@ -151,7 +151,7 @@ const config = { }, { position: "left", - to: "/blog", + to: "https://blog.sealos.run/blog", label: "Blog" }, { @@ -182,12 +182,8 @@ const config = { title: "Product", items: [ { - label: "Laf", - to: "https://github.com/labring/laf", - }, - { - label: "Sealfs", - to: "https://github.com/labring/sealfs", + label: "Devbox", + to: "/devbox", }, { label: "FastGPT", @@ -394,4 +390,4 @@ const config = { ] } -module.exports = config +module.exports = config \ No newline at end of file diff --git a/docs/website/src/pages/components/Header/index.tsx b/docs/website/src/pages/components/Header/index.tsx index d11a8eefc8f..f9e3d4442ae 100644 --- a/docs/website/src/pages/components/Header/index.tsx +++ b/docs/website/src/pages/components/Header/index.tsx @@ -24,7 +24,7 @@ const navbar = [ { key: 'blog', label: <Translate>Blog</Translate>, - to: '/blog' + to: 'https://blog.sealos.run/blog' }, { key: 'hosting', diff --git a/docs/website/static/tencent9825051126872297210.txt b/docs/website/static/tencent9825051126872297210.txt new file mode 100644 index 00000000000..78d2374349d --- /dev/null +++ b/docs/website/static/tencent9825051126872297210.txt @@ -0,0 +1 @@ +3670376592260537668 \ No newline at end of file diff --git a/extensions/ide/vscode/devbox/.eslintrc.json b/extensions/ide/vscode/devbox/.eslintrc.json new file mode 100644 index 00000000000..bcebba360ff --- /dev/null +++ b/extensions/ide/vscode/devbox/.eslintrc.json @@ -0,0 +1,24 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "rules": { + "@typescript-eslint/naming-convention": [ + "warn", + { + "selector": "import", + "format": ["camelCase", "PascalCase"] + } + ], + "@typescript-eslint/semi": "off", + "curly": "warn", + "eqeqeq": "warn", + "no-throw-literal": "warn", + "semi": "off" + }, + "ignorePatterns": ["out", "dist", "**/*.d.ts"] +} diff --git a/extensions/ide/vscode/devbox/.gitignore b/extensions/ide/vscode/devbox/.gitignore new file mode 100644 index 00000000000..b9bd7da471b --- /dev/null +++ b/extensions/ide/vscode/devbox/.gitignore @@ -0,0 +1,9 @@ +out +dist +node_modules +.vscode-test/ +*.vsix + +.env +!.vscode/ +resources/codicons/ \ No newline at end of file diff --git a/extensions/ide/vscode/devbox/.vscode-test.mjs b/extensions/ide/vscode/devbox/.vscode-test.mjs new file mode 100644 index 00000000000..b62ba25f015 --- /dev/null +++ b/extensions/ide/vscode/devbox/.vscode-test.mjs @@ -0,0 +1,5 @@ +import { defineConfig } from '@vscode/test-cli'; + +export default defineConfig({ + files: 'out/test/**/*.test.js', +}); diff --git a/extensions/ide/vscode/devbox/.vscode/extensions.json b/extensions/ide/vscode/devbox/.vscode/extensions.json new file mode 100644 index 00000000000..5906abf06f5 --- /dev/null +++ b/extensions/ide/vscode/devbox/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "dbaeumer.vscode-eslint", + "ms-vscode.extension-test-runner" + ] +} diff --git a/extensions/ide/vscode/devbox/.vscode/launch.json b/extensions/ide/vscode/devbox/.vscode/launch.json new file mode 100644 index 00000000000..5bacc6bd03b --- /dev/null +++ b/extensions/ide/vscode/devbox/.vscode/launch.json @@ -0,0 +1,20 @@ +// A launch configuration that compiles the extension and then opens it inside a new window +// Use IntelliSense to learn about possible attributes. +// Hover to view descriptions of existing attributes. +// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}"], + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "preLaunchTask": "${defaultBuildTask}", + "env": { + // "NODE_ENV": "development" + } + } + ] +} diff --git a/extensions/ide/vscode/devbox/.vscode/settings.json b/extensions/ide/vscode/devbox/.vscode/settings.json new file mode 100644 index 00000000000..0fb874633a8 --- /dev/null +++ b/extensions/ide/vscode/devbox/.vscode/settings.json @@ -0,0 +1,14 @@ +// Place your settings in this file to overwrite default and user settings. +{ + "files.exclude": { + "out": false, // set this to true to hide the "out" folder with the compiled JS files + "dist": false // set this to true to hide the "dist" folder with the compiled JS files + }, + "search.exclude": { + "out": true, // set this to false to include "out" folder in search results + "dist": true // set this to false to include "dist" folder in search results + }, + // Turn off tsc task auto detection since we have the necessary tasks as npm scripts + "typescript.tsc.autoDetect": "off", + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/extensions/ide/vscode/devbox/.vscode/tasks.json b/extensions/ide/vscode/devbox/.vscode/tasks.json new file mode 100644 index 00000000000..9e3300b04c2 --- /dev/null +++ b/extensions/ide/vscode/devbox/.vscode/tasks.json @@ -0,0 +1,37 @@ +// See https://go.microsoft.com/fwlink/?LinkId=733558 +// for the documentation about the tasks.json format +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "watch", + "problemMatcher": "$ts-webpack-watch", + "isBackground": true, + "presentation": { + "reveal": "never", + "group": "watchers" + }, + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "type": "npm", + "script": "watch-tests", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "presentation": { + "reveal": "never", + "group": "watchers" + }, + "group": "build" + }, + { + "label": "tasks: watch-tests", + "dependsOn": ["npm: watch", "npm: watch-tests"], + "problemMatcher": [] + } + ] +} diff --git a/extensions/ide/vscode/devbox/.vscodeignore b/extensions/ide/vscode/devbox/.vscodeignore new file mode 100644 index 00000000000..a4b3966a58c --- /dev/null +++ b/extensions/ide/vscode/devbox/.vscodeignore @@ -0,0 +1,14 @@ +.vscode/** +.vscode-test/** +out/** +node_modules/** +src/** +.gitignore +.yarnrc +webpack.config.js +vsc-extension-quickstart.md +**/tsconfig.json +**/.eslintrc.json +**/*.map +**/*.ts +**/.vscode-test.* diff --git a/extensions/ide/vscode/devbox/CHANGELOG.md b/extensions/ide/vscode/devbox/CHANGELOG.md new file mode 100644 index 00000000000..952b3dd16e2 --- /dev/null +++ b/extensions/ide/vscode/devbox/CHANGELOG.md @@ -0,0 +1,45 @@ +# Change Log + +All notable changes to the "devbox" extension will be documented in this file. + +Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. + +## [Unreleased] + +## [1.2.0] - 2024-12-3 + +### Added + +- Simple backup devbox ssh config. +- UI style optimization and some little buttons of `Database` panel and `Network` panel. +- I18n support. + +### Fixed + +### Changed + +- remove the `devbox` control of the `Remote-SSH` config. + +## [1.0.0] - 2024-11-13 + +### Added + +- Devbox basic management: Create(in web page),Delete(delete local ssh config),Open,Refresh,State. +- Network panel,Database panel: View network and database information,open network port in browser or vscode integrated browser,copy database connection string. +- Adapt to dark theme. +- custom your own API Region and Zone. +- Delete devbox in web page will delete local ssh config. + +### Fixed + +- windows file authority issue cause connection error. +- Connection error caused by `Remote-SSH` custom ssh config path. + +### Changed + +- update ssh config file format. + +### Security + +- Replace `child_process` with `execa` to avoid security issues. +- Deal with path traversal attack. diff --git a/extensions/ide/vscode/devbox/LICENSE b/extensions/ide/vscode/devbox/LICENSE new file mode 100644 index 00000000000..3b28c5b7009 --- /dev/null +++ b/extensions/ide/vscode/devbox/LICENSE @@ -0,0 +1,201 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/extensions/ide/vscode/devbox/README.md b/extensions/ide/vscode/devbox/README.md new file mode 100644 index 00000000000..18a72cec6de --- /dev/null +++ b/extensions/ide/vscode/devbox/README.md @@ -0,0 +1,69 @@ +# Devbox + +Sailos Devbox is a remote development&production environment that helps you develop and deploy your projects. + +This plugin support connection and management of Devbox. + +> Note: Currently, only connections and simple devbox management are supported, other functions(including amazing AI features integrated with sailos natively) will be supported next version. + +## Features + +- Remote environment is based on Kubernetes, it has the advantages of K8S's environment. +- Zero-configuration, no need to configure the environment, just connect to the Devbox. +- Preset popular languages and frameworks make it easy to get started with development. +- It has all the features of the editor, such as VSCode and Cursor. +- Support local port forwarding and public port export. + +## Usage + +### 1. Connect to the remote environment + +Login to the [Sailos Devbox](https://usw.sailos.io/) and create a new Devbox. + + + +Then you can connect to the Devbox by your own IDE in the list page. + + + +After that, you can use the Devbox just like your local environment. + +### 2. Develop your project just like your local environment. + + + +### 3. Get your port export result + +You can use local port forwarding supported by VSCode or Cursor to get your own page. + + + + + +If you want to **share your port (maybe a page or an API) with others**,you can update your network config in Sailos Devbox Website to export your port **in public network**. + +you can update a public port or there is **a preset default public export port**(Different runtime has a different default public export port). + + + +## Managements + +### 1. basic + +You can do some simple management operations or give us feedback in the Devbox list page. + +> Note: Delete devbox only influence local ssh config, will not delete remote devbox. + + + +### 2. Network and Database panel + +You can view the network and database(all your databases in your namespace) of the Devbox in the Network and Database panel. + + + + +## Requirements + +1. You need to install `Remote - SSH` extension in your IDE firstly. +2. You need `a SSH client` in your local environment. diff --git a/extensions/ide/vscode/devbox/docs/note.md b/extensions/ide/vscode/devbox/docs/note.md new file mode 100644 index 00000000000..cfacac74845 --- /dev/null +++ b/extensions/ide/vscode/devbox/docs/note.md @@ -0,0 +1,3 @@ +# 踩坑指南 + +1. react 的 tsx 文件里必须有导入 React,即便你没有使用,否则开发时会报错 diff --git a/extensions/ide/vscode/devbox/images/cloud.svg b/extensions/ide/vscode/devbox/images/cloud.svg new file mode 100644 index 00000000000..3a2748703fa --- /dev/null +++ b/extensions/ide/vscode/devbox/images/cloud.svg @@ -0,0 +1 @@ +<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#d7dae0"><path fill-rule="evenodd" clip-rule="evenodd" d="M11.957 6h.05a2.99 2.99 0 0 1 2.116.879 3.003 3.003 0 0 1 0 4.242 2.99 2.99 0 0 1-2.117.879v-.013L12 12H4.523a3.486 3.486 0 0 1-2.628-1.16 3.502 3.502 0 0 1 1.958-5.78 3.462 3.462 0 0 1 1.468.04 3.486 3.486 0 0 1 3.657-2.06A3.479 3.479 0 0 1 11.957 6zM5 11h7.01a1.994 1.994 0 0 0 1.992-2 2.002 2.002 0 0 0-1.996-2h-.914l-.123-.857a2.49 2.49 0 0 0-2.126-2.122A2.478 2.478 0 0 0 6.231 5.5l-.333.762-.809-.189A2.49 2.49 0 0 0 4.523 6c-.662 0-1.297.263-1.764.732A2.503 2.503 0 0 0 4.523 11H5z"/></svg> \ No newline at end of file diff --git a/extensions/ide/vscode/devbox/images/dark/copy.svg b/extensions/ide/vscode/devbox/images/dark/copy.svg new file mode 100644 index 00000000000..621ad50b7e3 --- /dev/null +++ b/extensions/ide/vscode/devbox/images/dark/copy.svg @@ -0,0 +1 @@ +<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#C5C5C5"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4l1-1h5.414L14 6.586V14l-1 1H5l-1-1V4zm9 3l-3-3H5v10h8V7z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M3 1L2 2v10l1 1V2h6.414l-1-1H3z"/></svg> \ No newline at end of file diff --git a/extensions/ide/vscode/devbox/images/dark/create.svg b/extensions/ide/vscode/devbox/images/dark/create.svg new file mode 100644 index 00000000000..b57a90c83cd --- /dev/null +++ b/extensions/ide/vscode/devbox/images/dark/create.svg @@ -0,0 +1 @@ +<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#C5C5C5"><path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/></svg> \ No newline at end of file diff --git a/extensions/ide/vscode/devbox/images/dark/delete.svg b/extensions/ide/vscode/devbox/images/dark/delete.svg new file mode 100644 index 00000000000..2aeb11cb530 --- /dev/null +++ b/extensions/ide/vscode/devbox/images/dark/delete.svg @@ -0,0 +1 @@ +<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#C5C5C5"><path fill-rule="evenodd" clip-rule="evenodd" d="M10 3h3v1h-1v9l-1 1H4l-1-1V4H2V3h3V2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1zM9 2H6v1h3V2zM4 13h7V4H4v9zm2-8H5v7h1V5zm1 0h1v7H7V5zm2 0h1v7H9V5z"/></svg> \ No newline at end of file diff --git a/extensions/ide/vscode/devbox/images/dark/link-external.svg b/extensions/ide/vscode/devbox/images/dark/link-external.svg new file mode 100644 index 00000000000..16162dff9ec --- /dev/null +++ b/extensions/ide/vscode/devbox/images/dark/link-external.svg @@ -0,0 +1 @@ +<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#C5C5C5"><path d="M1.5 1H6v1H2v12h12v-4h1v4.5l-.5.5h-13l-.5-.5v-13l.5-.5z"/><path d="M15 1.5V8h-1V2.707L7.243 9.465l-.707-.708L13.293 2H8V1h6.5l.5.5z"/></svg> \ No newline at end of file diff --git a/extensions/ide/vscode/devbox/images/dark/open.svg b/extensions/ide/vscode/devbox/images/dark/open.svg new file mode 100644 index 00000000000..5f78b324216 --- /dev/null +++ b/extensions/ide/vscode/devbox/images/dark/open.svg @@ -0,0 +1 @@ +<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#C5C5C5"><path fill-rule="evenodd" clip-rule="evenodd" d="M9 13.887l5-5V8.18l-5-5-.707.707 4.146 4.147H2v1h10.44L8.292 13.18l.707.707z"/></svg> \ No newline at end of file diff --git a/extensions/ide/vscode/devbox/images/dark/refresh.svg b/extensions/ide/vscode/devbox/images/dark/refresh.svg new file mode 100644 index 00000000000..3fd56ae95f0 --- /dev/null +++ b/extensions/ide/vscode/devbox/images/dark/refresh.svg @@ -0,0 +1 @@ +<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#C5C5C5"><path fill-rule="evenodd" clip-rule="evenodd" d="M4.681 3H2V2h3.5l.5.5V6H5V4a5 5 0 1 0 4.53-.761l.302-.954A6 6 0 1 1 4.681 3z"/></svg> \ No newline at end of file diff --git a/extensions/ide/vscode/devbox/images/database.svg b/extensions/ide/vscode/devbox/images/database.svg new file mode 100644 index 00000000000..cbf53d73c4b --- /dev/null +++ b/extensions/ide/vscode/devbox/images/database.svg @@ -0,0 +1 @@ +<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#d7dae0"><path d="M13 3.5C13 2.119 10.761 1 8 1S3 2.119 3 3.5c0 .04.02.077.024.117H3v8.872l.056.357C3.336 14.056 5.429 15 8 15c2.571 0 4.664-.944 4.944-2.154l.056-.357V3.617h-.024c.004-.04.024-.077.024-.117zM8 2.032c2.442 0 4 .964 4 1.468s-1.558 1.468-4 1.468S4 4 4 3.5s1.558-1.468 4-1.468zm4 10.458l-.03.131C11.855 13.116 10.431 14 8 14s-3.855-.884-3.97-1.379L4 12.49v-7.5A7.414 7.414 0 0 0 8 6a7.414 7.414 0 0 0 4-1.014v7.504z"/></svg> \ No newline at end of file diff --git a/extensions/ide/vscode/devbox/images/explorer.svg b/extensions/ide/vscode/devbox/images/explorer.svg new file mode 100644 index 00000000000..1e0873631e8 --- /dev/null +++ b/extensions/ide/vscode/devbox/images/explorer.svg @@ -0,0 +1,4 @@ +<svg width="28" height="28" viewBox="0 0 45 45" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill="#000" fill-rule="evenodd" d="M31.6749 4.53139C32.5322 4.48136 33.3829 4.7086 34.1012 5.17951L42.8406 10.9096C43.6863 11.4641 44.1957 12.4073 44.1957 13.4185V40.0806C44.1957 41.6858 42.9322 43.0065 41.3286 43.0777L14.0979 44.2856C13.1077 44.3295 12.1597 43.8815 11.565 43.0886L5.54906 35.0674C5.02978 34.375 4.74907 33.5329 4.74906 32.6674L4.74902 7.98947C4.74902 6.93016 5.57499 6.05458 6.6325 5.99286L31.6749 4.53139ZM30.2351 8.50782C30.7395 8.47702 31.4066 8.6742 31.9627 9.0185L35.5225 11.2223C36.3209 11.7167 36.4012 12.3558 35.6694 12.3916L17.7358 13.2683C17.0832 13.3002 16.1961 12.9606 15.6228 12.4596L13.568 10.6638C12.9326 10.1084 13.0062 9.5598 13.7221 9.51609L30.2351 8.50782ZM26.65 36.0386C26.1913 35.952 25.8988 35.5164 25.9841 35.0467L28.2694 22.4621C28.3615 21.9548 28.8556 21.607 29.3512 21.7006L30.38 21.8949C30.8387 21.9816 31.1311 22.4172 31.0458 22.8869L28.7606 35.4714C28.6684 35.9788 28.1743 36.3266 27.6788 36.233L26.65 36.0386ZM23.0582 22.9586C22.7005 22.5771 22.0734 22.6161 21.7235 23.0415L18.9874 26.368L16.7358 29.1118C16.4452 29.4659 16.4501 29.9678 16.7472 30.2853L19.2293 32.9379L21.8468 35.7286C22.2045 36.11 22.8316 36.0711 23.1815 35.6457L23.9827 34.6715C24.2742 34.3172 24.2693 33.8142 23.9713 33.4965L20.7282 30.0387C20.4302 29.7209 20.4253 29.2179 20.7168 28.8636L23.8888 25.007C24.1803 24.6527 24.1754 24.1497 23.8774 23.832L23.0582 22.9586ZM35.1832 22.2049L37.931 25.1346L40.2828 27.6482C40.5799 27.9657 40.5848 28.4677 40.2942 28.8217L37.9941 31.6245L35.3065 34.892C34.9566 35.3174 34.3295 35.3564 33.9717 34.975L33.1526 34.1016C32.8546 33.7838 32.8497 33.2808 33.1412 32.9265L36.3132 29.0699C36.6047 28.7156 36.5998 28.2126 36.3018 27.8949L33.0586 24.4371C32.7607 24.1193 32.7558 23.6163 33.0472 23.262L33.8484 22.2879C34.1983 21.8625 34.8255 21.8235 35.1832 22.2049Z" +clip-rule="evenodd"/> +</svg> diff --git a/extensions/ide/vscode/devbox/images/icon.png b/extensions/ide/vscode/devbox/images/icon.png new file mode 100644 index 00000000000..dae81a13b88 Binary files /dev/null and b/extensions/ide/vscode/devbox/images/icon.png differ diff --git a/extensions/ide/vscode/devbox/images/light/copy.svg b/extensions/ide/vscode/devbox/images/light/copy.svg new file mode 100644 index 00000000000..3a932546b3b --- /dev/null +++ b/extensions/ide/vscode/devbox/images/light/copy.svg @@ -0,0 +1 @@ +<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4l1-1h5.414L14 6.586V14l-1 1H5l-1-1V4zm9 3l-3-3H5v10h8V7z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M3 1L2 2v10l1 1V2h6.414l-1-1H3z"/></svg> \ No newline at end of file diff --git a/extensions/ide/vscode/devbox/images/light/create.svg b/extensions/ide/vscode/devbox/images/light/create.svg new file mode 100644 index 00000000000..aed9bc6919e --- /dev/null +++ b/extensions/ide/vscode/devbox/images/light/create.svg @@ -0,0 +1 @@ +<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/></svg> \ No newline at end of file diff --git a/extensions/ide/vscode/devbox/images/light/delete.svg b/extensions/ide/vscode/devbox/images/light/delete.svg new file mode 100644 index 00000000000..3ff1185f5c5 --- /dev/null +++ b/extensions/ide/vscode/devbox/images/light/delete.svg @@ -0,0 +1 @@ +<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M10 3h3v1h-1v9l-1 1H4l-1-1V4H2V3h3V2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1zM9 2H6v1h3V2zM4 13h7V4H4v9zm2-8H5v7h1V5zm1 0h1v7H7V5zm2 0h1v7H9V5z"/></svg> \ No newline at end of file diff --git a/extensions/ide/vscode/devbox/images/light/link-external.svg b/extensions/ide/vscode/devbox/images/light/link-external.svg new file mode 100644 index 00000000000..9a594b72b63 --- /dev/null +++ b/extensions/ide/vscode/devbox/images/light/link-external.svg @@ -0,0 +1 @@ +<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M1.5 1H6v1H2v12h12v-4h1v4.5l-.5.5h-13l-.5-.5v-13l.5-.5z"/><path d="M15 1.5V8h-1V2.707L7.243 9.465l-.707-.708L13.293 2H8V1h6.5l.5.5z"/></svg> \ No newline at end of file diff --git a/extensions/ide/vscode/devbox/images/light/open.svg b/extensions/ide/vscode/devbox/images/light/open.svg new file mode 100644 index 00000000000..f61ddd17549 --- /dev/null +++ b/extensions/ide/vscode/devbox/images/light/open.svg @@ -0,0 +1 @@ +<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M9 13.887l5-5V8.18l-5-5-.707.707 4.146 4.147H2v1h10.44L8.292 13.18l.707.707z"/></svg> \ No newline at end of file diff --git a/extensions/ide/vscode/devbox/images/light/refresh.svg b/extensions/ide/vscode/devbox/images/light/refresh.svg new file mode 100644 index 00000000000..9196015694d --- /dev/null +++ b/extensions/ide/vscode/devbox/images/light/refresh.svg @@ -0,0 +1 @@ +<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M4.681 3H2V2h3.5l.5.5V6H5V4a5 5 0 1 0 4.53-.761l.302-.954A6 6 0 1 1 4.681 3z"/></svg> \ No newline at end of file diff --git a/extensions/ide/vscode/devbox/images/network.svg b/extensions/ide/vscode/devbox/images/network.svg new file mode 100644 index 00000000000..3a2748703fa --- /dev/null +++ b/extensions/ide/vscode/devbox/images/network.svg @@ -0,0 +1 @@ +<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#d7dae0"><path fill-rule="evenodd" clip-rule="evenodd" d="M11.957 6h.05a2.99 2.99 0 0 1 2.116.879 3.003 3.003 0 0 1 0 4.242 2.99 2.99 0 0 1-2.117.879v-.013L12 12H4.523a3.486 3.486 0 0 1-2.628-1.16 3.502 3.502 0 0 1 1.958-5.78 3.462 3.462 0 0 1 1.468.04 3.486 3.486 0 0 1 3.657-2.06A3.479 3.479 0 0 1 11.957 6zM5 11h7.01a1.994 1.994 0 0 0 1.992-2 2.002 2.002 0 0 0-1.996-2h-.914l-.123-.857a2.49 2.49 0 0 0-2.126-2.122A2.478 2.478 0 0 0 6.231 5.5l-.333.762-.809-.189A2.49 2.49 0 0 0 4.523 6c-.662 0-1.297.263-1.764.732A2.503 2.503 0 0 0 4.523 11H5z"/></svg> \ No newline at end of file diff --git a/extensions/ide/vscode/devbox/l10n/bundle.l10n.zh-CN.json b/extensions/ide/vscode/devbox/l10n/bundle.l10n.zh-CN.json new file mode 100644 index 00000000000..4d9f17e7b08 --- /dev/null +++ b/extensions/ide/vscode/devbox/l10n/bundle.l10n.zh-CN.json @@ -0,0 +1,22 @@ +{ + "Type": "数据库类型", + "Username": "用户名", + "Password": "密码", + "Host": "主机", + "Port": "端口", + "Copy Password": "复制密码", + "Copy Connection String": "复制连接串", + "Connection string copied to clipboard!": "连接串已复制到剪贴板!", + "Please select a region,RegionList are added by your each connection.": "请选择一个可用区,可用区来自于您的每个连接。", + "Only Devbox can be opened.": "只能打开 Devbox。", + "Are you sure to delete?": "确定删除Devbox?", + "This action will only delete the devbox ssh config in the local environment.": "此操作只会删除本地环境中的 Devbox SSH 配置。", + "Delete Devbox failed.": "删除 Devbox 失败。", + "Give us a feedback in our GitHub repository.": "在 GitHub 仓库反馈。", + "Give us a feedback in our help desk system.": "在工单系统反馈。", + "Protocol": "协议", + "Address": "地址", + "Open in Browser": "在浏览器中打开", + "Preview in Editor": "在编辑器中预览", + "Open Database Web Terminal": "打开数据库 Web 终端" +} diff --git a/extensions/ide/vscode/devbox/package-lock.json b/extensions/ide/vscode/devbox/package-lock.json new file mode 100644 index 00000000000..64c03531542 --- /dev/null +++ b/extensions/ide/vscode/devbox/package-lock.json @@ -0,0 +1,4756 @@ +{ + "name": "devbox-aio", + "version": "1.2.2024112203", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "devbox-aio", + "version": "1.2.2024112203", + "license": "Apache-2.0", + "dependencies": { + "@vscode/codicons": "^0.0.36", + "axios": "^1.7.5", + "dayjs": "^1.11.13", + "execa": "^9.5.1", + "ssh-config": "^5.0.0" + }, + "devDependencies": { + "@types/node": "20.x", + "@types/vscode": "^1.91.0", + "@typescript-eslint/eslint-plugin": "^7.14.1", + "@typescript-eslint/parser": "^7.11.0", + "@vscode/test-cli": "^0.0.9", + "@vscode/test-electron": "^2.4.0", + "autoprefixer": "^10.4.20", + "eslint": "^8.57.0", + "ts-loader": "^9.5.1", + "typescript": "^5.4.5", + "webpack": "^5.93.0", + "webpack-cli": "^5.1.4" + }, + "engines": { + "vscode": "^1.91.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmmirror.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmmirror.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.11.0", + "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmmirror.com/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.0", + "resolved": "https://registry.npmmirror.com/@types/eslint/-/eslint-9.6.0.tgz", + "integrity": "sha512-gi6WQJ7cHRgZxtkQEoyHMppPjq9Kxo5Tjn2prSKDSmZrCz8TZ3jSRCeTJm+WoM+oB0WG37bRqLzaaU3q7JypGg==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmmirror.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/mocha": { + "version": "10.0.7", + "resolved": "https://registry.npmmirror.com/@types/mocha/-/mocha-10.0.7.tgz", + "integrity": "sha512-GN8yJ1mNTcFcah/wKEFIJckJx9iJLoMSzWcfRRuxz/Jk+U6KQNnml+etbtxFK8lPjzOw3zp4Ha/kjSst9fsHYw==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.14.11", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.14.11.tgz", + "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/vscode": { + "version": "1.91.0", + "resolved": "https://registry.npmmirror.com/@types/vscode/-/vscode-1.91.0.tgz", + "integrity": "sha512-PgPr+bUODjG3y+ozWUCyzttqR9EHny9sPAfJagddQjDwdtf66y2sDKJMnFZRuzBA2YtBGASqJGPil8VDUPvO6A==", + "dev": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.16.1", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.16.1.tgz", + "integrity": "sha512-SxdPak/5bO0EnGktV05+Hq8oatjAYVY3Zh2bye9pGZy6+jwyR3LG3YKkV4YatlsgqXP28BTeVm9pqwJM96vf2A==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.16.1", + "@typescript-eslint/type-utils": "7.16.1", + "@typescript-eslint/utils": "7.16.1", + "@typescript-eslint/visitor-keys": "7.16.1", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.16.1", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-7.16.1.tgz", + "integrity": "sha512-u+1Qx86jfGQ5i4JjK33/FnawZRpsLxRnKzGE6EABZ40KxVT/vWsiZFEBBHjFOljmmV3MBYOHEKi0Jm9hbAOClA==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "7.16.1", + "@typescript-eslint/types": "7.16.1", + "@typescript-eslint/typescript-estree": "7.16.1", + "@typescript-eslint/visitor-keys": "7.16.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.16.1", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-7.16.1.tgz", + "integrity": "sha512-nYpyv6ALte18gbMz323RM+vpFpTjfNdyakbf3nsLvF43uF9KeNC289SUEW3QLZ1xPtyINJ1dIsZOuWuSRIWygw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.16.1", + "@typescript-eslint/visitor-keys": "7.16.1" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.16.1", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-7.16.1.tgz", + "integrity": "sha512-rbu/H2MWXN4SkjIIyWcmYBjlp55VT+1G3duFOIukTNFxr9PI35pLc2ydwAfejCEitCv4uztA07q0QWanOHC7dA==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "7.16.1", + "@typescript-eslint/utils": "7.16.1", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.16.1", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-7.16.1.tgz", + "integrity": "sha512-AQn9XqCzUXd4bAVEsAXM/Izk11Wx2u4H3BAfQVhSfzfDOm/wAON9nP7J5rpkCxts7E5TELmN845xTUCQrD1xIQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.16.1", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.1.tgz", + "integrity": "sha512-0vFPk8tMjj6apaAZ1HlwM8w7jbghC8jc1aRNJG5vN8Ym5miyhTQGMqU++kuBFDNKe9NcPeZ6x0zfSzV8xC1UlQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.16.1", + "@typescript-eslint/visitor-keys": "7.16.1", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.16.1", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-7.16.1.tgz", + "integrity": "sha512-WrFM8nzCowV0he0RlkotGDujx78xudsxnGMBHI88l5J8wEhED6yBwaSLP99ygfrzAjsQvcYQ94quDwI0d7E1fA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.16.1", + "@typescript-eslint/types": "7.16.1", + "@typescript-eslint/typescript-estree": "7.16.1" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.16.1", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.1.tgz", + "integrity": "sha512-Qlzzx4sE4u3FsHTPQAAQFJFNOuqtuY0LFrZHwQ8IHK705XxBiWOFkfKRWu6niB7hwfgnwIpO4jTC75ozW1PHWg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.16.1", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, + "node_modules/@vscode/codicons": { + "version": "0.0.36", + "resolved": "https://registry.npmmirror.com/@vscode/codicons/-/codicons-0.0.36.tgz", + "integrity": "sha512-wsNOvNMMJ2BY8rC2N2MNBG7yOowV3ov8KlvUE/AiVUlHKTfWsw3OgAOQduX7h0Un6GssKD3aoTVH+TF3DSQwKQ==" + }, + "node_modules/@vscode/test-cli": { + "version": "0.0.9", + "resolved": "https://registry.npmmirror.com/@vscode/test-cli/-/test-cli-0.0.9.tgz", + "integrity": "sha512-vsl5/ueE3Jf0f6XzB0ECHHMsd5A0Yu6StElb8a+XsubZW7kHNAOw4Y3TSSuDzKEpLnJ92nbMy1Zl+KLGCE6NaA==", + "dev": true, + "dependencies": { + "@types/mocha": "^10.0.2", + "c8": "^9.1.0", + "chokidar": "^3.5.3", + "enhanced-resolve": "^5.15.0", + "glob": "^10.3.10", + "minimatch": "^9.0.3", + "mocha": "^10.2.0", + "supports-color": "^9.4.0", + "yargs": "^17.7.2" + }, + "bin": { + "vscode-test": "out/bin.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vscode/test-electron": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/@vscode/test-electron/-/test-electron-2.4.1.tgz", + "integrity": "sha512-Gc6EdaLANdktQ1t+zozoBVRynfIsMKMc94Svu1QreOBC8y76x4tvaK32TljrLi1LI2+PK58sDVbL7ALdqf3VRQ==", + "dev": true, + "dependencies": { + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "jszip": "^3.10.1", + "ora": "^7.0.1", + "semver": "^7.6.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.12.1", + "resolved": "https://registry.npmmirror.com/@webassemblyjs/ast/-/ast-1.12.1.tgz", + "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "dev": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmmirror.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.11.6", + "resolved": "https://registry.npmmirror.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.12.1", + "resolved": "https://registry.npmmirror.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.11.6", + "resolved": "https://registry.npmmirror.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.6", + "resolved": "https://registry.npmmirror.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.12.1", + "resolved": "https://registry.npmmirror.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", + "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/wasm-gen": "1.12.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.11.6", + "resolved": "https://registry.npmmirror.com/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.11.6", + "resolved": "https://registry.npmmirror.com/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.11.6", + "resolved": "https://registry.npmmirror.com/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.12.1", + "resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", + "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/helper-wasm-section": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-opt": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1", + "@webassemblyjs/wast-printer": "1.12.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.12.1", + "resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", + "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.12.1", + "resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", + "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.12.1", + "resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", + "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.12.1", + "resolved": "https://registry.npmmirror.com/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", + "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/@webpack-cli/configtest/-/configtest-2.1.1.tgz", + "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/@webpack-cli/info/-/info-2.0.2.tgz", + "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@webpack-cli/serve/-/serve-2.0.5.tgz", + "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.12.1", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmmirror.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "dev": true, + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmmirror.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmmirror.com/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/autoprefixer": { + "version": "10.4.20", + "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.7.5.tgz", + "integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/bl/-/bl-5.1.0.tgz", + "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", + "dev": true, + "dependencies": { + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "node_modules/browserslist": { + "version": "4.23.3", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/c8": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/c8/-/c8-9.1.0.tgz", + "integrity": "sha512-mBWcT5iqNir1zIkzSPyI3NCR9EZCVI3WUD+AVO17MVWTSFNyUueXE82qTeampNtTr+ilN/5Ua3j24LgbCKjDVg==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^6.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=14.14.0" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001653", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001653.tgz", + "integrity": "sha512-XGWQVB8wFQ2+9NZwZ10GxTYC5hk0Fa+q8cSkr0tgvMhYhMHP/QC+WTgrePMDBWiWc/pV+1ik82Al20XOK25Gcw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "dev": true, + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmmirror.com/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmmirror.com/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" + }, + "node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/diff": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.13", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.13.tgz", + "integrity": "sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/enhanced-resolve": { + "version": "5.17.0", + "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz", + "integrity": "sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/envinfo": { + "version": "7.13.0", + "resolved": "https://registry.npmmirror.com/envinfo/-/envinfo-7.13.0.tgz", + "integrity": "sha512-cvcaMr7KqXVh4nyzGTVqTum+gAiL265x5jUWQIDLq//zOGbW+gSW/C+OWLleY/rs9Qole6AZLMXPbtIFQbqu+Q==", + "dev": true, + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.5.4.tgz", + "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "dev": true + }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmmirror.com/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "9.5.1", + "resolved": "https://registry.npmmirror.com/execa/-/execa-9.5.1.tgz", + "integrity": "sha512-QY5PPtSonnGwhhHDNI7+3RvY285c7iuJFFB+lU+oEzMY/gEGJ808owqJsrr8Otd1E/x07po1LkUBmdAc5duPAg==", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.3", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.0", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmmirror.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/figures/node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.2.1.tgz", + "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmmirror.com/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.5", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/human-signals/-/human-signals-8.0.0.tgz", + "integrity": "sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "5.3.1", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.14.0", + "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.14.0.tgz", + "integrity": "sha512-a5dFJih5ZLYlRtDc0dZWP7RiKr6xIKzmn/oAYCDvdLThadVgyJwlaoQPmRtMSpz+rk0OGAgIu+TcM9HUF0fk1A==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmmirror.com/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmmirror.com/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.7.tgz", + "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mocha": { + "version": "10.6.0", + "resolved": "https://registry.npmmirror.com/mocha/-/mocha-10.6.0.tgz", + "integrity": "sha512-hxjt4+EEB0SA0ZDygSS015t65lJw/I2yRCS3Ae+SJ5FrbzrXgfYwJr96f0OvIXdj7h4lv/vLCrH3rkiuizFSvw==", + "dev": true, + "dependencies": { + "ansi-colors": "^4.1.3", + "browser-stdout": "^1.3.1", + "chokidar": "^3.5.3", + "debug": "^4.3.5", + "diff": "^5.2.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^8.1.0", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^5.1.6", + "ms": "^2.1.3", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^6.5.1", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/mocha/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/mocha/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/mocha/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mocha/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/mocha/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/mocha/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmmirror.com/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.18", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/ora/-/ora-7.0.1.tgz", + "integrity": "sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw==", + "dev": true, + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^4.0.0", + "cli-spinners": "^2.9.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^1.3.0", + "log-symbols": "^5.1.0", + "stdin-discarder": "^0.1.0", + "string-width": "^6.1.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", + "dev": true + }, + "node_modules/ora/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-5.1.0.tgz", + "integrity": "sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==", + "dev": true, + "dependencies": { + "chalk": "^5.0.0", + "is-unicode-supported": "^1.1.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/string-width": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-6.1.0.tgz", + "integrity": "sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^10.2.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "dev": true + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/postcss": { + "version": "8.4.41", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.41.tgz", + "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "peer": true, + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.1", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-ms": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/pretty-ms/-/pretty-ms-9.1.0.tgz", + "integrity": "sha512-o1piW0n3tgKIKCwk2vpM/vOV13zjJzvP37Ioze54YlTHE06m4tjEbzg9WsKkvTuyYln2DHjo5pY4qrZGI0otpw==", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "dev": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssh-config": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/ssh-config/-/ssh-config-5.0.0.tgz", + "integrity": "sha512-RVJemF95DYAKfn2xogb75e4s3tyq6seexTg1CHXVxaas+OKv1PUsdIdz7ZyaZbW3TvSnJP+zgzNy5oRsrIhIAg==" + }, + "node_modules/stdin-discarder": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/stdin-discarder/-/stdin-discarder-0.1.0.tgz", + "integrity": "sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==", + "dev": true, + "dependencies": { + "bl": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "9.4.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-9.4.0.tgz", + "integrity": "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/terser": { + "version": "5.31.3", + "resolved": "https://registry.npmmirror.com/terser/-/terser-5.31.3.tgz", + "integrity": "sha512-pAfYn3NIZLyZpa83ZKigvj6Rn9c/vd5KfYGX7cN1mnzqgDcxWvrU5ZtAfIKhEXz9nRecw4z3LXkjaq96/qZqAA==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.10", + "resolved": "https://registry.npmmirror.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.20", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.26.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-loader": { + "version": "9.5.1", + "resolved": "https://registry.npmmirror.com/ts-loader/-/ts-loader-9.5.1.tgz", + "integrity": "sha512-rNH3sK9kGZcH9dYzC7CewQm4NtxJTjSEVRJ2DyBZR7f8/wcta+iV44UPCXc5+nzDzivKtlzV6c9P4e+oFhDLYg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.5.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.2", + "picocolors": "^1.0.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmmirror.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/watchpack": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/watchpack/-/watchpack-2.4.1.tgz", + "integrity": "sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.93.0", + "resolved": "https://registry.npmmirror.com/webpack/-/webpack-5.93.0.tgz", + "integrity": "sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", + "acorn": "^8.7.1", + "acorn-import-attributes": "^1.9.5", + "browserslist": "^4.21.10", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.0", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "5.1.4", + "resolved": "https://registry.npmmirror.com/webpack-cli/-/webpack-cli-5.1.4.tgz", + "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", + "dev": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^2.1.1", + "@webpack-cli/info": "^2.0.2", + "@webpack-cli/serve": "^2.0.5", + "colorette": "^2.0.14", + "commander": "^10.0.1", + "cross-spawn": "^7.0.3", + "envinfo": "^7.7.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmmirror.com/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/webpack-cli/node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmmirror.com/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dev": true, + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workerpool": { + "version": "6.5.1", + "resolved": "https://registry.npmmirror.com/workerpool/-/workerpool-6.5.1.tgz", + "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", + "dev": true + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/yoctocolors/-/yoctocolors-2.1.1.tgz", + "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/extensions/ide/vscode/devbox/package.json b/extensions/ide/vscode/devbox/package.json new file mode 100644 index 00000000000..f1c9a55f422 --- /dev/null +++ b/extensions/ide/vscode/devbox/package.json @@ -0,0 +1,245 @@ +{ + "name": "devbox-aio", + "displayName": "%displayName%", + "description": "%description%", + "version": "1.2.0", + "keywords": [ + "devbox", + "remote development", + "remote" + ], + "bugs": { + "url": "https://github.com/labring/sealos/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/labring/sealos" + }, + "homepage": "https://github.com/labring/sealos/blob/main/extensions/ide/vscode/devbox/README.md", + "publisher": "labring", + "license": "Apache-2.0", + "icon": "images/icon.png", + "engines": { + "vscode": "^1.91.0" + }, + "l10n": "./l10n", + "categories": [ + "Other" + ], + "extensionKind": [ + "ui" + ], + "activationEvents": [ + "onStartupFinished", + "onUri" + ], + "main": "./dist/extension.js", + "contributes": { + "commands": [ + { + "command": "devbox.connectRemoteSSH", + "title": "%devbox.connectRemoteSSH.title%" + }, + { + "command": "devboxDashboard.refresh", + "title": "%devbox.refresh.title%", + "icon": { + "light": "images/light/refresh.svg", + "dark": "images/dark/refresh.svg" + } + }, + { + "command": "devboxDashboard.createDevbox", + "title": "%devbox.create.title%", + "icon": { + "light": "images/light/create.svg", + "dark": "images/dark/create.svg" + } + }, + { + "command": "devboxDashboard.openDevbox", + "title": "%devbox.open.title%", + "icon": { + "light": "images/light/open.svg", + "dark": "images/dark/open.svg" + } + }, + { + "command": "devboxDashboard.deleteDevbox", + "title": "%devbox.delete.title%", + "icon": { + "light": "images/light/delete.svg", + "dark": "images/dark/delete.svg" + } + }, + { + "command": "devbox.openExternalLink", + "title": "%devbox.openInBrowser.title%" + }, + { + "command": "devbox.copy", + "title": "%devbox.copy.title%", + "icon": { + "light": "images/light/copy.svg", + "dark": "images/dark/copy.svg" + } + }, + { + "command": "devbox.refreshDatabase", + "title": "%devbox.refreshDatabase.title%", + "icon": { + "light": "images/light/refresh.svg", + "dark": "images/dark/refresh.svg" + } + }, + { + "command": "devbox.refreshNetwork", + "title": "%devbox.refreshNetwork.title%", + "icon": { + "light": "images/light/refresh.svg", + "dark": "images/dark/refresh.svg" + } + }, + { + "command": "devbox.gotoDatabaseWebPage", + "title": "%devbox.gotoDatabaseWebPage.title%", + "icon": { + "light": "images/light/link-external.svg", + "dark": "images/dark/link-external.svg" + } + } + ], + "views": { + "devboxListView": [ + { + "id": "devboxDashboard", + "name": "%devbox.myProjects.title%" + }, + { + "id": "devboxFeedback", + "name": "%devbox.feedback.title%" + } + ], + "networkView": [ + { + "id": "networkView", + "name": "%devbox.network.title%", + "type": "webview", + "when": "remoteName == ssh-remote" + } + ], + "dbView": [ + { + "type": "webview", + "id": "dbView", + "name": "%devbox.database.title%", + "when": "remoteName == ssh-remote" + } + ] + }, + "viewsContainers": { + "activitybar": [ + { + "id": "devboxListView", + "title": "%devbox.devbox.title%", + "icon": "images/explorer.svg" + } + ], + "panel": [ + { + "id": "networkView", + "title": "%devbox.network.title%", + "icon": "images/network.svg" + }, + { + "id": "dbView", + "title": "%devbox.database.title%", + "icon": "images/database.svg" + } + ] + }, + "viewsWelcome": [ + { + "view": "devboxDashboard", + "contents": "%devbox.welcome.title%" + } + ], + "menus": { + "view/title": [ + { + "command": "devboxDashboard.createDevbox", + "when": "view == devboxDashboard", + "group": "navigation" + }, + { + "command": "devboxDashboard.refresh", + "when": "view == devboxDashboard", + "group": "navigation" + }, + { + "command": "devbox.refreshDatabase", + "when": "view == dbView", + "group": "navigation@2" + }, + { + "command": "devbox.refreshNetwork", + "when": "view == networkView", + "group": "navigation" + }, + { + "command": "devbox.gotoDatabaseWebPage", + "when": "view == dbView", + "group": "navigation@1" + } + ], + "view/item/context": [ + { + "command": "devboxDashboard.openDevbox", + "when": "view == devboxDashboard && viewItem == devbox", + "group": "inline@1" + }, + { + "command": "devboxDashboard.deleteDevbox", + "when": "view == devboxDashboard && viewItem == devbox", + "group": "inline@2" + } + ] + } + }, + "extensionDependencies": [ + "ms-vscode-remote.remote-ssh" + ], + "scripts": { + "vscode:prepublish": "npm run package", + "compile": "webpack", + "watch": "npm run copy-codicons && webpack --watch", + "package": "npm run copy-codicons && webpack --mode production --devtool hidden-source-map", + "compile-tests": "tsc -p . --outDir out", + "watch-tests": "tsc -p . -w --outDir out", + "pretest": "npm run compile-tests && npm run compile && npm run lint", + "copy-codicons": "mkdir -p resources/codicons && cp -r node_modules/@vscode/codicons/dist/* resources/codicons/", + "lint": "eslint src", + "test": "vscode-test" + }, + "devDependencies": { + "@types/node": "20.x", + "@types/vscode": "^1.91.0", + "@typescript-eslint/eslint-plugin": "^7.14.1", + "@typescript-eslint/parser": "^7.11.0", + "@vscode/test-cli": "^0.0.9", + "@vscode/test-electron": "^2.4.0", + "autoprefixer": "^10.4.20", + "eslint": "^8.57.0", + "ts-loader": "^9.5.1", + "typescript": "^5.4.5", + "webpack": "^5.93.0", + "webpack-cli": "^5.1.4" + }, + "dependencies": { + "@vscode/codicons": "^0.0.36", + "axios": "^1.7.5", + "dayjs": "^1.11.13", + "execa": "^9.5.1", + "ssh-config": "^5.0.0" + } +} diff --git a/extensions/ide/vscode/devbox/package.nls.json b/extensions/ide/vscode/devbox/package.nls.json new file mode 100644 index 00000000000..e1ef2266b04 --- /dev/null +++ b/extensions/ide/vscode/devbox/package.nls.json @@ -0,0 +1,20 @@ +{ + "displayName": "Devbox", + "description": "help code for cloud devbox in sailos/sealos", + "devbox.connectRemoteSSH.title": "Devbox: Connect to Remote SSH", + "devbox.refresh.title": "Devbox: Refresh", + "devbox.create.title": "Devbox: Create new Devbox", + "devbox.open.title": "Devbox: Open Devbox", + "devbox.delete.title": "Devbox: Delete Devbox", + "devbox.openInBrowser.title": "Devbox: Open in Browser", + "devbox.copy.title": "Devbox: Copy", + "devbox.refreshDatabase.title": "Devbox: Refresh Database", + "devbox.refreshNetwork.title": "Devbox: Refresh Network", + "devbox.gotoDatabaseWebPage.title": "Devbox: Open Database Web Page", + "devbox.myProjects.title": "My Devboxes", + "devbox.feedback.title": "Feedback", + "devbox.network.title": "Network", + "devbox.database.title": "Database", + "devbox.devbox.title": "Devbox", + "devbox.welcome.title": "No Devbox yet, please create a new devbox.\n [Create Devbox](command:devboxDashboard.createDevbox)\n To learn more about how to use Devbox, please visit [Devbox Documentation](https://sailos.io/docs/quick-start)." +} diff --git a/extensions/ide/vscode/devbox/package.nls.zh-CN.json b/extensions/ide/vscode/devbox/package.nls.zh-CN.json new file mode 100644 index 00000000000..cf9cd353bc3 --- /dev/null +++ b/extensions/ide/vscode/devbox/package.nls.zh-CN.json @@ -0,0 +1,20 @@ +{ + "displayName": "Devbox", + "description": "用于 sailos/sealos 中云Devbox的辅助工具", + "devbox.connectRemoteSSH.title": "Devbox: 连接远程 SSH", + "devbox.refresh.title": "Devbox: 刷新", + "devbox.create.title": "Devbox: 创建", + "devbox.open.title": "Devbox: 打开", + "devbox.delete.title": "Devbox: 删除", + "devbox.openInBrowser.title": "Devbox: 在浏览器中打开", + "devbox.copy.title": "Devbox: 复制", + "devbox.refreshDatabase.title": "Devbox: 刷新数据库", + "devbox.refreshNetwork.title": "Devbox: 刷新网络", + "devbox.gotoDatabaseWebPage.title": "Devbox: 打开数据库网页", + "devbox.myProjects.title": "我的项目", + "devbox.feedback.title": "反馈", + "devbox.network.title": "网络", + "devbox.database.title": "数据库", + "devbox.devbox.title": "Devbox", + "devbox.welcome.title": "还没有Devbox,请创建一个新的Devbox。\n [创建Devbox](command:devboxDashboard.createDevbox)\n 要了解更多关于如何使用Devbox的信息,请访问[Devbox文档](https://sailos.io/docs/quick-start)。" +} diff --git a/extensions/ide/vscode/devbox/src/api/db.ts b/extensions/ide/vscode/devbox/src/api/db.ts new file mode 100644 index 00000000000..605eddb1ed3 --- /dev/null +++ b/extensions/ide/vscode/devbox/src/api/db.ts @@ -0,0 +1,16 @@ +import { GET } from './index' + +export const getDBList = async () => { + const { dbList } = await GET('/api/v1/getDBSecretList') + return dbList +} + +export interface DBResponse { + dbName: string + dbType: string + username: string + password: string + host: string + port: number + connection: string +} diff --git a/extensions/ide/vscode/devbox/src/api/devbox.ts b/extensions/ide/vscode/devbox/src/api/devbox.ts new file mode 100644 index 00000000000..1da38dcc036 --- /dev/null +++ b/extensions/ide/vscode/devbox/src/api/devbox.ts @@ -0,0 +1,14 @@ +import { GET } from './index' + +export const getDevboxDetail = async (token: string, hostName: string) => { + const { devbox } = await GET( + `https://devbox.${hostName}/api/v1/getDevboxDetail`, + {}, + { + headers: { + Authorization: encodeURIComponent(token), + }, + } + ) + return devbox +} diff --git a/extensions/ide/vscode/devbox/src/api/index.ts b/extensions/ide/vscode/devbox/src/api/index.ts new file mode 100644 index 00000000000..e3c93354b1a --- /dev/null +++ b/extensions/ide/vscode/devbox/src/api/index.ts @@ -0,0 +1,159 @@ +import * as vscode from 'vscode' +import axios, { + InternalAxiosRequestConfig, + AxiosHeaders, + AxiosResponse, + AxiosRequestConfig, +} from 'axios' + +import { developmentUrl, isDevelopment } from '../constant/api' +import { GlobalStateManager } from '../utils/globalStateManager' + +const showStatus = (status: number) => { + let message = '' + switch (status) { + case 400: + message = 'request error(400)' + break + case 401: + message = 'unauthorized, please login again(401)' + break + case 403: + message = 'access denied(403)' + break + case 404: + message = 'request error(404)' + break + case 408: + message = 'request timeout(408)' + break + case 500: + message = 'server error(500)' + break + case 501: + message = 'service not implemented(501)' + break + case 502: + message = 'network error(502)' + break + case 503: + message = 'service unavailable(503)' + break + case 504: + message = 'network timeout(504)' + break + case 505: + message = 'HTTP version not supported(505)' + break + default: + message = `connection error(${status})!` + } + return `${message}, please check the network or contact the administrator!` +} + +export const updateBaseUrl = (newBaseUrl: string) => { + request.defaults.baseURL = newBaseUrl +} + +const request = axios.create({ + baseURL: isDevelopment ? developmentUrl : '.', + withCredentials: true, + timeout: 60000, +}) + +// request interceptor +request.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + // auto append service prefix + if (config.url && !config.url?.startsWith('/api/')) { + config.url = '' + config.url + } + let _headers: AxiosHeaders = config.headers + + const workspaceFolders = vscode.workspace.workspaceFolders + + if (workspaceFolders && workspaceFolders.length > 0) { + const workspaceFolder = workspaceFolders[0] + const remoteUri = workspaceFolder.uri.authority + const devboxId = remoteUri.replace(/^ssh-remote\+/, '') // devbox = sshHostLabel + if (!_headers['Authorization']) { + _headers['Authorization'] = encodeURIComponent( + GlobalStateManager.getToken(devboxId) || '' + ) + } + } + + if (!config.headers || config.headers['Content-Type'] === '') { + _headers['Content-Type'] = 'application/json' + } + + config.headers = _headers + return config + }, + (error: any) => { + error.data = {} + error.data.msg = 'server error, please contact the administrator!' + return Promise.resolve(error) + } +) + +// response interceptor +request.interceptors.response.use( + (response: AxiosResponse) => { + const { status, data } = response + if (status < 200 || status >= 300) { + return Promise.reject( + status + ':' + showStatus(status) + ', ' + typeof data === 'string' + ? data + : String(data) + ) + } + + const apiResp = data + if (apiResp.code < 200 || apiResp.code >= 400) { + return Promise.reject(apiResp.code + ':' + apiResp.message) + } + + response.data = apiResp.data + return response.data + }, + (error: any) => { + if (axios.isCancel(error)) { + return Promise.reject('cancel request' + String(error)) + } else { + error.errMessage = + 'request timeout or server error, please check the network or contact the administrator!' + } + return Promise.reject(error) + } +) + +export function GET<T = any>( + url: string, + data?: { [key: string]: any }, + config?: AxiosRequestConfig +): Promise<T> { + return request.get(url, { + params: data, + ...config, + }) +} + +export function POST<T = any>( + url: string, + data?: { [key: string]: any }, + config?: AxiosRequestConfig +): Promise<T> { + return request.post(url, data, config) +} + +export function DELETE<T = any>( + url: string, + data?: { [key: string]: any }, + config?: AxiosRequestConfig +): Promise<T> { + return request.delete(url, { + params: data, + ...config, + }) +} diff --git a/extensions/ide/vscode/devbox/src/api/network.ts b/extensions/ide/vscode/devbox/src/api/network.ts new file mode 100644 index 00000000000..dd201a94295 --- /dev/null +++ b/extensions/ide/vscode/devbox/src/api/network.ts @@ -0,0 +1,14 @@ +import { GET } from './index' + +export const getNetworkList = async () => { + const { networks } = await GET('/api/v1/getNetworkList') + return networks +} + +export interface NetworkResponse { + address: string + port: number + protocol: string + name: string + namespace: string +} diff --git a/extensions/ide/vscode/devbox/src/api/ssh.ts b/extensions/ide/vscode/devbox/src/api/ssh.ts new file mode 100644 index 00000000000..94fecae7414 --- /dev/null +++ b/extensions/ide/vscode/devbox/src/api/ssh.ts @@ -0,0 +1,53 @@ +import fs from 'fs' + +import { GlobalStateManager } from '../utils/globalStateManager' + +export const parseSSHConfig = (filePath: string) => { + return new Promise((resolve, reject) => { + fs.readFile(filePath, 'utf-8', (err: any, data: any) => { + if (err) { + return reject(err) + } + + const lines = data.split('\n') + const devboxList = [] as any[] + let currentHostObj = {} as any + + lines.forEach((line: string) => { + line = line.trim() + + if (line.startsWith('Host ')) { + if (currentHostObj.strictHostKeyChecking) { + currentHostObj.remotePath = GlobalStateManager.getWorkDir( + currentHostObj.host + ) + devboxList.push(currentHostObj) + } + currentHostObj = { host: line.split(' ')[1] } + } else if (line.startsWith('HostName ')) { + currentHostObj.hostName = line.split(' ')[1] + } else if (line.startsWith('User ')) { + currentHostObj.user = line.split(' ')[1] + } else if (line.startsWith('Port ')) { + currentHostObj.port = line.split(' ')[1] + } else if (line.startsWith('IdentityFile ')) { + currentHostObj.identityFile = line.split(' ')[1] + } else if (line.startsWith('IdentitiesOnly ')) { + currentHostObj.identitiesOnly = line.split(' ')[1] + } else if (line.startsWith('StrictHostKeyChecking ')) { + currentHostObj.strictHostKeyChecking = line.split(' ')[1] + } + }) + + // the last one + if (!!currentHostObj.strictHostKeyChecking) { + currentHostObj.remotePath = GlobalStateManager.getWorkDir( + currentHostObj.host + ) + devboxList.push(currentHostObj) + } + + resolve(devboxList) + }) + }) +} diff --git a/extensions/ide/vscode/devbox/src/commands/remoteConnector.ts b/extensions/ide/vscode/devbox/src/commands/remoteConnector.ts new file mode 100644 index 00000000000..ce3e47eb629 --- /dev/null +++ b/extensions/ide/vscode/devbox/src/commands/remoteConnector.ts @@ -0,0 +1,288 @@ +import * as os from 'os' +import * as fs from 'fs' +import dayjs from 'dayjs' +import * as vscode from 'vscode' +import SSHConfig from 'ssh-config' + +import { + defaultSSHConfigPath, + defaultDevboxSSHConfigPath, + defaultSSHKeyPath, +} from '../constant/file' +import { Logger } from '../common/logger' +import { Disposable } from '../common/dispose' +import { convertSSHConfigToVersion2 } from '../utils/sshConfig' +import { GlobalStateManager } from '../utils/globalStateManager' +import { ensureFileAccessPermission, ensureFileExists } from '../utils/file' + +export class RemoteSSHConnector extends Disposable { + constructor(context: vscode.ExtensionContext) { + super() + if (context.extension.extensionKind === vscode.ExtensionKind.UI) { + this._register( + vscode.commands.registerCommand('devbox.connectRemoteSSH', (args) => + this.connectRemoteSSH(args) + ) + ) + } + } + + private replaceHomePathInConfig(content: string): string { + const includePattern = new RegExp( + `Include ${os.homedir()}/.ssh/sealos/devbox_config`, + 'g' + ) + const includePattern2 = new RegExp( + `Include "${os.homedir()}/.ssh/sealos/devbox_config"`, + 'g' + ) + + const includeLine = `Include ~/.ssh/sealos/devbox_config` + + if (includePattern.test(content)) { + return content.replace(includePattern, '') + } + + if (includePattern2.test(content)) { + return content.replace(includePattern2, '') + } + + if (content.includes(includeLine)) { + return content + } + + return `${includeLine}\n${content}` + } + + private sshConfigPreProcess() { + Logger.info('SSH config pre-processing') + // 1. ensure .ssh/config exists + ensureFileExists(defaultSSHConfigPath, '.ssh') + // 2. ensure .ssh/sealos/devbox_config exists + ensureFileExists(defaultDevboxSSHConfigPath, '.ssh/sealos') + + const customConfigFile = vscode.workspace + .getConfiguration('remote.SSH') + .get<string>('configFile', '') + + if (customConfigFile) { + const resolvedPath = customConfigFile.replace(/^~/, os.homedir()) + try { + const existingSSHConfig = fs.readFileSync(resolvedPath, 'utf8') + const updatedConfig = this.replaceHomePathInConfig(existingSSHConfig) + if (updatedConfig !== existingSSHConfig) { + fs.writeFileSync(resolvedPath, updatedConfig) + } + } catch (error) { + console.error(`Error reading/writing SSH config: ${error}`) + this.handleDefaultSSHConfig() + } + } else { + this.handleDefaultSSHConfig() + } + // 4. ensure sshConfig from version1 to version2 + convertSSHConfigToVersion2(defaultDevboxSSHConfigPath) + + Logger.info('SSH config pre-processing completed') + } + // backup the devbox ssh config + private sshConfigPostProcess() { + Logger.info('SSH config post-processing') + + try { + const devboxSSHConfig = fs.readFileSync( + defaultDevboxSSHConfigPath, + 'utf8' + ) + const backupFolderPath = defaultSSHKeyPath + '/backup/devbox_config' + if (!fs.existsSync(backupFolderPath)) { + fs.mkdirSync(backupFolderPath, { recursive: true }) + } + const backFileName = dayjs().format('YYYY-MM-DD_HH-mm-ss') + const backupFilePath = `${backupFolderPath}/${backFileName}` + + fs.writeFileSync(backupFilePath, devboxSSHConfig) + Logger.info(`SSH config backed up to ${backupFilePath}`) + } catch (error) { + Logger.error(`Failed to backup SSH config: ${error}`) + } + } + + private handleDefaultSSHConfig() { + const existingSSHConfig = fs.readFileSync(defaultSSHConfigPath, 'utf8') + const updatedConfig = this.replaceHomePathInConfig(existingSSHConfig) + if (updatedConfig !== existingSSHConfig) { + fs.writeFileSync(defaultSSHConfigPath, updatedConfig) + } + } + + private async connectRemoteSSH(args: { + sshDomain: string + sshPort: string + base64PrivateKey: string + sshHostLabel: string + workingDir: string + }) { + Logger.info(`Connecting to remote SSH: ${args.sshHostLabel}`) + + this.ensureRemoteSSHExtInstalled() + + const { sshDomain, sshPort, base64PrivateKey, sshHostLabel, workingDir } = + args + + const sshUser = sshDomain.split('@')[0] + const sshHost = sshDomain.split('@')[1] + + // sshHostLabel: usw.sailos.io_ns-admin_devbox-1 + + const normalPrivateKey = Buffer.from(base64PrivateKey, 'base64') + + const sshConfig = new SSHConfig().append({ + Host: sshHostLabel, + HostName: sshHost, + User: sshUser, + Port: sshPort, + IdentityFile: `~/.ssh/sealos/${sshHostLabel}`, + IdentitiesOnly: 'yes', + StrictHostKeyChecking: 'no', + }) + const sshConfigString = SSHConfig.stringify(sshConfig) + + GlobalStateManager.addApiRegion(sshHost) + + this.sshConfigPreProcess() + + try { + Logger.info('Writing SSH config to .ssh/sealos/devbox_config') + + const existingDevboxConfigLines = fs + .readFileSync(defaultDevboxSSHConfigPath, 'utf8') + .split('\n') + + // replace the existing ssh config item + const newDevboxConfigLines = [] + let skipLines = false + + for (let i = 0; i < existingDevboxConfigLines.length; i++) { + const line = existingDevboxConfigLines[i].trim() + + if ( + line.startsWith('Host ') && + line.substring(5).trim().startsWith(sshHostLabel) + ) { + skipLines = true + continue + } + + if (skipLines) { + if ( + line.startsWith('Host ') || + i === existingDevboxConfigLines.length + ) { + skipLines = false + } + } + + if (!skipLines) { + newDevboxConfigLines.push(existingDevboxConfigLines[i]) + } + } + + fs.writeFileSync( + defaultDevboxSSHConfigPath, + newDevboxConfigLines.join('\n') + ) + + // 5. write new ssh config to .ssh/sealos/devbox_config + fs.appendFileSync(defaultDevboxSSHConfigPath, `\n${sshConfigString}\n`) + + Logger.info('SSH config written to .ssh/sealos/devbox_config') + } catch (error) { + Logger.error(`Failed to write SSH configuration: ${error}`) + vscode.window.showErrorMessage( + `Failed to write SSH configuration: ${error}` + ) + } + + // 6. create sealos privateKey file in .ssh/sealos + try { + Logger.info('Creating sealos privateKey file in .ssh/sealos') + const sshKeyPath = defaultSSHKeyPath + `/${sshHostLabel}` + fs.writeFileSync(sshKeyPath, normalPrivateKey) + ensureFileAccessPermission(sshKeyPath) + Logger.info('Sealos privateKey file created in .ssh/sealos') + } catch (error) { + Logger.error(`Failed to write SSH private key: ${error}`) + vscode.window.showErrorMessage( + `Failed to write SSH private key: ${error}` + ) + } + + Logger.info('Opening Devbox in VSCode') + + await vscode.commands.executeCommand( + 'vscode.openFolder', + vscode.Uri.parse( + `vscode-remote://ssh-remote+${sshHostLabel}${workingDir}` + ), + { + forceNewWindow: true, + } + ) + + Logger.info('Devbox opened in VSCode') + + // refresh devboxList + await vscode.commands.executeCommand('devboxDashboard.refresh') + + this.sshConfigPostProcess() + } + + private async ensureRemoteSSHExtInstalled(): Promise<boolean> { + const isOfficialVscode = + vscode.env.uriScheme === 'vscode' || + vscode.env.uriScheme === 'vscode-insiders' || + vscode.env.uriScheme === 'cursor' + if (!isOfficialVscode) { + return true + } + + const msVscodeRemoteExt = vscode.extensions.getExtension( + 'ms-vscode-remote.remote-ssh' + ) + if (msVscodeRemoteExt) { + return true + } + + const install = 'Install' + const cancel = 'Cancel' + + const action = await vscode.window.showInformationMessage( + 'Please install "Remote - SSH" extension to connect to a Gitpod workspace.', + { modal: true }, + install, + cancel + ) + + if (action === cancel) { + return false + } + + vscode.window.showInformationMessage( + 'Installing "ms-vscode-remote.remote-ssh" extension' + ) + + await vscode.commands.executeCommand( + 'extension.open', + 'ms-vscode-remote.remote-ssh' + ) + await vscode.commands.executeCommand( + 'workbench.extensions.installExtension', + 'ms-vscode-remote.remote-ssh' + ) + + Logger.info('"ms-vscode-remote.remote-ssh" extension is installed') + + return true + } +} diff --git a/extensions/ide/vscode/devbox/src/commands/tools.ts b/extensions/ide/vscode/devbox/src/commands/tools.ts new file mode 100644 index 00000000000..a94ac643bf8 --- /dev/null +++ b/extensions/ide/vscode/devbox/src/commands/tools.ts @@ -0,0 +1,17 @@ +import * as vscode from 'vscode' + +import { Disposable } from '../common/dispose' + +export class ToolCommands extends Disposable { + constructor(context: vscode.ExtensionContext) { + super() + if (context.extension.extensionKind === vscode.ExtensionKind.UI) { + // open external link + this._register( + vscode.commands.registerCommand('devbox.openExternalLink', (args) => { + vscode.env.openExternal(vscode.Uri.parse(args)) + }) + ) + } + } +} diff --git a/extensions/ide/vscode/devbox/src/common/dispose.ts b/extensions/ide/vscode/devbox/src/common/dispose.ts new file mode 100644 index 00000000000..d2768ea0117 --- /dev/null +++ b/extensions/ide/vscode/devbox/src/common/dispose.ts @@ -0,0 +1,37 @@ +import * as vscode from 'vscode' + +export function disposeAll(disposables: vscode.Disposable[]): void { + while (disposables.length) { + const item = disposables.pop() + if (item) { + item.dispose() + } + } +} + +export abstract class Disposable { + private _isDisposed = false + + protected _disposables: vscode.Disposable[] = [] + + public dispose(): any { + if (this._isDisposed) { + return + } + this._isDisposed = true + disposeAll(this._disposables) + } + + protected _register<T extends vscode.Disposable>(value: T): T { + if (this._isDisposed) { + value.dispose() + } else { + this._disposables.push(value) + } + return value + } + + protected get isDisposed(): boolean { + return this._isDisposed + } +} diff --git a/extensions/ide/vscode/devbox/src/common/logger.ts b/extensions/ide/vscode/devbox/src/common/logger.ts new file mode 100644 index 00000000000..0419b436767 --- /dev/null +++ b/extensions/ide/vscode/devbox/src/common/logger.ts @@ -0,0 +1,31 @@ +import * as vscode from 'vscode' + +export class Logger { + private static outputChannel: vscode.OutputChannel + + static init(context: vscode.ExtensionContext) { + this.outputChannel = vscode.window.createOutputChannel('Devbox') + } + + static info(message: string) { + const log = `[INFO] ${new Date().toISOString()} ${message}` + this.outputChannel.appendLine(log) + } + + static error(message: string, error?: any) { + const errorMessage = error ? `${message}: ${error.toString()}` : message + const log = `[ERROR] ${new Date().toISOString()} ${errorMessage}` + this.outputChannel.appendLine(log) + } + + static debug(message: string) { + if (process.env.NODE_ENV === 'development') { + const log = `[DEBUG] ${new Date().toISOString()} ${message}` + this.outputChannel.appendLine(log) + } + } + + static show() { + this.outputChannel.show() + } +} diff --git a/extensions/ide/vscode/devbox/src/constant/api.ts b/extensions/ide/vscode/devbox/src/constant/api.ts new file mode 100644 index 00000000000..07d4c2c28dc --- /dev/null +++ b/extensions/ide/vscode/devbox/src/constant/api.ts @@ -0,0 +1,3 @@ +export const isDevelopment = process.env.NODE_ENV === 'development' + +export const developmentUrl = 'http://127.0.0.1:3000' diff --git a/extensions/ide/vscode/devbox/src/constant/file.ts b/extensions/ide/vscode/devbox/src/constant/file.ts new file mode 100644 index 00000000000..71d5657f405 --- /dev/null +++ b/extensions/ide/vscode/devbox/src/constant/file.ts @@ -0,0 +1,9 @@ +import path from 'path' +import * as os from 'os' + +export const defaultSSHConfigPath = path.resolve(os.homedir(), '.ssh/config') +export const defaultDevboxSSHConfigPath = path.resolve( + os.homedir(), + '.ssh/sealos/devbox_config' +) +export const defaultSSHKeyPath = path.resolve(os.homedir(), '.ssh/sealos') diff --git a/extensions/ide/vscode/devbox/src/extension.ts b/extensions/ide/vscode/devbox/src/extension.ts new file mode 100644 index 00000000000..d512c644974 --- /dev/null +++ b/extensions/ide/vscode/devbox/src/extension.ts @@ -0,0 +1,62 @@ +import * as vscode from 'vscode' + +import { updateBaseUrl } from './api' +import { Logger } from './common/logger' +import { UriHandler } from './utils/handleUri' +import { isDevelopment } from './constant/api' +import { ToolCommands } from './commands/tools' +import { RemoteSSHConnector } from './commands/remoteConnector' +import { DevboxListViewProvider } from './providers/DevboxListViewProvider' +import { NetworkViewProvider } from './providers/NetworkViewProvider' +import { DBViewProvider } from './providers/DBViewProvider' +import { GlobalStateManager } from './utils/globalStateManager' + +export async function activate(context: vscode.ExtensionContext) { + // Logger + Logger.init(context) + + // tools + const tools = new ToolCommands(context) + context.subscriptions.push(tools) + + // globalState manager + GlobalStateManager.init(context) + + // remote connector + const remoteConnector = new RemoteSSHConnector(context) + context.subscriptions.push(remoteConnector) + + // devboxList view + const devboxListViewProvider = new DevboxListViewProvider(context) + context.subscriptions.push(devboxListViewProvider) + + // update api base url + const workspaceFolders = vscode.workspace.workspaceFolders + if (workspaceFolders && workspaceFolders.length > 0 && !isDevelopment) { + const workspaceFolder = workspaceFolders[0] + const remoteUri = workspaceFolder.uri.authority + const devboxId = remoteUri.replace(/^ssh-remote\+/, '') // devbox = sshHostLabel + const region = GlobalStateManager.getRegion(devboxId) + updateBaseUrl(`http://devbox.${region}`) + } + + // network view + const networkViewProvider = new NetworkViewProvider(context) + context.subscriptions.push(networkViewProvider) + + // db view + const dbViewProvider = new DBViewProvider(context) + context.subscriptions.push(dbViewProvider) + + // handle uri + const uriHandler = new UriHandler() + + context.subscriptions.push( + vscode.window.registerUriHandler({ + handleUri: (uri) => uriHandler.handle(uri), + }) + ) + console.log('Your extension "devbox" is now active!') +} + +export function deactivate() {} diff --git a/extensions/ide/vscode/devbox/src/providers/DBViewProvider.ts b/extensions/ide/vscode/devbox/src/providers/DBViewProvider.ts new file mode 100644 index 00000000000..b1c99f5a7ca --- /dev/null +++ b/extensions/ide/vscode/devbox/src/providers/DBViewProvider.ts @@ -0,0 +1,326 @@ +import * as vscode from 'vscode' + +import { getDBList, DBResponse } from '../api/db' +import { Disposable } from '../common/dispose' +import { GlobalStateManager } from '../utils/globalStateManager' +import { Logger } from '../common/logger' + +enum DBTypeEnum { + postgresql = 'postgresql', + mongodb = 'mongodb', + mysql = 'mysql', + redis = 'redis', + kafka = 'kafka', + qdrant = 'qdrant', + nebula = 'nebula', + weaviate = 'weaviate', + milvus = 'milvus', +} + +interface Database { + dbType: DBTypeEnum + username: string + password: string + host: string + port: number + connection: string +} +interface Messages { + columnDBType: string + columnUsername: string + columnPassword: string + columnHost: string + columnPort: string + copyPassword: string + copyConnection: string + connectionStringCopied: string + openWebTerminal: string +} + +const messages: Messages = { + columnDBType: vscode.l10n.t('Type'), + columnUsername: vscode.l10n.t('Username'), + columnPassword: vscode.l10n.t('Password'), + columnHost: vscode.l10n.t('Host'), + columnPort: vscode.l10n.t('Port'), + copyPassword: vscode.l10n.t('Copy Password'), + copyConnection: vscode.l10n.t('Copy Connection String'), + connectionStringCopied: vscode.l10n.t( + 'Connection string copied to clipboard!' + ), + openWebTerminal: vscode.l10n.t('Open Database Web Terminal'), +} + +export class DBViewProvider + extends Disposable + implements vscode.WebviewViewProvider +{ + private _view?: vscode.WebviewView + private _extensionUri: vscode.Uri + constructor(context: vscode.ExtensionContext) { + super() + Logger.info('Initializing DBViewProvider') + this._extensionUri = context.extensionUri + if (context.extension.extensionKind === vscode.ExtensionKind.UI) { + // view + context.subscriptions.push( + vscode.window.registerWebviewViewProvider('dbView', this, { + webviewOptions: { + retainContextWhenHidden: true, + }, + }) + ) + // commands + this._register( + vscode.commands.registerCommand('devbox.refreshDatabase', () => { + this.refreshDatabases() + }) + ) + let targetUrl = '' + const workspaceFolders = vscode.workspace.workspaceFolders + if (workspaceFolders && workspaceFolders.length > 0) { + const workspaceFolder = workspaceFolders[0] + const remoteUri = workspaceFolder.uri.authority + const devboxId = remoteUri.replace(/^ssh-remote\+/, '') // devbox = sshHostLabel + const region = GlobalStateManager.getRegion(devboxId) + targetUrl = `http://${region}?openapp=system-dbprovider` + this._register( + vscode.commands.registerCommand('devbox.gotoDatabaseWebPage', () => { + vscode.commands.executeCommand('devbox.openExternalLink', [ + targetUrl, + ]) + }) + ) + } + } + } + + public async resolveWebviewView( + webviewView: vscode.WebviewView, + context: vscode.WebviewViewResolveContext, + token: vscode.CancellationToken + ) { + this._view = webviewView + + webviewView.webview.options = { + enableScripts: true, + } + + webviewView.webview.onDidReceiveMessage(async (message) => { + switch (message.command) { + case 'refresh': + await this.refreshDatabases() + break + case 'copy': + await this.copyConnectionString(message.connection) + break + case 'openWebTerminal': + await this.openWebTerminal(message.dbInfo) + break + } + }) + + await this.refreshDatabases() + } + private async openWebTerminal(dbInfo: Database) { + console.log('dbInfo', dbInfo) + const commandMap = { + postgresql: `psql '${dbInfo.connection}'`, + mongodb: `mongosh '${dbInfo.connection}'`, + mysql: `mysql -h ${dbInfo.host} -P ${dbInfo.port} -u ${dbInfo.username} -p${dbInfo.password}`, + redis: `redis-cli -u redis://${dbInfo.username}:${dbInfo.password}@${dbInfo.host}:${dbInfo.port}`, + kafka: ``, + qdrant: ``, + nebula: ``, + weaviate: ``, + milvus: ``, + } + const targetCommand = encodeURIComponent(commandMap[dbInfo.dbType]) + let targetUrl = '' + const workspaceFolders = vscode.workspace.workspaceFolders + if (workspaceFolders && workspaceFolders.length > 0) { + const workspaceFolder = workspaceFolders[0] + const remoteUri = workspaceFolder.uri.authority + const devboxId = remoteUri.replace(/^ssh-remote\+/, '') // devbox = sshHostLabel + const region = GlobalStateManager.getRegion(devboxId) + targetUrl = `http://${region}?openapp=system-terminal?defaultCommand=${targetCommand}` + vscode.commands.executeCommand('devbox.openExternalLink', [targetUrl]) + } + } + + private async refreshDatabases() { + if (!this._view) { + return + } + + const dbList = await getDBList() + const databases = dbList.map((db: DBResponse) => ({ + dbType: db.dbType, + username: db.username, + password: db.password, + host: db.host, + port: db.port, + connection: db.connection, + })) + + this._view.webview.html = this.getWebviewContent(databases, messages) + } + + private async copyConnectionString(connection: string) { + await vscode.env.clipboard.writeText(connection) + vscode.window.showInformationMessage(messages.connectionStringCopied) + } + + private getWebviewContent(databases: Database[], messages: Messages) { + const codiconsUri = this._view?.webview.asWebviewUri( + vscode.Uri.joinPath( + this._extensionUri, + 'resources', + 'codicons', + 'codicon.css' + ) + ) + + return ` + <!DOCTYPE html> + <html> + <head> + <link rel="stylesheet" href="${codiconsUri}"> + <style> + body { + padding: 0; + margin: 0; + height: 100vh; + overflow: auto; + } + table { + width: 100%; + border-collapse: collapse; + } + th, td { + padding: 0px 8px; + text-align: left; + border: none; + color: var(--vscode-foreground) !important; + font-size: 13px; + font-family: var(--vscode-font-family); + } + th { + font-size: 14px !important; + position: sticky; + top: 0; + z-index: 1; + font-weight: 600; + } + tr:nth-child(even) { + background-color: color-mix(in srgb, var(--vscode-list-hoverBackground) 30%, transparent); + } + td { + padding: 0px 8px; + text-align: left; + border: none; + color: var(--vscode-editor-foreground); + } + .codicon { + font-family: codicons; + cursor: pointer; + padding: 4px; + color: var(--vscode-foreground) !important; + } + .codicon:hover { + background-color: var(--vscode-list-hoverBackground); + border-radius: 3px; + } + .actions { + opacity: 0; + transition: opacity 0.2s; + } + tr:hover .actions { + opacity: 1; + } + td:nth-child(5) { + color: var(--vscode-textLink-foreground) !important; + text-decoration: none; + cursor: pointer; + } + td:nth-child(5):hover { + text-decoration: underline; + } + </style> + </head> + <body> + <table> + <thead> + <tr> + <th style="width: 16px;"></th> + <th>${messages.columnDBType}</th> + <th>${messages.columnUsername}</th> + <th>${messages.columnPassword}</th> + <th>${messages.columnHost}</th> + <th>${messages.columnPort}</th> + </tr> + </thead> + <tbody> + ${databases + .map( + (db) => ` + <tr> + <td style="width: 16px;"></td> + <td>${db.dbType}</td> + <td>${db.username}</td> + <td> + ${'*'.repeat(8)} + <span class="actions"> + <span class="codicon codicon-clippy" onclick="copyPassword('${ + db.password + }')" title="${messages.copyPassword}"></span> + </span> + </td> + <td>${db.host}</td> + <td>${db.port}</td> + <td class="actions"> + <span class="codicon codicon-copy" onclick="copyConnection('${ + db.connection + }')" title="${messages.copyConnection}"></span> + <span class="codicon codicon-terminal" onclick="openWebTerminal({ + dbType: '${db.dbType}', + host: '${db.host}', + port: ${db.port}, + password: '${db.password}', + username: '${db.username}', + connection: '${db.connection}' + })" title="${messages.openWebTerminal}"></span> + </td> + + </tr> + ` + ) + .join('')} + </tbody> + </table> + <script> + const vscode = acquireVsCodeApi(); + function copyConnection(connection) { + vscode.postMessage({ + command: 'copy', + connection: connection + }); + } + function copyPassword(password) { + vscode.postMessage({ + command: 'copy', + connection: password + }); + } + function openWebTerminal(dbInfo) { + vscode.postMessage({ + command: 'openWebTerminal', + dbInfo: dbInfo, + }) + } + </script> + </body> + </html> + ` + } +} diff --git a/extensions/ide/vscode/devbox/src/providers/DevboxListViewProvider.ts b/extensions/ide/vscode/devbox/src/providers/DevboxListViewProvider.ts new file mode 100644 index 00000000000..3600a9851ee --- /dev/null +++ b/extensions/ide/vscode/devbox/src/providers/DevboxListViewProvider.ts @@ -0,0 +1,430 @@ +import fs from 'fs' +import * as vscode from 'vscode' + +import { Logger } from '../common/logger' +import { parseSSHConfig } from '../api/ssh' +import { Disposable } from '../common/dispose' +import { DevboxListItem } from '../types/devbox' +import { getDevboxDetail } from '../api/devbox' +import { convertSSHConfigToVersion2 } from '../utils/sshConfig' +import { GlobalStateManager } from '../utils/globalStateManager' +import { defaultDevboxSSHConfigPath, defaultSSHKeyPath } from '../constant/file' + +const messages = { + pleaseSelectARegion: vscode.l10n.t( + 'Please select a region,RegionList are added by your each connection.' + ), + onlyDevboxCanBeOpened: vscode.l10n.t('Only Devbox can be opened.'), + areYouSureToDelete: vscode.l10n.t('Are you sure to delete?'), + deleteLocalConfigOnly: vscode.l10n.t( + 'This action will only delete the devbox ssh config in the local environment.' + ), + deleteDevboxFailed: vscode.l10n.t('Delete Devbox failed.'), + feedbackInGitHub: vscode.l10n.t( + 'Give us a feedback in our GitHub repository.' + ), + feedbackInHelpDesk: vscode.l10n.t( + 'Give us a feedback in our help desk system.' + ), +} + +export class DevboxListViewProvider extends Disposable { + constructor(context: vscode.ExtensionContext) { + super() + Logger.info('Initializing DevboxListViewProvider') + if (context.extension.extensionKind === vscode.ExtensionKind.UI) { + // view + const projectTreeDataProvider = new ProjectTreeDataProvider() + const feedbackTreeDataProvider = new FeedbackTreeDataProvider() + const devboxDashboardView = vscode.window.createTreeView( + 'devboxDashboard', + { + treeDataProvider: projectTreeDataProvider, + } + ) + const feedbackTreeView = vscode.window.createTreeView('devboxFeedback', { + treeDataProvider: feedbackTreeDataProvider, + }) + this._register(feedbackTreeView) + this._register(devboxDashboardView) + this._register( + devboxDashboardView.onDidChangeVisibility(() => { + if (devboxDashboardView.visible) { + projectTreeDataProvider.refresh() + } + }) + ) + // commands + this._register( + vscode.commands.registerCommand('devboxDashboard.refresh', () => { + projectTreeDataProvider.refresh() + }) + ) + this._register( + vscode.commands.registerCommand( + 'devboxDashboard.createDevbox', + (item: ProjectTreeItem) => { + projectTreeDataProvider.create(item) + } + ) + ) + this._register( + vscode.commands.registerCommand( + 'devboxDashboard.openDevbox', + (item: ProjectTreeItem) => { + projectTreeDataProvider.open(item) + } + ) + ) + this._register( + vscode.commands.registerCommand( + 'devboxDashboard.deleteDevbox', + (item: ProjectTreeItem) => { + projectTreeDataProvider.delete( + item.host, + item.label as string, + false + ) + } + ) + ) + } + } +} + +class ProjectTreeDataProvider + implements vscode.TreeDataProvider<ProjectTreeItem> +{ + private _onDidChangeTreeData: vscode.EventEmitter< + ProjectTreeItem | undefined + > = new vscode.EventEmitter<ProjectTreeItem | undefined>() + readonly onDidChangeTreeData: vscode.Event<ProjectTreeItem | undefined> = + this._onDidChangeTreeData.event + private treeData: DevboxListItem[] = [] + + constructor() { + convertSSHConfigToVersion2(defaultDevboxSSHConfigPath) + this.refreshData() + setInterval(() => { + this.refresh() + }, 3 * 1000) + } + + refresh(): void { + this.refreshData() + } + + private async refreshData(): Promise<void> { + const data = (await parseSSHConfig( + defaultDevboxSSHConfigPath + )) as DevboxListItem[] + + data.forEach((item) => { + GlobalStateManager.addApiRegion(item.hostName) + }) + + this.treeData = data + + await Promise.all( + this.treeData.map(async (item) => { + const token = GlobalStateManager.getToken(item.host) + if (!token) { + return + } + try { + const data = await getDevboxDetail(token, item.hostName) + const status = data.status.value + switch (status) { + case 'Running': + item.iconPath = new vscode.ThemeIcon('debug-start') + break + case 'Stopped': + item.iconPath = new vscode.ThemeIcon('debug-pause') + break + case 'Error': + item.iconPath = new vscode.ThemeIcon('error') + break + default: + item.iconPath = new vscode.ThemeIcon('question') + } + } catch (error) { + console.error(`get devbox detail failed: ${error}`) + // if ( + // error.toString().includes('500:secrets') && + // error.toString().includes('not found') + // ) { + // const hostParts = item.host.split('_') + // const devboxName = hostParts.slice(2).join('_') + // if (error.toString().includes(devboxName)) { + // await this.delete(item.host, devboxName, true) + + // return + // } + // } + item.iconPath = new vscode.ThemeIcon('warning') + } + }) + ) + + this._onDidChangeTreeData.fire(undefined) + } + + getTreeItem(element: ProjectTreeItem): vscode.TreeItem { + return element + } + + async create(item: ProjectTreeItem) { + const regions = GlobalStateManager.getApiRegionList() + + const selected = await vscode.window.showQuickPick(regions, { + placeHolder: messages.pleaseSelectARegion, + }) + if (selected) { + const targetUrl = selected + vscode.commands.executeCommand('devbox.openExternalLink', [ + `https://${targetUrl}/?openapp=system-devbox?${encodeURIComponent( + 'page=create' + )}`, + ]) + } + } + + async open(item: ProjectTreeItem) { + if (item.contextValue !== 'devbox') { + vscode.window.showInformationMessage(messages.onlyDevboxCanBeOpened) + return + } + + vscode.commands.executeCommand( + 'vscode.openFolder', + vscode.Uri.parse( + `vscode-remote://ssh-remote+${item.host}${item.remotePath}` + ), + { + forceNewWindow: true, + } + ) + } + + async delete( + deletedHost: string, + devboxName: string, + isDeletedByWeb?: boolean + ) { + if (!isDeletedByWeb) { + const result = await vscode.window.showWarningMessage( + `${messages.areYouSureToDelete} ${devboxName}?\n(${messages.deleteLocalConfigOnly})`, + { modal: true }, + 'Yes', + 'No' + ) + if (result !== 'Yes') { + return + } + } + + try { + // 1. remove global state + GlobalStateManager.remove(deletedHost) + + // 2. remove remote-ssh config + const existingSSHHostPlatforms = vscode.workspace + .getConfiguration('remote.SSH') + .get<{ [host: string]: string }>('remotePlatform', {}) + const newSSHHostPlatforms = Object.keys(existingSSHHostPlatforms).reduce( + (acc: { [host: string]: string }, host: string) => { + if (host.startsWith(deletedHost)) { + return acc + } + acc[host] = existingSSHHostPlatforms[host] + return acc + }, + {} + ) + await vscode.workspace + .getConfiguration('remote.SSH') + .update( + 'remotePlatform', + newSSHHostPlatforms, + vscode.ConfigurationTarget.Global + ) + + // 3. remove ssh config + const content = await fs.promises.readFile( + defaultDevboxSSHConfigPath, + 'utf8' + ) + const lines = content.split('\n') + + let newLines = [] + let skipLines = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim() + + if (line.startsWith('Host ')) { + const hostValue = line.split(' ')[1] + if (hostValue === deletedHost) { + skipLines = true + continue + } else { + skipLines = false + } + } + + if (skipLines && line.startsWith('Host ')) { + skipLines = false + } + + if (!skipLines) { + newLines.push(lines[i]) + } + } + + await fs.promises.writeFile( + defaultDevboxSSHConfigPath, + newLines.join('\n') + ) + + // 4. delete private key file + const privateKeyPath = `${defaultSSHKeyPath}/${deletedHost}` + fs.rmSync(privateKeyPath) + + // TODO: delete known_host public key + + this.refresh() + } catch (error) { + vscode.window.showErrorMessage( + `${messages.deleteDevboxFailed}: ${error.message}` + ) + } + } + + getChildren(element?: ProjectTreeItem): Thenable<ProjectTreeItem[]> { + if (!element) { + // domain/namespace + const domainNamespacePairs = this.treeData.reduce((acc, item) => { + const [domain, namespace] = item.host.split('_') + acc.add(`${domain}/${namespace}`) + return acc + }, new Set<string>()) + + return Promise.resolve( + Array.from(domainNamespacePairs).map((pair) => { + const [domain, namespace] = pair.split('/') + return new ProjectTreeItem( + pair, + domain, + 0, + vscode.TreeItemCollapsibleState.Collapsed, + namespace + ) + }) + ) + } else { + // devbox + const [domain, namespace] = element.label?.toString().split('/') || [] + const devboxes = this.treeData.filter((item) => { + const parts = item.host.split('_') + return parts[0] === domain && parts[1] === namespace + }) + + return Promise.resolve( + devboxes.map((devbox) => { + const parts = devbox.host.split('_') + const devboxName = parts.slice(2).join('_') + const treeItem = new ProjectTreeItem( + devboxName, + devbox.hostName, + devbox.port, + vscode.TreeItemCollapsibleState.None, + namespace, + devboxName, + devbox.host, + devbox.remotePath, + devbox.iconPath + ) + treeItem.contextValue = 'devbox' + return treeItem + }) + ) + } + } +} + +class ProjectTreeItem extends vscode.TreeItem { + domain: string + namespace?: string + devboxName?: string + sshPort: number + host: string + remotePath: string + + constructor( + label: string, + domain: string, + sshPort: number, + collapsibleState: vscode.TreeItemCollapsibleState, + namespace?: string, + devboxName?: string, + host?: string, + remotePath?: string, + iconPath?: vscode.ThemeIcon + ) { + super(label, collapsibleState) + this.domain = domain + this.namespace = namespace + this.devboxName = devboxName + this.sshPort = sshPort + this.host = host || '' + this.remotePath = remotePath || '/home/sealos/project' + this.iconPath = iconPath + + this.contextValue = devboxName ? 'devbox' : undefined + } +} + +class FeedbackTreeDataProvider + implements vscode.TreeDataProvider<FeedbackTreeItem> +{ + getTreeItem(element: FeedbackTreeItem): vscode.TreeItem { + return element + } + getChildren(element?: FeedbackTreeItem): Thenable<FeedbackTreeItem[]> { + return Promise.resolve([ + new FeedbackTreeItem( + messages.feedbackInGitHub, + vscode.TreeItemCollapsibleState.None, + new vscode.ThemeIcon('github'), + { + command: 'devbox.openExternalLink', + title: 'Open GitHub', + arguments: ['https://github.com/labring/sealos/issues/new/choose'], + } + ), + new FeedbackTreeItem( + messages.feedbackInHelpDesk, + vscode.TreeItemCollapsibleState.None, + new vscode.ThemeIcon('comment'), + { + command: 'devbox.openExternalLink', + title: 'Open Help Desk', + arguments: ['https://hzh.sealos.run/?openapp=system-workorder'], + } + ), + ]) + } +} +class FeedbackTreeItem extends vscode.TreeItem { + constructor( + label: string, + collapsibleState: vscode.TreeItemCollapsibleState, + iconPath?: vscode.ThemeIcon, + command?: vscode.Command + ) { + super(label, collapsibleState) + this.iconPath = iconPath + this.contextValue = 'feedback' + this.command = command + } +} diff --git a/extensions/ide/vscode/devbox/src/providers/NetworkViewProvider.ts b/extensions/ide/vscode/devbox/src/providers/NetworkViewProvider.ts new file mode 100644 index 00000000000..23988cf9b4c --- /dev/null +++ b/extensions/ide/vscode/devbox/src/providers/NetworkViewProvider.ts @@ -0,0 +1,215 @@ +import * as vscode from 'vscode' +import { Logger } from '../common/logger' +import { Disposable } from '../common/dispose' +import { getNetworkList, NetworkResponse } from '../api/network' + +interface Network { + address: string + port: number + protocol: string +} + +const messages = { + port: vscode.l10n.t('Port'), + protocol: vscode.l10n.t('Protocol'), + address: vscode.l10n.t('Address'), + openInBrowser: vscode.l10n.t('Open in Browser'), + previewInEditor: vscode.l10n.t('Preview in Editor'), +} + +export class NetworkViewProvider + extends Disposable + implements vscode.WebviewViewProvider +{ + private _view?: vscode.WebviewView + private _extensionUri: vscode.Uri + + constructor(context: vscode.ExtensionContext) { + super() + Logger.info('Initializing NetworkViewProvider') + this._extensionUri = context.extensionUri + if (context.extension.extensionKind === vscode.ExtensionKind.UI) { + context.subscriptions.push( + vscode.window.registerWebviewViewProvider('networkView', this, { + webviewOptions: { + retainContextWhenHidden: true, + }, + }) + ) + this._register( + vscode.commands.registerCommand('devbox.refreshNetwork', () => { + this.refreshNetworks() + }) + ) + } + } + + public async resolveWebviewView( + webviewView: vscode.WebviewView, + context: vscode.WebviewViewResolveContext, + token: vscode.CancellationToken + ) { + this._view = webviewView + + webviewView.webview.options = { + enableScripts: true, + } + + webviewView.webview.onDidReceiveMessage(async (message) => { + switch (message.command) { + case 'refresh': + await this.refreshNetworks() + break + case 'openExternal': + vscode.commands.executeCommand('devbox.openExternalLink', message.url) + break + case 'openIntegrated': + vscode.commands.executeCommand('simpleBrowser.show', message.url) + break + } + }) + + await this.refreshNetworks() + } + + private async refreshNetworks() { + if (!this._view) { + return + } + + const networks = await getNetworkList() + const networkItems = networks.map((network: NetworkResponse) => ({ + address: network.address, + port: network.port, + protocol: network.protocol, + })) + + this._view.webview.html = this.getWebviewContent(networkItems) + } + + private getWebviewContent(networks: Network[]) { + const codiconsUri = this._view?.webview.asWebviewUri( + vscode.Uri.joinPath( + this._extensionUri, + 'resources', + 'codicons', + 'codicon.css' + ) + ) + + return ` + <!DOCTYPE html> + <html> + <head> + <link rel="stylesheet" href="${codiconsUri}"> + <style> + body { + padding: 0; + margin: 0; + height: 100vh; + overflow: auto; + } + table { + width: 100%; + border-collapse: collapse; + } + th, td { + padding: 0px 8px; + text-align: left; + border: none; + color: var(--vscode-foreground) !important; + font-size: 13px; + font-family: var(--vscode-font-family); + } + th { + font-size: 14px !important; + position: sticky; + top: 0; + z-index: 1; + font-weight: 600; + } + tr:nth-child(even) { + background-color: color-mix(in srgb, var(--vscode-list-hoverBackground) 30%, transparent); + } + td { + padding: 0px 8px; + text-align: left; + border: none; + color: var(--vscode-editor-foreground); + } + td:nth-child(4) { + color: var(--vscode-textLink-foreground) !important; + text-decoration: none; + cursor: pointer; + } + td:nth-child(4):hover { + text-decoration: underline; + } + .codicon { + font-family: codicons; + cursor: pointer; + padding: 4px; + color: var(--vscode-foreground) !important; + } + .codicon:hover { + background-color: var(--vscode-list-hoverBackground); + border-radius: 3px; + } + .actions { + opacity: 0; + transition: opacity 0.2s; + } + tr:hover .actions { + opacity: 1; + } + </style> + </head> + <body> + <table> + <thead> + <tr> + <th style="width: 16px;"></th> + <th>${messages.port}</th> + <th>${messages.protocol}</th> + <th>${messages.address}</th> + </tr> + </thead> + <tbody> + ${networks + .map( + (network) => ` + <tr> + <td style="width: 16px;"></td> + <td>${network.port}</td> + <td>${network.protocol}</td> + <td>${network.address}</td> + <td class="actions"> + <span class="codicon codicon-globe" onclick="openExternal('https://${network.address}')" title="${messages.openInBrowser}"></span> + <span class="codicon codicon-open-preview" onclick="openIntegrated('https://${network.address}')" title="${messages.previewInEditor}"></span> + </td> + </tr> + ` + ) + .join('')} + </tbody> + </table> + <script> + const vscode = acquireVsCodeApi(); + function openExternal(url) { + vscode.postMessage({ + command: 'openExternal', + url: url + }); + } + function openIntegrated(url) { + vscode.postMessage({ + command: 'openIntegrated', + url: url + }); + } + </script> + </body> + </html> + ` + } +} diff --git a/extensions/ide/vscode/devbox/src/types/devbox.d.ts b/extensions/ide/vscode/devbox/src/types/devbox.d.ts new file mode 100644 index 00000000000..2b8468c0012 --- /dev/null +++ b/extensions/ide/vscode/devbox/src/types/devbox.d.ts @@ -0,0 +1,12 @@ +import * as vscode from 'vscode' + +export interface DevboxListItem { + hostName: string + host: string + user?: string + port: number + identityFile?: string + status?: string + remotePath?: string + iconPath?: vscode.ThemeIcon +} diff --git a/extensions/ide/vscode/devbox/src/utils/file.ts b/extensions/ide/vscode/devbox/src/utils/file.ts new file mode 100644 index 00000000000..b6b888b19c5 --- /dev/null +++ b/extensions/ide/vscode/devbox/src/utils/file.ts @@ -0,0 +1,46 @@ +import * as os from 'os' +import path from 'path' +import * as fs from 'fs' +import { execa } from 'execa' +import { Logger } from '../common/logger' + +// File access permission modification +export const ensureFileAccessPermission = async (path: string) => { + Logger.info(`Ensuring file access permission for ${path}`) + if (os.platform() === 'win32') { + try { + const username = os.userInfo().username + if (!username) { + throw new Error('can not get username') + } + // await execa('icacls', [path, '/inheritance:r']) + // await execa('icacls', [path, '/grant:r', `${username}:F`]) + // await execa('icacls', [path, '/remove:g', 'everyone']) + } catch (error) { + Logger.error(`Failed to set file access permission: ${error}`) + } + } else { + await execa('chmod', ['600', path]) + } + + Logger.info(`File access permission set for ${path}`) +} + +export function ensureFileExists(filePath: string, parentDir: string) { + if (filePath.indexOf('\0') !== -1 || parentDir.indexOf('\0') !== -1) { + throw new Error('Invalid path') + } + const safeFilePath = path.normalize(filePath).replace(/^(\.\.(\/|\\|$))+/, '') + const safeParentDir = path + .normalize(parentDir) + .replace(/^(\.\.(\/|\\|$))+/, '') + + if (!fs.existsSync(safeFilePath)) { + fs.mkdirSync(path.resolve(os.homedir(), safeParentDir), { + recursive: true, + }) + fs.writeFileSync(filePath, '', 'utf8') + // .ssh/config authority + ensureFileAccessPermission(filePath) + } +} diff --git a/extensions/ide/vscode/devbox/src/utils/globalStateManager.ts b/extensions/ide/vscode/devbox/src/utils/globalStateManager.ts new file mode 100644 index 00000000000..74ef8af9a19 --- /dev/null +++ b/extensions/ide/vscode/devbox/src/utils/globalStateManager.ts @@ -0,0 +1,109 @@ +import * as vscode from 'vscode' + +interface DevboxGlobalState { + token: string + workDir: string + region: string +} + +export class GlobalStateManager { + private static context: vscode.ExtensionContext + + static init(context: vscode.ExtensionContext) { + GlobalStateManager.context = context + } + + // devboxId = `sshDomain_namespace_devboxName` = sshHostLabel + + // devboxId:{ + // token: string + // workDir: string + // region: string + // } + static getToken(devboxId: string): string | undefined { + const state = (GlobalStateManager.context.globalState.get( + devboxId + ) as DevboxGlobalState) || { + token: '', + workDir: '', + region: '', + } + return state.token + } + static getWorkDir(devboxId: string): string | undefined { + const state = (GlobalStateManager.context.globalState.get( + devboxId + ) as DevboxGlobalState) || { + token: '', + workDir: '', + region: '', + } + return state.workDir + } + + static getRegion(devboxId: string): string | undefined { + const state = (GlobalStateManager.context.globalState.get( + devboxId + ) as DevboxGlobalState) || { + token: '', + workDir: '', + region: '', + } + return state.region + } + + static setToken(devboxId: string, token: string) { + const state = + (GlobalStateManager.context.globalState.get( + devboxId + ) as DevboxGlobalState) || {} + const newState = { + ...state, + token, + } + GlobalStateManager.context.globalState.update(devboxId, newState) + } + + static setWorkDir(devboxId: string, workDir: string) { + const state = + (GlobalStateManager.context.globalState.get( + devboxId + ) as DevboxGlobalState) || {} + const newState = { + ...state, + workDir, + } + GlobalStateManager.context.globalState.update(devboxId, newState) + } + + static setRegion(devboxId: string, region: string) { + const state = + (GlobalStateManager.context.globalState.get( + devboxId + ) as DevboxGlobalState) || {} + const newState = { + ...state, + region, + } + GlobalStateManager.context.globalState.update(devboxId, newState) + } + + static getApiRegionList(): string[] { + const state = + (GlobalStateManager.context.globalState.get( + 'api-region-list' + ) as string[]) || [] + return state + } + static addApiRegion(region: string) { + const state = GlobalStateManager.getApiRegionList() + if (!state.includes(region)) { + const newState = [...state, region] + GlobalStateManager.context.globalState.update('api-region-list', newState) + } + } + + static remove(devboxId: string) { + GlobalStateManager.context.globalState.update(devboxId, undefined) + } +} diff --git a/extensions/ide/vscode/devbox/src/utils/handleUri.ts b/extensions/ide/vscode/devbox/src/utils/handleUri.ts new file mode 100644 index 00000000000..f053a6cb4fc --- /dev/null +++ b/extensions/ide/vscode/devbox/src/utils/handleUri.ts @@ -0,0 +1,74 @@ +import * as vscode from 'vscode' +import { GlobalStateManager } from './globalStateManager' +import { Logger } from '../common/logger' + +export class UriHandler { + constructor() {} + + public handle(uri: vscode.Uri): void { + Logger.info(`Handling URI: ${uri.toString()}`) + if ( + uri.scheme !== 'vscode' && + uri.scheme !== 'cursor' && + uri.scheme !== 'vscode-insiders' && + uri.scheme !== 'windsurf' + ) { + return + } + + if (uri.scheme === 'cursor') { + vscode.window.showInformationMessage( + "Cursor's Devbox is often not the latest. If there are any issues, please manually install the [plugin](https://marketplace.visualstudio.com/items?itemName=labring.devbox-aio&ssr=false#overview) referenced this [URI](https://www.cursor.com/how-to-install-extension)." + ) + } + + const queryParams = new URLSearchParams(uri.query) + const params = this.extractParams(queryParams) + + if (params.token && params.sshHostLabel) { + GlobalStateManager.setToken(params.sshHostLabel, params.token) + } + + if (params.workingDir && params.sshHostLabel) { + GlobalStateManager.setWorkDir(params.sshHostLabel, params.workingDir) + } + + if (params.sshDomain && params.sshHostLabel) { + const region = params.sshDomain.split('@')[1] + GlobalStateManager.setRegion(params.sshHostLabel, region) + } + + if (params.sshPort === '0') { + vscode.window.showInformationMessage( + `SSH Port is not correct,maybe your devbox's nodeport is over the limit` + ) + return + } + + if (this.validateParams(params)) { + vscode.commands.executeCommand('devbox.connectRemoteSSH', params) + } + } + + private extractParams(queryParams: URLSearchParams) { + return { + sshDomain: queryParams.get('sshDomain'), + sshPort: queryParams.get('sshPort'), + base64PrivateKey: queryParams.get('base64PrivateKey'), + workingDir: queryParams.get('workingDir'), + sshHostLabel: queryParams.get('sshHostLabel'), // usw.sailos.io_ns-admin_devbox-1 + token: queryParams.get('token'), + } + } + + private validateParams(params: any): boolean { + return !!( + params.sshDomain && + params.sshPort && + params.base64PrivateKey && + params.sshHostLabel && + params.workingDir && + params.token + ) + } +} diff --git a/extensions/ide/vscode/devbox/src/utils/sshConfig.ts b/extensions/ide/vscode/devbox/src/utils/sshConfig.ts new file mode 100644 index 00000000000..f964a40a05e --- /dev/null +++ b/extensions/ide/vscode/devbox/src/utils/sshConfig.ts @@ -0,0 +1,100 @@ +import * as fs from 'fs' +import { GlobalStateManager } from './globalStateManager' +import { Logger } from '../common/logger' + +// 将老版本的 ssh 配置改成新版本的 ssh 配置 +// # WorkingDir: /home/sealos/project +// Host bja.sealos.run-ns-wappehp7-test-t6unaf4bbob +// HostName bja.sealos.run +// User sealos +// Port 40398 +// IdentityFile ~/.ssh/sealos/bja.sealos.run_ns-wappehp7_test +// IdentitiesOnly yes +// StrictHostKeyChecking no + +// 转换为下边的: +// Host的转换,去掉随机串,然后-改为_ +// 去掉WorkingDir 的注释,改为全局存储 +// Host usw.sailos.io_ns-rqtny6y6_devbox +// HostName usw.sailos.io +// User devbox +// Port 31328 +// IdentityFile ~/.ssh/sealos/usw.sailos.io_ns-rqtny6y6_devbox +// IdentitiesOnly yes +// StrictHostKeyChecking no +export function convertSSHConfigToVersion2(filePath: string) { + const output: Record<string, Record<string, string>> = {} + let result = '' + + const data = fs.readFileSync(filePath, 'utf8') + + if (!data.includes('# WorkingDir:')) { + Logger.info('SSH config is already in the latest version2.') + return + } + + Logger.info('Converting SSH config to the latest version2.') + + const lines = data.split('\n') + let currentWorkDir: any = null + let formattedHostName = '' + + lines.forEach((line) => { + line = line.trim() + + if (line.startsWith('# WorkingDir:')) { + currentWorkDir = line.split(': ')[1].trim() + return + } + + const hostMatch = line.match(/^Host (.+)/) + if (hostMatch) { + let hostName = hostMatch[1] + if (hostName.includes('_ns-')) { + formattedHostName = hostName + } else { + hostName = hostName.replace(/-([^-\s]+)$/, '') + const namespace = hostName.match(/ns-([a-z0-9]+)(?=-)/) + if (namespace) { + formattedHostName = hostName.replace( + /^(.+)-ns-([a-z0-9]+)-(.+)$/, + '$1_ns-$2_$3' + ) + } else { + formattedHostName = hostName + } + } + + output[formattedHostName] = { + workDir: currentWorkDir, + } + return + } + + if (line && !line.startsWith('# WorkingDir:')) { + const keyValueMatch = line.match(/(\S+)\s+(.+)/) + if (keyValueMatch) { + const [_, key, value] = keyValueMatch + output[formattedHostName][key] = value + } + } + }) + + for (const [host, config] of Object.entries(output)) { + if (config.workDir) { + GlobalStateManager.setWorkDir(host, config.workDir) + } + + result += `Host ${host}\n` + for (const [key, value] of Object.entries(config)) { + if (key !== 'workDir') { + result += ` ${key} ${value}\n` + } + } + result += '\n' + } + result = result.trim() + fs.writeFileSync(filePath, result, { encoding: 'utf8', flag: 'w' }) + + Logger.info('SSH config converted to the latest version2.') +} diff --git a/extensions/ide/vscode/devbox/tsconfig.json b/extensions/ide/vscode/devbox/tsconfig.json new file mode 100644 index 00000000000..9d75c8ca57e --- /dev/null +++ b/extensions/ide/vscode/devbox/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "outDir": "./out", + "sourceMap": true, + "rootDir": "src", + "jsx": "react-jsx", + "exactOptionalPropertyTypes": false, + "useUnknownInCatchVariables": false, + "alwaysStrict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitOverride": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "strict": true + }, + "include": ["src/**/*", "src/types/icon.d.ts"] +} diff --git a/extensions/ide/vscode/devbox/vsc-extension-quickstart.md b/extensions/ide/vscode/devbox/vsc-extension-quickstart.md new file mode 100644 index 00000000000..f518bb846b1 --- /dev/null +++ b/extensions/ide/vscode/devbox/vsc-extension-quickstart.md @@ -0,0 +1,48 @@ +# Welcome to your VS Code Extension + +## What's in the folder + +* This folder contains all of the files necessary for your extension. +* `package.json` - this is the manifest file in which you declare your extension and command. + * The sample plugin registers a command and defines its title and command name. With this information VS Code can show the command in the command palette. It doesn’t yet need to load the plugin. +* `src/extension.ts` - this is the main file where you will provide the implementation of your command. + * The file exports one function, `activate`, which is called the very first time your extension is activated (in this case by executing the command). Inside the `activate` function we call `registerCommand`. + * We pass the function containing the implementation of the command as the second parameter to `registerCommand`. + +## Setup + +* install the recommended extensions (amodio.tsl-problem-matcher, ms-vscode.extension-test-runner, and dbaeumer.vscode-eslint) + + +## Get up and running straight away + +* Press `F5` to open a new window with your extension loaded. +* Run your command from the command palette by pressing (`Ctrl+Shift+P` or `Cmd+Shift+P` on Mac) and typing `Hello World`. +* Set breakpoints in your code inside `src/extension.ts` to debug your extension. +* Find output from your extension in the debug console. + +## Make changes + +* You can relaunch the extension from the debug toolbar after changing code in `src/extension.ts`. +* You can also reload (`Ctrl+R` or `Cmd+R` on Mac) the VS Code window with your extension to load your changes. + + +## Explore the API + +* You can open the full set of our API when you open the file `node_modules/@types/vscode/index.d.ts`. + +## Run tests + +* Install the [Extension Test Runner](https://marketplace.visualstudio.com/items?itemName=ms-vscode.extension-test-runner) +* Run the "watch" task via the **Tasks: Run Task** command. Make sure this is running, or tests might not be discovered. +* Open the Testing view from the activity bar and click the Run Test" button, or use the hotkey `Ctrl/Cmd + ; A` +* See the output of the test result in the Test Results view. +* Make changes to `src/test/extension.test.ts` or create new test files inside the `test` folder. + * The provided test runner will only consider files matching the name pattern `**.test.ts`. + * You can create folders inside the `test` folder to structure your tests any way you want. + +## Go further + +* Reduce the extension size and improve the startup time by [bundling your extension](https://code.visualstudio.com/api/working-with-extensions/bundling-extension). +* [Publish your extension](https://code.visualstudio.com/api/working-with-extensions/publishing-extension) on the VS Code extension marketplace. +* Automate builds by setting up [Continuous Integration](https://code.visualstudio.com/api/working-with-extensions/continuous-integration). diff --git a/extensions/ide/vscode/devbox/webpack.config.js b/extensions/ide/vscode/devbox/webpack.config.js new file mode 100644 index 00000000000..17d24cb61f9 --- /dev/null +++ b/extensions/ide/vscode/devbox/webpack.config.js @@ -0,0 +1,48 @@ +//@ts-check + +'use strict' + +const path = require('path') + +//@ts-check +/** @typedef {import('webpack').Configuration} WebpackConfig **/ + +/** @type WebpackConfig */ +const extensionConfig = { + target: 'node', // VS Code extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/ + mode: 'none', // this leaves the source code as close as possible to the original (when packaging we set this to 'production') + + entry: './src/extension.ts', // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/ + output: { + // the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/ + path: path.resolve(__dirname, 'dist'), + filename: 'extension.js', + libraryTarget: 'commonjs2', + }, + externals: { + vscode: 'commonjs vscode', // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ + // modules added here also need to be added in the .vscodeignore file + }, + resolve: { + // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader + extensions: ['.ts', '.js'], + }, + module: { + rules: [ + { + test: /\.ts$/, + exclude: /node_modules/, + use: [ + { + loader: 'ts-loader', + }, + ], + }, + ], + }, + devtool: 'nosources-source-map', + infrastructureLogging: { + level: 'log', // enables logging required for problem matchers + }, +} +module.exports = [extensionConfig] diff --git a/frontend/desktop/next-i18next.config.js b/frontend/desktop/next-i18next.config.js index 381e292c0cc..1ef264034d3 100644 --- a/frontend/desktop/next-i18next.config.js +++ b/frontend/desktop/next-i18next.config.js @@ -4,9 +4,9 @@ module.exports = { i18n: { - defaultLocale: 'zh', + defaultLocale: 'en', locales: ['en', 'zh'], localeDetection: false }, reloadOnPrerender: process.env.NODE_ENV === 'development' -}; +} diff --git a/frontend/desktop/public/locales/en/common.json b/frontend/desktop/public/locales/en/common.json index 55132c941e7..6009fb2c576 100644 --- a/frontend/desktop/public/locales/en/common.json +++ b/frontend/desktop/public/locales/en/common.json @@ -70,7 +70,7 @@ "enterprise_name": "Company name", "enterprise_name_required": "Please enter the correct company name", "enterprise_verification": "Enterprise real name", - "expected_to_use_next_month": "Estimated Next Invoice", + "expected_to_use_next_month": "Usage for the next 30 days", "expected_used": "Estimated Runaway", "face_recognition_failed": "Personal real name failed", "face_recognition_success": "Personal real-name success", @@ -152,6 +152,7 @@ "official_account_login": "Official account login", "old_email": "Old email", "old_phone": "old mobile number", + "online_service": "Help", "operating": "Operating", "order_number": "Order Number", "password": "Password", @@ -237,7 +238,7 @@ "under_active_development": "Under active development 🚧", "unread": "Unread", "upload_success": "Upload successful", - "used_last_month": " Last Invoice", + "used_last_month": "Usage for the last 30 days", "used_resources": "Resources Used", "user_name": "User Name", "username": "Username", diff --git a/frontend/desktop/public/locales/zh/common.json b/frontend/desktop/public/locales/zh/common.json index 3637b703759..97cf3d82a85 100644 --- a/frontend/desktop/public/locales/zh/common.json +++ b/frontend/desktop/public/locales/zh/common.json @@ -67,7 +67,7 @@ "enterprise_name": "企业名称", "enterprise_name_required": "请输入正确的企业名字", "enterprise_verification": "企业实名", - "expected_to_use_next_month": "下月预计使用", + "expected_to_use_next_month": "未来30天预计使用", "expected_used": "预计还能使用", "face_recognition_failed": "个人实名失败", "face_recognition_success": "个人实名成功", @@ -148,6 +148,7 @@ "official_account_login": "公众号登录", "old_email": "旧电子邮箱", "old_phone": "旧手机号", + "online_service": "在线客服", "operating": "操作", "order_number": "订单号", "password": "密码", @@ -230,7 +231,7 @@ "under_active_development": "正在积极开发中 🚧", "unread": "未读", "upload_success": "上传成功", - "used_last_month": "上月已使用", + "used_last_month": "过去30天已使用", "used_resources": "已用资源", "user_name": "用户名", "username": "用户名", @@ -260,4 +261,4 @@ "you_can_view_fees_through_the_fee_center": "您可通过费用中心查看费用", "you_have_not_purchased_the_license": "您还没有购买 License", "yuan": "元" -} +} \ No newline at end of file diff --git a/frontend/desktop/src/components/LangSelect/simple.tsx b/frontend/desktop/src/components/LangSelect/simple.tsx index 2ff8f543f41..57528b61adc 100644 --- a/frontend/desktop/src/components/LangSelect/simple.tsx +++ b/frontend/desktop/src/components/LangSelect/simple.tsx @@ -1,11 +1,36 @@ +import { useConfigStore } from '@/stores/config'; import { setCookie } from '@/utils/cookieUtils'; import { Flex, FlexProps } from '@chakra-ui/react'; import { useTranslation } from 'next-i18next'; +import { useEffect } from 'react'; import { EVENT_NAME } from 'sealos-desktop-sdk'; import { masterApp } from 'sealos-desktop-sdk/master'; export default function LangSelectSimple(props: FlexProps) { const { t, i18n } = useTranslation(); + const { layoutConfig } = useConfigStore(); + + const switchLanguage = (targetLang: string) => { + masterApp?.sendMessageToAll({ + apiName: 'event-bus', + eventName: EVENT_NAME.CHANGE_I18N, + data: { + currentLanguage: targetLang + } + }); + setCookie('NEXT_LOCALE', targetLang, { + expires: 30, + sameSite: 'None', + secure: true + }); + i18n?.changeLanguage(targetLang); + }; + + useEffect(() => { + if (layoutConfig?.forcedLanguage && i18n?.language !== layoutConfig.forcedLanguage) { + switchLanguage(layoutConfig.forcedLanguage); + } + }, [layoutConfig?.forcedLanguage, i18n]); return ( <Flex @@ -20,21 +45,11 @@ export default function LangSelectSimple(props: FlexProps) { cursor={'pointer'} fontWeight={500} {...props} - onClick={() => { - masterApp?.sendMessageToAll({ - apiName: 'event-bus', - eventName: EVENT_NAME.CHANGE_I18N, - data: { - currentLanguage: i18n?.language === 'en' ? 'zh' : 'en' - } - }); - setCookie('NEXT_LOCALE', i18n?.language === 'en' ? 'zh' : 'en', { - expires: 30, - sameSite: 'None', - secure: true - }); - i18n?.changeLanguage(i18n?.language === 'en' ? 'zh' : 'en'); - }} + onClick={ + layoutConfig?.forcedLanguage + ? undefined + : () => switchLanguage(i18n?.language === 'en' ? 'zh' : 'en') + } > {i18n?.language === 'en' ? 'En' : '中'} </Flex> diff --git a/frontend/desktop/src/components/account/cost.tsx b/frontend/desktop/src/components/account/cost.tsx index 3d6b73ea90e..a57361058c8 100644 --- a/frontend/desktop/src/components/account/cost.tsx +++ b/frontend/desktop/src/components/account/cost.tsx @@ -16,7 +16,7 @@ import { Text, useBreakpointValue } from '@chakra-ui/react'; -import { MonitorIcon } from '@sealos/ui'; +import { CurrencySymbol, MonitorIcon } from '@sealos/ui'; import { useQuery } from '@tanstack/react-query'; import { Decimal } from 'decimal.js'; import { useTranslation } from 'next-i18next'; @@ -24,7 +24,7 @@ import { useMemo } from 'react'; import CustomTooltip from '../AppDock/CustomTooltip'; import { blurBackgroundStyles } from '../desktop_content'; import Monitor from '../desktop_content/monitor'; -import { ClockIcon, DesktopSealosCoinIcon, HelpIcon, InfiniteIcon } from '../icons'; +import { ClockIcon, HelpIcon, InfiniteIcon } from '../icons'; export default function Cost() { const { t } = useTranslation(); @@ -34,6 +34,9 @@ export default function Cost() { const { session } = useSessionStore(); const user = session?.user; const isLargerThanXl = useBreakpointValue({ base: true, xl: false }); + const currencySymbol = useConfigStore( + (state) => state.layoutConfig?.currencySymbol || 'shellCoin' + ); const { data } = useQuery({ queryKey: ['getAmount', { userId: user?.userCrUid }], @@ -113,7 +116,7 @@ export default function Cost() { <Text fontSize={'20px'} color={'#7CE7FF'}> {formatMoney(balance).toFixed(2)} </Text> - <DesktopSealosCoinIcon /> + <CurrencySymbol type={currencySymbol} color={'white'} fontSize={'16px'} /> </Flex> </Box> {rechargeEnabled && ( @@ -178,7 +181,7 @@ export default function Cost() { <Text mr={'4px'} ml={'auto'} color={'white'} fontSize={'14px'} fontWeight={700}> {formatMoney(calculations.prevMonthAmount).toFixed(2)} </Text> - <DesktopSealosCoinIcon /> + <CurrencySymbol type={currencySymbol} color={'white'} fontSize={'14px'} /> </Flex> <Flex alignItems={'center'} px={'16px'} py={'18px'}> <Center @@ -201,7 +204,7 @@ export default function Cost() { <Text mr={'4px'} ml={'auto'} color={'white'} fontSize={'14px'} fontWeight={700}> {formatMoney(calculations.estimatedNextMonthAmount).toFixed(2)} </Text> - <DesktopSealosCoinIcon /> + <CurrencySymbol type={currencySymbol} color={'white'} fontSize={'14px'} /> </Flex> </Flex> )} diff --git a/frontend/desktop/src/components/desktop_content/index.tsx b/frontend/desktop/src/components/desktop_content/index.tsx index 41e2c06bd71..1544265a304 100644 --- a/frontend/desktop/src/components/desktop_content/index.tsx +++ b/frontend/desktop/src/components/desktop_content/index.tsx @@ -27,6 +27,7 @@ import { useQuery } from '@tanstack/react-query'; import { UserInfo } from '@/api/auth'; import TaskModal from '../task/taskModal'; import FloatingTaskButton from '../task/floatButton'; +import OnlineServiceButton from './serviceButton'; const AppDock = dynamic(() => import('../AppDock'), { ssr: false }); const FloatButton = dynamic(() => import('@/components/floating_button'), { ssr: false }); @@ -152,7 +153,7 @@ export default function Desktop(props: any) { message({ title: title, status: 'info', - duration: null + isClosable: true }); } else { if (!newID || newID === localStorage.getItem('GlobalNotification')) return; @@ -160,7 +161,7 @@ export default function Desktop(props: any) { message({ title: title, status: 'info', - duration: null + isClosable: true }); } }; @@ -176,6 +177,7 @@ export default function Desktop(props: any) { backgroundSize={'cover'} position={'relative'} > + {isClient && layoutConfig?.customerServiceURL && <OnlineServiceButton />} <ChakraIndicator /> <Flex gap={'8px'} diff --git a/frontend/desktop/src/components/desktop_content/searchBox.tsx b/frontend/desktop/src/components/desktop_content/searchBox.tsx index f897bc68772..8da1c92bb0d 100644 --- a/frontend/desktop/src/components/desktop_content/searchBox.tsx +++ b/frontend/desktop/src/components/desktop_content/searchBox.tsx @@ -29,7 +29,7 @@ export default function SearchBox() { // Filter apps based on search term const filteredApps = apps.filter((app) => { const appNames = getAppNames(app); - return appNames.some((name) => name.toLowerCase().includes(searchTerm.toLowerCase())); + return appNames.some((name) => name?.toLowerCase().includes(searchTerm?.toLowerCase())); }); return ( diff --git a/frontend/desktop/src/components/desktop_content/serviceButton.tsx b/frontend/desktop/src/components/desktop_content/serviceButton.tsx new file mode 100644 index 00000000000..50589b14acb --- /dev/null +++ b/frontend/desktop/src/components/desktop_content/serviceButton.tsx @@ -0,0 +1,147 @@ +import { useConfigStore } from '@/stores/config'; +import { useDesktopConfigStore } from '@/stores/desktopConfig'; +import { Box, Center, Flex, Icon, Text } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; + +const OnlineServiceButton = () => { + const { isServiceButtonOpen, setServiceButtonOpen } = useDesktopConfigStore(); + const { t } = useTranslation(); + const { layoutConfig } = useConfigStore(); + + return ( + <Box zIndex={9999} position="absolute" right="0px" bottom="80px"> + <Flex + w={isServiceButtonOpen ? '58px' : '20px'} + h={isServiceButtonOpen ? '62px' : '44px'} + position="relative" + flexDirection={'column'} + alignItems={'center'} + justifyContent={'center'} + borderRadius="8px 0px 0px 8px" + borderTop="1px solid #69AEFF" + borderBottom="1px solid #69AEFF" + borderLeft="1px solid #69AEFF" + background="linear-gradient(139deg, rgba(255, 255, 255, 0.80) 0%, rgba(255, 255, 255, 0.70) 100%)" + boxShadow="0px 12px 32px -4px rgba(0, 23, 86, 0.20)" + backdropFilter="blur(200px)" + cursor="pointer" + onClick={(e) => { + if (!isServiceButtonOpen) { + setServiceButtonOpen(true); + } else { + window.open(layoutConfig?.customerServiceURL ?? '', '_blank'); + } + }} + > + {isServiceButtonOpen ? ( + <Icon + xmlns="http://www.w3.org/2000/svg" + width="28px" + height="28px" + viewBox="0 0 28 28" + fill="none" + > + <path + d="M23.7288 10.3246H23.8146C24.4654 10.3246 25.0895 10.5831 25.5496 11.0433C26.0097 11.5034 26.2682 12.1275 26.2682 12.7782V15.2318C26.2682 15.8826 26.0097 16.5066 25.5496 16.9668C25.0895 17.4269 24.4654 17.6854 23.8146 17.6854H23.7288C23.4301 20.0556 22.2773 22.2355 20.4863 23.8165C18.6953 25.3974 16.3892 26.2709 14.0002 26.273C13.6748 26.273 13.3628 26.1438 13.1327 25.9137C12.9027 25.6837 12.7734 25.3716 12.7734 25.0462C12.7734 24.7209 12.9027 24.4088 13.1327 24.1788C13.3628 23.9487 13.6748 23.8194 14.0002 23.8194C14.9668 23.8194 15.924 23.629 16.8171 23.2591C17.7101 22.8892 18.5216 22.347 19.2051 21.6635C19.8886 20.98 20.4308 20.1685 20.8007 19.2755C21.1706 18.3824 21.361 17.4253 21.361 16.4586V11.5514C21.361 9.5992 20.5855 7.72695 19.2051 6.34652C17.8247 4.9661 15.9524 4.19059 14.0002 4.19059C12.048 4.19059 10.1757 4.9661 8.79532 6.34652C7.4149 7.72695 6.63939 9.5992 6.63939 11.5514V16.4586C6.63939 16.784 6.51014 17.096 6.28007 17.3261C6.05 17.5562 5.73796 17.6854 5.41259 17.6854H4.18578C3.53505 17.6854 2.91096 17.4269 2.45082 16.9668C1.99068 16.5066 1.73218 15.8826 1.73218 15.2318V12.7782C1.73218 12.1275 1.99068 11.5034 2.45082 11.0433C2.91096 10.5831 3.53505 10.3246 4.18578 10.3246H4.27166C4.58562 7.96687 5.74513 5.80337 7.53459 4.23641C9.32404 2.66945 11.6217 1.80566 14.0002 1.80566C16.3788 1.80566 18.6764 2.66945 20.4658 4.23641C22.2553 5.80337 23.4148 7.96687 23.7288 10.3246Z" + fill="url(#paint0_linear_973_6663)" + /> + <path + d="M18.9081 14.7049C18.8771 15.0304 18.7179 15.3303 18.4658 15.5386V15.5263C17.2067 16.5589 15.6286 17.1233 14.0002 17.1233C12.3718 17.1233 10.7938 16.5589 9.53466 15.5263C9.28412 15.318 9.12657 15.0188 9.09666 14.6944C9.06676 14.37 9.16694 14.047 9.37517 13.7965C9.58341 13.546 9.88264 13.3884 10.207 13.3585C10.5314 13.3286 10.8544 13.4288 11.105 13.637C11.9315 14.2837 12.9508 14.635 14.0002 14.635C15.0497 14.635 16.0689 14.2837 16.8955 13.637C17.1476 13.4288 17.4722 13.3292 17.7977 13.3603C18.1233 13.3914 18.4232 13.5505 18.6314 13.8026C18.8396 14.0548 18.9392 14.3793 18.9081 14.7049Z" + fill="url(#paint1_linear_973_6663)" + /> + <defs> + <linearGradient + id="paint0_linear_973_6663" + x1="14.0002" + y1="2.60006" + x2="14.0002" + y2="25.4787" + gradientUnits="userSpaceOnUse" + > + <stop stopColor="#499DFF" /> + <stop offset="0.432292" stopColor="#2770FF" /> + <stop offset="1" stopColor="#6E80FF" /> + </linearGradient> + <linearGradient + id="paint1_linear_973_6663" + x1="14.0002" + y1="2.60006" + x2="14.0002" + y2="25.4787" + gradientUnits="userSpaceOnUse" + > + <stop stopColor="#499DFF" /> + <stop offset="0.432292" stopColor="#2770FF" /> + <stop offset="1" stopColor="#6E80FF" /> + </linearGradient> + </defs> + </Icon> + ) : ( + <Icon + xmlns="http://www.w3.org/2000/svg" + width="16px" + height="16px" + viewBox="0 0 16 16" + fill="none" + > + <path + fillRule="evenodd" + clipRule="evenodd" + d="M10.4715 3.52827C10.7318 3.78862 10.7318 4.21073 10.4715 4.47108L6.94289 7.99967L10.4715 11.5283C10.7318 11.7886 10.7318 12.2107 10.4715 12.4711C10.2111 12.7314 9.78903 12.7314 9.52868 12.4711L5.52868 8.47108C5.26833 8.21073 5.26833 7.78862 5.52868 7.52827L9.52868 3.52827C9.78903 3.26792 10.2111 3.26792 10.4715 3.52827Z" + fill="#071B41" + fillOpacity="0.5" + /> + </Icon> + )} + + {/* close button */} + {isServiceButtonOpen && ( + <Center + w={'14px'} + h={'14px'} + position="absolute" + left="-6px" + top="-6px" + borderRadius="999px" + border="1px solid #8BABE7" + background="linear-gradient(139deg, rgba(255, 255, 255, 0.80) 0%, rgba(255, 255, 255, 0.70) 100%)" + boxShadow="0px 12px 32px -4px rgba(0, 23, 86, 0.20)" + backdropFilter="blur(200px)" + onClick={(e) => { + e.stopPropagation(); + setServiceButtonOpen(!isServiceButtonOpen); + }} + > + <Icon width="12px" height="12px" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg"> + <g style={{ mixBlendMode: 'multiply' }}> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M2.84371 2.84469C2.64845 3.03995 2.64845 3.35653 2.84371 3.5518L5.29285 6.00093L2.84384 8.44994C2.64858 8.6452 2.64858 8.96178 2.84384 9.15704C3.0391 9.35231 3.35568 9.35231 3.55095 9.15704L5.99995 6.70804L8.44896 9.15704C8.64422 9.3523 8.9608 9.3523 9.15607 9.15704C9.35133 8.96178 9.35133 8.6452 9.15607 8.44994L6.70706 6.00093L9.15619 3.5518C9.35146 3.35653 9.35146 3.03995 9.15619 2.84469C8.96093 2.64943 8.64435 2.64943 8.44909 2.84469L5.99995 5.29382L3.55082 2.84469C3.35556 2.64943 3.03897 2.64943 2.84371 2.84469Z" + fill="#071B41" + fillOpacity="0.5" + /> + </g> + </Icon> + </Center> + )} + + {isServiceButtonOpen && ( + <Text + color="#000" + fontSize="11px" + fontStyle="normal" + fontWeight={500} + lineHeight="16px" + letterSpacing="0.5px" + whiteSpace="wrap" + > + {t('online_service')} + </Text> + )} + </Flex> + </Box> + ); +}; + +export default OnlineServiceButton; diff --git a/frontend/desktop/src/components/icons/index.tsx b/frontend/desktop/src/components/icons/index.tsx index ca179965342..5e42b63ca27 100644 --- a/frontend/desktop/src/components/icons/index.tsx +++ b/frontend/desktop/src/components/icons/index.tsx @@ -170,45 +170,6 @@ export function ClockIcon(props: IconProps) { ); } -export function DesktopSealosCoinIcon(props: IconProps) { - return ( - <Icon - xmlns="http://www.w3.org/2000/svg" - width="14px" - height="14px" - viewBox="0 0 14 14" - fill="none" - {...props} - > - <g style={{ mixBlendMode: 'hard-light' }}> - <circle cx="7" cy="7" r="6.762" fill="#E8E8E8" stroke="#37383A" strokeWidth="0.476" /> - <circle cx="6.9999" cy="7.00002" r="6.11562" fill="#CFCFCF" /> - <path - d="M6.99978 13.1156C10.3773 13.1156 13.1154 10.3776 13.1154 7.00002C13.1154 5.61364 12.6541 4.335 11.8766 3.30923C11.4852 3.25402 11.0853 3.22546 10.6787 3.22546C6.2888 3.22546 2.6764 6.55431 2.22803 10.8254C3.34887 12.2218 5.06993 13.1156 6.99978 13.1156Z" - fill="#BEBEBE" - /> - <circle cx="7.00017" cy="6.99992" r="4.74284" fill="#828386" /> - <path - d="M5.04555 6.78559C5.439 7.36085 6.25304 7.3098 6.25304 7.3098C6.04953 7.11237 5.91725 6.93196 5.90368 6.41797C5.89011 5.90398 5.59842 5.76783 5.59842 5.76783C6.12076 5.43765 5.93421 5.08024 5.91725 4.68198C5.90707 4.4335 6.05292 4.24969 6.16824 4.14417C5.50449 4.24395 4.90473 4.59698 4.49399 5.12967C4.08325 5.66237 3.89324 6.3336 3.96357 7.00344C4.01105 6.87069 4.68263 6.25459 5.04555 6.78559Z" - fill="#E8E8E8" - /> - <path - d="M9.86536 5.76104C9.84782 5.7051 9.82628 5.6505 9.80092 5.59766V5.59425C9.68254 5.35256 9.48628 5.1581 9.24395 5.0424C9.00163 4.9267 8.72746 4.89655 8.46592 4.95685C8.20439 5.01715 7.97082 5.16435 7.8031 5.37458C7.63539 5.58482 7.54337 5.84575 7.54197 6.11505C7.54199 6.19975 7.55109 6.2842 7.56911 6.36694C7.56919 6.36807 7.56919 6.36921 7.56911 6.37034C7.57589 6.40438 7.58606 6.43842 7.59624 6.47246C7.65678 6.71225 7.66856 6.96181 7.63088 7.20627C7.59319 7.45073 7.50682 7.68506 7.37692 7.89528C7.24701 8.1055 7.07624 8.2873 6.87479 8.42983C6.67333 8.57236 6.44533 8.6727 6.20439 8.72486C5.96345 8.77701 5.7145 8.77992 5.47241 8.73341C5.23033 8.68689 5.00006 8.5919 4.79536 8.45412C4.59065 8.31633 4.41571 8.13857 4.28096 7.93144C4.14622 7.72431 4.05443 7.49206 4.01109 7.24855C4.07238 7.67066 4.22132 8.07514 4.44825 8.43582C4.67519 8.79649 4.97507 9.10532 5.32848 9.34231C5.68189 9.5793 6.08095 9.73917 6.49984 9.81157C6.91872 9.88396 7.34808 9.86727 7.76012 9.76257C8.17217 9.65788 8.55771 9.46751 8.8918 9.20379C9.22588 8.94008 9.50106 8.60889 9.69952 8.23168C9.89797 7.85447 10.0153 7.43963 10.0438 7.01404C10.0724 6.58844 10.0116 6.16156 9.86536 5.76104Z" - fill="#E8E8E8" - /> - <path - d="M9.35991 6.58134C9.35991 8.0646 8.16176 9.26702 6.68377 9.26702C5.89932 9.26702 5.19371 8.9283 4.70422 8.38865C4.73387 8.41135 4.7643 8.43322 4.79536 8.45412C5.00006 8.5919 5.23033 8.68689 5.47241 8.73341C5.7145 8.77992 5.96345 8.77701 6.20439 8.72486C6.44533 8.6727 6.67333 8.57236 6.87479 8.42983C7.07624 8.2873 7.24701 8.1055 7.37692 7.89528C7.50682 7.68506 7.59319 7.45073 7.63088 7.20627C7.66856 6.96181 7.65678 6.71225 7.59624 6.47246C7.58606 6.43842 7.57589 6.40438 7.56911 6.37034C7.56919 6.36921 7.56919 6.36807 7.56911 6.36694C7.55109 6.2842 7.54199 6.19975 7.54197 6.11505C7.54337 5.84575 7.63539 5.58482 7.8031 5.37458C7.97082 5.16435 8.20439 5.01715 8.46592 4.95685C8.57382 4.93197 8.6838 4.92245 8.79286 4.92802C9.14815 5.38384 9.35991 5.95777 9.35991 6.58134Z" - fill="#E8E8E8" - /> - <path - d="M9.47934 2.44482L9.75865 2.94614L10.26 3.22544L9.75865 3.50474L9.47934 4.00605L9.20004 3.50474L8.69873 3.22544L9.20004 2.94614L9.47934 2.44482Z" - fill="#F0F0F0" - /> - </g> - </Icon> - ); -} - export function DesktopExchangeIcon(props: IconProps) { return ( <Icon diff --git a/frontend/desktop/src/components/task/useDriver.tsx b/frontend/desktop/src/components/task/useDriver.tsx index dcdbe41d02c..dafaf0bf0c8 100644 --- a/frontend/desktop/src/components/task/useDriver.tsx +++ b/frontend/desktop/src/components/task/useDriver.tsx @@ -38,12 +38,16 @@ export default function useDriver() { const allTasksCompleted = data.data.every((task) => task.isCompleted); if (!desktopTask?.isCompleted && desktopTask?.id) { + // setTaskComponentState('none'); + // setDesktopGuide(true); setTaskComponentState('none'); - setDesktopGuide(true); + setDesktopGuide(false); // Hide First Guides + driverObj.drive(); } else if (allTasksCompleted) { setTaskComponentState('none'); } else { - setTaskComponentState(taskComponentState !== 'none' ? taskComponentState : 'button'); + setTaskComponentState('none'); // Hide task modal for all users + // setTaskComponentState(taskComponentState !== 'none' ? taskComponentState : 'button'); } }; @@ -62,7 +66,7 @@ export default function useDriver() { const desktopTask = tasks.find((task) => task.taskType === 'DESKTOP'); if (desktopTask) { await updateTask(desktopTask.id); - setTaskComponentState('modal'); + // setTaskComponentState('modal'); // disable task modal for all users } } catch (error) {} }; diff --git a/frontend/desktop/src/pages/WorkspaceInvite.tsx b/frontend/desktop/src/pages/WorkspaceInvite.tsx index 6179d0985a2..febd7cfe773 100644 --- a/frontend/desktop/src/pages/WorkspaceInvite.tsx +++ b/frontend/desktop/src/pages/WorkspaceInvite.tsx @@ -15,6 +15,7 @@ import { useEffect } from 'react'; const Callback: NextPage = () => { const router = useRouter(); + const { layoutConfig } = useConfigStore(); const { token: curToken, session } = useSessionStore((s) => s); const { lastWorkSpaceId } = useSessionStore(); const logo = useConfigStore().layoutConfig?.logo; @@ -109,7 +110,7 @@ const Callback: NextPage = () => { > <Image boxSize={'34px'} borderRadius="full" src={logo} alt="logo" /> <Text fontWeight={700} fontSize={'24px'}> - Sealos + {layoutConfig?.title ?? 'Sealos'} </Text> </Flex> {isValid ? ( diff --git a/frontend/desktop/src/pages/index.tsx b/frontend/desktop/src/pages/index.tsx index e90c17333ea..6d74c5787c4 100644 --- a/frontend/desktop/src/pages/index.tsx +++ b/frontend/desktop/src/pages/index.tsx @@ -212,7 +212,7 @@ export default function Home({ sealos_cloud_domain }: { sealos_cloud_domain: str <title>{layoutConfig?.meta.title}</title> <meta name="description" content={layoutConfig?.meta.description} /> <link rel="shortcut icon" href={layoutConfig?.logo ? layoutConfig?.logo : '/favicon.ico'} /> - <link rel="icon" href={layoutConfig?.logo ? layoutConfig?.logo : '/favicon.ico'} />w{' '} + <link rel="icon" href={layoutConfig?.logo ? layoutConfig?.logo : '/favicon.ico'} /> </Head> {layoutConfig?.meta.scripts?.map((item, i) => { return <Script key={i} {...item} />; diff --git a/frontend/desktop/src/stores/desktopConfig.ts b/frontend/desktop/src/stores/desktopConfig.ts index 4c934ed1511..a47c34e58ba 100644 --- a/frontend/desktop/src/stores/desktopConfig.ts +++ b/frontend/desktop/src/stores/desktopConfig.ts @@ -5,6 +5,8 @@ import { immer } from 'zustand/middleware/immer'; type TaskComponentState = 'none' | 'modal' | 'button'; type State = { + isServiceButtonOpen: boolean; + setServiceButtonOpen: (value: boolean) => void; canShowGuide: boolean; setCanShowGuide: (value: boolean) => void; isAppBar: boolean; @@ -26,6 +28,12 @@ export const useDesktopConfigStore = create<State>()( isAnimationEnabled: true, taskComponentState: 'none', canShowGuide: false, + isServiceButtonOpen: true, + setServiceButtonOpen(value) { + set((state) => { + state.isServiceButtonOpen = value; + }); + }, setCanShowGuide(value) { set((state) => { state.canShowGuide = value; diff --git a/frontend/desktop/src/types/system.ts b/frontend/desktop/src/types/system.ts index 02b22c05c47..2f25e2f4f7d 100644 --- a/frontend/desktop/src/types/system.ts +++ b/frontend/desktop/src/types/system.ts @@ -61,6 +61,9 @@ export type LayoutConfigType = { logo: string; backgroundImage: string; meta: MetaConfigType; + customerServiceURL?: string; + forcedLanguage?: string; + currencySymbol?: 'shellCoin' | 'cny' | 'usd'; protocol?: ProtocolConfigType; common: { diff --git a/frontend/packages/ui/src/components/icons/CurrencySymbol.tsx b/frontend/packages/ui/src/components/icons/CurrencySymbol.tsx new file mode 100644 index 00000000000..3cce9ea6b27 --- /dev/null +++ b/frontend/packages/ui/src/components/icons/CurrencySymbol.tsx @@ -0,0 +1,48 @@ +import { Icon, IconProps, Text, TextProps } from '@chakra-ui/react'; + +export default function CurrencySymbol({ + type = 'shellCoin', + shellCoin, + ...props +}: { + shellCoin?: IconProps; + type?: 'shellCoin' | 'cny' | 'usd'; +} & TextProps) { + return type === 'shellCoin' ? ( + <Icon + xmlns="http://www.w3.org/2000/svg" + width="14px" + height="14px" + viewBox="0 0 20 20" + {...shellCoin} + > + <circle cx="10" cy="10" r="9.66" fill="#E8E8E8" stroke="#37383A" strokeWidth="0.68" /> + <circle cx="9.99995" cy="10" r="8.7366" fill="#CFCFCF" /> + <path + d="M10.0001 18.7366C14.8252 18.7366 18.7367 14.8251 18.7367 10C18.7367 8.01946 18.0776 6.19283 16.9669 4.72746C16.4078 4.64858 15.8365 4.60779 15.2557 4.60779C8.98439 4.60779 3.82381 9.36328 3.18328 15.4649C4.78448 17.4596 7.24314 18.7366 10.0001 18.7366Z" + fill="#BEBEBE" + /> + <circle cx="10.0001" cy="9.99998" r="6.77549" fill="#828386" /> + <path + d="M7.20815 9.69376C7.77022 10.5156 8.93312 10.4426 8.93312 10.4426C8.6424 10.1606 8.45342 9.90286 8.43404 9.16859C8.41466 8.43431 7.99795 8.23981 7.99795 8.23981C8.74415 7.76812 8.47765 7.25754 8.45342 6.6886C8.43889 6.33362 8.64724 6.07103 8.81199 5.92029C7.86377 6.06283 7.00696 6.56717 6.4202 7.32816C5.83343 8.08915 5.56198 9.04805 5.66245 10.005C5.73028 9.81533 6.68968 8.93517 7.20815 9.69376Z" + fill="#E8E8E8" + /> + <path + d="M14.0936 8.23012C14.0685 8.1502 14.0378 8.07219 14.0015 7.99671V7.99184C13.8324 7.64657 13.552 7.36876 13.2059 7.20348C12.8597 7.03819 12.468 6.99513 12.0944 7.08126C11.7208 7.1674 11.3871 7.37769 11.1475 7.67803C10.9079 7.97836 10.7765 8.35112 10.7745 8.73584C10.7745 8.85683 10.7875 8.97747 10.8132 9.09568C10.8133 9.0973 10.8133 9.09892 10.8132 9.10054C10.8229 9.14917 10.8374 9.1978 10.852 9.24642C10.9385 9.58898 10.9553 9.9455 10.9015 10.2947C10.8476 10.6439 10.7242 10.9787 10.5387 11.279C10.3531 11.5793 10.1091 11.8391 9.82133 12.0427C9.53354 12.2463 9.20783 12.3896 8.86362 12.4641C8.51942 12.5386 8.16378 12.5428 7.81795 12.4763C7.47211 12.4099 7.14315 12.2742 6.85072 12.0774C6.55828 11.8805 6.30836 11.6266 6.11587 11.3307C5.92338 11.0348 5.79226 10.703 5.73035 10.3551C5.8179 10.9581 6.03066 11.536 6.35486 12.0512C6.67905 12.5665 7.10745 13.0077 7.61233 13.3462C8.1172 13.6848 8.68729 13.9132 9.28569 14.0166C9.88409 14.12 10.4975 14.0962 11.0861 13.9466C11.6747 13.797 12.2255 13.5251 12.7028 13.1483C13.18 12.7716 13.5732 12.2985 13.8567 11.7596C14.1402 11.2207 14.3078 10.6281 14.3486 10.0201C14.3894 9.41211 14.3025 8.80228 14.0936 8.23012Z" + fill="#E8E8E8" + /> + <path + d="M13.3715 9.40197C13.3715 11.5209 11.6599 13.2387 9.54846 13.2387C8.42782 13.2387 7.41979 12.7548 6.72052 11.9838C6.76288 12.0163 6.80636 12.0475 6.85072 12.0774C7.14315 12.2742 7.47211 12.4099 7.81795 12.4763C8.16378 12.5428 8.51942 12.5386 8.86362 12.4641C9.20783 12.3896 9.53354 12.2463 9.82133 12.0427C10.1091 11.8391 10.3531 11.5793 10.5387 11.279C10.7242 10.9787 10.8476 10.6439 10.9015 10.2947C10.9553 9.9455 10.9385 9.58898 10.852 9.24642C10.8374 9.1978 10.8229 9.14917 10.8132 9.10054C10.8133 9.09892 10.8133 9.0973 10.8132 9.09568C10.7875 8.97747 10.7745 8.85683 10.7745 8.73584C10.7765 8.35112 10.9079 7.97836 11.1475 7.67803C11.3871 7.37769 11.7208 7.1674 12.0944 7.08126C12.2485 7.04573 12.4056 7.03213 12.5614 7.04008C13.069 7.69125 13.3715 8.51116 13.3715 9.40197Z" + fill="#E8E8E8" + /> + <path + d="M13.5419 3.49261L13.9409 4.20878L14.6571 4.60778L13.9409 5.00678L13.5419 5.72294L13.1429 5.00678L12.4268 4.60778L13.1429 4.20878L13.5419 3.49261Z" + fill="#F0F0F0" + /> + </Icon> + ) : type === 'cny' ? ( + <Text {...props}>¥</Text> + ) : ( + <Text {...props}>$</Text> + ); +} diff --git a/frontend/packages/ui/src/components/icons/SealosCoin.tsx b/frontend/packages/ui/src/components/icons/SealosCoin.tsx index dd459a70a17..0e93f7ba154 100644 --- a/frontend/packages/ui/src/components/icons/SealosCoin.tsx +++ b/frontend/packages/ui/src/components/icons/SealosCoin.tsx @@ -1,5 +1,8 @@ import { Icon, IconProps } from '@chakra-ui/react'; +/** + * @deprecated + */ export default function SealosCoin(props: IconProps) { return ( <Icon diff --git a/frontend/packages/ui/src/components/index.ts b/frontend/packages/ui/src/components/index.ts index db02c0a1cc6..68700c36361 100644 --- a/frontend/packages/ui/src/components/index.ts +++ b/frontend/packages/ui/src/components/index.ts @@ -51,6 +51,8 @@ import EditTabs from './EditTabs'; import YamlCode from './YamlCode'; import ProviderIcon from './icons/ProviderIcon'; import WarnTriangeIcon from './icons/line/WarnTriange'; +import CurrencySymbol from './icons/CurrencySymbol'; + export { SealosMenu } from './Menu'; export { Tabs } from './Tabs'; export { MyRangeSlider } from './RangeSlider'; @@ -116,5 +118,6 @@ export { SortPolygonDownIcon, ProviderIcon, WebHostIcon, - WarnTriangeIcon + WarnTriangeIcon, + CurrencySymbol }; diff --git a/frontend/providers/aiproxy/api/platform.ts b/frontend/providers/aiproxy/api/platform.ts index a00da229dcf..3fccfdc95cf 100644 --- a/frontend/providers/aiproxy/api/platform.ts +++ b/frontend/providers/aiproxy/api/platform.ts @@ -11,9 +11,13 @@ import { GetTokensQueryParams, GetTokensResponse } from '@/app/api/user/token/ro import { TokenInfo } from '@/types/user/token' import { UserLogSearchResponse } from '@/app/api/user/log/route' import { UserLogQueryParams } from '@/app/api/user/log/route' -// user -export const initAppConfig = () => GET<{ aiproxyBackend: string }>('/api/init-app-config') +export const initAppConfig = () => + GET<{ aiproxyBackend: string; currencySymbol: 'shellCoin' | 'cny' | 'usd' }>( + '/api/init-app-config' + ) + +// user export const getModelConfig = () => GET<GetEnabledModelsResponse['data']>('/api/models/enabled') export const getUserLogs = (params: UserLogQueryParams) => diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx index 8ea9d221648..e8a4ba7d6ad 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx @@ -1,7 +1,7 @@ 'use client' import { Box, Flex, Text, Button, Icon } from '@chakra-ui/react' -import { MySelect, MyTooltip, SealosCoin } from '@sealos/ui' +import { CurrencySymbol, MySelect, MyTooltip } from '@sealos/ui' import { useMemo, useState } from 'react' import { getTokens, getUserLogs, getModelConfig } from '@/api/platform' @@ -14,6 +14,7 @@ import { LogItem } from '@/types/user/logs' import { useQuery } from '@tanstack/react-query' import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { QueryKey } from '@/types/query-key' +import { useBackendStore } from '@/store/backend' export default function Home(): React.JSX.Element { const { lng } = useI18n() @@ -31,6 +32,7 @@ export default function Home(): React.JSX.Element { const [pageSize, setPageSize] = useState(10) const [logData, setLogData] = useState<LogItem[]>([]) const [total, setTotal] = useState(0) + const { currencySymbol } = useBackendStore() const { data: modelConfigs = [] } = useQuery([QueryKey.GetModelConfig], () => getModelConfig()) const { data: tokenData } = useQuery([QueryKey.GetTokens], () => @@ -117,7 +119,7 @@ export default function Home(): React.JSX.Element { <MyTooltip placement="bottom-end" label={t('logs.total_price_tip')}> <Flex alignItems={'center'} gap={'4px'}> {t('logs.total_price')} - <SealosCoin /> + <CurrencySymbol type={currencySymbol} /> </Flex> </MyTooltip> </Box> diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx index e2063c9747b..1c9d87b4f0b 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx @@ -24,7 +24,7 @@ import { useReactTable, flexRender } from '@tanstack/react-table' -import { SealosCoin } from '@sealos/ui' +import { CurrencySymbol } from '@sealos/ui' import { ModelIdentifier } from '@/types/front' import { MyTooltip } from '@/components/common/MyTooltip' import { useMessage } from '@sealos/ui' @@ -44,6 +44,7 @@ import ErnieIcon from '@/ui/svg/icons/modelist/ernie.svg' import BaaiIcon from '@/ui/svg/icons/modelist/baai.svg' import HunyuanIcon from '@/ui/svg/icons/modelist/hunyuan.svg' import { getTranslationWithFallback } from '@/utils/common' +import { useBackendStore } from '@/store/backend' function Price() { const { lng } = useI18n() @@ -73,11 +74,12 @@ function Price() { function PriceTable() { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') - const { isLoading, data: modelConfigs = [] } = useQuery([QueryKey.GetModelConfig], () => getModelConfig() ) + const { currencySymbol } = useBackendStore() + const modelGroups = { ernie: { icon: ErnieIcon, @@ -239,17 +241,18 @@ function PriceTable() { header: () => { return ( <Box position={'relative'}> - <Flex alignItems={'center'} gap={'4px'}> + <Flex alignItems={'center'}> <Text color="grayModern.600" fontFamily="PingFang SC" fontSize="12px" fontWeight={500} lineHeight="16px" + mr={'4px'} letterSpacing="0.5px"> {t('key.inputPrice')} </Text> - <SealosCoin /> + <CurrencySymbol type={currencySymbol} fontSize={'12px'} h={'15px'} /> <Text color="grayModern.500" fontFamily="PingFang SC" @@ -258,7 +261,7 @@ function PriceTable() { lineHeight="16px" letterSpacing="0.5px" textTransform="lowercase"> - {t('price.per1kTokens').toLowerCase()} + /{t('price.per1kTokens').toLowerCase()} </Text> </Flex> </Box> @@ -280,17 +283,18 @@ function PriceTable() { id: 'outputPrice', header: () => ( <Box position={'relative'}> - <Flex alignItems={'center'} gap={'4px'}> + <Flex alignItems={'center'}> <Text color="grayModern.600" fontFamily="PingFang SC" fontSize="12px" fontWeight={500} lineHeight="16px" + mr={'4px'} letterSpacing="0.5px"> {t('key.outputPrice')} </Text> - <SealosCoin /> + <CurrencySymbol type={currencySymbol} fontSize={'12px'} h={'15px'} /> <Text color="grayModern.500" fontFamily="PingFang SC" @@ -299,7 +303,7 @@ function PriceTable() { lineHeight="16px" letterSpacing="0.5px" textTransform="lowercase"> - {t('price.per1kTokens').toLowerCase()} + /{t('price.per1kTokens').toLowerCase()} </Text> </Flex> </Box> diff --git a/frontend/providers/aiproxy/app/api/init-app-config/route.ts b/frontend/providers/aiproxy/app/api/init-app-config/route.ts index 31ee62f2a42..91061dc0148 100644 --- a/frontend/providers/aiproxy/app/api/init-app-config/route.ts +++ b/frontend/providers/aiproxy/app/api/init-app-config/route.ts @@ -24,6 +24,9 @@ function getAppConfig(appConfig: AppConfigType): AppConfigType { if (process.env.ADMIN_NAMESPACES) { appConfig.adminNameSpace = getAdminNamespaces() } + if (process.env.CURRENCY_SYMBOL) { + appConfig.currencySymbol = process.env.CURRENCY_SYMBOL as 'shellCoin' | 'cny' | 'usd' + } return appConfig } @@ -38,7 +41,8 @@ function initAppConfig(): AppConfigType { aiproxy: '', aiproxyInternal: '' }, - adminNameSpace: [] + adminNameSpace: [], + currencySymbol: 'shellCoin' } if (!global.AppConfig) { @@ -61,7 +65,8 @@ export async function GET(): Promise<NextResponse> { code: 200, message: 'Success', data: { - aiproxyBackend: config.backend.aiproxy + aiproxyBackend: config.backend.aiproxy, + currencySymbol: config.currencySymbol } }) } catch (error) { diff --git a/frontend/providers/aiproxy/components/InitializeApp.tsx b/frontend/providers/aiproxy/components/InitializeApp.tsx index 74b2447b8e5..b8016b6e46c 100644 --- a/frontend/providers/aiproxy/components/InitializeApp.tsx +++ b/frontend/providers/aiproxy/components/InitializeApp.tsx @@ -16,7 +16,7 @@ export default function InitializeApp() { const pathname = usePathname() const { lng } = useI18n() const { i18n } = useTranslationClientSide(lng) - const { setAiproxyBackend } = useBackendStore() + const { setAiproxyBackend, setCurrencySymbol } = useBackendStore() const handleI18nChange = useCallback( (data: { currentLanguage: string }) => { @@ -111,8 +111,9 @@ export default function InitializeApp() { // init config const initConfig = async () => { try { - const { aiproxyBackend } = await initAppConfig() + const { aiproxyBackend, currencySymbol } = await initAppConfig() setAiproxyBackend(aiproxyBackend) + setCurrencySymbol(currencySymbol) console.log('aiproxy: init config success') } catch (error) { console.error('aiproxy: init config error:', error) diff --git a/frontend/providers/aiproxy/store/backend.ts b/frontend/providers/aiproxy/store/backend.ts index a15d4b7e7a4..de6bec27dae 100644 --- a/frontend/providers/aiproxy/store/backend.ts +++ b/frontend/providers/aiproxy/store/backend.ts @@ -3,14 +3,18 @@ import { persist } from 'zustand/middleware' interface BackendState { aiproxyBackend: string + currencySymbol: 'shellCoin' | 'usd' | 'cny' setAiproxyBackend: (backend: string) => void + setCurrencySymbol: (symbol: 'shellCoin' | 'usd' | 'cny') => void } export const useBackendStore = create<BackendState>()( persist( (set) => ({ aiproxyBackend: '', - setAiproxyBackend: (backend) => set({ aiproxyBackend: backend }) + currencySymbol: 'shellCoin', + setAiproxyBackend: (backend) => set({ aiproxyBackend: backend }), + setCurrencySymbol: (symbol) => set({ currencySymbol: symbol }) }), { name: 'aiproxy-backend-storage' diff --git a/frontend/providers/aiproxy/types/app-config.d.ts b/frontend/providers/aiproxy/types/app-config.d.ts index d3d1c32bb3f..28e923a889c 100644 --- a/frontend/providers/aiproxy/types/app-config.d.ts +++ b/frontend/providers/aiproxy/types/app-config.d.ts @@ -8,6 +8,7 @@ export type AppConfigType = { aiproxyInternal: string } adminNameSpace: string[] + currencySymbol: 'shellCoin' | 'cny' | 'usd' } declare global { diff --git a/frontend/providers/applaunchpad/public/locales/en/common.json b/frontend/providers/applaunchpad/public/locales/en/common.json index f29baeaeb26..0b7147775e8 100644 --- a/frontend/providers/applaunchpad/public/locales/en/common.json +++ b/frontend/providers/applaunchpad/public/locales/en/common.json @@ -272,9 +272,9 @@ "first_charge_tip": "For some specifications, you can enjoy double the gift amount when you recharge for the first time.", "gift": "gift", "balance": "balance", - "guide_deploy_button": "Complete creation and get it now", "form": { "add_configmap": "Add Configmaps", "storage_path_placeholder": "For Example: /data" - } -} + }, + "guide_deploy_button": "Complete creation" +} \ No newline at end of file diff --git a/frontend/providers/applaunchpad/public/locales/zh/common.json b/frontend/providers/applaunchpad/public/locales/zh/common.json index e86de277adf..da5b88f3929 100644 --- a/frontend/providers/applaunchpad/public/locales/zh/common.json +++ b/frontend/providers/applaunchpad/public/locales/zh/common.json @@ -273,9 +273,9 @@ "first_charge_tip": "部分规格首次充值可享双倍赠送金额", "gift": "赠", "balance": "余额", - "guide_deploy_button": "完成创建,立即获得", "form": { "add_configmap": "新增配置文件", "storage_path_placeholder": "如:/data" - } -} + }, + "guide_deploy_button": "完成创建" +} \ No newline at end of file diff --git a/frontend/providers/applaunchpad/src/api/app.ts b/frontend/providers/applaunchpad/src/api/app.ts index adc06655ee2..b18c98e2d7c 100644 --- a/frontend/providers/applaunchpad/src/api/app.ts +++ b/frontend/providers/applaunchpad/src/api/app.ts @@ -7,7 +7,7 @@ import { adaptMetrics, adaptEvents } from '@/utils/adapt'; -import type { AppPatchPropsType } from '@/types/app'; +import type { AppPatchPropsType, PodDetailType } from '@/types/app'; import { MonitorDataResult, MonitorQueryKey } from '@/types/monitor'; export const postDeployApp = (yamlList: string[]) => POST('/api/applyApp', { yamlList }); @@ -27,7 +27,7 @@ export const getAppByName = (name: string) => GET(`/api/getAppByAppName?appName=${name}`).then(adaptAppDetail); export const getAppPodsByAppName = (name: string) => - GET<V1Pod[]>('/api/getAppPodsByAppName', { name }).then((item) => item.map(adaptPod)); + GET<PodDetailType[]>('/api/getAppPodsByAppName', { name }); export const getPodsMetrics = (podsName: string[]) => POST<SinglePodMetrics[]>('/api/getPodsMetrics', { podsName }).then((item) => diff --git a/frontend/providers/applaunchpad/src/constants/editApp.ts b/frontend/providers/applaunchpad/src/constants/editApp.ts index 3f904094475..9b19828fbfc 100644 --- a/frontend/providers/applaunchpad/src/constants/editApp.ts +++ b/frontend/providers/applaunchpad/src/constants/editApp.ts @@ -62,7 +62,8 @@ export const defaultEditVal: AppEditType = { manufacturers: 'nvidia', type: '', amount: 1 - } + }, + labels: {} }; export const GpuAmountMarkList = [ diff --git a/frontend/providers/applaunchpad/src/hooks/useDetailDriver.tsx b/frontend/providers/applaunchpad/src/hooks/useDetailDriver.tsx index 36ec95d5a70..2932444336d 100644 --- a/frontend/providers/applaunchpad/src/hooks/useDetailDriver.tsx +++ b/frontend/providers/applaunchpad/src/hooks/useDetailDriver.tsx @@ -4,11 +4,12 @@ import { useGuideStore } from '@/store/guide'; import { formatMoney } from '@/utils/tools'; import { Center, Flex, FlexProps, Icon, Text } from '@chakra-ui/react'; import { DriveStep, driver } from '@sealos/driver'; -import { SealosCoin } from '@sealos/ui'; +import { CurrencySymbol } from '@sealos/ui'; import { useTranslation } from 'next-i18next'; import { useEffect, useMemo, useState } from 'react'; import { sealosApp } from 'sealos-desktop-sdk/app'; import { DriverStarIcon } from './useDriver'; +import { CURRENCY } from '@/store/static'; export default function useDetailDriver() { const { t, i18n } = useTranslation(); @@ -162,7 +163,7 @@ export default function useDetailDriver() { > {t('receive')} </Text> - <SealosCoin /> + <CurrencySymbol type={CURRENCY} /> <Text mx="4px">{reward}</Text> <Text fontSize={'14px'} fontWeight={500}> {t('Balance')} @@ -199,7 +200,7 @@ export default function useDetailDriver() { h={'72px'} position={'relative'} > - <SealosCoin w="14px" /> + <CurrencySymbol type={CURRENCY} /> <Text fontSize={'20px'} fontWeight={500} color={'rgba(17, 24, 36, 1)'} pl="4px"> {item.amount} </Text> @@ -219,7 +220,7 @@ export default function useDetailDriver() { height={'20px'} > <Text>{t('gift')}</Text> - <SealosCoin w="10px" /> + <CurrencySymbol type={CURRENCY} /> <Text>{item.gift}</Text> </Flex> </Center> @@ -273,7 +274,7 @@ export default function useDetailDriver() { allowPreviousStep: false, isShowButtons: false, allowKeyboardControl: false, - steps: [...baseSteps, ...giftStep], + steps: [...baseSteps], onDestroyed: () => { console.log('onDestroyed Detail'); setDetailCompleted(true); diff --git a/frontend/providers/applaunchpad/src/hooks/useDriver.tsx b/frontend/providers/applaunchpad/src/hooks/useDriver.tsx index fcbd40fe68c..6993fa1eab3 100644 --- a/frontend/providers/applaunchpad/src/hooks/useDriver.tsx +++ b/frontend/providers/applaunchpad/src/hooks/useDriver.tsx @@ -3,7 +3,6 @@ import { useGuideStore } from '@/store/guide'; import { formatMoney } from '@/utils/tools'; import { Flex, FlexProps, Icon, Text } from '@chakra-ui/react'; import { driver } from '@sealos/driver'; -import { SealosCoin } from '@sealos/ui'; import { useTranslation } from 'next-i18next'; import { useCallback, useEffect, useState } from 'react'; @@ -146,9 +145,6 @@ export default function useDriver({ <Flex gap={'6px'} alignItems={'center'} fontSize={'13px'} fontWeight={500}> <DriverStarIcon /> <Text color={'#24282C'}>{t('guide_deploy_button')}</Text> - <Text>{reward}</Text> - <SealosCoin /> - <Text>{t('balance')}</Text> </Flex> ) } diff --git a/frontend/providers/applaunchpad/src/mock/apps.ts b/frontend/providers/applaunchpad/src/mock/apps.ts index 66b02e00f69..f6809b3a744 100644 --- a/frontend/providers/applaunchpad/src/mock/apps.ts +++ b/frontend/providers/applaunchpad/src/mock/apps.ts @@ -315,5 +315,6 @@ export const MockAppEditSyncedFields: AppEditSyncedFields = { } ], cmdParam: 'sleep 10', - runCMD: '/bin/bash -c' + runCMD: '/bin/bash -c', + labels: {} }; diff --git a/frontend/providers/applaunchpad/src/pages/_app.tsx b/frontend/providers/applaunchpad/src/pages/_app.tsx index d8a4479dfa8..3f4e5f25314 100644 --- a/frontend/providers/applaunchpad/src/pages/_app.tsx +++ b/frontend/providers/applaunchpad/src/pages/_app.tsx @@ -20,6 +20,7 @@ import '@/styles/reset.scss'; import 'nprogress/nprogress.css'; import '@sealos/driver/src/driver.css'; import { AppEditSyncedFields } from '@/types/app'; +import Script from 'next/script'; //Binding events. Router.events.on('routeChangeStart', () => NProgress.start()); @@ -121,9 +122,12 @@ const App = ({ Component, pageProps }: AppProps) => { // record route useEffect(() => { return () => { - setLastRoute(router.asPath); + const currentPath = router.asPath; + if (router.isReady && !currentPath.includes('/redirect')) { + setLastRoute(currentPath); + } }; - }, [router.pathname]); + }, [router.pathname, router.isReady, setLastRoute]); useEffect(() => { const lang = getLangStore() || 'zh'; @@ -147,19 +151,15 @@ const App = ({ Component, pageProps }: AppProps) => { try { if (e.data?.type === 'InternalAppCall') { const { name, formData } = e.data; - if (name) { - router.push({ - pathname: '/app/detail', - query: { - name: name - } + if (formData) { + router.replace({ + pathname: '/redirect', + query: { formData } }); - } else if (formData) { - router.push({ - pathname: '/app/edit', - query: { - formData: formData - } + } else if (name) { + router.replace({ + pathname: '/app/detail', + query: { name } }); } } @@ -184,7 +184,7 @@ const App = ({ Component, pageProps }: AppProps) => { </Head> <QueryClientProvider client={queryClient}> <ChakraProvider theme={theme}> - <button + {/* <button onClick={() => { const lastLang = getLangStore(); let lang = lastLang === 'en' ? 'zh' : 'en'; @@ -196,7 +196,7 @@ const App = ({ Component, pageProps }: AppProps) => { }} > changeLanguage - </button> + </button> */} <Component {...pageProps} /> <ConfirmChild /> <Loading loading={loading} /> diff --git a/frontend/providers/applaunchpad/src/pages/api/getAppPodsByAppName.ts b/frontend/providers/applaunchpad/src/pages/api/getAppPodsByAppName.ts index ee0ccf45a34..f398b7a24f1 100644 --- a/frontend/providers/applaunchpad/src/pages/api/getAppPodsByAppName.ts +++ b/frontend/providers/applaunchpad/src/pages/api/getAppPodsByAppName.ts @@ -3,6 +3,7 @@ import { ApiResp } from '@/services/kubernet'; import { authSession } from '@/services/backend/auth'; import { getK8s } from '@/services/backend/kubernetes'; import { jsonRes } from '@/services/backend/response'; +import { adaptPod } from '@/utils/adapt'; // get App Metrics By DeployName. compute average value export default async function handler(req: NextApiRequest, res: NextApiResponse<ApiResp>) { @@ -30,7 +31,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< ); jsonRes(res, { - data: pods + data: pods.map((item) => adaptPod(item)) }); } catch (err: any) { // console.log(err, 'get metrics error') diff --git a/frontend/providers/applaunchpad/src/pages/api/guide/getTasks.ts b/frontend/providers/applaunchpad/src/pages/api/guide/getTasks.ts index 9321febd240..a684e88f588 100644 --- a/frontend/providers/applaunchpad/src/pages/api/guide/getTasks.ts +++ b/frontend/providers/applaunchpad/src/pages/api/guide/getTasks.ts @@ -19,6 +19,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< } const domain = global.AppConfig.cloud.desktopDomain; + const response = await fetch(`https://${domain}/api/account/getTasks`, { method: 'GET', headers: { diff --git a/frontend/providers/applaunchpad/src/pages/api/platform/getInitData.ts b/frontend/providers/applaunchpad/src/pages/api/platform/getInitData.ts index b3d6be33b70..0b899ff4348 100644 --- a/frontend/providers/applaunchpad/src/pages/api/platform/getInitData.ts +++ b/frontend/providers/applaunchpad/src/pages/api/platform/getInitData.ts @@ -37,6 +37,7 @@ export const defaultAppConfig: AppConfigType = { gpuEnabled: false }, launchpad: { + currencySymbol: Coin.shellCoin, eventAnalyze: { enabled: false, fastGPTKey: '' @@ -90,7 +91,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) FORM_SLIDER_LIST_CONFIG: global.AppConfig.launchpad.appResourceFormSliderConfig, guideEnabled: global.AppConfig.common.guideEnabled, fileMangerConfig: global.AppConfig.launchpad.fileManger, - CURRENCY: Coin.shellCoin, + CURRENCY: global.AppConfig.launchpad.currencySymbol || Coin.shellCoin, SEALOS_USER_DOMAINS: global.AppConfig.cloud.userDomains || [], DESKTOP_DOMAIN: global.AppConfig.cloud.desktopDomain } diff --git a/frontend/providers/applaunchpad/src/pages/app/edit/components/Header.tsx b/frontend/providers/applaunchpad/src/pages/app/edit/components/Header.tsx index c50a36208f2..e116c1c57df 100644 --- a/frontend/providers/applaunchpad/src/pages/app/edit/components/Header.tsx +++ b/frontend/providers/applaunchpad/src/pages/app/edit/components/Header.tsx @@ -41,7 +41,9 @@ const Header = ({ alignItems={'center'} cursor={'pointer'} gap={'6px'} - onClick={() => router.replace(lastRoute)} + onClick={() => { + router.replace(lastRoute); + }} > <MyIcon name="arrowLeft" w={'24px'} /> <Box fontWeight={'bold'} color={'grayModern.900'} fontSize={'2xl'}> diff --git a/frontend/providers/applaunchpad/src/pages/app/edit/components/PriceBox.tsx b/frontend/providers/applaunchpad/src/pages/app/edit/components/PriceBox.tsx index 0941217f72d..f379225f6d8 100644 --- a/frontend/providers/applaunchpad/src/pages/app/edit/components/PriceBox.tsx +++ b/frontend/providers/applaunchpad/src/pages/app/edit/components/PriceBox.tsx @@ -5,52 +5,8 @@ import { Text, Icon } from '@chakra-ui/react'; import { CURRENCY } from '@/store/static'; import { useUserStore } from '@/store/user'; import MyIcon from '@/components/Icon'; -import { MyTooltip } from '@sealos/ui'; +import { CurrencySymbol, MyTooltip } from '@sealos/ui'; -function Currencysymbol({ - type = 'shellCoin', - ...props -}: { - type?: 'shellCoin' | 'cny' | 'usd'; -} & Pick<Parameters<typeof Icon>[0], 'w' | 'h' | 'color'>) { - return type === 'shellCoin' ? ( - <Icon - xmlns="http://www.w3.org/2000/svg" - width="14px" - height="14px" - viewBox="0 0 20 20" - fill="none" - > - <circle cx="10" cy="10" r="9.66" fill="#E8E8E8" stroke="#37383A" strokeWidth="0.68" /> - <circle cx="9.99995" cy="10" r="8.7366" fill="#CFCFCF" /> - <path - d="M10.0001 18.7366C14.8252 18.7366 18.7367 14.8251 18.7367 10C18.7367 8.01946 18.0776 6.19283 16.9669 4.72746C16.4078 4.64858 15.8365 4.60779 15.2557 4.60779C8.98439 4.60779 3.82381 9.36328 3.18328 15.4649C4.78448 17.4596 7.24314 18.7366 10.0001 18.7366Z" - fill="#BEBEBE" - /> - <circle cx="10.0001" cy="9.99998" r="6.77549" fill="#828386" /> - <path - d="M7.20815 9.69376C7.77022 10.5156 8.93312 10.4426 8.93312 10.4426C8.6424 10.1606 8.45342 9.90286 8.43404 9.16859C8.41466 8.43431 7.99795 8.23981 7.99795 8.23981C8.74415 7.76812 8.47765 7.25754 8.45342 6.6886C8.43889 6.33362 8.64724 6.07103 8.81199 5.92029C7.86377 6.06283 7.00696 6.56717 6.4202 7.32816C5.83343 8.08915 5.56198 9.04805 5.66245 10.005C5.73028 9.81533 6.68968 8.93517 7.20815 9.69376Z" - fill="#E8E8E8" - /> - <path - d="M14.0936 8.23012C14.0685 8.1502 14.0378 8.07219 14.0015 7.99671V7.99184C13.8324 7.64657 13.552 7.36876 13.2059 7.20348C12.8597 7.03819 12.468 6.99513 12.0944 7.08126C11.7208 7.1674 11.3871 7.37769 11.1475 7.67803C10.9079 7.97836 10.7765 8.35112 10.7745 8.73584C10.7745 8.85683 10.7875 8.97747 10.8132 9.09568C10.8133 9.0973 10.8133 9.09892 10.8132 9.10054C10.8229 9.14917 10.8374 9.1978 10.852 9.24642C10.9385 9.58898 10.9553 9.9455 10.9015 10.2947C10.8476 10.6439 10.7242 10.9787 10.5387 11.279C10.3531 11.5793 10.1091 11.8391 9.82133 12.0427C9.53354 12.2463 9.20783 12.3896 8.86362 12.4641C8.51942 12.5386 8.16378 12.5428 7.81795 12.4763C7.47211 12.4099 7.14315 12.2742 6.85072 12.0774C6.55828 11.8805 6.30836 11.6266 6.11587 11.3307C5.92338 11.0348 5.79226 10.703 5.73035 10.3551C5.8179 10.9581 6.03066 11.536 6.35486 12.0512C6.67905 12.5665 7.10745 13.0077 7.61233 13.3462C8.1172 13.6848 8.68729 13.9132 9.28569 14.0166C9.88409 14.12 10.4975 14.0962 11.0861 13.9466C11.6747 13.797 12.2255 13.5251 12.7028 13.1483C13.18 12.7716 13.5732 12.2985 13.8567 11.7596C14.1402 11.2207 14.3078 10.6281 14.3486 10.0201C14.3894 9.41211 14.3025 8.80228 14.0936 8.23012Z" - fill="#E8E8E8" - /> - <path - d="M13.3715 9.40197C13.3715 11.5209 11.6599 13.2387 9.54846 13.2387C8.42782 13.2387 7.41979 12.7548 6.72052 11.9838C6.76288 12.0163 6.80636 12.0475 6.85072 12.0774C7.14315 12.2742 7.47211 12.4099 7.81795 12.4763C8.16378 12.5428 8.51942 12.5386 8.86362 12.4641C9.20783 12.3896 9.53354 12.2463 9.82133 12.0427C10.1091 11.8391 10.3531 11.5793 10.5387 11.279C10.7242 10.9787 10.8476 10.6439 10.9015 10.2947C10.9553 9.9455 10.9385 9.58898 10.852 9.24642C10.8374 9.1978 10.8229 9.14917 10.8132 9.10054C10.8133 9.09892 10.8133 9.0973 10.8132 9.09568C10.7875 8.97747 10.7745 8.85683 10.7745 8.73584C10.7765 8.35112 10.9079 7.97836 11.1475 7.67803C11.3871 7.37769 11.7208 7.1674 12.0944 7.08126C12.2485 7.04573 12.4056 7.03213 12.5614 7.04008C13.069 7.69125 13.3715 8.51116 13.3715 9.40197Z" - fill="#E8E8E8" - /> - <path - d="M13.5419 3.49261L13.9409 4.20878L14.6571 4.60778L13.9409 5.00678L13.5419 5.72294L13.1429 5.00678L12.4268 4.60778L13.1429 4.20878L13.5419 3.49261Z" - fill="#F0F0F0" - /> - </Icon> - ) : type === 'cny' ? ( - <Text {...props}>¥</Text> - ) : ( - <Text {...props}>$</Text> - ); -} const PriceBox = ({ cpu, memory, @@ -91,7 +47,7 @@ const PriceBox = ({ const max = (val * pods[1]).toFixed(2); return ( <Flex alignItems={'center'}> - <Currencysymbol type={CURRENCY} /> + <CurrencySymbol type={CURRENCY} /> <Text ml="4px">{pods[0] === pods[1] ? `${min}` : `${min} ~ ${max}`}</Text> </Flex> ); diff --git a/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx b/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx index 5607697e0bd..e23a0a94b97 100644 --- a/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx +++ b/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx @@ -201,8 +201,7 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) => t, applySuccess, userSourcePrice?.gpu, - refetchPrice, - isGuided + refetchPrice ] ); @@ -286,21 +285,18 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) => useEffect(() => { try { - const query = router.query as { formData?: string }; + console.log('edit page already', already, router.query); + if (!already) return; + const query = router.query as { formData?: string; name?: string }; if (!query.formData) return; + const parsedData: Partial<AppEditSyncedFields> = JSON.parse( decodeURIComponent(query.formData) ); - const basicFields: (keyof AppEditSyncedFields)[] = [ - 'imageName', - 'replicas', - 'cpu', - 'memory', - 'cmdParam', - 'runCMD', - 'appName' - ]; + const basicFields: (keyof AppEditSyncedFields)[] = router.query?.name + ? ['imageName', 'cpu', 'memory'] + : ['imageName', 'replicas', 'cpu', 'memory', 'cmdParam', 'runCMD', 'appName', 'labels']; basicFields.forEach((field) => { if (parsedData[field] !== undefined) { @@ -322,7 +318,7 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) => formHook.setValue('networks', completeNetworks); } } catch (error) {} - }, []); + }, [router.query, already]); return ( <> diff --git a/frontend/providers/applaunchpad/src/pages/redirect.tsx b/frontend/providers/applaunchpad/src/pages/redirect.tsx new file mode 100644 index 00000000000..03ddc8b5b88 --- /dev/null +++ b/frontend/providers/applaunchpad/src/pages/redirect.tsx @@ -0,0 +1,61 @@ +import { getAppByName } from '@/api/app'; +import { useGlobalStore } from '@/store/global'; +import { AppEditSyncedFields } from '@/types/app'; +import { useRouter } from 'next/router'; +import { useEffect } from 'react'; + +const RedirectPage = () => { + const router = useRouter(); + const { setLastRoute } = useGlobalStore(); + + useEffect(() => { + const handleRedirect = (formData?: string) => { + if (formData) { + const parsedData: Partial<AppEditSyncedFields> = JSON.parse(decodeURIComponent(formData)); + const appName = parsedData?.appName; + + if (appName) { + getAppByName(appName) + .then((app) => { + if (app.isPause) { + router.replace({ + pathname: '/app/detail', + query: { name: appName } + }); + } else { + setLastRoute(`/app/detail?name=${appName}`); + router.replace({ + pathname: '/app/edit', + query: { name: appName, formData } + }); + } + }) + .catch((err) => { + setLastRoute('/'); + router.replace({ + pathname: '/app/edit', + query: { formData } + }); + }); + } else { + router.replace('/apps'); + } + } else { + router.replace('/apps'); + } + }; + + const handleUrlParams = () => { + const { formData } = router.query as { formData?: string }; + handleRedirect(formData); + }; + + if (router.isReady) { + handleUrlParams(); + } + }, [router, router.isReady, router.query]); + + return null; +}; + +export default RedirectPage; diff --git a/frontend/providers/applaunchpad/src/types/app.d.ts b/frontend/providers/applaunchpad/src/types/app.d.ts index 69db7c38f67..e6539a03fb0 100644 --- a/frontend/providers/applaunchpad/src/types/app.d.ts +++ b/frontend/providers/applaunchpad/src/types/app.d.ts @@ -107,11 +107,20 @@ export interface AppEditType { path: string; value: number; }[]; + labels: { [key: string]: string }; } export type AppEditSyncedFields = Pick< AppEditType, - 'imageName' | 'replicas' | 'cpu' | 'memory' | 'networks' | 'cmdParam' | 'runCMD' | 'appName' + | 'imageName' + | 'replicas' + | 'cpu' + | 'memory' + | 'networks' + | 'cmdParam' + | 'runCMD' + | 'appName' + | 'labels' >; export type TAppSourceType = 'app_store' | 'sealaf'; diff --git a/frontend/providers/applaunchpad/src/types/index.d.ts b/frontend/providers/applaunchpad/src/types/index.d.ts index 8caee522f67..2d3df3c88b5 100644 --- a/frontend/providers/applaunchpad/src/types/index.d.ts +++ b/frontend/providers/applaunchpad/src/types/index.d.ts @@ -1,5 +1,5 @@ import { WstLogger } from 'sealos-desktop-sdk/service'; -import { defaultSliderKey } from '@/constants/app'; +import { Coin, defaultSliderKey } from '@/constants/app'; export type QueryType = { name: string; @@ -40,6 +40,7 @@ export type AppConfigType = { gpuEnabled: boolean; }; launchpad: { + currencySymbol: Coin; eventAnalyze: { enabled: boolean; fastGPTKey?: string; diff --git a/frontend/providers/applaunchpad/src/utils/adapt.ts b/frontend/providers/applaunchpad/src/utils/adapt.ts index 0c05b3787f0..5b5b723db81 100644 --- a/frontend/providers/applaunchpad/src/utils/adapt.ts +++ b/frontend/providers/applaunchpad/src/utils/adapt.ts @@ -382,8 +382,10 @@ export const adaptEditAppData = (app: AppDetailType): AppEditType => { 'configMapList', 'secret', 'storeList', - 'gpu' + 'gpu', + 'labels' ]; + const res: Record<string, any> = {}; keys.forEach((key) => { diff --git a/frontend/providers/applaunchpad/src/utils/deployYaml2Json.ts b/frontend/providers/applaunchpad/src/utils/deployYaml2Json.ts index 1f10a7a932e..a290b372ed8 100644 --- a/frontend/providers/applaunchpad/src/utils/deployYaml2Json.ts +++ b/frontend/providers/applaunchpad/src/utils/deployYaml2Json.ts @@ -25,6 +25,7 @@ export const json2DeployCr = (data: AppEditType, type: 'deployment' | 'statefuls [deployPVCResizeKey]: `${totalStorage}Gi` }, labels: { + ...(data.labels || {}), [appDeployKey]: data.appName, app: data.appName } diff --git a/frontend/providers/costcenter/src/components/RechargeModal.tsx b/frontend/providers/costcenter/src/components/RechargeModal.tsx index 24529ddc6b6..b39c30068a7 100644 --- a/frontend/providers/costcenter/src/components/RechargeModal.tsx +++ b/frontend/providers/costcenter/src/components/RechargeModal.tsx @@ -346,14 +346,14 @@ const RechargeModal = forwardRef( {} ); const [defaultSteps, ratios, steps, specialBonus] = useMemo(() => { - const defaultSteps = Object.entries(bonuses?.data?.discount.defaultSteps || {}).toSorted( + const defaultSteps = Object.entries(bonuses?.data?.discount.defaultSteps || {}).sort( (a, b) => +a[0] - +b[0] ); const ratios = defaultSteps.map(([key, value]) => value); const steps = defaultSteps.map(([key, value]) => +key); - const specialBonus = Object.entries( - bonuses?.data?.discount.firstRechargeDiscount || {} - ).toSorted((a, b) => +a[0] - +b[0]); + const specialBonus = Object.entries(bonuses?.data?.discount.firstRechargeDiscount || {}).sort( + (a, b) => +a[0] - +b[0] + ); const temp: number[] = []; specialBonus.forEach(([k, v]) => { const step = +k; diff --git a/frontend/providers/costcenter/src/components/billing/BillingLineChart.tsx b/frontend/providers/costcenter/src/components/billing/BillingLineChart.tsx index 2443e056ad7..0703244162f 100644 --- a/frontend/providers/costcenter/src/components/billing/BillingLineChart.tsx +++ b/frontend/providers/costcenter/src/components/billing/BillingLineChart.tsx @@ -1,15 +1,7 @@ import ReactEChartsCore from 'echarts-for-react/lib/core'; // Import the echarts core module, which provides the necessary interfaces for using echarts. -import * as echarts from 'echarts/core'; -import { - GridComponent, - VisualMapComponent, - MarkLineComponent, - TooltipComponent -} from 'echarts/components'; -import { LineChart } from 'echarts/charts'; -import { UniversalTransition } from 'echarts/features'; -import { CanvasRenderer } from 'echarts/renderers'; +import useOverviewStore from '@/stores/overview'; +import { Cycle } from '@/types/cycle'; import { addDays, addHours, @@ -38,9 +30,17 @@ import { subWeeks, subYears } from 'date-fns'; +import { LineChart } from 'echarts/charts'; +import { + GridComponent, + MarkLineComponent, + TooltipComponent, + VisualMapComponent +} from 'echarts/components'; +import * as echarts from 'echarts/core'; +import { UniversalTransition } from 'echarts/features'; +import { CanvasRenderer } from 'echarts/renderers'; import { useTranslation } from 'next-i18next'; -import { Cycle } from '@/types/cycle'; -import useOverviewStore from '@/stores/overview'; echarts.use([ GridComponent, @@ -76,8 +76,8 @@ export default function Trend({ data, cycle }: { data: [number, string][]; cycle const startOfTime = methods[1](startTime); const source = [ // ['date', 'amount'], - ...data - .toSorted(([aD], [bD]) => aD - bD) + ...[...data] + .sort(([aD], [bD]) => aD - bD) .reduce<[Date, number][]>( (pre, [curDate, curVal]) => { const len = pre.length; diff --git a/frontend/providers/costcenter/src/components/cost_overview/components/barChart.tsx b/frontend/providers/costcenter/src/components/cost_overview/components/barChart.tsx index 599bb4a9dce..d26cb8f02f8 100644 --- a/frontend/providers/costcenter/src/components/cost_overview/components/barChart.tsx +++ b/frontend/providers/costcenter/src/components/cost_overview/components/barChart.tsx @@ -21,8 +21,8 @@ export default function Chart({ const { t } = useTranslation(); const series = data.map(([sourceRaw, seriesName]) => { const source = [ - ...sourceRaw - .toSorted(([aDate], [bDate]) => aDate - bDate) + ...[...sourceRaw] + .sort(([aDate], [bDate]) => aDate - bDate) .reduce<[Date, number][]>( (pre, [curDate, curVal]) => { const len = pre.length; diff --git a/frontend/providers/costcenter/src/components/cost_overview/components/lineChart.tsx b/frontend/providers/costcenter/src/components/cost_overview/components/lineChart.tsx index dab46cf00f1..fdd98214685 100644 --- a/frontend/providers/costcenter/src/components/cost_overview/components/lineChart.tsx +++ b/frontend/providers/costcenter/src/components/cost_overview/components/lineChart.tsx @@ -44,8 +44,8 @@ export default function Trend({ const series = data.map(([sourceRaw, seriesName]) => { const source = [ // ['date', 'amount'], - ...sourceRaw - .toSorted((a, b) => a[0] - b[0]) + ...[...sourceRaw] + .sort((a, b) => a[0] - b[0]) .reduce<[Date, number][]>( (pre, [curDate, curVal]) => { const len = pre.length; diff --git a/frontend/providers/costcenter/src/pages/api/billing/costs.ts b/frontend/providers/costcenter/src/pages/api/billing/costs.ts index 26c5b85d851..72b6b4740df 100644 --- a/frontend/providers/costcenter/src/pages/api/billing/costs.ts +++ b/frontend/providers/costcenter/src/pages/api/billing/costs.ts @@ -45,7 +45,7 @@ export default async function handler(req: NextApiRequest, resp: NextApiResponse en: string; } ] - >((d) => [d.data.toSorted(([aDate], [bDate]) => aDate - bDate), d.region.name]); + >((d) => [d.data.sort(([aDate], [bDate]) => aDate - bDate), d.region.name]); let pointers: number[] = new Array(someArr.length).fill(0); const maxPointers = someArr.map((d) => d[0].length); diff --git a/frontend/providers/costcenter/src/pages/valuation/index.tsx b/frontend/providers/costcenter/src/pages/valuation/index.tsx index ae3d5e2cab6..e4798028443 100644 --- a/frontend/providers/costcenter/src/pages/valuation/index.tsx +++ b/frontend/providers/costcenter/src/pages/valuation/index.tsx @@ -84,7 +84,7 @@ function Valuation() { } ]; }) - .toSorted((a, b) => a.idx - b.idx) || [], + .sort((a, b) => a.idx - b.idx) || [], [_data, t, cycleIdx] ); const PriceTableData = useMemo( diff --git a/frontend/providers/dbprovider/public/locales/en/common.json b/frontend/providers/dbprovider/public/locales/en/common.json index 9f051b919e5..93c6f82c577 100644 --- a/frontend/providers/dbprovider/public/locales/en/common.json +++ b/frontend/providers/dbprovider/public/locales/en/common.json @@ -75,7 +75,7 @@ "backup_database": "Backup Database", "backup_deleting": "Purging Backup", "backup_failed": "Backup Failed", - "backup_list": "Backup History", + "backup_list": "Data Backups", "backup_name": "Backup Name", "backup_name_cannot_empty": "Must provide backup name", "backup_processing": "Saving Backup", @@ -316,5 +316,6 @@ "within_1_hour": "Within 1 hour", "within_5_minutes": "Within 5 minutes", "yaml_file": "YAML", - "you_have_successfully_deployed_database": "You have successfully deployed and created a database!" + "you_have_successfully_deployed_database": "You have successfully deployed and created a database!", + "database_name_max_length": "Database name length cannot exceed {{length}} characters" } \ No newline at end of file diff --git a/frontend/providers/dbprovider/public/locales/zh/common.json b/frontend/providers/dbprovider/public/locales/zh/common.json index 78f4a432032..8c5fe2582b8 100644 --- a/frontend/providers/dbprovider/public/locales/zh/common.json +++ b/frontend/providers/dbprovider/public/locales/zh/common.json @@ -75,7 +75,7 @@ "backup_database": "备份数据库", "backup_deleting": "删除中", "backup_failed": "备份失败", - "backup_list": "备份历史", + "backup_list": "数据备份", "backup_name": "备份名", "backup_name_cannot_empty": "备份名称不能为空", "backup_processing": "备份中", @@ -197,7 +197,7 @@ "first_charge": "首充福利", "gift": "赠", "go_to_recharge": "去充值", - "guide_deploy_button": "完成创建,立即获得", + "guide_deploy_button": "完成创建", "guide_terminal_button": "便捷的终端连接方式,提升数据处理效率", "have_error": "出现异常", "hits_ratio": "命中率", @@ -316,5 +316,6 @@ "within_1_hour": "一小时内", "within_5_minutes": "五分钟内", "yaml_file": "YAML 文件", - "you_have_successfully_deployed_database": "您已成功部署创建一个数据库!" -} + "you_have_successfully_deployed_database": "您已成功部署创建一个数据库!", + "database_name_max_length": "数据库名长度不能超过 {{length}} 个字符" +} \ No newline at end of file diff --git a/frontend/providers/dbprovider/src/api/backup.ts b/frontend/providers/dbprovider/src/api/backup.ts index f4bfaa496a4..02c0baa180e 100644 --- a/frontend/providers/dbprovider/src/api/backup.ts +++ b/frontend/providers/dbprovider/src/api/backup.ts @@ -11,7 +11,7 @@ import type { Props as UpdatePolicyProps } from '@/pages/api/backup/updatePolicy * for the specific database in the cluster. * * To update the auto-backup policy, use the PATCH operation on the 'cluster spec backup' resource. - * + * @deprecated * @param data - Object containing information about the database, including dbName and dbType. * @returns {Promise<AutoBackupFormType>} - A promise resolving to the auto-backup configuration form. */ diff --git a/frontend/providers/dbprovider/src/api/db.ts b/frontend/providers/dbprovider/src/api/db.ts index 58d416b18ea..1ff7eb6d159 100644 --- a/frontend/providers/dbprovider/src/api/db.ts +++ b/frontend/providers/dbprovider/src/api/db.ts @@ -43,7 +43,7 @@ export const applyYamlList = (yamlList: string[], type: 'create' | 'replace' | ' POST('/api/applyYamlList', { yamlList, type }); export const getPodsByDBName = (name: string): Promise<PodDetailType[]> => - GET('/api/pod/getPodsByDBName', { name }).then((res) => res.map(adaptPod)); + GET('/api/pod/getPodsByDBName', { name }); export const getPodLogs = (data: { dbName: string; @@ -66,21 +66,11 @@ export const restartDB = (data: { dbName: string; dbType: DBType }) => { return applyYamlList([yaml], 'update'); }; -export const pauseDBByName = (data: { dbName: string; dbType: DBType }) => { - const yaml = json2StartOrStop({ - ...data, - type: 'Stop' - }); - return applyYamlList([yaml], 'update'); -}; +export const pauseDBByName = (data: { dbName: string; dbType: DBType }) => + POST('/api/pauseDBByName', data); -export const startDBByName = (data: { dbName: string; dbType: DBType }) => { - const yaml = json2StartOrStop({ - ...data, - type: 'Start' - }); - return applyYamlList([yaml], 'update'); -}; +export const startDBByName = (data: { dbName: string; dbType: DBType }) => + POST('/api/startDBByName', data); export const getDBServiceByName = (name: string) => GET<V1Service>(`/api/getServiceByName?name=${name}`); diff --git a/frontend/providers/dbprovider/src/components/PriceBox/index.tsx b/frontend/providers/dbprovider/src/components/PriceBox/index.tsx index 009b4559161..0294131acb7 100644 --- a/frontend/providers/dbprovider/src/components/PriceBox/index.tsx +++ b/frontend/providers/dbprovider/src/components/PriceBox/index.tsx @@ -1,10 +1,11 @@ import { SOURCE_PRICE } from '@/store/static'; import { I18nCommonKey } from '@/types/i18next'; import { Box, Flex, useTheme, Text, Center } from '@chakra-ui/react'; -import { MyTooltip, SealosCoin } from '@sealos/ui'; +import { CurrencySymbol, MyTooltip } from '@sealos/ui'; import { useTranslation } from 'next-i18next'; import { useMemo } from 'react'; import MyIcon from '@/components/Icon'; +import useEnvStore from '@/store/env'; export const colorMap = { cpu: '#33BABB', @@ -24,6 +25,8 @@ const PriceBox = ({ }) => { const theme = useTheme(); const { t } = useTranslation(); + const { SystemEnv } = useEnvStore(); + const priceList: { label: I18nCommonKey; color: string; @@ -89,7 +92,7 @@ const PriceBox = ({ : </Flex> <Flex alignItems={'center'} gap={'4px'}> - <SealosCoin /> + <CurrencySymbol type={SystemEnv.CurrencySymbol} /> {item.value} </Flex> </Flex> diff --git a/frontend/providers/dbprovider/src/constants/db.ts b/frontend/providers/dbprovider/src/constants/db.ts index 7af586b0c04..2806376806b 100644 --- a/frontend/providers/dbprovider/src/constants/db.ts +++ b/frontend/providers/dbprovider/src/constants/db.ts @@ -269,7 +269,8 @@ export const defaultDBEditValue: DBEditType = { replicas: 1, cpu: CpuSlideMarkList[1].value, memory: MemorySlideMarkList[1].value, - storage: 3 + storage: 3, + labels: {} }; export const defaultDBDetail: DBDetailType = { diff --git a/frontend/providers/dbprovider/src/hooks/useDetailDriver.tsx b/frontend/providers/dbprovider/src/hooks/useDetailDriver.tsx index 7d875861e41..1c800a1806a 100644 --- a/frontend/providers/dbprovider/src/hooks/useDetailDriver.tsx +++ b/frontend/providers/dbprovider/src/hooks/useDetailDriver.tsx @@ -4,7 +4,6 @@ import { useGuideStore } from '@/store/guide'; import { formatMoney } from '@/utils/tools'; import { Center, Flex, FlexProps, Icon, Text } from '@chakra-ui/react'; import { driver } from '@sealos/driver'; -import { SealosCoin } from '@sealos/ui'; import { useTranslation } from 'next-i18next'; import { useEffect, useState } from 'react'; import { sealosApp } from 'sealos-desktop-sdk/app'; @@ -86,134 +85,131 @@ export default function useDetailDriver() { </Flex> ) } - }, - { - popover: { - borderRadius: '12px 12px 12px 12px', - PopoverBody: ( - <Flex flexDirection={'column'} alignItems={'center'} padding={'27px 40px'} w="540px"> - <Flex - w="100%" - color={'#24282C'} - fontSize={'14px'} - fontWeight={500} - bg="#F6EEFA" - borderRadius={'8px'} - p={'16px'} - alignItems={'center'} - > - <DriverStarIcon /> - <Text fontWeight={500} ml="8px"> - {t('you_have_successfully_deployed_database')} - </Text> - <Text - ml="auto" - mr={'12px'} - color={'grayModern.900'} - fontSize={'12px'} - fontWeight={500} - > - {t('receive')} - </Text> - <SealosCoin /> - <Text mx="4px">{reward}</Text> - <Text fontSize={'14px'} fontWeight={500}> - {t('balance')} - </Text> - </Flex> + } + // { + // popover: { + // borderRadius: '12px 12px 12px 12px', + // PopoverBody: ( + // <Flex flexDirection={'column'} alignItems={'center'} padding={'27px 40px'} w="540px"> + // <Flex + // w="100%" + // color={'#24282C'} + // fontSize={'14px'} + // fontWeight={500} + // bg="#F6EEFA" + // borderRadius={'8px'} + // p={'16px'} + // alignItems={'center'} + // > + // <DriverStarIcon /> + // <Text fontWeight={500} ml="8px"> + // {t('you_have_successfully_deployed_database')} + // </Text> + // <Text + // ml="auto" + // mr={'12px'} + // color={'grayModern.900'} + // fontSize={'12px'} + // fontWeight={500} + // > + // {t('receive')} + // </Text> + // <Text mx="4px">{reward}</Text> + // <Text fontSize={'14px'} fontWeight={500}> + // {t('balance')} + // </Text> + // </Flex> - <Flex - alignItems={'center'} - justifyContent={'center'} - color={'#24282C'} - fontSize={'14px'} - fontWeight={500} - mt="42px" - > - <MyIcon name="gift" w={'20px'} h={'20px'} /> - <Text fontSize={'20px'} fontWeight={500} ml="8px" mr={'4px'}> - {t('first_charge')} - </Text> - </Flex> + // <Flex + // alignItems={'center'} + // justifyContent={'center'} + // color={'#24282C'} + // fontSize={'14px'} + // fontWeight={500} + // mt="42px" + // > + // <MyIcon name="gift" w={'20px'} h={'20px'} /> + // <Text fontSize={'20px'} fontWeight={500} ml="8px" mr={'4px'}> + // {t('first_charge')} + // </Text> + // </Flex> - <Flex - justifyContent={'center'} - fontSize={i18n.language === 'en' ? '18px' : '24px'} - fontWeight={500} - mt="28px" - gap={'16px'} - > - {rechargeOptions.map((item, index) => ( - <Center - key={index} - bg="#F4F4F7" - borderRadius="2px" - w={'100px'} - h={'72px'} - position={'relative'} - > - <SealosCoin w="14px" /> - <Text fontSize={'20px'} fontWeight={500} color={'rgba(17, 24, 36, 1)'} pl="4px"> - {item.amount} - </Text> - <Flex - bg={'#F7E7FF'} - position={'absolute'} - top={0} - right={'-15px'} - borderRadius={'10px 10px 10px 0px'} - color={'#9E53C1'} - fontSize={'12px'} - fontWeight={500} - gap={'2px'} - alignItems={'center'} - justifyContent={'center'} - w={'60px'} - height={'20px'} - > - <Text>{t('gift')}</Text> - <SealosCoin w="10px" /> - <Text>{item.gift}</Text> - </Flex> - </Center> - ))} - </Flex> + // <Flex + // justifyContent={'center'} + // fontSize={i18n.language === 'en' ? '18px' : '24px'} + // fontWeight={500} + // mt="28px" + // gap={'16px'} + // > + // {rechargeOptions.map((item, index) => ( + // <Center + // key={index} + // bg="#F4F4F7" + // borderRadius="2px" + // w={'100px'} + // h={'72px'} + // position={'relative'} + // > + // <Text fontSize={'20px'} fontWeight={500} color={'rgba(17, 24, 36, 1)'} pl="4px"> + // {item.amount} + // </Text> + // <Flex + // bg={'#F7E7FF'} + // position={'absolute'} + // top={0} + // right={'-15px'} + // borderRadius={'10px 10px 10px 0px'} + // color={'#9E53C1'} + // fontSize={'12px'} + // fontWeight={500} + // gap={'2px'} + // alignItems={'center'} + // justifyContent={'center'} + // w={'60px'} + // height={'20px'} + // > + // <Text>{t('gift')}</Text> + // <Text>{item.gift}</Text> + // </Flex> + // </Center> + // ))} + // </Flex> - <Flex - mt={'40px'} - bg={'#111824'} - borderRadius={'6px'} - alignItems={'center'} - justifyContent={'center'} - w={'179px'} - h={'36px'} - color={'#FFF'} - fontSize={'14px'} - fontWeight={500} - cursor={'pointer'} - onClick={() => { - driverObj.destroy(); - openCostCenterApp(); - }} - > - {t('go_to_recharge')} - </Flex> - <Text - mt="16px" - cursor={'pointer'} - color={'rgba(72, 82, 100, 1)'} - fontSize={'14px'} - fontWeight={500} - onClick={() => { - driverObj.destroy(); - }} - > - {t('let_me_think_again')} - </Text> - </Flex> - ) - } - } + // <Flex + // mt={'40px'} + // bg={'#111824'} + // borderRadius={'6px'} + // alignItems={'center'} + // justifyContent={'center'} + // w={'179px'} + // h={'36px'} + // color={'#FFF'} + // fontSize={'14px'} + // fontWeight={500} + // cursor={'pointer'} + // onClick={() => { + // driverObj.destroy(); + // openCostCenterApp(); + // }} + // > + // {t('go_to_recharge')} + // </Flex> + // <Text + // mt="16px" + // cursor={'pointer'} + // color={'rgba(72, 82, 100, 1)'} + // fontSize={'14px'} + // fontWeight={500} + // onClick={() => { + // driverObj.destroy(); + // }} + // > + // {t('let_me_think_again')} + // </Text> + // </Flex> + // ) + // } + // } ], onDestroyed: () => { console.log('onDestroyed Detail'); @@ -246,7 +242,8 @@ export default function useDetailDriver() { console.log(error); } }; - handleUserGuide(); + // hide guide + // handleUserGuide(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/frontend/providers/dbprovider/src/hooks/useDriver.tsx b/frontend/providers/dbprovider/src/hooks/useDriver.tsx index d3ab08ea66b..bd71a08599f 100644 --- a/frontend/providers/dbprovider/src/hooks/useDriver.tsx +++ b/frontend/providers/dbprovider/src/hooks/useDriver.tsx @@ -3,7 +3,6 @@ import { useGuideStore } from '@/store/guide'; import { formatMoney } from '@/utils/tools'; import { Flex, FlexProps, Icon, Text } from '@chakra-ui/react'; import { driver } from '@sealos/driver'; -import { SealosCoin } from '@sealos/ui'; import { useTranslation } from 'next-i18next'; import { useCallback, useEffect, useState } from 'react'; @@ -38,6 +37,7 @@ export default function useDriver() { const { t } = useTranslation(); const [reward, setReward] = useState(1); const { createCompleted, setCreateCompleted } = useGuideStore(); + const [isGuided, setIsGuided] = useState(false); const PopoverBodyInfo = (props: FlexProps) => { return ( @@ -90,10 +90,6 @@ export default function useDriver() { <Flex gap={'6px'} alignItems={'center'} fontSize={'13px'} fontWeight={500}> <DriverStarIcon /> <Text color={'#24282C'}>{t('guide_deploy_button')}</Text> - <Text>{reward}</Text> - <SealosCoin /> - <Text>{t('balance')}</Text> - <PopoverBodyInfo /> </Flex> ) } @@ -117,16 +113,20 @@ export default function useDriver() { try { const data = await getUserTasks(); if (data.needGuide && !createCompleted) { - setReward(formatMoney(Number(data.task.reward))); - requestAnimationFrame(() => { - startGuide(); - }); + setIsGuided(true); + // hide guide + // setReward(formatMoney(Number(data.task.reward))); + // requestAnimationFrame(() => { + // startGuide(); + // }); } - } catch (error) {} + } catch (error) { + setIsGuided(false); + } }; handleUserGuide(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - return { startGuide, closeGuide }; + return { startGuide, closeGuide, isGuided }; } diff --git a/frontend/providers/dbprovider/src/pages/api/createDB.ts b/frontend/providers/dbprovider/src/pages/api/createDB.ts index 3daf8e84620..76126e612c9 100644 --- a/frontend/providers/dbprovider/src/pages/api/createDB.ts +++ b/frontend/providers/dbprovider/src/pages/api/createDB.ts @@ -4,7 +4,7 @@ import { jsonRes } from '@/services/backend/response'; import { ApiResp } from '@/services/kubernet'; import { KbPgClusterType } from '@/types/cluster'; import { BackupItemType, DBEditType } from '@/types/db'; -import { json2Account, json2CreateCluster } from '@/utils/json2Yaml'; +import { json2Account, json2ClusterOps, json2CreateCluster } from '@/utils/json2Yaml'; import type { NextApiRequest, NextApiResponse } from 'next'; export default async function handler(req: NextApiRequest, res: NextApiResponse<ApiResp>) { @@ -20,8 +20,63 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< }); if (isEdit) { - const cluster = json2CreateCluster(dbForm); - await applyYamlList([cluster], 'replace'); + const { body } = (await k8sCustomObjects.getNamespacedCustomObject( + 'apps.kubeblocks.io', + 'v1alpha1', + namespace, + 'clusters', + dbForm.dbName + )) as { + body: KbPgClusterType; + }; + + const currentConfig = { + cpu: parseInt(body.spec.componentSpecs[0].resources.limits.cpu.replace('m', '')), + memory: parseInt(body.spec.componentSpecs[0].resources.limits.memory.replace('Mi', '')), + replicas: body.spec.componentSpecs[0].replicas, + storage: parseInt( + body.spec.componentSpecs[0].volumeClaimTemplates[0].spec.resources.requests.storage.replace( + 'Gi', + '' + ) + ) + }; + + const opsRequests = []; + + if (currentConfig.cpu !== dbForm.cpu || currentConfig.memory !== dbForm.memory) { + const verticalScalingYaml = json2ClusterOps(dbForm, 'VerticalScaling'); + opsRequests.push(verticalScalingYaml); + } + + if (currentConfig.replicas !== dbForm.replicas) { + const horizontalScalingYaml = json2ClusterOps(dbForm, 'HorizontalScaling'); + opsRequests.push(horizontalScalingYaml); + } + + if (dbForm.storage > currentConfig.storage) { + const volumeExpansionYaml = json2ClusterOps(dbForm, 'VolumeExpansion'); + opsRequests.push(volumeExpansionYaml); + } + + console.log('DB Edit Operation:', { + dbName: dbForm.dbName, + changes: { + cpu: currentConfig.cpu !== dbForm.cpu, + memory: currentConfig.memory !== dbForm.memory, + replicas: currentConfig.replicas !== dbForm.replicas, + storage: dbForm.storage > currentConfig.storage + }, + opsCount: opsRequests.length + }); + + if (opsRequests.length > 0) { + await applyYamlList(opsRequests, 'create'); + return jsonRes(res, { + data: `Successfully submitted ${opsRequests.length} change requests` + }); + } + return jsonRes(res, { data: 'success update db' }); diff --git a/frontend/providers/dbprovider/src/pages/api/getEnv.ts b/frontend/providers/dbprovider/src/pages/api/getEnv.ts index 02701531213..8880b9d2826 100644 --- a/frontend/providers/dbprovider/src/pages/api/getEnv.ts +++ b/frontend/providers/dbprovider/src/pages/api/getEnv.ts @@ -10,6 +10,8 @@ export type SystemEnvResponse = { minio_url: string; BACKUP_ENABLED: boolean; SHOW_DOCUMENT: boolean; + CurrencySymbol: 'shellCoin' | 'cny' | 'usd'; + STORAGE_MAX_SIZE: number; }; process.on('unhandledRejection', (reason, promise) => { @@ -29,7 +31,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< migrate_file_image: process.env.MIGRATE_FILE_IMAGE || 'ghcr.io/wallyxjh/test:7.1', minio_url: process.env.MINIO_URL || '', BACKUP_ENABLED: process.env.BACKUP_ENABLED === 'true', - SHOW_DOCUMENT: process.env.SHOW_DOCUMENT === 'true' + SHOW_DOCUMENT: process.env.SHOW_DOCUMENT === 'true', + CurrencySymbol: (process.env.CURRENCY_SYMBOL || 'shellCoin') as 'shellCoin' | 'cny' | 'usd', + STORAGE_MAX_SIZE: Number(process.env.STORAGE_MAX_SIZE) || 300 } }); } diff --git a/frontend/providers/dbprovider/src/pages/api/getStatefulSetByName.ts b/frontend/providers/dbprovider/src/pages/api/getStatefulSetByName.ts index d9834ea0e08..3538c0c3b7f 100644 --- a/frontend/providers/dbprovider/src/pages/api/getStatefulSetByName.ts +++ b/frontend/providers/dbprovider/src/pages/api/getStatefulSetByName.ts @@ -31,7 +31,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< key: 'redis' }, [DBTypeEnum.kafka]: { - key: 'kafka' + key: 'kafka-broker' }, [DBTypeEnum.qdrant]: { key: 'qdrant' diff --git a/frontend/providers/dbprovider/src/pages/api/pauseDBByName.ts b/frontend/providers/dbprovider/src/pages/api/pauseDBByName.ts new file mode 100644 index 00000000000..602e14de0f3 --- /dev/null +++ b/frontend/providers/dbprovider/src/pages/api/pauseDBByName.ts @@ -0,0 +1,73 @@ +import { authSession } from '@/services/backend/auth'; +import { getK8s } from '@/services/backend/kubernetes'; +import { jsonRes } from '@/services/backend/response'; +import { ApiResp } from '@/services/kubernet'; +import { KbPgClusterType } from '@/types/cluster'; +import { json2StartOrStop } from '@/utils/json2Yaml'; +import { PatchUtils } from '@kubernetes/client-node'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse<ApiResp>) { + try { + const { dbName } = req.body as { + dbName: string; + }; + + if (!dbName) { + return jsonRes(res, { + code: 400, + error: 'params error' + }); + } + + const { applyYamlList, k8sCustomObjects, namespace } = await getK8s({ + kubeconfig: await authSession(req) + }); + + const { yaml, yamlObj } = json2StartOrStop({ + dbName, + type: 'Stop' + }); + + const { body } = (await k8sCustomObjects.getNamespacedCustomObject( + 'apps.kubeblocks.io', + 'v1alpha1', + namespace, + 'clusters', + dbName + )) as { + body: KbPgClusterType; + }; + + if (body.spec.backup?.enabled === true) { + const patch = [ + { + op: 'replace', + path: '/spec/backup/enabled', + value: false + } + ]; + await k8sCustomObjects.patchNamespacedCustomObject( + 'apps.kubeblocks.io', + 'v1alpha1', + namespace, + 'clusters', + dbName, + patch, + undefined, + undefined, + undefined, + { headers: { 'Content-type': PatchUtils.PATCH_FORMAT_JSON_PATCH } } + ); + } + + await applyYamlList([yaml], 'update'); + + jsonRes(res, { data: 'pause success' }); + } catch (err: any) { + jsonRes(res, { + code: 500, + error: err + }); + } +} diff --git a/frontend/providers/dbprovider/src/pages/api/pod/getPodsByDBName.ts b/frontend/providers/dbprovider/src/pages/api/pod/getPodsByDBName.ts index a5f34939155..ae6bf7b4c2c 100644 --- a/frontend/providers/dbprovider/src/pages/api/pod/getPodsByDBName.ts +++ b/frontend/providers/dbprovider/src/pages/api/pod/getPodsByDBName.ts @@ -4,6 +4,7 @@ import { authSession } from '@/services/backend/auth'; import { getK8s } from '@/services/backend/kubernetes'; import { jsonRes } from '@/services/backend/response'; import { KBBackupNameLabel } from '@/constants/db'; +import { adaptPod } from '@/utils/adapt'; // get App Metrics By DeployName. compute average value export default async function handler(req: NextApiRequest, res: NextApiResponse<ApiResp>) { @@ -31,7 +32,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< ); jsonRes(res, { - data: pods + data: pods.map((pod) => adaptPod(pod)) }); } catch (err: any) { // console.log(err, 'get metrics error') diff --git a/frontend/providers/dbprovider/src/pages/api/startDBByName.ts b/frontend/providers/dbprovider/src/pages/api/startDBByName.ts new file mode 100644 index 00000000000..ec8859284eb --- /dev/null +++ b/frontend/providers/dbprovider/src/pages/api/startDBByName.ts @@ -0,0 +1,75 @@ +import { authSession } from '@/services/backend/auth'; +import { getK8s } from '@/services/backend/kubernetes'; +import { jsonRes } from '@/services/backend/response'; +import { ApiResp } from '@/services/kubernet'; +import { KbPgClusterType } from '@/types/cluster'; +import { json2StartOrStop } from '@/utils/json2Yaml'; +import { PatchUtils } from '@kubernetes/client-node'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse<ApiResp>) { + try { + const { dbName } = req.body as { + dbName: string; + }; + + if (!dbName) { + return jsonRes(res, { + code: 400, + error: 'params error' + }); + } + + const { applyYamlList, k8sCustomObjects, namespace } = await getK8s({ + kubeconfig: await authSession(req) + }); + + const { yaml, yamlObj } = json2StartOrStop({ + dbName, + type: 'Start' + }); + + const { body } = (await k8sCustomObjects.getNamespacedCustomObject( + 'apps.kubeblocks.io', + 'v1alpha1', + namespace, + 'clusters', + dbName + )) as { + body: KbPgClusterType; + }; + + console.log(yamlObj, body.spec.backup, body.spec.backup?.enabled === false, 'yaml'); + + if (body.spec.backup?.enabled === false) { + const patch = [ + { + op: 'replace', + path: '/spec/backup/enabled', + value: true + } + ]; + + await k8sCustomObjects.patchNamespacedCustomObject( + 'apps.kubeblocks.io', + 'v1alpha1', + namespace, + 'clusters', + dbName, + patch, + undefined, + undefined, + undefined, + { headers: { 'Content-type': PatchUtils.PATCH_FORMAT_JSON_PATCH } } + ); + } + await applyYamlList([yaml], 'update'); + + jsonRes(res, { data: 'start success' }); + } catch (err: any) { + jsonRes(res, { + code: 500, + error: err + }); + } +} diff --git a/frontend/providers/dbprovider/src/pages/db/detail/components/AppBaseInfo.tsx b/frontend/providers/dbprovider/src/pages/db/detail/components/AppBaseInfo.tsx index 5061fd41595..e15951ec28a 100644 --- a/frontend/providers/dbprovider/src/pages/db/detail/components/AppBaseInfo.tsx +++ b/frontend/providers/dbprovider/src/pages/db/detail/components/AppBaseInfo.tsx @@ -28,7 +28,7 @@ import { Text, useDisclosure } from '@chakra-ui/react'; -import { MyTooltip, SealosCoin, useMessage } from '@sealos/ui'; +import { CurrencySymbol, MyTooltip, useMessage } from '@sealos/ui'; import { useQuery } from '@tanstack/react-query'; import { pick } from 'lodash'; import { useTranslation } from 'next-i18next'; @@ -50,7 +50,7 @@ const AppBaseInfo = ({ db = defaultDBDetail }: { db: DBDetailType }) => { ); }, [db.dbType]); - const { data: dbStatefulSet } = useQuery( + const { data: dbStatefulSet, refetch: refetchDBStatefulSet } = useQuery( ['getDBStatefulSetByName', db.dbName, db.dbType], () => getDBStatefulSetByName(db.dbName, db.dbType), { @@ -59,7 +59,7 @@ const AppBaseInfo = ({ db = defaultDBDetail }: { db: DBDetailType }) => { } ); - const { data: secret } = useQuery( + const { data: secret, refetch: refetchSecret } = useQuery( ['getDBSecret', db.dbName, db.dbType], () => (db.dbName ? getDBSecret({ dbName: db.dbName, dbType: db.dbType }) : null), { @@ -67,7 +67,7 @@ const AppBaseInfo = ({ db = defaultDBDetail }: { db: DBDetailType }) => { } ); - const { data: service, refetch } = useQuery( + const { data: service, refetch: refetchService } = useQuery( ['getDBService', db.dbName, db.dbType], () => (db.dbName ? getDBServiceByName(`${db.dbName}-export`) : null), { @@ -94,6 +94,10 @@ const AppBaseInfo = ({ db = defaultDBDetail }: { db: DBDetailType }) => { connection += '/?directConnection=true'; } + if (db?.dbType === 'kafka' || db?.dbType === 'milvus') { + connection = host + ':' + port; + } + return { host, port, @@ -168,6 +172,12 @@ const AppBaseInfo = ({ db = defaultDBDetail }: { db: DBDetailType }) => { }); }, [db.dbType, secret]); + const refetchAll = () => { + refetchDBStatefulSet(); + refetchSecret(); + refetchService(); + }; + const openNetWorkService = async () => { try { console.log('openNetWorkService', dbStatefulSet, db); @@ -181,7 +191,7 @@ const AppBaseInfo = ({ db = defaultDBDetail }: { db: DBDetailType }) => { await applyYamlList([yaml], 'create'); onClose(); setIsChecked(true); - refetch(); + refetchAll(); toast({ title: t('Success'), status: 'success' @@ -324,37 +334,37 @@ const AppBaseInfo = ({ db = defaultDBDetail }: { db: DBDetailType }) => { > <MyIcon name={showSecret ? 'read' : 'unread'} w={'16px'}></MyIcon> </Center> + {['milvus', 'kafka'].indexOf(db.dbType) === -1 && ( - <> - <Center - className="driver-detail-terminal-button" - gap={'6px'} - h="28px" - fontSize={'12px'} - bg="grayModern.150" - borderRadius={'md'} - px="8px" - cursor={'pointer'} - fontWeight={'bold'} - onClick={() => onclickConnectDB()} - _hover={{ - color: 'brightBlue.600' - }} - > - <MyIcon name="terminal" w="16px" h="16px" /> - {t('direct_connection')} - </Center> - <Center ml="auto"> - <Text color={'grayModern.900'}> {t('external_network')} </Text> - <Switch - ml="12px" - size="md" - isChecked={isChecked} - onChange={(e) => (isChecked ? closeNetWorkService() : onOpen())} - /> - </Center> - </> + <Center + className="driver-detail-terminal-button" + gap={'6px'} + h="28px" + fontSize={'12px'} + bg="grayModern.150" + borderRadius={'md'} + px="8px" + cursor={'pointer'} + fontWeight={'bold'} + onClick={() => onclickConnectDB()} + _hover={{ + color: 'brightBlue.600' + }} + > + <MyIcon name="terminal" w="16px" h="16px" /> + {t('direct_connection')} + </Center> )} + + <Center ml="auto"> + <Text color={'grayModern.900'}> {t('external_network')} </Text> + <Switch + ml="12px" + size="md" + isChecked={isChecked} + onChange={(e) => (isChecked ? closeNetWorkService() : onOpen())} + /> + </Center> </Flex> {['milvus', 'kafka'].indexOf(db.dbType) === -1 && ( <Box @@ -454,9 +464,16 @@ const AppBaseInfo = ({ db = defaultDBDetail }: { db: DBDetailType }) => { {t('billing_standards')} </Text> <Center mt="16px" color={'#24282C'} fontSize={'24px'} fontWeight={600}> - {SOURCE_PRICE.nodeports} - <SealosCoin ml="8px" mr={'2px'} name="currency" w="20px" h="20px"></SealosCoin> / - {t('Hour')} + <Text mr={'8px'}>{SOURCE_PRICE.nodeports.toFixed(3)}</Text> + <CurrencySymbol + type={SystemEnv.CurrencySymbol} + shellCoin={{ + mr: '2px', + w: '20px', + h: '20px' + }} + /> + /{t('Hour')} </Center> <Button minW={'100px'} diff --git a/frontend/providers/dbprovider/src/pages/db/edit/components/Form.tsx b/frontend/providers/dbprovider/src/pages/db/edit/components/Form.tsx index 5d09a9fe88b..83baeeb9cd9 100644 --- a/frontend/providers/dbprovider/src/pages/db/edit/components/Form.tsx +++ b/frontend/providers/dbprovider/src/pages/db/edit/components/Form.tsx @@ -5,6 +5,7 @@ import QuotaBox from '@/components/QuotaBox'; import Tip from '@/components/Tip'; import { DBTypeEnum, DBTypeList, RedisHAConfig } from '@/constants/db'; import { CpuSlideMarkList, MemorySlideMarkList } from '@/constants/editApp'; +import useEnvStore from '@/store/env'; import { DBVersionMap, INSTALL_ACCOUNT } from '@/store/static'; import type { QueryType } from '@/types'; import type { DBEditType } from '@/types/db'; @@ -46,7 +47,7 @@ const Form = ({ }) => { if (!formHook) return null; const { t } = useTranslation(); - + const { SystemEnv } = useEnvStore(); const router = useRouter(); const { name } = router.query as QueryType; const theme = useTheme(); @@ -329,6 +330,10 @@ const Form = ({ pattern: { value: /^[a-z]([-a-z0-9]*[a-z0-9])?$/g, message: t('database_name_regex_error') + }, + maxLength: { + value: 30, + message: t('database_name_max_length', { length: 30 }) } })} /> @@ -432,10 +437,12 @@ const Form = ({ <FormControl isInvalid={!!errors.storage} w={'500px'}> <Flex alignItems={'center'}> <Label w={100}>{t('storage')}</Label> - <MyTooltip label={`${t('storage_range')}${minStorage}~300 Gi`}> + <MyTooltip + label={`${t('storage_range')}${minStorage}~${SystemEnv.STORAGE_MAX_SIZE} Gi`} + > <NumberInput w={'180px'} - max={300} + max={SystemEnv.STORAGE_MAX_SIZE} min={minStorage} step={1} position={'relative'} @@ -452,13 +459,13 @@ const Form = ({ message: `${t('storage_min')}${minStorage} Gi` }, max: { - value: 300, - message: `${t('storage_max')}300 Gi` + value: SystemEnv.STORAGE_MAX_SIZE, + message: `${t('storage_max')}${SystemEnv.STORAGE_MAX_SIZE} Gi` }, valueAsNumber: true })} min={minStorage} - max={300} + max={SystemEnv.STORAGE_MAX_SIZE} borderRadius={'md'} borderColor={'#E8EBF0'} bg={'#F7F8FA'} diff --git a/frontend/providers/dbprovider/src/pages/db/edit/index.tsx b/frontend/providers/dbprovider/src/pages/db/edit/index.tsx index a986576c182..f8d72d7324e 100644 --- a/frontend/providers/dbprovider/src/pages/db/edit/index.tsx +++ b/frontend/providers/dbprovider/src/pages/db/edit/index.tsx @@ -19,8 +19,8 @@ import debounce from 'lodash/debounce'; import { useTranslation } from 'next-i18next'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/router'; -import { useCallback, useMemo, useRef, useState } from 'react'; -import { useForm } from 'react-hook-form'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { FieldErrors, useForm } from 'react-hook-form'; import Form from './components/Form'; import Header from './components/Header'; import Yaml from './components/Yaml'; @@ -34,7 +34,7 @@ const defaultEdit = { }; const EditApp = ({ dbName, tabType }: { dbName?: string; tabType?: 'form' | 'yaml' }) => { - const { startGuide } = useDriver(); + const { startGuide, isGuided } = useDriver(); const { t } = useTranslation(); const router = useRouter(); const [yamlList, setYamlList] = useState<YamlItemType[]>([]); @@ -68,6 +68,12 @@ const EditApp = ({ dbName, tabType }: { dbName?: string; tabType?: 'form' | 'yam defaultValues: defaultEdit }); + useEffect(() => { + if (isGuided) { + formHook.setValue('storage', 1); + } + }, [isGuided]); + const generateYamlList = (data: DBEditType) => { return [ ...(isEdit @@ -136,7 +142,7 @@ const EditApp = ({ dbName, tabType }: { dbName?: string; tabType?: 'form' | 'yam setIsLoading(false); }; - const submitError = useCallback(() => { + const submitError = (err: FieldErrors<DBEditType>) => { // deep search message const deepSearch = (obj: any): string => { if (!obj || typeof obj !== 'object') return t('submit_error'); @@ -146,13 +152,13 @@ const EditApp = ({ dbName, tabType }: { dbName?: string; tabType?: 'form' | 'yam return deepSearch(Object.values(obj)[0]); }; toast({ - title: deepSearch(formHook.formState.errors), + title: deepSearch(err), status: 'error', position: 'top', duration: 3000, isClosable: true }); - }, [formHook.formState.errors, t, toast]); + }; useQuery( ['init'], @@ -207,7 +213,10 @@ const EditApp = ({ dbName, tabType }: { dbName?: string; tabType?: 'form' | 'yam yamlList={yamlList} applyBtnText={applyBtnText} applyCb={() => - formHook.handleSubmit((data) => openConfirm(() => submitSuccess(data))(), submitError)() + formHook.handleSubmit( + (data) => openConfirm(() => submitSuccess(data))(), + (err) => submitError(err) + )() } /> diff --git a/frontend/providers/dbprovider/src/pages/db/migrate/index.tsx b/frontend/providers/dbprovider/src/pages/db/migrate/index.tsx index 58b25171912..62381c5d251 100644 --- a/frontend/providers/dbprovider/src/pages/db/migrate/index.tsx +++ b/frontend/providers/dbprovider/src/pages/db/migrate/index.tsx @@ -25,8 +25,9 @@ import Yaml from './components/Yaml'; const ErrorModal = dynamic(() => import('@/components/ErrorModal')); -const defaultEdit = { +const defaultEdit: MigrateForm = { ...defaultDBEditValue, + labels: {}, sinkHost: '', sinkPort: '', sinkPassword: '', diff --git a/frontend/providers/dbprovider/src/store/env.ts b/frontend/providers/dbprovider/src/store/env.ts index 86f9458f437..64381b8dcc1 100644 --- a/frontend/providers/dbprovider/src/store/env.ts +++ b/frontend/providers/dbprovider/src/store/env.ts @@ -20,7 +20,9 @@ const useEnvStore = create<EnvState>()( migrate_file_image: '', minio_url: '', BACKUP_ENABLED: false, - SHOW_DOCUMENT: true + SHOW_DOCUMENT: true, + CurrencySymbol: 'shellCoin', + STORAGE_MAX_SIZE: 300 }, initSystemEnv: async () => { const data = await getAppEnv(); diff --git a/frontend/providers/dbprovider/src/types/db.d.ts b/frontend/providers/dbprovider/src/types/db.d.ts index e2f6775fba2..054612e8636 100644 --- a/frontend/providers/dbprovider/src/types/db.d.ts +++ b/frontend/providers/dbprovider/src/types/db.d.ts @@ -67,6 +67,7 @@ export interface DBEditType { cpu: number; memory: number; storage: number; + labels: { [key: string]: string }; } export type DBSourceType = 'app_store' | 'sealaf'; diff --git a/frontend/providers/dbprovider/src/types/migrate.d.ts b/frontend/providers/dbprovider/src/types/migrate.d.ts index 66a8efa5f9a..8a1fab267c0 100644 --- a/frontend/providers/dbprovider/src/types/migrate.d.ts +++ b/frontend/providers/dbprovider/src/types/migrate.d.ts @@ -98,6 +98,7 @@ export type MigrateForm = { cpu: number; memory: number; storage: number; + labels: Record<string, string>; sinkHost: string; sinkPort: string; diff --git a/frontend/providers/dbprovider/src/utils/adapt.ts b/frontend/providers/dbprovider/src/utils/adapt.ts index de52da7eb3b..d8429ec9b10 100644 --- a/frontend/providers/dbprovider/src/utils/adapt.ts +++ b/frontend/providers/dbprovider/src/utils/adapt.ts @@ -149,7 +149,8 @@ export const adaptDBForm = (db: DBDetailType): DBEditType => { cpu: 1, memory: 1, replicas: 1, - storage: 1 + storage: 1, + labels: 1 }; const form: any = {}; diff --git a/frontend/providers/dbprovider/src/utils/json2Yaml.ts b/frontend/providers/dbprovider/src/utils/json2Yaml.ts index 905d7d00f48..88fb8333efd 100644 --- a/frontend/providers/dbprovider/src/utils/json2Yaml.ts +++ b/frontend/providers/dbprovider/src/utils/json2Yaml.ts @@ -43,6 +43,7 @@ export const json2CreateCluster = (data: DBEditType, backupInfo?: BackupItemType const metadata = { finalizers: ['cluster.kubeblocks.io/finalizer'], labels: { + ...data.labels, 'clusterdefinition.kubeblocks.io/name': data.dbType, 'clusterversion.kubeblocks.io/name': data.dbVersion, [crLabelKey]: data.dbName @@ -853,11 +854,13 @@ export const json2Upgrade = ({ dbName, dbVersion }: DBEditType) => { }; export const json2StartOrStop = ({ dbName, type }: { dbName: string; type: 'Start' | 'Stop' }) => { + const nameType = type.toLocaleLowerCase(); + const template = { apiVersion: 'apps.kubeblocks.io/v1alpha1', kind: 'OpsRequest', metadata: { - name: `ops-stop-${dayjs().format('YYYYMMDDHHmmss')}`, + name: `ops-${nameType}-${dayjs().format('YYYYMMDDHHmmss')}`, labels: { [crLabelKey]: dbName } @@ -867,7 +870,10 @@ export const json2StartOrStop = ({ dbName, type }: { dbName: string; type: 'Star type } }; - return yaml.dump(template); + return { + yaml: yaml.dump(template), + yamlObj: template + }; }; export const json2Restart = ({ dbName, dbType }: { dbName: string; dbType: DBType }) => { @@ -1036,11 +1042,11 @@ export const json2NetworkService = ({ mongodb: 27017, 'apecloud-mysql': 3306, redis: 6379, - kafka: '', + kafka: 9092, qdrant: '', nebula: '', weaviate: '', - milvus: '' + milvus: 19530 }; const labelMap = { postgresql: { @@ -1055,11 +1061,15 @@ export const json2NetworkService = ({ redis: { 'kubeblocks.io/role': 'primary' }, - kafka: {}, + kafka: { + 'apps.kubeblocks.io/component-name': 'kafka-broker' + }, qdrant: {}, nebula: {}, weaviate: {}, - milvus: {} + milvus: { + 'apps.kubeblocks.io/component-name': 'milvus' + } }; const template = { @@ -1069,7 +1079,9 @@ export const json2NetworkService = ({ name: `${dbDetail.dbName}-export`, labels: { 'app.kubernetes.io/instance': dbDetail.dbName, - 'apps.kubeblocks.io/component-name': dbDetail.dbType + 'app.kubernetes.io/managed-by': 'kubeblocks', + 'apps.kubeblocks.io/component-name': dbDetail.dbType, + ...labelMap[dbDetail.dbType] }, ownerReferences: [ { @@ -1093,6 +1105,7 @@ export const json2NetworkService = ({ ], selector: { 'app.kubernetes.io/instance': dbDetail.dbName, + 'app.kubernetes.io/managed-by': 'kubeblocks', ...labelMap[dbDetail.dbType] }, type: 'NodePort' @@ -1161,3 +1174,80 @@ export const json2Reconfigure = ( return yaml.dump(template); }; + +export const json2ClusterOps = ( + data: DBEditType, + type: 'VerticalScaling' | 'HorizontalScaling' | 'VolumeExpansion' +) => { + const componentName = + data.dbType === 'apecloud-mysql' ? 'mysql' : data.dbType === 'kafka' ? 'broker' : data.dbType; + + const getOpsName = () => { + const timeStr = dayjs().format('YYYYMMDDHHmmss'); + return `ops-${type.toLowerCase()}-${timeStr}`; + }; + + const baseTemplate = { + apiVersion: 'apps.kubeblocks.io/v1alpha1', + kind: 'OpsRequest', + metadata: { + name: getOpsName(), + labels: { + [crLabelKey]: data.dbName + } + }, + spec: { + clusterRef: data.dbName, + type: type + } + }; + + const opsConfig = { + VerticalScaling: { + verticalScaling: [ + { + componentName, + requests: { + cpu: `${Math.floor(str2Num(data.cpu) * 0.1)}m`, + memory: `${Math.floor(str2Num(data.memory) * 0.1)}Mi` + }, + limits: { + cpu: `${str2Num(Math.floor(data.cpu))}m`, + memory: `${str2Num(data.memory)}Mi` + } + } + ] + }, + HorizontalScaling: { + horizontalScaling: [ + { + componentName, + replicas: data.replicas + } + ] + }, + VolumeExpansion: { + volumeExpansion: [ + { + componentName, + volumeClaimTemplates: [ + { + name: 'data', + storage: `${data.storage}Gi` + } + ] + } + ] + } + }; + + const template = { + ...baseTemplate, + spec: { + ...baseTemplate.spec, + ...opsConfig[type] + } + }; + + return yaml.dump(template); +}; diff --git a/frontend/providers/devbox/api/devbox.ts b/frontend/providers/devbox/api/devbox.ts index 0e3296a42c4..6cec0b4fc2e 100644 --- a/frontend/providers/devbox/api/devbox.ts +++ b/frontend/providers/devbox/api/devbox.ts @@ -1,4 +1,4 @@ -import { V1Pod } from '@kubernetes/client-node' +import { V1Deployment, V1Pod, V1StatefulSet } from '@kubernetes/client-node' import { DevboxEditType, @@ -8,6 +8,7 @@ import { runtimeNamespaceMapType } from '@/types/devbox' import { + adaptAppListItem, adaptDevboxDetail, adaptDevboxListItem, adaptDevboxVersionListItem, @@ -82,3 +83,8 @@ export const getDevboxMonitorData = (payload: { export const getSSHRuntimeInfo = (runtimeName: string) => GET('/api/getSSHRuntimeInfo', { runtimeName }) + +export const getAppsByDevboxId = (devboxId: string) => + GET<V1Deployment & V1StatefulSet[]>('/api/getAppsByDevboxId', { devboxId }).then((res) => + res.map(adaptAppListItem) + ) diff --git a/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/DevboxList.tsx b/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/DevboxList.tsx index 0be248a0071..70d056c873c 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/DevboxList.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/(home)/components/DevboxList.tsx @@ -171,7 +171,7 @@ const DevboxList = ({ key: 'cpu', render: (item: DevboxListItemType) => ( <Box h={'35px'} w={['120px', '130px', '140px']}> - <Box h={'35px'} w={['120px', '130px', '140px']} position={'relative'}> + <Box h={'35px'} w={['120px', '130px', '140px']} position={'absolute'}> <PodLineChart type="blue" data={item.usedCpu} /> <Text color={'#0077A9'} @@ -193,7 +193,7 @@ const DevboxList = ({ key: 'storage', render: (item: DevboxListItemType) => ( <Box h={'35px'} w={['120px', '130px', '140px']}> - <Box h={'35px'} w={['120px', '130px', '140px']} position={'relative'}> + <Box h={'35px'} w={['120px', '130px', '140px']} position={'absolute'}> <PodLineChart type="purple" data={item.usedMemory} /> <Text color={'#6F5DD7'} diff --git a/frontend/providers/devbox/app/[lang]/(platform)/(home)/page.tsx b/frontend/providers/devbox/app/[lang]/(platform)/(home)/page.tsx index 8410dd6d756..5e852d40690 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/(home)/page.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/(home)/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useRouter } from 'next/navigation' +import { useRouter } from '@/i18n' import { useQuery } from '@tanstack/react-query' import { useCallback, useEffect, useRef, useState } from 'react' @@ -104,7 +104,7 @@ const EmptyPage = () => { ) useEffect(() => { - router.prefetch('/devbox/detail') + // router.prefetch('/devbox/detail') router.prefetch('/devbox/create') }, [router]) diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Form.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Form.tsx index 7d59eecce2c..15142e8adcf 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Form.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/components/Form.tsx @@ -6,7 +6,6 @@ import { Center, Flex, FormControl, - FormErrorMessage, Grid, IconButton, Image, @@ -235,7 +234,7 @@ const Form = ({ h={'20px'} color={activeNav === item.id ? 'myGray.400' : 'grayModern.600'} /> - <Box>{item.label}</Box> + <Box>{item?.label}</Box> </Flex> </Box> ))} @@ -377,6 +376,9 @@ const Form = ({ height={'32px'} alt={item.id} src={`/images/${item.id}.svg`} + onError={(e) => { + e.currentTarget.src = '/images/custom.svg' + }} /> <Text _firstLetter={{ @@ -384,7 +386,7 @@ const Form = ({ }} mt={'4px'} textAlign={'center'}> - {item.label} + {item?.label} </Text> </Center> ) @@ -455,6 +457,9 @@ const Form = ({ height={'32px'} alt={item.id} src={`/images/${item.id}.svg`} + onError={(e) => { + e.currentTarget.src = '/images/custom.svg' + }} /> <Text _firstLetter={{ @@ -462,7 +467,7 @@ const Form = ({ }} mt={'4px'} textAlign={'center'}> - {item.label} + {item?.label} </Text> </Center> ) @@ -533,6 +538,9 @@ const Form = ({ height={'32px'} alt={item.id} src={`/images/${item.id}.svg`} + onError={(e) => { + e.currentTarget.src = '/images/custom.svg' + }} /> <Text _firstLetter={{ @@ -540,7 +548,7 @@ const Form = ({ }} mt={'4px'} textAlign={'center'}> - {item.label} + {item?.label} </Text> </Center> ) @@ -555,10 +563,12 @@ const Form = ({ <Input opacity={0.5} width={'200px'} - defaultValue={getRuntimeDetailLabel( - getValues('runtimeType'), - getValues('runtimeVersion') - )} + value={ + getRuntimeDetailLabel( + getValues('runtimeType'), + getValues('runtimeVersion') + ) || '' + } disabled /> ) : ( @@ -768,7 +778,7 @@ const Form = ({ alignItems={'center'} h={'32px'} bg={'grayModern.50'} - px={2} + px={4} border={theme.borders.base} borderLeft={0} borderTopRightRadius={'md'} @@ -777,9 +787,11 @@ const Form = ({ {network.customDomain ? network.customDomain : network.publicDomain} </Box> <Box + pl={4} fontSize={'11px'} color={'brightBlue.600'} cursor={'pointer'} + whiteSpace={'nowrap'} onClick={() => setCustomAccessModalData({ publicDomain: network.publicDomain, diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/page.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/page.tsx index 8df365c74f8..644dcc29d52 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/page.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/create/page.tsx @@ -31,7 +31,13 @@ import { useRuntimeStore } from '@/stores/runtime' import { patchYamlList } from '@/utils/tools' import { createDevbox, updateDevbox } from '@/api/devbox' import { json2Devbox, json2Ingress, json2Service } from '@/utils/json2Yaml' -import { LanguageTypeEnum, defaultDevboxEditValue, editModeMap } from '@/constants/devbox' +import { + FrameworkTypeEnum, + LanguageTypeEnum, + OSTypeEnum, + defaultDevboxEditValue, + editModeMap +} from '@/constants/devbox' const ErrorModal = dynamic(() => import('@/components/modals/ErrorModal')) @@ -46,7 +52,8 @@ const DevboxCreatePage = () => { const { env } = useEnvStore() const { checkQuotaAllow } = useUserStore() - const { runtimeNamespaceMap, languageVersionMap } = useRuntimeStore() + const { runtimeNamespaceMap, languageVersionMap, frameworkVersionMap, osVersionMap } = + useRuntimeStore() const { setDevboxDetail, devboxList } = useDevboxStore() const crOldYamls = useRef<DevboxKindsType[]>([]) @@ -60,6 +67,7 @@ const DevboxCreatePage = () => { const tabType = searchParams.get('type') || 'form' const devboxName = searchParams.get('name') || '' + const runtime = searchParams.get('runtime') || '' const formData2Yamls = (data: DevboxEditType) => [ { @@ -86,8 +94,18 @@ const DevboxCreatePage = () => { const defaultEdit = { ...defaultDevboxEditValue, - runtimeVersion: languageVersionMap[LanguageTypeEnum.go][0].id, - networks: languageVersionMap[LanguageTypeEnum.go][0].defaultPorts.map((port) => ({ + runtimeType: runtime || LanguageTypeEnum.go, + runtimeVersion: runtime + ? languageVersionMap[runtime as LanguageTypeEnum]?.[0]?.id || + frameworkVersionMap[runtime as FrameworkTypeEnum]?.[0]?.id || + osVersionMap[runtime as OSTypeEnum]?.[0]?.id + : languageVersionMap[LanguageTypeEnum.go]?.[0]?.id, + networks: ( + languageVersionMap[runtime as LanguageTypeEnum]?.[0]?.defaultPorts || + frameworkVersionMap[runtime as FrameworkTypeEnum]?.[0]?.defaultPorts || + osVersionMap[runtime as OSTypeEnum]?.[0]?.defaultPorts || + languageVersionMap[LanguageTypeEnum.go]?.[0]?.defaultPorts + ).map((port) => ({ networkName: `${defaultDevboxEditValue.name}-${nanoid()}`, portName: nanoid(), port: port, diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/Header.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/Header.tsx index 55b9b4e56fe..c5541257358 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/Header.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/Header.tsx @@ -14,6 +14,7 @@ import MyIcon from '@/components/Icon' import IDEButton from '@/components/IDEButton' import DelModal from '@/components/modals/DelModal' import DevboxStatusTag from '@/components/DevboxStatusTag' +import { sealosApp } from 'sealos-desktop-sdk/app' const Header = ({ refetchDevboxDetail, @@ -98,6 +99,27 @@ const Header = ({ }, [setLoading, t, toast, refetchDevboxDetail] ) + const handleGoToTerminal = useCallback( + async (devbox: DevboxDetailType) => { + const defaultCommand = `kubectl exec -it $(kubectl get po -l app.kubernetes.io/name=${devbox.name} -oname) -- sh -c "clear; (bash || ash || sh)"` + try { + sealosApp.runEvents('openDesktopApp', { + appKey: 'system-terminal', + query: { + defaultCommand + }, + messageData: { type: 'new terminal', command: defaultCommand } + }) + } catch (error: any) { + toast({ + title: typeof error === 'string' ? error : error.message || t('jump_terminal_error'), + status: 'error' + }) + console.error(error) + } + }, + [t, toast] + ) return ( <Flex justify="space-between" align="center" pl={4} pt={2} flexWrap={'wrap'} gap={5}> {/* left back button and title */} @@ -161,6 +183,19 @@ const Header = ({ }} /> </Box> + <Button + h={'40px'} + fontSize={'14px'} + bg={'white'} + color={'grayModern.600'} + _hover={{ + color: 'brightBlue.600' + }} + borderWidth={1} + leftIcon={isBigButton ? <MyIcon name={'terminal'} w={'16px'} /> : undefined} + onClick={() => handleGoToTerminal(devboxDetail)}> + {isBigButton ? t('terminal') : <MyIcon name={'terminal'} w={'16px'} />} + </Button> {devboxDetail.status.value === 'Running' && ( <Button h={'40px'} diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/MainBody.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/MainBody.tsx index 8cf38bb7f81..09d44dcfa37 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/MainBody.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/MainBody.tsx @@ -27,7 +27,20 @@ const MainBody = () => { dataIndex?: keyof NetworkType key: string render?: (item: NetworkType) => JSX.Element + width?: string }[] = [ + { + title: t('port'), + key: 'port', + render: (item: NetworkType) => { + return ( + <Text pl={4} color={'grayModern.600'}> + {item.port} + </Text> + ) + }, + width: '0.5fr' + }, { title: t('internal_address'), key: 'internalAddress', @@ -47,7 +60,6 @@ const MainBody = () => { _hover={{ textDecoration: 'underline' }} - ml={4} color={'grayModern.600'} onClick={() => copyData( diff --git a/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/Version.tsx b/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/Version.tsx index 20b250b4f7a..de64902cb8b 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/Version.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/devbox/detail/[name]/components/Version.tsx @@ -12,14 +12,16 @@ import ReleaseModal from '@/components/modals/releaseModal' import EditVersionDesModal from '@/components/modals/EditVersionDesModal' import { DevboxVersionListItemType } from '@/types/devbox' -import { DevboxReleaseStatusEnum } from '@/constants/devbox' -import { delDevboxVersionByName, getSSHRuntimeInfo } from '@/api/devbox' +import { DevboxReleaseStatusEnum, devboxIdKey } from '@/constants/devbox' +import { delDevboxVersionByName, getAppsByDevboxId, getSSHRuntimeInfo } from '@/api/devbox' import { useConfirm } from '@/hooks/useConfirm' import { useLoading } from '@/hooks/useLoading' import { useEnvStore } from '@/stores/env' import { useDevboxStore } from '@/stores/devbox' +import AppSelectModal from '@/components/modals/AppSelectModal' +import { AppListItemType } from '@/types/app' const Version = () => { const t = useTranslations() @@ -32,6 +34,9 @@ const Version = () => { const [initialized, setInitialized] = useState(false) const [onOpenRelease, setOnOpenRelease] = useState(false) + const [onOpenSelectApp, setOnOpenSelectApp] = useState(false) + const [apps, setApps] = useState<AppListItemType[]>([]) + const [deployData, setDeployData] = useState<any>(null) const [currentVersion, setCurrentVersion] = useState<DevboxVersionListItemType | null>(null) const { openConfirm, ConfirmChild } = useConfirm({ @@ -55,6 +60,8 @@ const Version = () => { const handleDeploy = useCallback( async (version: DevboxVersionListItemType) => { + const devboxId = devbox.id + const { releaseCommand, releaseArgs } = await getSSHRuntimeInfo(devbox.runtimeVersion) const { cpu, memory, networks, name } = devbox const newNetworks = networks.map((network) => { @@ -65,12 +72,13 @@ const Version = () => { domain: env.ingressDomain } }) + const imageName = `${env.registryAddr}/${env.namespace}/${devbox.name}:${version.tag}` const transformData = { appName: `${name}-release`, cpu: cpu, memory: memory, - imageName: `${env.registryAddr}/${env.namespace}/${devbox.name}:${version.tag}`, + imageName: imageName, networks: newNetworks.length > 0 ? newNetworks @@ -83,20 +91,33 @@ const Version = () => { } ], runCMD: releaseCommand, - cmdParam: releaseArgs + cmdParam: releaseArgs, + labels: { + [devboxIdKey]: devboxId + } } + setDeployData(transformData) + const apps = await getAppsByDevboxId(devboxId) - const formData = encodeURIComponent(JSON.stringify(transformData)) + // when: there is no app,create a new app + if (apps.length === 0) { + const tempFormDataStr = encodeURIComponent(JSON.stringify(transformData)) + sealosApp.runEvents('openDesktopApp', { + appKey: 'system-applaunchpad', + pathname: '/redirect', + query: { formData: tempFormDataStr }, + messageData: { + type: 'InternalAppCall', + formData: tempFormDataStr + } + }) + } - sealosApp.runEvents('openDesktopApp', { - appKey: 'system-applaunchpad', - pathname: '/app/edit', - query: { formData }, - messageData: { - type: 'InternalAppCall', - formData: formData - } - }) + // when: there have apps,show the app select modal + if (apps.length >= 1) { + setApps(apps) + setOnOpenSelectApp(true) + } }, [devbox, env.ingressDomain, env.namespace, env.registryAddr] ) @@ -228,6 +249,7 @@ const Version = () => { ) } ] + return ( <Box borderWidth={1} @@ -291,6 +313,15 @@ const Version = () => { devbox={{ ...devbox, sshPort: devbox.sshPort || 0 }} /> )} + {!!onOpenSelectApp && ( + <AppSelectModal + apps={apps} + devboxName={devbox.name} + deployData={deployData} + onSuccess={() => setOnOpenSelectApp(false)} + onClose={() => setOnOpenSelectApp(false)} + /> + )} <ConfirmChild /> </Box> ) diff --git a/frontend/providers/devbox/app/[lang]/(platform)/layout.tsx b/frontend/providers/devbox/app/[lang]/(platform)/layout.tsx index b8c92913113..287949f07ae 100644 --- a/frontend/providers/devbox/app/[lang]/(platform)/layout.tsx +++ b/frontend/providers/devbox/app/[lang]/(platform)/layout.tsx @@ -4,6 +4,7 @@ import throttle from 'lodash/throttle' import { useEffect, useState } from 'react' import { EVENT_NAME } from 'sealos-desktop-sdk' import { usePathname, useRouter } from '@/i18n' +import { useSearchParams } from 'next/navigation' import { createSealosApp, sealosApp } from 'sealos-desktop-sdk/app' import { useLoading } from '@/hooks/useLoading' @@ -25,6 +26,7 @@ export default function PlatformLayout({ children }: { children: React.ReactNode const { Loading } = useLoading() const { setEnv, env } = useEnvStore() const { setRuntime } = useRuntimeStore() + const searchParams = useSearchParams() const { setSourcePrice } = usePriceStore() const [refresh, setRefresh] = useState(false) const { setScreenWidth, loading, setLastRoute } = useGlobalStore() @@ -120,6 +122,15 @@ export default function PlatformLayout({ children }: { children: React.ReactNode // eslint-disable-next-line react-hooks/exhaustive-deps }, [refresh, pathname]) + useEffect(() => { + const page = searchParams.get('page') + const runtime = searchParams.get('runtime') + + const path = `${page ? `/devbox/${page}` : ''}${runtime ? `?runtime=${runtime}` : ''}` + + router.push(path) + }, [router, searchParams]) + return ( <ChakraProvider> <QueryProvider> diff --git a/frontend/providers/devbox/app/api/getAppsByDevboxId/route.ts b/frontend/providers/devbox/app/api/getAppsByDevboxId/route.ts new file mode 100644 index 00000000000..de3650a24ba --- /dev/null +++ b/frontend/providers/devbox/app/api/getAppsByDevboxId/route.ts @@ -0,0 +1,54 @@ +import type { NextRequest } from 'next/server' + +import { devboxIdKey } from '@/constants/devbox' +import { authSession } from '@/services/backend/auth' +import { getK8s } from '@/services/backend/kubernetes' +import { jsonRes } from '@/services/backend/response' + +export const dynamic = 'force-dynamic' + +export async function GET(req: NextRequest) { + try { + const apps = await getApps(req) + return jsonRes({ data: apps }) + } catch (err: any) { + return jsonRes({ + code: 500, + error: err + }) + } +} + +async function getApps(req: NextRequest) { + const { searchParams } = req.nextUrl + const devboxId = searchParams.get('devboxId') as string + + const { k8sApp, namespace } = await getK8s({ + kubeconfig: await authSession(req.headers) + }) + + const response = await Promise.allSettled([ + k8sApp.listNamespacedDeployment( + namespace, + undefined, + undefined, + undefined, + undefined, + `${devboxIdKey}=${devboxId}` + ), + k8sApp.listNamespacedStatefulSet( + namespace, + undefined, + undefined, + undefined, + undefined, + `${devboxIdKey}=${devboxId}` + ) + ]) + const apps = response + .filter((item) => item.status === 'fulfilled') + .map((item: any) => item?.value?.body?.items) + .filter((item) => item) + .flat() + return apps +} diff --git a/frontend/providers/devbox/app/api/getDevboxByName/route.ts b/frontend/providers/devbox/app/api/getDevboxByName/route.ts index 2f466a556fa..0ef2f8e6ffa 100644 --- a/frontend/providers/devbox/app/api/getDevboxByName/route.ts +++ b/frontend/providers/devbox/app/api/getDevboxByName/route.ts @@ -5,7 +5,7 @@ import { authSession } from '@/services/backend/auth' import { jsonRes } from '@/services/backend/response' import { getK8s } from '@/services/backend/kubernetes' import { KBDevboxType, KBRuntimeType } from '@/types/k8s' -import { devboxKey, publicDomainKey } from '@/constants/devbox' +import { devboxKey, ingressProtocolKey, publicDomainKey } from '@/constants/devbox' export const dynamic = 'force-dynamic' @@ -43,13 +43,12 @@ export async function GET(req: NextRequest) { devboxBody.spec.runtimeRef.name )) as { body: KBRuntimeType } - // add runtimeType, runtimeVersion, runtimeNamespace, networks to devbox yaml + // add runtimeType, runtimeVersion, networks to devbox yaml let resp = { ...devboxBody, spec: { ...devboxBody.spec, runtimeType: runtimeBody.spec.classRef - // NOTE: where use runtimeNamespace }, portInfos: [] } as KBDevboxType & { portInfos: any[] } @@ -58,8 +57,8 @@ export async function GET(req: NextRequest) { return jsonRes({ data: resp }) } - // get ingresses and certificates and service - const [ingressesResponse, certificatesResponse, serviceResponse] = await Promise.all([ + // get ingresses and service + const [ingressesResponse, serviceResponse] = await Promise.all([ k8sCustomObjects.listNamespacedCustomObject( 'networking.k8s.io', 'v1', @@ -71,32 +70,25 @@ export async function GET(req: NextRequest) { undefined, `${devboxKey}=${devboxName}` ), - k8sCustomObjects.listNamespacedCustomObject( - 'cert-manager.io', - 'v1', - namespace, - 'certificates', - undefined, - undefined, - undefined, - undefined, - `${devboxKey}=${devboxName}` - ), k8sCore.readNamespacedService(devboxName, namespace).catch(() => null) ]) - const ingresses: any = ingressesResponse.body - const certificates: any = certificatesResponse.body + + const ingresses: any = (ingressesResponse.body as { items: any[] }).items const service = serviceResponse?.body - const customDomain = certificates.items[0]?.spec.dnsNames[0] - const ingressList = ingresses.items.map((item: any) => ({ - networkName: item.metadata.name, - port: item.spec.rules[0].http.paths[0].backend.service.port.number, - protocol: item.metadata.annotations['nginx.ingress.kubernetes.io/backend-protocol'], - openPublicDomain: !!item.metadata.labels[publicDomainKey], - publicDomain: item.spec.tls[0].hosts[0], - customDomain: customDomain || '' - })) + const ingressList = ingresses.map((item: any) => { + const defaultDomain = item.metadata.labels[publicDomainKey] + const tlsHost = item.spec.tls[0].hosts[0] + + return { + networkName: item.metadata.name, + port: item.spec.rules[0].http.paths[0].backend.service.port.number, + protocol: item.metadata.annotations[ingressProtocolKey], + openPublicDomain: !!item.metadata.labels[publicDomainKey], + publicDomain: defaultDomain === tlsHost ? tlsHost : defaultDomain, + customDomain: defaultDomain === tlsHost ? '' : tlsHost + } + }) resp.portInfos = devboxBody.spec.network.extraPorts.map((network: any) => { const matchingIngress = ingressList.find( diff --git a/frontend/providers/devbox/app/api/getEnv/route.ts b/frontend/providers/devbox/app/api/getEnv/route.ts index 6b3d1a8dc41..72dabd8e4fa 100644 --- a/frontend/providers/devbox/app/api/getEnv/route.ts +++ b/frontend/providers/devbox/app/api/getEnv/route.ts @@ -25,7 +25,11 @@ export async function GET(req: NextRequest) { squashEnable: process.env.SQUASH_ENABLE || defaultEnv.squashEnable, namespace: namespace || defaultEnv.namespace, rootRuntimeNamespace: process.env.ROOT_RUNTIME_NAMESPACE || defaultEnv.rootRuntimeNamespace, - ingressDomain: process.env.INGRESS_DOMAIN || defaultEnv.ingressDomain + ingressDomain: process.env.INGRESS_DOMAIN || defaultEnv.ingressDomain, + currencySymbol: (process.env.CURRENCY_SYMBOL || defaultEnv.currencySymbol) as + | 'shellCoin' + | 'cny' + | 'usd' } }) } catch (err: any) { diff --git a/frontend/providers/devbox/app/api/platform/getRuntime/route.ts b/frontend/providers/devbox/app/api/platform/getRuntime/route.ts index 8742092a3ac..de50f1c05e2 100644 --- a/frontend/providers/devbox/app/api/platform/getRuntime/route.ts +++ b/frontend/providers/devbox/app/api/platform/getRuntime/route.ts @@ -77,15 +77,23 @@ export async function GET(req: NextRequest) { languageList.forEach((item: any) => { const language = item.metadata.name const versions = runtimes.filter((runtime: any) => runtime.spec.classRef === language) - languageVersionMap[language] = [] - versions.forEach((version: any) => { + const defaultVersion = versions.find( + (v: any) => v.metadata.annotations?.['devbox.sealos.io/defaultVersion'] === 'true' + ) + const otherVersions = versions.filter( + (v: any) => v.metadata.annotations?.['devbox.sealos.io/defaultVersion'] !== 'true' + ) + const sortedVersions = defaultVersion ? [defaultVersion, ...otherVersions] : versions + + sortedVersions.forEach((version: any) => { runtimeNamespaceMap[version.metadata.name] = item.metadata.namespace - languageVersionMap[language].push({ - id: version.metadata.name, - label: version.spec.version, - defaultPorts: version.spec.config.appPorts.map((item: any) => item.port) - }) }) + + languageVersionMap[language] = sortedVersions.map((version: any) => ({ + id: version.metadata.name, + label: version.spec.version, + defaultPorts: version.spec.config.appPorts.map((item: any) => item.port) + })) if (languageVersionMap[language].length === 0) { delete languageVersionMap[language] const index = languageTypeList.findIndex((item) => item.id === language) @@ -98,15 +106,23 @@ export async function GET(req: NextRequest) { frameworkList.forEach((item: any) => { const framework = item.metadata.name const versions = runtimes.filter((runtime: any) => runtime.spec.classRef === framework) - frameworkVersionMap[framework] = [] - versions.forEach((version: any) => { + const defaultVersion = versions.find( + (v: any) => v.metadata.annotations?.['devbox.sealos.io/defaultVersion'] === 'true' + ) + const otherVersions = versions.filter( + (v: any) => v.metadata.annotations?.['devbox.sealos.io/defaultVersion'] !== 'true' + ) + const sortedVersions = defaultVersion ? [defaultVersion, ...otherVersions] : versions + + sortedVersions.forEach((version: any) => { runtimeNamespaceMap[version.metadata.name] = item.metadata.namespace - frameworkVersionMap[framework].push({ - id: version.metadata.name, - label: version.spec.version, - defaultPorts: version.spec.config.appPorts.map((item: any) => item.port) - }) }) + + frameworkVersionMap[framework] = sortedVersions.map((version: any) => ({ + id: version.metadata.name, + label: version.spec.version, + defaultPorts: version.spec.config.appPorts.map((item: any) => item.port) + })) if (frameworkVersionMap[framework].length === 0) { delete frameworkVersionMap[framework] const index = frameworkTypeList.findIndex((item) => item.id === framework) @@ -115,23 +131,32 @@ export async function GET(req: NextRequest) { } } }) + osList.forEach((item: any) => { const os = item.metadata.name const versions = runtimes.filter((runtime: any) => runtime.spec.classRef === os) - osVersionMap[os] = [] - versions.forEach((version: any) => { + const defaultVersion = versions.find( + (v: any) => v.metadata.annotations?.['devbox.sealos.io/defaultVersion'] === 'true' + ) + const otherVersions = versions.filter( + (v: any) => v.metadata.annotations?.['devbox.sealos.io/defaultVersion'] !== 'true' + ) + const sortedVersions = defaultVersion ? [defaultVersion, ...otherVersions] : versions + + sortedVersions.forEach((version: any) => { runtimeNamespaceMap[version.metadata.name] = item.metadata.namespace - osVersionMap[os].push({ - id: version.metadata.name, - label: version.spec.version, - defaultPorts: version.spec.config.appPorts.map((item: any) => item.port) - }) }) + + osVersionMap[os] = sortedVersions.map((version: any) => ({ + id: version.metadata.name, + label: version.spec.version, + defaultPorts: version.spec.config.appPorts.map((item: any) => item.port) + })) if (osVersionMap[os].length === 0) { delete osVersionMap[os] const index = osTypeList.findIndex((item) => item.id === os) if (index !== -1) { - frameworkTypeList.splice(index, 1) + osTypeList.splice(index, 1) } } }) diff --git a/frontend/providers/devbox/app/api/v1/getDBSecretList/route.ts b/frontend/providers/devbox/app/api/v1/getDBSecretList/route.ts new file mode 100644 index 00000000000..04fa4bad6e5 --- /dev/null +++ b/frontend/providers/devbox/app/api/v1/getDBSecretList/route.ts @@ -0,0 +1,207 @@ +import { getPayloadWithoutVerification, verifyToken } from '@/services/backend/auth' +import { getK8s } from '@/services/backend/kubernetes' +import { jsonRes } from '@/services/backend/response' +import type { DBType } from '@/types/db' +import { DBTypeEnum } from '@/constants/db' +import { NextRequest } from 'next/server' +import { KbPgClusterType } from '@/types/cluster' +import { adaptDBListItem } from '@/utils/adapt' + +export type SecretResponse = { + username: string + password: string + host: string + port: string + connection: string +} + +const base = { + passwordKey: 'password', + usernameKey: 'username', + portKey: 'port', + hostKey: 'host' +} + +const dbTypeMap = { + [DBTypeEnum.postgresql]: { + ...base, + connectKey: 'postgresql' + }, + [DBTypeEnum.mongodb]: { + ...base, + connectKey: 'mongodb' + }, + [DBTypeEnum.mysql]: { + ...base, + connectKey: 'mysql' + }, + [DBTypeEnum.redis]: { + ...base, + connectKey: 'redis' + }, + [DBTypeEnum.kafka]: { + ...base, + connectKey: 'kafka', + portKey: 'endpoint', + hostKey: 'endpoint' + }, + [DBTypeEnum.qdrant]: { + ...base, + connectKey: 'qdrant' + }, + [DBTypeEnum.nebula]: { + ...base, + connectKey: 'nebula' + }, + [DBTypeEnum.weaviate]: { + ...base, + connectKey: 'weaviate' + }, + [DBTypeEnum.milvus]: { + ...base, + portKey: 'port', + hostKey: 'host', + connectKey: 'milvus' + } +} + +const buildConnectionInfo = ( + dbType: DBType, + username: string, + password: string, + host: string, + port: string, + namespace: string +) => { + if (dbTypeMap[dbType].connectKey === 'milvus') { + return { + connection: `${host}:${port}` + } + } else if (dbTypeMap[dbType].connectKey === 'kafka') { + const kafkaHost = port.split(':')[0].replace('-server', '-broker') + const kafkaPort = port.split(':')[1] + const host = kafkaHost + '.' + namespace + '.svc' + + return { + host, + port: kafkaPort, + connection: `${host}:${kafkaPort}` + } + } else { + return { + connection: `${dbTypeMap[dbType].connectKey}://${username}:${password}@${host}:${port}` + } + } +} + +export const dynamic = 'force-dynamic' + +export async function GET(req: NextRequest) { + try { + const { payload, token } = getPayloadWithoutVerification(req.headers) + if (!payload || !token) { + return jsonRes({ + code: 401, + error: 'Unauthorized' + }) + } + const devboxName = payload.devboxName + const namespace = payload.namespace + + const { k8sCore, k8sCustomObjects } = await getK8s({ + kubeconfig: + process.env.NODE_ENV === 'development' ? process.env.NEXT_PUBLIC_MOCK_USER || '' : '', + useDefaultConfig: process.env.NODE_ENV !== 'development' + }) + + const response = await k8sCore.readNamespacedSecret(devboxName, namespace) + + const jwtSecret = Buffer.from( + response.body.data?.['SEALOS_DEVBOX_JWT_SECRET'] as string, + 'base64' + ).toString('utf-8') + + if (!verifyToken(token, jwtSecret)) { + return jsonRes({ + code: 401, + error: 'Unauthorized' + }) + } + + const clustersResult = await k8sCustomObjects.listNamespacedCustomObject( + 'apps.kubeblocks.io', + 'v1alpha1', + namespace, + 'clusters' + ) + const clusters = (clustersResult.body as { items: KbPgClusterType[] }).items.map( + adaptDBListItem + ) + + const dbList = clusters.map(async (cluster) => { + const dbName = cluster.name + const dbType = cluster.dbType + const secretName = dbName + '-conn-credential' + + const secret = await k8sCore.readNamespacedSecret(secretName, namespace) + + if (!secret.body?.data) { + return jsonRes({ + code: 500, + message: 'secret is empty' + }) + } + + const username = Buffer.from( + secret.body.data[dbTypeMap[dbType].usernameKey] || '', + 'base64' + ).toString('utf-8') + + const password = Buffer.from( + secret.body.data[dbTypeMap[dbType].passwordKey] || '', + 'base64' + ).toString('utf-8') + + const hostKey = Buffer.from( + secret.body.data[dbTypeMap[dbType].hostKey] || '', + 'base64' + ).toString('utf-8') + + const host = hostKey.includes('.svc') ? hostKey : hostKey + `.${namespace}.svc` + + const port = Buffer.from( + secret.body.data[dbTypeMap[dbType].portKey] || '', + 'base64' + ).toString('utf-8') + + const connectionInfo = buildConnectionInfo(dbType, username, password, host, port, namespace) + + const data = { + dbName, + dbType, + username, + password, + host, + port, + ...connectionInfo + } + + return data + }) + + const dbListResult = await Promise.all(dbList) + + console.log('dbListResult', dbListResult) + + return jsonRes({ + data: { + dbList: dbListResult + } + }) + } catch (err: any) { + return jsonRes({ + code: 500, + error: err + }) + } +} diff --git a/frontend/providers/devbox/app/api/v1/getDevboxDetail/route.ts b/frontend/providers/devbox/app/api/v1/getDevboxDetail/route.ts index e49e5527581..be7cefbb5b8 100644 --- a/frontend/providers/devbox/app/api/v1/getDevboxDetail/route.ts +++ b/frontend/providers/devbox/app/api/v1/getDevboxDetail/route.ts @@ -1,12 +1,10 @@ import { NextRequest } from 'next/server' import { KBDevboxType } from '@/types/k8s' -import { devboxKey } from '@/constants/devbox' -import { KbPgClusterType } from '@/types/cluster' import { jsonRes } from '@/services/backend/response' import { getK8s } from '@/services/backend/kubernetes' import { getPayloadWithoutVerification, verifyToken } from '@/services/backend/auth' -import { adaptDBListItem, adaptDevboxListItem, adaptIngressListItem } from '@/utils/adapt' +import { adaptDevboxListItem } from '@/utils/adapt' export const dynamic = 'force-dynamic' @@ -22,8 +20,10 @@ export async function GET(req: NextRequest) { const devboxName = payload.devboxName const namespace = payload.namespace - const { k8sCore, k8sCustomObjects, k8sNetworkingApp } = await getK8s({ - useDefaultConfig: true + const { k8sCore, k8sCustomObjects } = await getK8s({ + kubeconfig: + process.env.NODE_ENV === 'development' ? process.env.NEXT_PUBLIC_MOCK_USER || '' : '', + useDefaultConfig: process.env.NODE_ENV !== 'development' }) const response = await k8sCore.readNamespacedSecret(devboxName, namespace) @@ -40,53 +40,19 @@ export async function GET(req: NextRequest) { }) } - const results = await Promise.allSettled([ - k8sCustomObjects.getNamespacedCustomObject( - 'devbox.sealos.io', - 'v1alpha1', - namespace, - 'devboxes', - devboxName - ), - k8sCustomObjects.listNamespacedCustomObject( - 'apps.kubeblocks.io', - 'v1alpha1', - namespace, - 'clusters' - ), - k8sNetworkingApp.listNamespacedIngress( - namespace, - undefined, - undefined, - undefined, - undefined, - `${devboxKey}=${devboxName}` - ) - ]) + const devboxResult = await k8sCustomObjects.getNamespacedCustomObject( + 'devbox.sealos.io', + 'v1alpha1', + namespace, + 'devboxes', + devboxName + ) - const [devboxResult, clustersResult, ingressesResult] = results - - let devbox, clusters, ingresses - - if (devboxResult.status === 'fulfilled') { - devbox = adaptDevboxListItem(devboxResult.value.body as KBDevboxType) - } - - if (clustersResult.status === 'fulfilled') { - clusters = (clustersResult.value.body as { items: KbPgClusterType[] }).items.map( - adaptDBListItem - ) - } - - if (ingressesResult.status === 'fulfilled') { - ingresses = ingressesResult.value.body.items.map(adaptIngressListItem) - } + const devbox = adaptDevboxListItem(devboxResult.body as KBDevboxType) return jsonRes({ data: { - devbox, - clusters, - ingresses + devbox } }) } catch (err: any) { diff --git a/frontend/providers/devbox/app/api/v1/getNetworkList/route.ts b/frontend/providers/devbox/app/api/v1/getNetworkList/route.ts new file mode 100644 index 00000000000..000859f1819 --- /dev/null +++ b/frontend/providers/devbox/app/api/v1/getNetworkList/route.ts @@ -0,0 +1,65 @@ +import { NextRequest } from 'next/server' + +import { devboxKey } from '@/constants/devbox' +import { jsonRes } from '@/services/backend/response' +import { getK8s } from '@/services/backend/kubernetes' +import { getPayloadWithoutVerification, verifyToken } from '@/services/backend/auth' +import { adaptIngressListItem } from '@/utils/adapt' + +export const dynamic = 'force-dynamic' + +export async function GET(req: NextRequest) { + try { + const { payload, token } = getPayloadWithoutVerification(req.headers) + if (!payload || !token) { + return jsonRes({ + code: 401, + error: 'Unauthorized' + }) + } + const devboxName = payload.devboxName + const namespace = payload.namespace + + const { k8sCore, k8sNetworkingApp } = await getK8s({ + kubeconfig: + process.env.NODE_ENV === 'development' ? process.env.NEXT_PUBLIC_MOCK_USER || '' : '', + useDefaultConfig: process.env.NODE_ENV !== 'development' + }) + + const response = await k8sCore.readNamespacedSecret(devboxName, namespace) + + const jwtSecret = Buffer.from( + response.body.data?.['SEALOS_DEVBOX_JWT_SECRET'] as string, + 'base64' + ).toString('utf-8') + + if (!verifyToken(token, jwtSecret)) { + return jsonRes({ + code: 401, + error: 'Unauthorized' + }) + } + + const ingressesResult = await k8sNetworkingApp.listNamespacedIngress( + namespace, + undefined, + undefined, + undefined, + undefined, + `${devboxKey}=${devboxName}` + ) + + const networks = ingressesResult.body.items.map(adaptIngressListItem) + + return jsonRes({ + data: { + networks + } + }) + } catch (err: any) { + return jsonRes({ + code: 500, + error: err?.body || err + }) + } +} diff --git a/frontend/providers/devbox/components/DevboxStatusTag.tsx b/frontend/providers/devbox/components/DevboxStatusTag.tsx index ec9f9ab0d6e..f0ef48e6de5 100644 --- a/frontend/providers/devbox/components/DevboxStatusTag.tsx +++ b/frontend/providers/devbox/components/DevboxStatusTag.tsx @@ -17,7 +17,7 @@ const DevboxStatusTag = ({ w?: string h?: string }) => { - const label = status.label + const label = status?.label const t = useTranslations() return ( diff --git a/frontend/providers/devbox/components/IDEButton.tsx b/frontend/providers/devbox/components/IDEButton.tsx index 057d2d600ec..03c4a4fdbaa 100644 --- a/frontend/providers/devbox/components/IDEButton.tsx +++ b/frontend/providers/devbox/components/IDEButton.tsx @@ -46,7 +46,7 @@ const IDEButton = ({ const { setCurrentIDE, currentIDE } = useGlobalStore() const handleGotoIDE = useCallback( - async (currentIDE: string = 'vscode') => { + async (currentIDE: IDEType = 'vscode') => { setLoading(true) toast({ @@ -55,34 +55,20 @@ const IDEButton = ({ }) try { - const { base64PrivateKey, userName } = await getSSHConnectionInfo({ + const { base64PrivateKey, userName, token } = await getSSHConnectionInfo({ devboxName, runtimeName: runtimeVersion }) const { workingDir } = await getSSHRuntimeInfo(runtimeVersion) - let editorUri = '' - switch (currentIDE) { - case 'cursor': - editorUri = `cursor://` - break - case 'vscodeInsider': - editorUri = `vscode-insiders://` - break - case 'vscode': - editorUri = `vscode://` - break - default: - editorUri = `vscode://` - } - - const fullUri = `${editorUri}labring.devbox-aio?sshDomain=${encodeURIComponent( + const idePrefix = ideObj[currentIDE].prefix + const fullUri = `${idePrefix}labring.devbox-aio?sshDomain=${encodeURIComponent( `${userName}@${env.sealosDomain}` )}&sshPort=${encodeURIComponent(sshPort)}&base64PrivateKey=${encodeURIComponent( base64PrivateKey )}&sshHostLabel=${encodeURIComponent( - `${env.sealosDomain}/${env.namespace}/${devboxName}` - )}&workingDir=${encodeURIComponent(workingDir)}` + `${env.sealosDomain}_${env.namespace}_${devboxName}` + )}&workingDir=${encodeURIComponent(workingDir)}&token=${encodeURIComponent(token)}` window.location.href = fullUri } catch (error: any) { @@ -109,18 +95,10 @@ const IDEButton = ({ borderRightWidth={0} borderRightRadius={0} onClick={() => handleGotoIDE(currentIDE)} - leftIcon={ - isBigButton ? ( - <MyIcon name={getCurrentIDELabelAndIcon(currentIDE).icon} w={'16px'} /> - ) : undefined - } + leftIcon={isBigButton ? <MyIcon name={currentIDE} w={'16px'} /> : undefined} isDisabled={status.value !== 'Running' || loading} {...leftButtonProps}> - {!isBigButton ? ( - <MyIcon name={getCurrentIDELabelAndIcon(currentIDE).icon} w={'16px'} /> - ) : ( - getCurrentIDELabelAndIcon(currentIDE).label - )} + {!isBigButton ? <MyIcon name={currentIDE} w={'16px'} /> : ideObj[currentIDE]?.label} </Button> </Tooltip> <Menu placement="bottom-end" isLazy> @@ -159,16 +137,12 @@ const IDEButton = ({ fontSize={'12px'} defaultValue={currentIDE} px={1}> - {[ - { value: 'vscode' as IDEType, label: 'VSCode' }, - { value: 'cursor' as IDEType, label: 'Cursor' }, - { value: 'vscodeInsider' as IDEType, label: 'VSCode Insider' } - ].map((item) => ( + {menuItems.map((item) => ( <MenuItem key={item.value} value={item.value} - onClick={() => setCurrentIDE(item.value)} - icon={<MyIcon name={item.value} w={'16px'} />} + onClick={() => setCurrentIDE(item.value as IDEType)} + icon={<MyIcon name={item.value as IDEType} w={'16px'} />} _hover={{ bg: '#1118240D', borderRadius: 4 @@ -178,7 +152,7 @@ const IDEButton = ({ borderRadius: 4 }}> <Flex justifyContent="space-between" alignItems="center" width="100%"> - {item.label} + {item?.label} {currentIDE === item.value && <MyIcon name="check" w={'16px'} />} </Flex> </MenuItem> @@ -189,34 +163,33 @@ const IDEButton = ({ ) } -const getCurrentIDELabelAndIcon = ( - currentIDE: IDEType -): { - label: string - icon: IDEType -} => { - switch (currentIDE) { - case 'vscode': - return { - label: 'VSCode', - icon: 'vscode' - } - case 'cursor': - return { - label: 'Cursor', - icon: 'cursor' - } - case 'vscodeInsider': - return { - label: 'VSCode Insider', - icon: 'vscodeInsider' - } - default: - return { - label: 'VSCode', - icon: 'vscode' - } +export const ideObj = { + vscode: { + label: 'VSCode', + icon: 'vscode', + prefix: 'vscode://', + value: 'vscode' + }, + vscodeInsiders: { + label: 'VSCode Insiders', + icon: 'vscodeInsiders', + prefix: 'vscode-insiders://', + value: 'vscodeInsiders' + }, + cursor: { + label: 'Cursor', + icon: 'cursor', + prefix: 'cursor://', + value: 'cursor' + }, + windsurf: { + label: 'Windsurf', + icon: 'windsurf', + prefix: 'windsurf://', + value: 'windsurf' } } +const menuItems = Object.values(ideObj).map(({ value, label }) => ({ value, label })) + export default IDEButton diff --git a/frontend/providers/devbox/components/Icon/icons/rocket.svg b/frontend/providers/devbox/components/Icon/icons/rocket.svg new file mode 100644 index 00000000000..b97d4d81815 --- /dev/null +++ b/frontend/providers/devbox/components/Icon/icons/rocket.svg @@ -0,0 +1,3 @@ +<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M18.455 2.34081L18.7201 2.37408C19.4208 2.47076 20.3481 2.60071 20.8731 3.12673C21.3035 3.55608 21.4688 4.25467 21.5675 4.87842L21.6257 5.27969C21.7567 6.22571 21.791 7.46488 21.5675 8.84128C21.1267 11.5577 19.6807 14.7991 16.0494 17.3086C16.0297 17.5041 16.0286 17.7016 16.0328 17.8991L16.0432 18.1943C16.0598 18.6486 16.0765 19.1019 15.9496 19.5437C15.7521 20.2298 15.0483 20.6821 14.3903 21.0064L14.068 21.1592L13.6522 21.3422C12.8746 21.6728 11.8246 22.0117 11.1666 21.3526C10.7715 20.9586 10.6093 20.3806 10.4773 19.8057L10.4285 19.5905C10.3734 19.3188 10.304 19.0502 10.2205 18.7859C10.1686 18.6341 10.1124 18.4792 10.0521 18.3233C9.98576 18.4047 9.91533 18.4828 9.84109 18.5572C9.48244 18.9158 8.94706 19.1663 8.50628 19.342C8.02495 19.5323 7.48022 19.6986 6.97602 19.8358L6.71717 19.9044L6.22129 20.0282L5.77219 20.1321L5.23473 20.2465L4.89895 20.312C4.73148 20.343 4.55899 20.3328 4.39634 20.2823C4.23369 20.2317 4.08578 20.1424 3.96535 20.022C3.84492 19.9016 3.7556 19.7536 3.70508 19.591C3.65456 19.4283 3.64436 19.2558 3.67537 19.0884L3.76477 18.6403L3.92487 17.9147L4.05377 17.3845L4.15149 17.0103C4.28872 16.5071 4.45505 15.9624 4.64633 15.4821C4.82098 15.0403 5.07152 14.5049 5.43017 14.1462L5.51334 14.0662L5.44681 14.0392C5.26914 13.9716 5.08918 13.9102 4.90726 13.8552L4.6193 13.7668C3.89784 13.5485 3.12647 13.3135 2.64723 12.8333C2.06506 12.2521 2.26154 11.3675 2.54223 10.6314L2.65658 10.3466L2.84059 9.93076L2.99341 9.60849C3.31775 8.95148 3.76997 8.24768 4.45609 8.05016C4.81994 7.94621 5.19835 7.93997 5.57883 7.95037L5.80754 7.95764C6.10486 7.96804 6.40114 7.97947 6.69118 7.9514C9.20071 4.31913 12.4421 2.87308 15.1585 2.4323C16.2482 2.25357 17.3571 2.2228 18.455 2.34081ZM8.2547 15.5174C8.09108 15.3963 7.89554 15.3259 7.69229 15.3148C7.48903 15.3037 7.28698 15.3525 7.11117 15.4551L6.99681 15.532L6.90013 15.6183L6.77019 15.7825C6.4999 16.1734 6.34604 16.714 6.22649 17.2192L6.11421 17.7057L6.06119 17.9272L6.25975 17.8794L6.6943 17.7796C7.29517 17.6392 7.96154 17.4573 8.37113 17.0882C8.54922 16.9102 8.65666 16.6737 8.67348 16.4224C8.69031 16.1712 8.61538 15.9224 8.46262 15.7222L8.37737 15.6245L8.35242 15.6006L8.2547 15.5174ZM15.722 8.26744C15.5289 8.07433 15.2998 7.92114 15.0475 7.8166C14.7953 7.71207 14.5249 7.65824 14.2519 7.65819C13.9788 7.65814 13.7084 7.71187 13.4562 7.81632C13.2039 7.92077 12.9746 8.07388 12.7815 8.26692C12.5884 8.45995 12.4352 8.68914 12.3307 8.94138C12.2262 9.19362 12.1723 9.46398 12.1723 9.73703C12.1722 10.0101 12.226 10.2805 12.3304 10.5327C12.4349 10.785 12.588 11.0143 12.781 11.2074C13.1709 11.5974 13.6997 11.8165 14.2511 11.8166C14.8026 11.8167 15.3315 11.5977 15.7215 11.2079C16.1114 10.818 16.3306 10.2892 16.3307 9.73776C16.3308 9.18632 16.1118 8.65743 15.722 8.26744Z"/> +</svg> diff --git a/frontend/providers/devbox/components/Icon/icons/vscodeInsider.svg b/frontend/providers/devbox/components/Icon/icons/vscodeInsiders.svg similarity index 100% rename from frontend/providers/devbox/components/Icon/icons/vscodeInsider.svg rename to frontend/providers/devbox/components/Icon/icons/vscodeInsiders.svg diff --git a/frontend/providers/devbox/components/Icon/icons/windsurf.svg b/frontend/providers/devbox/components/Icon/icons/windsurf.svg new file mode 100644 index 00000000000..c88cf74a394 --- /dev/null +++ b/frontend/providers/devbox/components/Icon/icons/windsurf.svg @@ -0,0 +1,13 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> +<rect width="16" height="16" rx="10" fill="url(#pattern0_15220_21808)"/> +<defs> +<pattern id="pattern0_15220_21808" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image0_15220_21808" transform="scale(0.00178891)"/> +</pattern> +<linearGradient id="paint0_linear_15220_21808" x1="24.6724" y1="-1.25484e-07" x2="26.5" y2="53" gradientUnits="userSpaceOnUse"> +<stop stop-color="#656565"/> +<stop offset="1" stop-color="#1A1A1A"/> +</linearGradient> +<image id="image0_15220_21808" width="559" height="559" xlink:href="data:image/png;base64,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"/> +</defs> +</svg> diff --git a/frontend/providers/devbox/components/Icon/index.tsx b/frontend/providers/devbox/components/Icon/index.tsx index 46a78e85080..530ff784ed0 100644 --- a/frontend/providers/devbox/components/Icon/index.tsx +++ b/frontend/providers/devbox/components/Icon/index.tsx @@ -58,11 +58,13 @@ const map = { list: require('./icons/list.svg').default, maximize: require('./icons/maximize.svg').default, chevronDown: require('./icons/chevronDown.svg').default, - vscodeInsider: require('./icons/vscodeInsider.svg').default, + vscodeInsiders: require('./icons/vscodeInsiders.svg').default, cursor: require('./icons/cursor.svg').default, check: require('./icons/check.svg').default, empty: require('./icons/empty.svg').default, - shutdown: require('./icons/shutdown.svg').default + shutdown: require('./icons/shutdown.svg').default, + windsurf: require('./icons/windsurf.svg').default, + rocket: require('./icons/rocket.svg').default } const MyIcon = ({ diff --git a/frontend/providers/devbox/components/MyTable.tsx b/frontend/providers/devbox/components/MyTable.tsx index fdc2db58f42..0b702558650 100644 --- a/frontend/providers/devbox/components/MyTable.tsx +++ b/frontend/providers/devbox/components/MyTable.tsx @@ -8,6 +8,7 @@ interface Props extends BoxProps { key: string render?: (item: any) => JSX.Element minWidth?: string + width?: string }[] data: any[] itemClass?: string @@ -18,7 +19,7 @@ const MyTable = ({ columns, data, itemClass = '', alternateRowColors = false }: return ( <> <Grid - templateColumns={`repeat(${columns.length}, 1fr)`} + templateColumns={columns.map((col) => col.width || '1fr').join(' ')} overflowX={'auto'} borderTopRadius={'md'} fontSize={'base'} @@ -41,7 +42,7 @@ const MyTable = ({ columns, data, itemClass = '', alternateRowColors = false }: </Grid> {data.map((item: any, index1) => ( <Grid - templateColumns={`repeat(${columns.length}, 1fr)`} + templateColumns={columns.map((col) => col.width || '1fr').join(' ')} overflowX={'auto'} key={index1} bg={alternateRowColors ? (index1 % 2 === 0 ? '#FBFBFC' : '#F4F4F7') : 'white'} diff --git a/frontend/providers/devbox/components/PriceBox.tsx b/frontend/providers/devbox/components/PriceBox.tsx index 5851c6b2372..6151a9b68c1 100644 --- a/frontend/providers/devbox/components/PriceBox.tsx +++ b/frontend/providers/devbox/components/PriceBox.tsx @@ -1,9 +1,10 @@ import { useMemo } from 'react' -import { SealosCoin } from '@sealos/ui' +import { CurrencySymbol } from '@sealos/ui' import { useTranslations } from 'next-intl' import { Box, Flex, useTheme, Text } from '@chakra-ui/react' import { usePriceStore } from '@/stores/price' +import { useEnvStore } from '@/stores/env' export const colorMap = { cpu: '#33BABB', @@ -22,6 +23,7 @@ const PriceBox = ({ }) => { const theme = useTheme() const t = useTranslations() + const { env } = useEnvStore() const { sourcePrice } = usePriceStore() @@ -68,11 +70,11 @@ const PriceBox = ({ </Flex> <Flex flexDirection={'column'} gap={'12px'} py={'16px'} px={'20px'}> {priceList.map((item) => ( - <Flex key={item.label} alignItems={'center'}> + <Flex key={item?.label} alignItems={'center'}> <Box bg={item.color} w={'8px'} h={'8px'} borderRadius={'10px'} mr={2}></Box> - <Box flex={'0 0 90px'}>{t(item.label)}:</Box> + <Box flex={'0 0 90px'}>{t(item?.label)}:</Box> <Flex alignItems={'center'} gap={'4px'}> - <SealosCoin /> + <CurrencySymbol type={env.currencySymbol} /> {item.value} </Flex> </Flex> diff --git a/frontend/providers/devbox/components/modals/AppSelectModal.tsx b/frontend/providers/devbox/components/modals/AppSelectModal.tsx new file mode 100644 index 00000000000..665ca3c5ee5 --- /dev/null +++ b/frontend/providers/devbox/components/modals/AppSelectModal.tsx @@ -0,0 +1,202 @@ +import { + Box, + Flex, + Modal, + ModalBody, + ModalContent, + ModalOverlay, + Button, + ModalHeader, + Text, + Divider +} from '@chakra-ui/react' +import { useTranslations } from 'next-intl' +import { useCallback } from 'react' +import { customAlphabet } from 'nanoid' +import { sealosApp } from 'sealos-desktop-sdk/app' + +import { AppListItemType } from '@/types/app' + +import MyIcon from '../Icon' +import MyTable from '../MyTable' +import { useEnvStore } from '@/stores/env' + +const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 6) + +interface NetworkConfig { + port: number + protocol: string + openPublicDomain: boolean + domain: string +} + +interface DeployData { + appName: string + cpu: number + memory: number + imageName: string + networks: NetworkConfig[] + runCMD: string + cmdParam: string[] + labels: { + [key: string]: string + } +} + +const AppSelectModal = ({ + apps, + deployData, + devboxName, + onSuccess, + onClose +}: { + apps: AppListItemType[] + devboxName: string + deployData: DeployData + onSuccess: () => void + onClose: () => void +}) => { + const t = useTranslations() + const { env } = useEnvStore() + + const handleCreate = useCallback(() => { + const tempFormData = { ...deployData, appName: `${deployData.appName}-${nanoid()}` } + const tempFormDataStr = encodeURIComponent(JSON.stringify(tempFormData)) + sealosApp.runEvents('openDesktopApp', { + appKey: 'system-applaunchpad', + pathname: '/redirect', + query: { formData: tempFormDataStr }, + messageData: { + type: 'InternalAppCall', + formData: tempFormDataStr + } + }) + }, [deployData]) + + const handleUpdate = useCallback( + (item: AppListItemType) => { + const tempFormData = { appName: item.name, imageName: deployData.imageName } + const tempFormDataStr = encodeURIComponent(JSON.stringify(tempFormData)) + sealosApp.runEvents('openDesktopApp', { + appKey: 'system-applaunchpad', + pathname: '/redirect', + query: { formData: tempFormDataStr }, + messageData: { + type: 'InternalAppCall', + formData: tempFormDataStr + } + }) + onSuccess() + }, + [deployData, onSuccess] + ) + + const columns: { + title: string + dataIndex?: keyof AppListItemType + key: string + width?: string + render?: (item: AppListItemType) => JSX.Element + }[] = [ + { + title: t('app_name'), + dataIndex: 'name', + key: 'name', + render: (item: AppListItemType) => { + return ( + <Text ml={4} color={'grayModern.600'}> + {item.name} + </Text> + ) + } + }, + { + title: t('current_image_name'), + dataIndex: 'imageName', + key: 'imageName', + render: (item: AppListItemType) => { + // note: no same devbox matched image will be dealt. + const dealImageName = item.imageName.startsWith( + `${env.registryAddr}/${env.namespace}/${devboxName}` + ) + ? item.imageName.replace(`${env.registryAddr}/${env.namespace}/`, '') + : '-' + return <Text color={'grayModern.600'}>{dealImageName}</Text> + } + }, + { + title: t('create_time'), + dataIndex: 'createTime', + key: 'createTime', + render: (item: AppListItemType) => { + return <Text color={'grayModern.600'}>{item.createTime}</Text> + } + }, + { + title: t('control'), + key: 'control', + render: (item: AppListItemType) => ( + <Flex> + <Button + height={'27px'} + w={'60px'} + size={'sm'} + fontSize={'base'} + bg={'grayModern.150'} + borderWidth={1} + color={'grayModern.900'} + _hover={{ + color: 'brightBlue.600' + }} + onClick={() => handleUpdate(item)}> + {t('to_update')} + </Button> + </Flex> + ) + } + ] + + return ( + <Box> + <Modal isOpen onClose={onClose} lockFocusAcrossFrames={false}> + <ModalOverlay /> + <ModalContent top={'30%'} maxWidth={'800px'} w={'700px'}> + <ModalHeader pl={10}>{t('deploy')}</ModalHeader> + <ModalBody pb={4}> + <Flex + alignItems={'center'} + direction={'column'} + mb={2} + justifyContent={'space-between'} + p={4}> + <Text fontSize={'lg'} fontWeight={'medium'}> + {t('create_directly')} + </Text> + <Button + onClick={handleCreate} + height={'36px'} + mt={4} + size={'md'} + px={8} + fontSize={'base'} + leftIcon={<MyIcon name="rocket" w={'15px'} h={'15px'} color={'white'} />}> + {t('deploy')} + </Button> + </Flex> + <Divider /> + <Box mt={4}> + <Flex alignItems={'center'} mb={4} justifyContent={'center'}> + <Text fontSize={'lg'} fontWeight={'medium'}> + {t('update_matched_apps_notes')} + </Text> + </Flex> + <MyTable columns={columns} data={apps} /> + </Box> + </ModalBody> + </ModalContent> + </Modal> + </Box> + ) +} + +export default AppSelectModal diff --git a/frontend/providers/devbox/components/modals/releaseModal.tsx b/frontend/providers/devbox/components/modals/releaseModal.tsx index 79154448ede..56ef6443b2a 100644 --- a/frontend/providers/devbox/components/modals/releaseModal.tsx +++ b/frontend/providers/devbox/components/modals/releaseModal.tsx @@ -19,7 +19,7 @@ import { useCallback, useState } from 'react' import { useEnvStore } from '@/stores/env' import { useConfirm } from '@/hooks/useConfirm' import { DevboxListItemType } from '@/types/devbox' -import { pauseDevbox, releaseDevbox, restartDevbox } from '@/api/devbox' +import { pauseDevbox, releaseDevbox, startDevbox } from '@/api/devbox' const ReleaseModal = ({ onClose, @@ -64,18 +64,22 @@ const ReleaseModal = ({ async (enableRestartMachine: boolean) => { try { setLoading(true) + // 1.pause devbox if (devbox.status.value === 'Running') { await pauseDevbox({ devboxName: devbox.name }) + // wait 3s + await new Promise((resolve) => setTimeout(resolve, 3000)) } + // 2.release devbox await releaseDevbox({ devboxName: devbox.name, tag, releaseDes, devboxUid: devbox.id }) - + // 3.start devbox if (enableRestartMachine) { - await restartDevbox({ devboxName: devbox.name }) + await startDevbox({ devboxName: devbox.name }) } toast({ title: t('submit_release_successful'), diff --git a/frontend/providers/devbox/constants/db.ts b/frontend/providers/devbox/constants/db.ts new file mode 100644 index 00000000000..8623fa9aafa --- /dev/null +++ b/frontend/providers/devbox/constants/db.ts @@ -0,0 +1,28 @@ +export enum DBTypeEnum { + postgresql = 'postgresql', + mongodb = 'mongodb', + mysql = 'apecloud-mysql', + redis = 'redis', + kafka = 'kafka', + qdrant = 'qdrant', + nebula = 'nebula', + weaviate = 'weaviate', + milvus = 'milvus' +} + +export enum DBStatusEnum { + Creating = 'Creating', + Starting = 'Starting', + Stopping = 'Stopping', + Stopped = 'Stopped', + Running = 'Running', + Updating = 'Updating', + SpecUpdating = 'SpecUpdating', + Rebooting = 'Rebooting', + Upgrade = 'Upgrade', + VerticalScaling = 'VerticalScaling', + VolumeExpanding = 'VolumeExpanding', + Failed = 'Failed', + UnKnow = 'UnKnow', + Deleting = 'Deleting' +} diff --git a/frontend/providers/devbox/constants/devbox.ts b/frontend/providers/devbox/constants/devbox.ts index bd8917eee49..9d9f86e4841 100644 --- a/frontend/providers/devbox/constants/devbox.ts +++ b/frontend/providers/devbox/constants/devbox.ts @@ -2,6 +2,8 @@ import { DevboxEditType, DevboxDetailType } from '@/types/devbox' export const crLabelKey = 'sealos-devbox-cr' export const devboxKey = 'cloud.sealos.io/devbox-manager' +export const devboxIdKey = 'cloud.sealos.io/app-devbox-id' +export const ingressProtocolKey = 'nginx.ingress.kubernetes.io/backend-protocol' export const publicDomainKey = `cloud.sealos.io/app-deploy-manager-domain` export enum LanguageTypeEnum { @@ -148,14 +150,14 @@ export const devboxReleaseStatusMap = { [DevboxReleaseStatusEnum.Pending]: { label: 'release_pending', value: DevboxReleaseStatusEnum.Pending, - color: '#787A90', + color: '#0884DD', backgroundColor: '#F5F5F8', dotColor: '#787A90' }, [DevboxReleaseStatusEnum.Failed]: { label: 'release_failed', value: DevboxReleaseStatusEnum.Failed, - color: '#F04438', + color: '#D92D20', backgroundColor: '#FEF3F2', dotColor: '#F04438' } diff --git a/frontend/providers/devbox/deploy/manifests/deploy.yaml.tmpl b/frontend/providers/devbox/deploy/manifests/deploy.yaml.tmpl index a9c9e5343d2..feef4308e72 100644 --- a/frontend/providers/devbox/deploy/manifests/deploy.yaml.tmpl +++ b/frontend/providers/devbox/deploy/manifests/deploy.yaml.tmpl @@ -55,6 +55,8 @@ spec: value: devbox-system - name: INGRESS_DOMAIN value: sealosusw.site + - name: CURRENCY_SYMBOL + value: usd # 'shellCoin' | 'cny' | 'usd' securityContext: runAsNonRoot: true runAsUser: 1001 diff --git a/frontend/providers/devbox/message/en.json b/frontend/providers/devbox/message/en.json index b3c814eb1a3..9fe45270117 100644 --- a/frontend/providers/devbox/message/en.json +++ b/frontend/providers/devbox/message/en.json @@ -20,9 +20,9 @@ "The maximum number of exposed ports is 65535": "Maximum port number is 65535", "The minimum exposed port is 1": "Minimum port number is 1", "This runtime field is required": "The runtime version field is required", + "app_name": "App Name", "basic_configuration": "Basic", "basic_info": "Basic", - "start": "Start", "cancel": "Cancel", "code_server": "CodeServer", "config_form": "Form", @@ -39,18 +39,22 @@ "cpu_exceeds_quota": "CPU requested exceeds quota. Contact admin.", "create": "Create", "create_devbox": "Create Devbox", + "create_directly": "Want to deploy a new app directly?", "create_failed": "Creation failed", "create_success": "Creation succeeded", "create_time": "Created At", + "current_image_name": "Current Image", "cursor": "Cursor", "daily": "/day", "delete": "Delete", "delete_failed": "Delete failed", "delete_successful": "Delete succeeded", + "delete_version_confirm_info": "Are you sure you want to delete this version?", "delete_warning": "Deletion Warning", "delete_warning_content": "Are you sure you want to delete Devbox?", "delete_warning_content_2": "Deleting Devbox will cause the remote environment to be deleted and you will not be able to access the remote development environment. (Your released version will be remaining).", "deploy": "Deploy", + "deploy_a_new_app": "Deploy a new app", "detail": "Detail", "devbox_creation": "Project Creation", "devbox_empty": "You have not created a new devbox yet.", @@ -65,13 +69,13 @@ "edit_successful": "Edit succeeded", "edit_version_description": "Edit Version Description", "enter_devbox_name": "Please enter the devbox name", - "enter_version_description": "Please enter the description", + "enter_version_description": "Please enter the description...", "enter_version_number": "Please enter the tag", "estimated_price": "The Estimated Cost", "event": "Event", "export_privateKey": "Export privateKey", "export_yaml": "Export YAML", - "external_address": "External Address", + "external_address": "Public Address", "framework": "Framework", "ide_tooltip": "Click to develop in IDE", "image": "Image", @@ -81,6 +85,7 @@ "jump_prompt": "Jump prompt", "jump_terminal_error": "Jump terminal failed", "language": "Language", + "matched_apps": "Deployed apps", "memory": "Memory", "memory_exceeds_quota": "Memory requested exceeds quota. Contact admin.", "monitor": "Monitor", @@ -94,6 +99,7 @@ "not_allow_standalone_use": "Not allowed to use standalone", "open_link": "Open link", "open_vscode": "VS Code", + "opening_ide": "Opening IDE...", "os": "OS", "pause": "Shutdown", "pause_devbox_info": "Automatically start the machine after released.", @@ -108,7 +114,10 @@ "recent_error": "Recent Errors", "release": "Release", "release_confirm_info": "During the release process, the machine will be temporarily shut down and the release will be in the current state. Please save the running project.", + "release_failed": "Release failed", + "release_pending": "Releasing...", "release_prompt": "The release process will Shutdown the machine and release in the current state.", + "release_success": "Release success", "release_successful": "Release succeeded", "release_version": "Release", "remaining": "Available", @@ -119,9 +128,11 @@ "runtime": "Runtime", "runtime_environment": "Runtime", "save": "Save", + "select_existing_app": "Update an existing application or deploy a new application...", "shutdown": "Shutdown", "ssh_config": "SSH Configuration", "ssh_connect_info": "SSH Connection String", + "start": "Start", "start_error": "Start error", "start_runtime": "Runtime", "start_success": "Start succeeded", @@ -133,12 +144,15 @@ "tag_format_error": "Tag format error", "tag_required": "Need to input tag", "terminal": "Terminal", + "to_update": "Update", "total": "Total", "total_price": "Total", "update": "Update", "update Time": "Updated At", + "update_app": "update/create", "update_devbox": "Update devbox", "update_failed": "Update failed", + "update_matched_apps_notes": "Or you can update application: ", "update_success": "Update succeeded", "used": "Used", "version": "Release", @@ -148,12 +162,7 @@ "version_info": "Version List", "version_list": "Version List", "version_number": "Tag", - "release_success": "Release success", - "release_pending": "Releasing...", - "release_failed": "Release failed", "vscode": "VS Code", "vscode_tooltip": "Click to develop in VSCode", - "yaml_file": "YAML", - "delete_version_confirm_info": "Are you sure you want to delete this version?", - "opening_ide": "Opening IDE..." + "yaml_file": "YAML" } diff --git a/frontend/providers/devbox/message/zh.json b/frontend/providers/devbox/message/zh.json index 8718f816394..e85d7a73f73 100644 --- a/frontend/providers/devbox/message/zh.json +++ b/frontend/providers/devbox/message/zh.json @@ -20,9 +20,9 @@ "The maximum number of exposed ports is 65535": "暴露端口最大为 65535", "The minimum exposed port is 1": "暴露端口最小为 1", "This runtime field is required": "运行时版本是必填项", + "app_name": "应用名称", "basic_configuration": "基础配置", "basic_info": "基础信息", - "start": "开机", "cancel": "取消", "code_server": "CodeServer", "config_form": "配置表单", @@ -39,19 +39,23 @@ "cpu_exceeds_quota": "申请的 CPU 超出限制,请联系管理员", "create": "创建", "create_devbox": "新建项目", + "create_directly": "想要直接上线新应用?", "create_failed": "创建失败", "create_success": "创建成功", "create_time": "创建时间", "creation_time": "创建时间", + "current_image_name": "当前镜像", "cursor": "Cursor", "daily": "/天", "delete": "删除", "delete_failed": "删除失败", "delete_successful": "删除成功", + "delete_version_confirm_info": "你确定要删除该版本吗?", "delete_warning": "删除警告", "delete_warning_content": "您确定要删除该云沙箱嘛?", "delete_warning_content_2": "删除云沙箱会导致云沙箱远程环境被删除,您将无法访问远程开发环境。(您已发布的版本仍然会保留)。", "deploy": "上线", + "deploy_a_new_app": "上线一个新应用", "detail": "详情", "devbox_creation": "项目创建", "devbox_empty": "您还没有新建项目", @@ -66,13 +70,13 @@ "edit_successful": "编辑成功", "edit_version_description": "编辑版本描述", "enter_devbox_name": "请输入项目名称", - "enter_version_description": "请输入版本描述", + "enter_version_description": "请输入版本描述...", "enter_version_number": "请输入版本号,例如:v1.0.0", "estimated_price": "预估价格", "event": "事件", "export_privateKey": "导出私钥", "export_yaml": "导出 YAML", - "external_address": "外网地址", + "external_address": "公网地址", "framework": "框架", "ide_tooltip": "点击在 IDE 中开发", "image": "镜像", @@ -83,6 +87,7 @@ "jump_prompt": "跳转提示", "jump_terminal_error": "跳转终端失败", "language": "语言", + "matched_apps": "已部署的同项目应用", "memory": "内存", "memory_exceeds_quota": "申请的 '内存' 超出限制,请联系管理员", "monitor": "实时监控", @@ -96,6 +101,7 @@ "not_allow_standalone_use": "不允许独立使用", "open_link": "打开链接", "open_vscode": "VS Code", + "opening_ide": "正在打开 IDE...", "os": "操作系统", "pause": "关机", "pause_devbox_info": "发布后自动启动机器", @@ -110,7 +116,10 @@ "recent_error": "最近错误", "release": "发布", "release_confirm_info": "发版过程中将暂时关闭机器,且以当前状态发版,请保存好正在运行的项目。", + "release_failed": "发版失败", + "release_pending": "发版中", "release_prompt": "发版过程将关机,以当前状态发版。", + "release_success": "发版成功", "release_successful": "发版成功", "release_version": "发布版本", "remaining": "剩余", @@ -121,9 +130,11 @@ "runtime": "运行环境", "runtime_environment": "运行环境", "save": "保存", + "select_existing_app": "更新一个现存的应用或者上线新应用...", "shutdown": "关机", "ssh_config": "SSH 配置", "ssh_connect_info": "连接串", + "start": "开机", "start_error": "开机失败", "start_runtime": "启动环境", "start_success": "开机成功", @@ -135,12 +146,15 @@ "tag_format_error": "版本号格式错误", "tag_required": "需要填写版本号", "terminal": "终端", + "to_update": "去更新", "total": "总共", "total_price": "总价格", "update": "变更", "update Time": "更新时间", + "update_app": "更新应用镜像/上线新应用", "update_devbox": "变更项目", "update_failed": "变更失败", + "update_matched_apps_notes": "或者你可以更新已有应用:", "update_success": "变更成功", "used": "已用", "version": "版本", @@ -150,13 +164,8 @@ "version_info": "版本列表", "version_list": "版本列表", "version_number": "版本号", - "release_success": "发版成功", - "release_pending": "发版中", - "release_failed": "发版失败", "vscode": "VS Code", "vscodeInsider": "VSCode Insider", "vscode_tooltip": "点击在 VSCode 中开发", - "yaml_file": "YAML 文件", - "delete_version_confirm_info": "你确定要删除该版本吗?", - "opening_ide": "正在打开 IDE..." + "yaml_file": "YAML 文件" } diff --git a/frontend/providers/devbox/stores/env.ts b/frontend/providers/devbox/stores/env.ts index e2ea9e56a36..65d9404992e 100644 --- a/frontend/providers/devbox/stores/env.ts +++ b/frontend/providers/devbox/stores/env.ts @@ -13,7 +13,8 @@ export const defaultEnv: Env = { squashEnable: 'false', namespace: 'default', rootRuntimeNamespace: 'devbox-system', - ingressDomain: 'sealosusw.site' + ingressDomain: 'sealosusw.site', + currencySymbol: 'shellCoin' } type State = { diff --git a/frontend/providers/devbox/stores/global.ts b/frontend/providers/devbox/stores/global.ts index 0f52630641b..c40b4f8655d 100644 --- a/frontend/providers/devbox/stores/global.ts +++ b/frontend/providers/devbox/stores/global.ts @@ -2,7 +2,7 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { immer } from 'zustand/middleware/immer' -export type IDEType = 'vscode' | 'cursor' | 'vscodeInsider' +export type IDEType = 'vscode' | 'cursor' | 'vscodeInsiders' | 'windsurf' type State = { screenWidth: number diff --git a/frontend/providers/devbox/stores/user.ts b/frontend/providers/devbox/stores/user.ts index 16000fe505c..ca9468cfa47 100644 --- a/frontend/providers/devbox/stores/user.ts +++ b/frontend/providers/devbox/stores/user.ts @@ -32,6 +32,7 @@ export const useUserStore = create<State>()( checkQuotaAllow: ({ cpu, memory, nodeports }, usedData): string | undefined => { const quote = get().userQuota + console.log(cpu, memory, nodeports) const request = { cpu: cpu / 1000, memory: memory / 1024, @@ -39,7 +40,7 @@ export const useUserStore = create<State>()( } if (usedData) { - const { cpu, memory, nodeports } = usedData + const { cpu = 0, memory = 0, nodeports = 0 } = usedData request.cpu -= cpu / 1000 request.memory -= memory / 1024 diff --git a/frontend/providers/devbox/types/app.d.ts b/frontend/providers/devbox/types/app.d.ts new file mode 100644 index 00000000000..6345369db14 --- /dev/null +++ b/frontend/providers/devbox/types/app.d.ts @@ -0,0 +1,6 @@ +export interface AppListItemType { + id: string + name: string + createTime: string + imageName: string +} diff --git a/frontend/providers/devbox/types/cluster.d.ts b/frontend/providers/devbox/types/cluster.d.ts index bf68c14030d..4c5ac5cb0b7 100644 --- a/frontend/providers/devbox/types/cluster.d.ts +++ b/frontend/providers/devbox/types/cluster.d.ts @@ -1,31 +1,4 @@ -export enum DBTypeEnum { - postgresql = 'postgresql', - mongodb = 'mongodb', - mysql = 'apecloud-mysql', - redis = 'redis', - kafka = 'kafka', - qdrant = 'qdrant', - nebula = 'nebula', - weaviate = 'weaviate', - milvus = 'milvus' -} - -export enum DBStatusEnum { - Creating = 'Creating', - Starting = 'Starting', - Stopping = 'Stopping', - Stopped = 'Stopped', - Running = 'Running', - Updating = 'Updating', - SpecUpdating = 'SpecUpdating', - Rebooting = 'Rebooting', - Upgrade = 'Upgrade', - VerticalScaling = 'VerticalScaling', - VolumeExpanding = 'VolumeExpanding', - Failed = 'Failed', - UnKnow = 'UnKnow', - Deleting = 'Deleting' -} +import { DBType, DBStatusEnum } from './db' export type KbPgClusterType = { apiVersion: 'apps.kubeblocks.io/v1alpha1' @@ -34,7 +7,7 @@ export type KbPgClusterType = { annotations: Record<string, string> creationTimestamp: Date labels: { - 'clusterdefinition.kubeblocks.io/name': `${DBTypeEnum}` + 'clusterdefinition.kubeblocks.io/name': DBType 'clusterversion.kubeblocks.io/name': string 'sealos-db-provider/postgresql': string [key: string]: string @@ -48,12 +21,12 @@ export type KbPgClusterType = { } export interface KubeBlockClusterSpec { - clusterDefinitionRef: `${DBTypeEnum}` + clusterDefinitionRef: DBType clusterVersionRef: string terminationPolicy: string componentSpecs: { - componentDefRef: `${DBTypeEnum}` - name: `${DBTypeEnum}` + componentDefRef: DBType + name: DBType replicas: number resources: { limits: { @@ -97,7 +70,7 @@ export interface KubeBlockClusterStatus { export interface DBListItemType { id: string name: string - dbType: string + dbType: DBType createTime: string cpu: number memory: number diff --git a/frontend/providers/devbox/types/db.d.ts b/frontend/providers/devbox/types/db.d.ts new file mode 100644 index 00000000000..242746a3321 --- /dev/null +++ b/frontend/providers/devbox/types/db.d.ts @@ -0,0 +1,3 @@ +import { DBTypeEnum } from '@/constants/db' + +export type DBType = `${DBTypeEnum}` diff --git a/frontend/providers/devbox/types/ingress.d.ts b/frontend/providers/devbox/types/ingress.d.ts index 3848c743b00..778c309df52 100644 --- a/frontend/providers/devbox/types/ingress.d.ts +++ b/frontend/providers/devbox/types/ingress.d.ts @@ -1,6 +1,7 @@ export interface IngressListItemType { name: string namespace: string - host: string + address: string port: number + protocol: string } diff --git a/frontend/providers/devbox/types/k8s.d.ts b/frontend/providers/devbox/types/k8s.d.ts index 2c9eb3fd226..481131ee99d 100644 --- a/frontend/providers/devbox/types/k8s.d.ts +++ b/frontend/providers/devbox/types/k8s.d.ts @@ -59,6 +59,7 @@ export interface KBDevboxSpec { network: { type: 'NodePort' | 'Tailnet' extraPorts: { + // NOTE: this object is deprecated, will be removed in the future containerPort: number hostPort?: number protocol?: string @@ -128,6 +129,9 @@ export type KBRuntimeType = { namespace: string uid: string creationTimestamp: string + annotations: { + 'devbox.sealos.io/defaultVersion': boolean + } } spec: { classRef: string diff --git a/frontend/providers/devbox/types/static.d.ts b/frontend/providers/devbox/types/static.d.ts index b08359a89f5..e6d15571be7 100644 --- a/frontend/providers/devbox/types/static.d.ts +++ b/frontend/providers/devbox/types/static.d.ts @@ -13,6 +13,7 @@ export interface Env { namespace: string rootRuntimeNamespace: string ingressDomain: string + currencySymbol: 'shellCoin' | 'cny' | 'usd' } export interface RuntimeTypeMap { diff --git a/frontend/providers/devbox/utils/adapt.ts b/frontend/providers/devbox/utils/adapt.ts index 7f9dacd4bea..979727d7084 100644 --- a/frontend/providers/devbox/utils/adapt.ts +++ b/frontend/providers/devbox/utils/adapt.ts @@ -14,9 +14,10 @@ import { DevboxVersionListItemType, PodDetailType } from '@/types/devbox' -import { V1Ingress, V1Pod } from '@kubernetes/client-node' +import { V1Deployment, V1Ingress, V1Pod, V1StatefulSet } from '@kubernetes/client-node' import { DBListItemType, KbPgClusterType } from '@/types/cluster' import { IngressListItemType } from '@/types/ingress' +import { AppListItemType } from '@/types/app' export const adaptDevboxListItem = (devbox: KBDevboxType): DevboxListItemType => { return { @@ -80,7 +81,7 @@ export const adaptDevboxDetail = ( xData: new Array(30).fill(0), yData: new Array(30).fill('0') }, - networks: devbox.portInfos || [], + networks: devbox.portInfos, lastTerminatedReason: devbox.status.lastState?.terminated && devbox.status.lastState.terminated.reason === 'Error' ? devbox.status.state.waiting @@ -186,11 +187,24 @@ export const adaptDBListItem = (db: KbPgClusterType): DBListItemType => { export const adaptIngressListItem = (ingress: V1Ingress): IngressListItemType => { const firstRule = ingress.spec?.rules?.[0] const firstPath = firstRule?.http?.paths?.[0] - + const protocol = ingress.metadata?.annotations?.['nginx.ingress.kubernetes.io/backend-protocol'] return { name: ingress.metadata?.name || '', namespace: ingress.metadata?.namespace || '', - host: firstRule?.host || '', - port: firstPath?.backend?.service?.port?.number || 0 + address: firstRule?.host || '', + port: firstPath?.backend?.service?.port?.number || 0, + protocol: protocol || 'http' + } +} + +export const adaptAppListItem = (app: V1Deployment & V1StatefulSet): AppListItemType => { + return { + id: app.metadata?.uid || ``, + name: app.metadata?.name || 'app name', + createTime: dayjs(app.metadata?.creationTimestamp).format('YYYY/MM/DD HH:mm'), + imageName: + app?.metadata?.annotations?.originImageName || + app.spec?.template?.spec?.containers?.[0]?.image || + '' } } diff --git a/frontend/providers/template/src/api/platform.ts b/frontend/providers/template/src/api/platform.ts index 68ca7ff8e26..45c319cc69a 100644 --- a/frontend/providers/template/src/api/platform.ts +++ b/frontend/providers/template/src/api/platform.ts @@ -7,8 +7,10 @@ import useSessionStore from '@/store/session'; export const updateRepo = () => GET('/api/updateRepo'); -export const getTemplates = () => - GET<{ templates: TemplateType[]; menuKeys: string }>('/api/listTemplate'); +export const getTemplates = (language?: string) => + GET<{ templates: TemplateType[]; menuKeys: string }>('/api/listTemplate', { + language + }); export const getPlatformEnv = () => GET<EnvResponse>('/api/platform/getEnv'); diff --git a/frontend/providers/template/src/components/Icon/icons/sealosCoin.svg b/frontend/providers/template/src/components/Icon/icons/sealosCoin.svg deleted file mode 100644 index 7cf408d5b17..00000000000 --- a/frontend/providers/template/src/components/Icon/icons/sealosCoin.svg +++ /dev/null @@ -1,56 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> - <circle cx="8" cy="8" r="7.818" fill="url(#paint0_linear_2486_3557)" stroke="url(#paint1_linear_2486_3557)" stroke-width="0.364"/> - <circle cx="8.00002" cy="8.00002" r="6.98928" fill="url(#paint2_linear_2486_3557)"/> - <path d="M8.00031 14.9893C11.8604 14.9893 14.9896 11.8601 14.9896 8.00006C14.9896 6.41562 14.4624 4.95432 13.5738 3.78202C13.1265 3.71891 12.6695 3.68628 12.2048 3.68628C7.18776 3.68628 3.0593 7.49067 2.54688 12.372C3.82784 13.9678 5.79477 14.9893 8.00031 14.9893Z" fill="url(#paint3_linear_2486_3557)"/> - <circle cx="7.99998" cy="7.99998" r="5.42039" fill="url(#paint4_linear_2486_3557)"/> - <path d="M5.76663 7.75511C6.21628 8.41254 7.14661 8.35419 7.14661 8.35419C6.91403 8.12856 6.76285 7.92238 6.74734 7.33497C6.73184 6.74755 6.39847 6.59194 6.39847 6.59194C6.99543 6.2146 6.78223 5.80613 6.76285 5.35098C6.75122 5.06699 6.9179 4.85692 7.0497 4.73633C6.29113 4.85036 5.60568 5.25383 5.13627 5.86262C4.66685 6.47141 4.4497 7.23854 4.53007 8.00408C4.58434 7.85236 5.35186 7.14824 5.76663 7.75511Z" fill="url(#paint5_linear_2486_3557)"/> - <path d="M11.275 6.58419C11.2549 6.52025 11.2303 6.45785 11.2013 6.39746V6.39357C11.066 6.11735 10.8417 5.89511 10.5648 5.76288C10.2879 5.63065 9.97452 5.5962 9.67562 5.66511C9.37672 5.73402 9.10979 5.90225 8.91811 6.14252C8.72644 6.38279 8.62128 6.68099 8.61967 6.98877C8.6197 7.08556 8.63009 7.18208 8.65069 7.27664C8.65078 7.27794 8.65078 7.27924 8.65069 7.28053C8.65844 7.31943 8.67007 7.35834 8.6817 7.39724C8.75088 7.67128 8.76434 7.9565 8.72128 8.23588C8.67822 8.51525 8.57951 8.78306 8.43104 9.02331C8.28258 9.26357 8.08741 9.47134 7.85718 9.63423C7.62694 9.79712 7.36637 9.9118 7.09101 9.9714C6.81565 10.031 6.53114 10.0343 6.25447 9.98118C5.9778 9.92801 5.71463 9.81946 5.48069 9.66199C5.24674 9.50452 5.0468 9.30136 4.89281 9.06464C4.73881 8.82792 4.63392 8.56249 4.58439 8.2842C4.65443 8.7666 4.82464 9.22887 5.084 9.64107C5.34335 10.0533 5.68607 10.4062 6.08997 10.6771C6.49387 10.9479 6.94994 11.1306 7.42866 11.2134C7.90738 11.2961 8.39808 11.277 8.86899 11.1574C9.3399 11.0377 9.78052 10.8202 10.1623 10.5188C10.5441 10.2174 10.8586 9.83888 11.0854 9.40777C11.3122 8.97667 11.4463 8.50258 11.479 8.01618C11.5116 7.52978 11.4421 7.04192 11.275 6.58419Z" fill="url(#paint6_linear_2486_3557)"/> - <path d="M10.6973 7.52167C10.6973 9.21683 9.32801 10.591 7.63888 10.591C6.74236 10.591 5.93594 10.2039 5.37653 9.58717C5.41041 9.61312 5.44519 9.6381 5.48069 9.66199C5.71463 9.81946 5.9778 9.92801 6.25447 9.98118C6.53114 10.0343 6.81565 10.031 7.09101 9.9714C7.36637 9.9118 7.62694 9.79712 7.85718 9.63423C8.08741 9.47134 8.28258 9.26357 8.43104 9.02331C8.57951 8.78306 8.67822 8.51525 8.72128 8.23588C8.76434 7.9565 8.75088 7.67128 8.6817 7.39724C8.67007 7.35834 8.65844 7.31943 8.65069 7.28053C8.65078 7.27924 8.65078 7.27794 8.65069 7.27664C8.63009 7.18208 8.6197 7.08556 8.61967 6.98877C8.62128 6.68099 8.72644 6.38279 8.91811 6.14252C9.10979 5.90225 9.37672 5.73402 9.67562 5.66511C9.79893 5.63668 9.92462 5.6258 10.0493 5.63216C10.4553 6.1531 10.6973 6.80902 10.6973 7.52167Z" fill="url(#paint7_linear_2486_3557)"/> - <g filter="url(#filter0_d_2486_3557)"> - <path d="M10.8335 2.79407L11.1527 3.367L11.7257 3.6862L11.1527 4.0054L10.8335 4.57833L10.5143 4.0054L9.94141 3.6862L10.5143 3.367L10.8335 2.79407Z" fill="#F7F7F7"/> - </g> - <defs> - <filter id="filter0_d_2486_3557" x="7.14141" y="2.79407" width="7.38418" height="7.3843" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> - <feFlood flood-opacity="0" result="BackgroundImageFix"/> - <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> - <feOffset dy="2.8"/> - <feGaussianBlur stdDeviation="1.4"/> - <feComposite in2="hardAlpha" operator="out"/> - <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/> - <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2486_3557"/> - <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2486_3557" result="shape"/> - </filter> - <linearGradient id="paint0_linear_2486_3557" x1="4.4" y1="0.8" x2="12" y2="15.2" gradientUnits="userSpaceOnUse"> - <stop stop-color="#F0F0F0"/> - <stop offset="1" stop-color="#EBEBED"/> - </linearGradient> - <linearGradient id="paint1_linear_2486_3557" x1="12.8" y1="14.4" x2="2.4" y2="2" gradientUnits="userSpaceOnUse"> - <stop stop-color="#2B3750"/> - <stop offset="1" stop-color="#9AA4B9"/> - </linearGradient> - <linearGradient id="paint2_linear_2486_3557" x1="3.2001" y1="2.8" x2="12.0001" y2="14" gradientUnits="userSpaceOnUse"> - <stop stop-color="#D6D8DF"/> - <stop offset="1" stop-color="#DADCE3"/> - </linearGradient> - <linearGradient id="paint3_linear_2486_3557" x1="12.8003" y1="12.8" x2="4.80029" y2="6.40005" gradientUnits="userSpaceOnUse"> - <stop stop-color="#ABAFBF"/> - <stop offset="1" stop-color="#B7BACC"/> - </linearGradient> - <linearGradient id="paint4_linear_2486_3557" x1="5.5999" y1="3.2" x2="11.1999" y2="12.4" gradientUnits="userSpaceOnUse"> - <stop stop-color="#9DA1B3"/> - <stop offset="1" stop-color="#535A73"/> - </linearGradient> - <linearGradient id="paint5_linear_2486_3557" x1="5.6001" y1="4.8001" x2="10.4001" y2="11.2001" gradientUnits="userSpaceOnUse"> - <stop stop-color="#FCFCFC"/> - <stop offset="1" stop-color="#DDDFE6"/> - </linearGradient> - <linearGradient id="paint6_linear_2486_3557" x1="5.6001" y1="4.8001" x2="10.4001" y2="11.2001" gradientUnits="userSpaceOnUse"> - <stop stop-color="#FCFCFC"/> - <stop offset="1" stop-color="#DDDFE6"/> - </linearGradient> - <linearGradient id="paint7_linear_2486_3557" x1="5.6001" y1="4.8001" x2="10.4001" y2="11.2001" gradientUnits="userSpaceOnUse"> - <stop stop-color="#FCFCFC"/> - <stop offset="1" stop-color="#DDDFE6"/> - </linearGradient> - </defs> -</svg> \ No newline at end of file diff --git a/frontend/providers/template/src/components/Icon/index.tsx b/frontend/providers/template/src/components/Icon/index.tsx index f96a939db8f..0fb336bc0df 100644 --- a/frontend/providers/template/src/components/Icon/index.tsx +++ b/frontend/providers/template/src/components/Icon/index.tsx @@ -39,7 +39,6 @@ const map = { gift: require('./icons/gift.svg').default, eyeShow: require('./icons/eyeShow.svg').default, tool: require('./icons/tool.svg').default, - sealosCoin: require('./icons/sealosCoin.svg').default, help: require('./icons/help.svg').default }; diff --git a/frontend/providers/template/src/hooks/useDetailDriver.tsx b/frontend/providers/template/src/hooks/useDetailDriver.tsx index 2e70cfdbc14..3f1aa4a5596 100644 --- a/frontend/providers/template/src/hooks/useDetailDriver.tsx +++ b/frontend/providers/template/src/hooks/useDetailDriver.tsx @@ -1,10 +1,12 @@ import { checkUserTask, getPriceBonus, getUserTasks } from '@/api/platform'; import MyIcon from '@/components/Icon'; +import { useSystemConfigStore } from '@/store/config'; import { useGuideStore } from '@/store/guide'; import { formatMoney } from '@/utils/tools'; import { Center, Flex, Icon, Text } from '@chakra-ui/react'; import { driver } from '@sealos/driver'; -import { SealosCoin } from '@sealos/ui'; +import { CurrencySymbol } from '@sealos/ui'; + import { useTranslation } from 'next-i18next'; import { useEffect, useState } from 'react'; import { sealosApp } from 'sealos-desktop-sdk/app'; @@ -40,6 +42,7 @@ export default function useDetailDriver() { const { t, i18n } = useTranslation(); const [reward, setReward] = useState(5); const { detailCompleted, setDetailCompleted } = useGuideStore(); + const { envs } = useSystemConfigStore(); const [rechargeOptions, setRechargeOptions] = useState([ { amount: 8, gift: 8 }, @@ -93,7 +96,7 @@ export default function useDetailDriver() { > {t('receive')} </Text> - <SealosCoin /> + <CurrencySymbol type={envs?.CURRENCY_SYMBOL} /> <Text mx="4px">{reward}</Text> <Text fontSize={'14px'} fontWeight={500}> {t('balance')} @@ -130,7 +133,7 @@ export default function useDetailDriver() { h={'72px'} position={'relative'} > - <SealosCoin w="14px" /> + <CurrencySymbol type={envs?.CURRENCY_SYMBOL} /> <Text fontSize={'20px'} fontWeight={500} color={'rgba(17, 24, 36, 1)'} pl="4px"> {item.amount} </Text> @@ -150,7 +153,7 @@ export default function useDetailDriver() { height={'20px'} > <Text>{t('gift')}</Text> - <SealosCoin w="10px" /> + <CurrencySymbol type={envs?.CURRENCY_SYMBOL} /> <Text>{item.gift}</Text> </Flex> </Center> diff --git a/frontend/providers/template/src/pages/_app.tsx b/frontend/providers/template/src/pages/_app.tsx index 0290b83e6f2..0434014537e 100644 --- a/frontend/providers/template/src/pages/_app.tsx +++ b/frontend/providers/template/src/pages/_app.tsx @@ -47,7 +47,7 @@ const App = ({ Component, pageProps }: AppProps) => { const { loadUserSourcePrice } = useUserStore(); useEffect(() => { - initSystemConfig(); + initSystemConfig(i18n.language); loadUserSourcePrice(); }, []); diff --git a/frontend/providers/template/src/pages/api/listTemplate.ts b/frontend/providers/template/src/pages/api/listTemplate.ts index 1dc294d4a7f..9205a12372b 100644 --- a/frontend/providers/template/src/pages/api/listTemplate.ts +++ b/frontend/providers/template/src/pages/api/listTemplate.ts @@ -21,7 +21,8 @@ export function replaceRawWithCDN(url: string, cdnUrl: string) { export const readTemplates = ( jsonPath: string, cdnUrl?: string, - blacklistedCategories?: string[] + blacklistedCategories?: string[], + language?: string ): TemplateType[] => { const jsonData = fs.readFileSync(jsonPath, 'utf8'); const _templates: TemplateType[] = JSON.parse(jsonData); @@ -41,12 +42,24 @@ export const readTemplates = ( item.spec.icon = replaceRawWithCDN(item.spec.icon, cdnUrl); } return item; + }) + .filter((item) => { + if (!language) return true; + + if (!item.spec.locale) return true; + + if (item.spec.locale === language || (item.spec.i18n && item.spec.i18n[language])) + return true; + + return false; }); return templates; }; export default async function handler(req: NextApiRequest, res: NextApiResponse<ApiResp>) { + const language = req.query.language as string; + const originalPath = process.cwd(); const jsonPath = path.resolve(originalPath, 'templates.json'); const cdnUrl = process.env.CDN_URL; @@ -76,7 +89,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< await fetch(`${baseurl}/api/updateRepo`); } - const templates = readTemplates(jsonPath, cdnUrl, blacklistedCategories); + const templates = readTemplates(jsonPath, cdnUrl, blacklistedCategories, language); + + const timestamp = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }); + console.log(`[${timestamp}] language: ${language}, templates count: ${templates.length}`); + const categories = templates.map((item) => (item.spec?.categories ? item.spec.categories : [])); const topKeys = findTopKeyWords(categories, menuCount); diff --git a/frontend/providers/template/src/pages/api/resource/delApplaunchpad.ts b/frontend/providers/template/src/pages/api/resource/delApplaunchpad.ts index 3b1289b08c6..d239951145d 100644 --- a/frontend/providers/template/src/pages/api/resource/delApplaunchpad.ts +++ b/frontend/providers/template/src/pages/api/resource/delApplaunchpad.ts @@ -31,22 +31,59 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< undefined, `${appDeployKey}=${instanceName}` ), // delete Ingress - k8sCustomObjects.deleteNamespacedCustomObject( - // delete Issuer - 'cert-manager.io', - 'v1', - namespace, - 'issuers', - instanceName - ), - k8sCustomObjects.deleteNamespacedCustomObject( - // delete Certificate - 'cert-manager.io', - 'v1', - namespace, - 'certificates', - instanceName - ), + k8sCustomObjects + .listNamespacedCustomObject( + 'cert-manager.io', + 'v1', + namespace, + 'issuers', + undefined, + undefined, + undefined, + undefined, + `${appDeployKey}=${instanceName}` + ) + .then(async (res: any) => { + const items = res.body.items || []; + console.log(items.map((item: any) => item.metadata.name)); + return Promise.all( + items.map((item: any) => + k8sCustomObjects.deleteNamespacedCustomObject( + 'cert-manager.io', + 'v1', + namespace, + 'issuers', + item.metadata.name + ) + ) + ); + }), + k8sCustomObjects + .listNamespacedCustomObject( + 'cert-manager.io', + 'v1', + namespace, + 'certificates', + undefined, + undefined, + undefined, + undefined, + `${appDeployKey}=${instanceName}` + ) + .then(async (res: any) => { + const items = res.body.items || []; + return Promise.all( + items.map((item: any) => + k8sCustomObjects.deleteNamespacedCustomObject( + 'cert-manager.io', + 'v1', + namespace, + 'certificates', + item.metadata.name + ) + ) + ); + }), k8sCore.deleteCollectionNamespacedPersistentVolumeClaim( // delete pvc namespace, diff --git a/frontend/providers/template/src/pages/deploy/components/Header.tsx b/frontend/providers/template/src/pages/deploy/components/Header.tsx index 196c2ac5e7f..5675d4a4363 100644 --- a/frontend/providers/template/src/pages/deploy/components/Header.tsx +++ b/frontend/providers/template/src/pages/deploy/components/Header.tsx @@ -24,7 +24,9 @@ import dayjs from 'dayjs'; import { nanoid } from 'nanoid'; import { useTranslation } from 'next-i18next'; import { MouseEvent, useCallback, useMemo } from 'react'; -import PriceBox, { Currencysymbol, usePriceCalculation } from './PriceBox'; +import PriceBox, { usePriceCalculation } from './PriceBox'; +import { CurrencySymbol } from '@sealos/ui'; +import { useSystemConfigStore } from '@/store/config'; const Header = ({ appName, @@ -43,8 +45,10 @@ const Header = ({ templateDetail: TemplateType; cloudDomain: string; }) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { copyData } = useCopyData(); + const { envs } = useSystemConfigStore(); + const handleExportYaml = useCallback(async () => { const exportYamlString = yamlList?.map((i) => i.value).join('---\n'); if (!exportYamlString) return; @@ -122,12 +126,17 @@ const Header = ({ backgroundColor={'#FBFBFC'} border={' 1px solid rgba(255, 255, 255, 0.50)'} > - <Image src={templateDetail?.spec?.icon} alt="" width={'60px'} height={'60px'} /> + <Image + src={templateDetail?.spec?.i18n?.[i18n.language]?.icon ?? templateDetail?.spec?.icon} + alt="" + width={'60px'} + height={'60px'} + /> </Flex> <Flex ml={'24px'} w="520px" flexDirection={'column'}> <Flex alignItems={'center'} gap={'12px'}> <Text fontSize={'24px'} fontWeight={600} color={'#24282C'}> - {templateDetail?.spec?.title} + {templateDetail?.spec?.i18n?.[i18n.language]?.title ?? templateDetail?.spec?.title} </Text> {DeployCountComponent} <Flex @@ -138,7 +147,13 @@ const Header = ({ _hover={{ background: '#F4F6F8' }} - onClick={(e) => goGithub(e, templateDetail?.spec?.gitRepo)} + onClick={(e) => + goGithub( + e, + templateDetail?.spec?.i18n?.[i18n.language]?.gitRepo ?? + templateDetail?.spec?.gitRepo + ) + } > <HomePageIcon /> <Text fontSize={'12px '} fontWeight={400} pl="6px"> @@ -224,7 +239,13 @@ const Header = ({ </Popover> </Flex> - <Tooltip label={templateDetail?.spec?.description} closeDelay={200}> + <Tooltip + label={ + templateDetail?.spec?.i18n?.[i18n.language]?.description ?? + templateDetail?.spec?.description + } + closeDelay={200} + > <Text overflow={'hidden'} noOfLines={1} @@ -233,9 +254,15 @@ const Header = ({ fontSize={'12px'} color={'5A646E'} fontWeight={400} - onClick={() => copyData(templateDetail?.spec?.description)} + onClick={() => + copyData( + templateDetail?.spec?.i18n?.[i18n.language]?.description ?? + templateDetail?.spec?.description + ) + } > - {templateDetail?.spec?.description} + {templateDetail?.spec?.i18n?.[i18n.language]?.description ?? + templateDetail?.spec?.description} </Text> </Tooltip> </Flex> @@ -251,7 +278,7 @@ const Header = ({ flexShrink={'0'} gap={'4px'} > - <Currencysymbol w={'16px'} h={'16px'} type={'shellCoin'} /> + <CurrencySymbol type={envs?.CURRENCY_SYMBOL} /> {priceList?.[priceList.length - 1]?.value} <Text fontSize={'16px'}>/{t('Day')}</Text> <MyIcon name="help" width={'16px'} height={'16px'} color={'grayModern.500'}></MyIcon> diff --git a/frontend/providers/template/src/pages/deploy/components/PriceBox.tsx b/frontend/providers/template/src/pages/deploy/components/PriceBox.tsx index 088073c04e0..0075ec8520e 100644 --- a/frontend/providers/template/src/pages/deploy/components/PriceBox.tsx +++ b/frontend/providers/template/src/pages/deploy/components/PriceBox.tsx @@ -1,27 +1,13 @@ import React from 'react'; -import { Box, Center, Flex, useTheme, Divider } from '@chakra-ui/react'; +import { Box, Center, Flex, Divider } from '@chakra-ui/react'; import { useTranslation } from 'next-i18next'; -import { Text, Icon } from '@chakra-ui/react'; +import { Text } from '@chakra-ui/react'; import { useUserStore } from '@/store/user'; import MyIcon from '@/components/Icon'; -import { MyTooltip } from '@sealos/ui'; +import { CurrencySymbol, MyTooltip } from '@sealos/ui'; import { type ResourceUsage } from '@/utils/usage'; import { PriceIcon } from '@/components/icons/PriceIcon'; - -export function Currencysymbol({ - type = 'shellCoin', - ...props -}: { - type?: 'shellCoin' | 'cny' | 'usd'; -} & Pick<Parameters<typeof Icon>[0], 'w' | 'h' | 'color'>) { - return type === 'shellCoin' ? ( - <MyIcon name="sealosCoin" /> - ) : type === 'cny' ? ( - <Text {...props}>¥</Text> - ) : ( - <Text {...props}>$</Text> - ); -} +import { useSystemConfigStore } from '@/store/config'; const scale = 1000000; @@ -80,6 +66,7 @@ export const usePriceCalculation = ({ cpu, memory, storage, nodeport }: Resource const PriceBox = (props: ResourceUsage) => { const { t } = useTranslation(); const priceList = usePriceCalculation(props); + const { envs } = useSystemConfigStore(); return ( <Box bg={'#FFF'} borderRadius={'10px'}> @@ -122,7 +109,7 @@ const PriceBox = (props: ResourceUsage) => { )} </Flex> <Flex ml={'auto'} minW={'45px'} alignItems={'center'} gap={'4px'} whiteSpace={'nowrap'}> - <Currencysymbol type={'shellCoin'} /> + <CurrencySymbol type={envs?.CURRENCY_SYMBOL} /> {item.value} </Flex> </Flex> diff --git a/frontend/providers/template/src/pages/deploy/components/ReadMe.tsx b/frontend/providers/template/src/pages/deploy/components/ReadMe.tsx index f1ddde44c36..55b0a878102 100644 --- a/frontend/providers/template/src/pages/deploy/components/ReadMe.tsx +++ b/frontend/providers/template/src/pages/deploy/components/ReadMe.tsx @@ -11,10 +11,15 @@ import rehypeRewrite from 'rehype-rewrite'; import styles from './index.module.scss'; import { parseGithubUrl } from '@/utils/tools'; import { Octokit, App } from 'octokit'; +import { useTranslation } from 'next-i18next'; const ReadMe = ({ templateDetail }: { templateDetail: TemplateType }) => { + const { i18n } = useTranslation(); const [templateReadMe, setTemplateReadMe] = useState(''); + const readme = + templateDetail?.spec?.i18n?.[i18n.language]?.readme ?? templateDetail?.spec?.readme; + // const octokit = new Octokit({ // auth: '' // }); @@ -29,23 +34,20 @@ const ReadMe = ({ templateDetail }: { templateDetail: TemplateType }) => { // })(); // }, []); - const githubOptions = useMemo( - () => parseGithubUrl(templateDetail?.spec?.readme), - [templateDetail?.spec?.readme] - ); + const githubOptions = useMemo(() => parseGithubUrl(readme), [readme]); useEffect(() => { - if (templateDetail?.spec?.readme) { + if (readme) { (async () => { try { - const res = await (await fetch(templateDetail?.spec?.readme)).text(); + const res = await (await fetch(readme)).text(); setTemplateReadMe(res); } catch (error) { console.log(error); } })(); } - }, [templateDetail?.spec?.readme]); + }, [readme]); // @ts-ignore const myRewrite = (node, index, parent) => { diff --git a/frontend/providers/template/src/pages/deploy/index.tsx b/frontend/providers/template/src/pages/deploy/index.tsx index 7360c40a499..4975fd4caa1 100644 --- a/frontend/providers/template/src/pages/deploy/index.tsx +++ b/frontend/providers/template/src/pages/deploy/index.tsx @@ -3,7 +3,6 @@ import { getPlatformEnv } from '@/api/platform'; import { editModeMap } from '@/constants/editApp'; import { useConfirm } from '@/hooks/useConfirm'; import { useLoading } from '@/hooks/useLoading'; -import { useToast } from '@/hooks/useToast'; import { useCachedStore } from '@/store/cached'; import { useGlobalStore } from '@/store/global'; import { useSearchStore } from '@/store/search'; @@ -12,7 +11,7 @@ import { ApplicationType, TemplateSourceType } from '@/types/app'; import { serviceSideProps } from '@/utils/i18n'; import { generateYamlList, parseTemplateString } from '@/utils/json-yaml'; import { compareFirstLanguages, deepSearch, useCopyData } from '@/utils/tools'; -import { Box, Flex, Icon, Text, Grid, GridItem } from '@chakra-ui/react'; +import { Box, Flex, Icon, Text } from '@chakra-ui/react'; import { useQuery } from '@tanstack/react-query'; import debounce from 'lodash/debounce'; import { useTranslation } from 'next-i18next'; @@ -23,8 +22,6 @@ import { useForm } from 'react-hook-form'; import Form from './components/Form'; import ReadMe from './components/ReadMe'; import { getTemplateInputDefaultValues, getTemplateValues } from '@/utils/template'; -import QuotaBox from './components/QuotaBox'; -import PriceBox from './components/PriceBox'; import { useUserStore } from '@/store/user'; import { getResourceUsage } from '@/utils/usage'; import Head from 'next/head'; diff --git a/frontend/providers/template/src/pages/index.tsx b/frontend/providers/template/src/pages/index.tsx index ee8dab5af52..5a3ac0c6701 100644 --- a/frontend/providers/template/src/pages/index.tsx +++ b/frontend/providers/template/src/pages/index.tsx @@ -38,7 +38,7 @@ export default function AppList({ showCarousel }: { showCarousel: boolean }) { const { setInsideCloud } = useCachedStore(); const { envs } = useSystemConfigStore(); - const { data } = useQuery(['listTemplate'], getTemplates, { + const { data } = useQuery(['listTemplate', i18n.language], () => getTemplates(i18n.language), { refetchInterval: 5 * 60 * 1000, staleTime: 5 * 60 * 1000, retry: 3 @@ -52,7 +52,9 @@ export default function AppList({ showCarousel }: { showCarousel: boolean }) { }); const searchResults = typeFilteredResults?.filter((item: TemplateType) => { - return item?.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase()); + const nameMatches = item?.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase()); + const titleMatches = item?.spec?.title?.toLowerCase().includes(searchValue.toLowerCase()); + return nameMatches || titleMatches; }); return searchResults; @@ -161,7 +163,7 @@ export default function AppList({ showCarousel }: { showCarousel: boolean }) { border={' 1px solid rgba(255, 255, 255, 0.50)'} > <Image - src={item?.spec?.icon} + src={item.spec.i18n?.[i18n.language]?.icon ?? item?.spec?.icon} alt="logo" width={'36px'} height={'36px'} @@ -170,11 +172,11 @@ export default function AppList({ showCarousel }: { showCarousel: boolean }) { </Box> <Flex ml="16px" noOfLines={2} flexDirection={'column'}> <Text fontSize={'18px'} fontWeight={600} color={'#111824'}> - {item?.spec?.title} + {item.spec.i18n?.[i18n.language]?.title ?? item.spec.title} </Text> {envs?.SHOW_AUTHOR === 'true' && ( <Text fontSize={'12px'} fontWeight={400} color={'#5A646E'}> - By {item?.spec?.author} + By {item.spec.author} </Text> )} </Flex> @@ -209,7 +211,7 @@ export default function AppList({ showCarousel }: { showCarousel: boolean }) { color={'5A646E'} fontWeight={400} > - {item?.spec?.description} + {item.spec.i18n?.[i18n.language]?.description ?? item.spec.description} </Text> <Flex mt="auto" @@ -232,7 +234,15 @@ export default function AppList({ showCarousel }: { showCarousel: boolean }) { </Tag> ))} </Flex> - <Center cursor={'pointer'} onClick={(e) => goGithub(e, item?.spec?.gitRepo)}> + <Center + cursor={'pointer'} + onClick={(e) => + goGithub( + e, + item.spec?.i18n?.[i18n.language]?.gitRepo ?? item?.spec?.gitRepo + ) + } + > <ShareIcon color={'#667085'} /> </Center> </Flex> diff --git a/frontend/providers/template/src/store/config.ts b/frontend/providers/template/src/store/config.ts index 6e4e26fcf0b..b9915692def 100644 --- a/frontend/providers/template/src/store/config.ts +++ b/frontend/providers/template/src/store/config.ts @@ -10,7 +10,7 @@ type State = { systemConfig: SystemConfigType | undefined; menuKeys: string; sideBarMenu: SideBarMenuType[]; - initSystemConfig: () => Promise<SystemConfigType>; + initSystemConfig: (language?: string) => Promise<SystemConfigType>; initSystemEnvs: () => Promise<EnvResponse>; setSideBarMenu: (data: SideBarMenuType[]) => void; }; @@ -27,9 +27,9 @@ export const useSystemConfigStore = create<State>()( value: 'SideBar.Applications' } ], - async initSystemConfig() { + async initSystemConfig(language?: string) { const data = await getSystemConfig(); - const { menuKeys } = await getTemplates(); + const { menuKeys } = await getTemplates(language); if (get().menuKeys !== menuKeys) { const menus = menuKeys.split(',').map((i) => ({ diff --git a/frontend/providers/template/src/types/app.ts b/frontend/providers/template/src/types/app.ts index 0df552e2887..85ab1c4288e 100644 --- a/frontend/providers/template/src/types/app.ts +++ b/frontend/providers/template/src/types/app.ts @@ -37,6 +37,8 @@ export type TemplateType = { required: boolean; } >; + locale?: string; + i18n?: Record<string, { [key: string]: string }>; }; }; diff --git a/frontend/providers/template/src/types/index.ts b/frontend/providers/template/src/types/index.ts index 8dea4080139..e22e40d31ed 100644 --- a/frontend/providers/template/src/types/index.ts +++ b/frontend/providers/template/src/types/index.ts @@ -28,4 +28,5 @@ export type EnvResponse = { SEALOS_SERVICE_ACCOUNT: string; SHOW_AUTHOR: string; DESKTOP_DOMAIN: string; + CURRENCY_SYMBOL: 'shellCoin' | 'cny' | 'usd'; }; diff --git a/frontend/providers/template/src/utils/json-yaml.ts b/frontend/providers/template/src/utils/json-yaml.ts index 5246fbbcf9f..759867b9eaa 100644 --- a/frontend/providers/template/src/utils/json-yaml.ts +++ b/frontend/providers/template/src/utils/json-yaml.ts @@ -62,7 +62,7 @@ export const parseTemplateString = ( try { const replacedString = sourceString.replace(regex, (match: string, key: string) => { - const value = evaluateExpression(key, dataSource) + const value = evaluateExpression(key, dataSource); return value !== undefined ? value : ''; }); return replacedString; @@ -139,7 +139,21 @@ export const handleTemplateToInstanceYaml = ( instanceName: string ): TemplateInstanceType => { const { - spec: { gitRepo, templateType, template_type, categories, ...resetSpec } + spec: { + gitRepo, + templateType, + template_type, + categories, + author, + title, + url, + readme, + icon, + description, + draft, + defaults, + inputs + } } = template; return { @@ -149,12 +163,18 @@ export const handleTemplateToInstanceYaml = ( name: instanceName }, spec: { - gitRepo: gitRepo, + gitRepo, templateType: templateType || template_type, categories: categories || [], - defaults: {}, - inputs: {}, - ...resetSpec + defaults: defaults || {}, + inputs: inputs || {}, + author: author || '', + title: title || '', + url: url || '', + readme: readme || '', + icon: icon || '', + description: description || '', + draft: draft || false } }; }; diff --git a/frontend/providers/template/src/utils/tools.ts b/frontend/providers/template/src/utils/tools.ts index 6909320ccf9..b26c3c07166 100644 --- a/frontend/providers/template/src/utils/tools.ts +++ b/frontend/providers/template/src/utils/tools.ts @@ -275,7 +275,8 @@ export function getTemplateEnvs(namespace?: string): EnvResponse { SEALOS_NAMESPACE: namespace || '', SEALOS_SERVICE_ACCOUNT: namespace?.replace('ns-', '') || '', SHOW_AUTHOR: process.env.SHOW_AUTHOR || 'false', - DESKTOP_DOMAIN: process.env.DESKTOP_DOMAIN || 'cloud.sealos.io' + DESKTOP_DOMAIN: process.env.DESKTOP_DOMAIN || 'cloud.sealos.io', + CURRENCY_SYMBOL: (process.env.CURRENCY_SYMBOL as 'shellCoin' | 'cny' | 'usd') || 'shellCoin' }; return TemplateEnvs; } diff --git a/pkg/runtime/kubernetes/kubeadm.go b/pkg/runtime/kubernetes/kubeadm.go index 3287d251113..b1b40627c9c 100644 --- a/pkg/runtime/kubernetes/kubeadm.go +++ b/pkg/runtime/kubernetes/kubeadm.go @@ -345,6 +345,17 @@ func (k *KubeadmRuntime) setAPIServerEndpoint(endpoint string) { k.kubeadmConfig.JoinConfiguration.Discovery.BootstrapToken.APIServerEndpoint = endpoint } +func (k *KubeadmRuntime) setJoinInternalIP(nodeIP string) { + k.kubeadmConfig.JoinConfiguration.NodeRegistration.KubeletExtraArgs = map[string]string{ + "node-ip": nodeIP, + } +} +func (k *KubeadmRuntime) setInitInternalIP(nodeIP string) { + k.kubeadmConfig.InitConfiguration.NodeRegistration.KubeletExtraArgs = map[string]string{ + "node-ip": nodeIP, + } +} + func (k *KubeadmRuntime) setInitAdvertiseAddress(advertiseAddress string) { k.kubeadmConfig.InitConfiguration.LocalAPIEndpoint.AdvertiseAddress = advertiseAddress } @@ -459,6 +470,7 @@ func (k *KubeadmRuntime) CompleteKubeadmConfig(fns ...func(*KubeadmRuntime) erro } } k.setInitAdvertiseAddress(k.getMaster0IP()) + k.setInitInternalIP(k.getMaster0IP()) k.setControlPlaneEndpoint(fmt.Sprintf("%s:%d", k.getAPIServerDomain(), k.getAPIServerPort())) if k.kubeadmConfig.ClusterConfiguration.APIServer.ExtraArgs == nil { k.kubeadmConfig.ClusterConfiguration.APIServer.ExtraArgs = make(map[string]string) @@ -481,6 +493,7 @@ func (k *KubeadmRuntime) generateJoinNodeConfigs(node string) ([]byte, error) { } k.cleanJoinLocalAPIEndPoint() k.setAPIServerEndpoint(k.getVipAndPort()) + k.setJoinInternalIP(iputils.GetHostIP(node)) conversion, err := k.kubeadmConfig.ToConvertedKubeadmConfig() if err != nil { @@ -499,6 +512,7 @@ func (k *KubeadmRuntime) generateJoinMasterConfigs(masterIP string) ([]byte, err return nil, err } k.setJoinAdvertiseAddress(iputils.GetHostIP(masterIP)) + k.setJoinInternalIP(iputils.GetHostIP(masterIP)) k.setAPIServerEndpoint(fmt.Sprintf("%s:%d", k.getMaster0IP(), k.getAPIServerPort())) conversion, err := k.kubeadmConfig.ToConvertedKubeadmConfig() diff --git a/service/account/Makefile b/service/account/Makefile index 9754adf8fb8..5a784626190 100644 --- a/service/account/Makefile +++ b/service/account/Makefile @@ -44,8 +44,7 @@ clean: .PHONY: build build: ## Build service-hub binary. LD_FLAGS="-s -w"; \ - [ -n "$(CRYPTOKEY)" ] && LD_FLAGS+=" -X github.com/labring/sealos/controllers/pkg/crypto.encryptionKey=${CRYPTOKEY}"; \ - CGO_ENABLED=0 GOOS=linux go build -ldflags "$${LD_FLAGS}" -trimpath -o bin/manager main.go + CGO_ENABLED=0 GOOS=linux go build -tags=jsoniter -ldflags "$${LD_FLAGS}" -trimpath -o bin/manager main.go .PHONY: docker-build docker-build: build diff --git a/service/account/api/admin.go b/service/account/api/admin.go new file mode 100644 index 00000000000..04d6a319ac4 --- /dev/null +++ b/service/account/api/admin.go @@ -0,0 +1,117 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/account/dao" + "github.com/labring/sealos/service/account/helper" +) + +// GetAccount +// @Summary Get user account +// @Description Get user account +// @Tags Account +// @Accept json +// @Produce json +// @Success 200 {object} map[string]interface{} "successfully retrieved user account" +// @Failure 401 {object} map[string]interface{} "authenticate error" +// @Failure 500 {object} map[string]interface{} "failed to get user account" +// @Router /admin/v1alpha1/account [get] +func AdminGetAccountWithWorkspaceID(c *gin.Context) { + err := authenticateAdminRequest(c) + if err != nil { + c.JSON(http.StatusUnauthorized, helper.ErrorMessage{Error: fmt.Sprintf("authenticate error : %v", err)}) + return + } + workspace, exist := c.GetQuery("namespace") + if !exist || workspace == "" { + c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: "empty workspace"}) + return + } + account, err := dao.DBClient.GetAccountWithWorkspace(workspace) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to get account : %v", err)}) + return + } + c.JSON(http.StatusOK, gin.H{ + "userUID": account.UserUID, + "balance": account.Balance - account.DeductionBalance, + }) +} + +// ChargeBilling +// @Summary Charge billing +// @Description Charge billing +// @Tags Account +// @Accept json +// @Produce json +// @Success 200 {object} map[string]interface{} "successfully charged billing" +// @Failure 401 {object} map[string]interface{} "authenticate error" +// @Failure 500 {object} map[string]interface{} "failed to charge billing" +// @Router /admin/v1alpha1/charge [post] +func AdminChargeBilling(c *gin.Context) { + err := authenticateAdminRequest(c) + if err != nil { + c.JSON(http.StatusUnauthorized, helper.ErrorMessage{Error: fmt.Sprintf("authenticate error : %v", err)}) + return + } + billingReq, err := helper.ParseAdminChargeBillingReq(c) + if err != nil { + c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: fmt.Sprintf("failed to parse request : %v", err)}) + return + } + helper.CallCounter.WithLabelValues("ChargeBilling", billingReq.UserUID.String()).Inc() + err = dao.DBClient.ChargeBilling(billingReq) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to charge billing : %v", err)}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "successfully charged billing", + }) +} + +// ActiveBilling +// @Summary Active billing +// @Description Active billing +// @Tags Account +// @Accept json +// @Produce json +// @Success 200 {object} map[string]interface{} "successfully activated billing" +// @Failure 401 {object} map[string]interface{} "authenticate error" +// @Failure 500 {object} map[string]interface{} "failed to activate billing" +// @Router /admin/v1alpha1/active [post] +//func AdminActiveBilling(c *gin.Context) { +// err := authenticateAdminRequest(c) +// if err != nil { +// c.JSON(http.StatusUnauthorized, helper.ErrorMessage{Error: fmt.Sprintf("authenticate error : %v", err)}) +// return +// } +// billingReq, err := dao.ParseAdminActiveBillingReq(c) +// if err != nil { +// c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: fmt.Sprintf("failed to parse request : %v", err)}) +// return +// } +// dao.ActiveBillingTask.AddTask(billingReq) +// c.JSON(http.StatusOK, gin.H{ +// "message": "successfully activated billing", +// }) +//} + +const AdminUserName = "sealos-admin" + +func authenticateAdminRequest(c *gin.Context) error { + user, err := dao.JwtMgr.ParseUser(c) + if err != nil { + return fmt.Errorf("failed to parse user: %v", err) + } + if user == nil { + return fmt.Errorf("user not found") + } + if user.Requester != AdminUserName { + return fmt.Errorf("user is not admin") + } + return nil +} diff --git a/service/account/dao/active.go b/service/account/dao/active.go new file mode 100644 index 00000000000..eba82e10037 --- /dev/null +++ b/service/account/dao/active.go @@ -0,0 +1,50 @@ +package dao + +import ( + "fmt" + "time" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/controllers/pkg/resources" +) + +type ActiveBillingReq struct { + resources.ActiveBilling +} + +func (a *ActiveBillingReq) Execute() error { + return DBClient.ActiveBilling(a.ActiveBilling) +} + +func ParseAdminActiveBillingReq(c *gin.Context) (*ActiveBillingReq, error) { + activeBilling := &ActiveBillingReq{} + if activeBilling.Time.IsZero() { + activeBilling.Time = time.Now().UTC() + } + if err := c.ShouldBindJSON(activeBilling); err != nil { + return nil, fmt.Errorf("bind json error: %v", err) + } + return activeBilling, nil +} + +type ActiveBillingReconcile struct { + StartTime, EndTime time.Time +} + +func (a *ActiveBillingReconcile) Execute() error { + if err := DBClient.ReconcileActiveBilling(a.StartTime, a.EndTime); err != nil { + return fmt.Errorf("reconcile active billing error: %v", err) + } + return nil +} + +type ArchiveBillingReconcile struct { + StartTime time.Time +} + +func (a *ArchiveBillingReconcile) Execute() error { + if err := DBClient.ArchiveHourlyBilling(a.StartTime, a.StartTime.Add(time.Hour)); err != nil { + return fmt.Errorf("archive hourly billing error: %v", err) + } + return nil +} diff --git a/service/account/dao/init.go b/service/account/dao/init.go index e95f519f7d1..7b2ff2eeda0 100644 --- a/service/account/dao/init.go +++ b/service/account/dao/init.go @@ -1,10 +1,13 @@ package dao import ( + "context" "fmt" "os" "time" + "github.com/labring/sealos/controllers/pkg/utils/env" + "github.com/goccy/go-json" "github.com/labring/sealos/service/account/helper" @@ -25,13 +28,15 @@ type Region struct { } var ( - DBClient Interface - JwtMgr *helper.JWTManager - Cfg *Config - Debug bool + DBClient Interface + JwtMgr *helper.JWTManager + Cfg *Config + BillingTask *helper.TaskQueue + Debug bool ) -func InitDB() error { +func Init(ctx context.Context) error { + BillingTask = helper.NewTaskQueue(ctx, env.GetIntEnvWithDefault("ACTIVE_BILLING_TASK_WORKER_COUNT", 10), env.GetIntEnvWithDefault("ACTIVE_BILLING_TASK_QUEUE_SIZE", 10000)) var err error globalCockroach := os.Getenv(helper.ENVGlobalCockroach) if globalCockroach == "" { diff --git a/service/account/dao/interface.go b/service/account/dao/interface.go index 4aa2af419bd..ebf798c9112 100644 --- a/service/account/dao/interface.go +++ b/service/account/dao/interface.go @@ -7,6 +7,10 @@ import ( "strings" "time" + "github.com/sirupsen/logrus" + + accountv1 "github.com/labring/sealos/controllers/account/api/v1" + "gorm.io/gorm" gonanoid "github.com/matoous/go-nanoid/v2" @@ -23,6 +27,7 @@ import ( "go.mongodb.org/mongo-driver/mongo/options" + "github.com/google/uuid" "github.com/labring/sealos/service/account/helper" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" @@ -30,9 +35,11 @@ import ( type Interface interface { GetBillingHistoryNamespaceList(req *helper.NamespaceBillingHistoryReq) ([][]string, error) + GetAccountWithWorkspace(workspace string) (*types.Account, error) GetProperties() ([]common.PropertyQuery, error) GetCosts(req helper.ConsumptionRecordReq) (common.TimeCostsMap, error) GetAppCosts(req *helper.AppCostsReq) (*common.AppCosts, error) + ChargeBilling(req *helper.AdminChargeBillingReq) error GetAppCostTimeRange(req helper.GetCostAppListReq) (helper.TimeRange, error) GetCostOverview(req helper.GetCostAppListReq) (helper.CostOverviewResp, error) GetBasicCostDistribution(req helper.GetCostAppListReq) (map[string]int64, error) @@ -60,6 +67,10 @@ type Interface interface { GetRechargeDiscount(req helper.AuthReq) (helper.RechargeDiscountResp, error) ProcessPendingTaskRewards() error GetUserRealNameInfo(req *helper.GetRealNameInfoReq) (*types.UserRealNameInfo, error) + ReconcileUnsettledLLMBilling(startTime, endTime time.Time) error + ReconcileActiveBilling(startTime, endTime time.Time) error + ArchiveHourlyBilling(hourStart, hourEnd time.Time) error + ActiveBilling(req resources.ActiveBilling) error } type Account struct { @@ -68,11 +79,12 @@ type Account struct { } type MongoDB struct { - Client *mongo.Client - AccountDBName string - BillingConn string - PropertiesConn string - Properties *resources.PropertyTypeLS + Client *mongo.Client + AccountDBName string + BillingConn string + ActiveBillingConn string + PropertiesConn string + Properties *resources.PropertyTypeLS } type Cockroach struct { @@ -87,6 +99,10 @@ func (g *Cockroach) GetAccount(ops types.UserQueryOpts) (*types.Account, error) return account, nil } +func (g *Cockroach) GetAccountWithWorkspace(workspace string) (*types.Account, error) { + return g.ck.GetAccountWithWorkspace(workspace) +} + func (g *Cockroach) GetWorkspaceName(namespaces []string) ([][]string, error) { workspaceList := make([][]string, 0) workspaces, err := g.ck.GetWorkspace(namespaces...) @@ -254,6 +270,52 @@ func (m *MongoDB) GetCosts(req helper.ConsumptionRecordReq) (common.TimeCostsMap return costsMap, nil } +func (m *Account) InitDB() error { + if err := m.ck.InitTables(); err != nil { + return fmt.Errorf("failed to init tables: %v", err) + } + return m.MongoDB.initTables() +} + +func (m *MongoDB) initTables() error { + if exist, err := m.collectionExist(m.AccountDBName, m.ActiveBillingConn); exist || err != nil { + return err + } + indexModel := mongo.IndexModel{ + Keys: bson.D{{Key: "time", Value: 1}}, + Options: options.Index().SetExpireAfterSeconds(30 * 24 * 60 * 60), + } + _, err := m.getActiveBillingCollection().Indexes().CreateOne(context.Background(), indexModel) + if err != nil { + return fmt.Errorf("failed to create index: %v", err) + } + return nil +} + +func (m *MongoDB) collectionExist(dbName, collectionName string) (bool, error) { + // Check if the collection already exists + collections, err := m.Client.Database(dbName).ListCollectionNames(context.Background(), bson.M{"name": collectionName}) + return len(collections) > 0, err +} + +func (m *MongoDB) SaveActiveBillings(billing ...*resources.ActiveBilling) error { + billings := make([]interface{}, len(billing)) + for i, b := range billing { + billings[i] = b + } + _, err := m.getActiveBillingCollection().InsertMany(context.Background(), billings) + return err +} + +func (m *MongoDB) SaveBillings(billing ...*resources.Billing) error { + billings := make([]interface{}, len(billing)) + for i, b := range billing { + billings[i] = b + } + _, err := m.getBillingCollection().InsertMany(context.Background(), billings) + return err +} + func (m *MongoDB) GetAppCosts(req *helper.AppCostsReq) (results *common.AppCosts, rErr error) { if req.Page <= 0 { req.Page = 1 @@ -308,23 +370,11 @@ func (m *MongoDB) GetAppCosts(req *helper.AppCostsReq) (results *common.AppCosts pipeline := mongo.Pipeline{ {{Key: "$match", Value: match}}, {{Key: "$facet", Value: bson.D{ - {Key: "totalRecords", Value: bson.A{ + {Key: "withAppCosts", Value: bson.A{ + bson.D{{Key: "$match", Value: bson.D{{Key: "app_costs", Value: bson.M{"$exists": true}}}}}, bson.D{{Key: "$unwind", Value: "$app_costs"}}, bson.D{{Key: "$match", Value: matchConditions}}, - bson.D{{Key: "$count", Value: "count"}}, - }}, - {Key: "costs", Value: bson.A{ - bson.D{{Key: "$unwind", Value: "$app_costs"}}, - bson.D{{Key: "$match", Value: matchConditions}}, - bson.D{{Key: "$sort", Value: bson.D{ - {Key: "time", Value: -1}, - {Key: "app_costs.name", Value: 1}, - {Key: "_id", Value: 1}, - }}}, - bson.D{{Key: "$skip", Value: (req.Page - 1) * pageSize}}, - bson.D{{Key: "$limit", Value: pageSize}}, bson.D{{Key: "$project", Value: bson.D{ - {Key: "_id", Value: 0}, {Key: "time", Value: 1}, {Key: "order_id", Value: 1}, {Key: "namespace", Value: 1}, @@ -335,6 +385,42 @@ func (m *MongoDB) GetAppCosts(req *helper.AppCostsReq) (results *common.AppCosts {Key: "app_type", Value: "$app_type"}, }}}, }}, + {Key: "withoutAppCosts", Value: bson.A{ + bson.D{{Key: "$match", Value: bson.D{ + {Key: "app_costs", Value: bson.M{"$exists": false}}, + {Key: "app_name", Value: bson.M{"$exists": true}}, + }}}, + bson.D{{Key: "$match", Value: matchConditions}}, + bson.D{{Key: "$project", Value: bson.D{ + {Key: "time", Value: 1}, + {Key: "order_id", Value: 1}, + {Key: "namespace", Value: 1}, + {Key: "used", Value: nil}, + {Key: "used_amount", Value: nil}, + {Key: "amount", Value: 1}, + {Key: "app_name", Value: 1}, + {Key: "app_type", Value: 1}, + }}}, + }}, + }}}, + {{Key: "$project", Value: bson.D{ + {Key: "combined", Value: bson.D{{Key: "$concatArrays", Value: bson.A{"$withAppCosts", "$withoutAppCosts"}}}}, + }}}, + {{Key: "$unwind", Value: "$combined"}}, + {{Key: "$replaceRoot", Value: bson.D{{Key: "newRoot", Value: "$combined"}}}}, + {{Key: "$sort", Value: bson.D{ + {Key: "time", Value: -1}, + {Key: "app_name", Value: 1}, + {Key: "_id", Value: 1}, + }}}, + {{Key: "$facet", Value: bson.D{ + {Key: "totalRecords", Value: bson.A{ + bson.D{{Key: "$count", Value: "count"}}, + }}, + {Key: "costs", Value: bson.A{ + bson.D{{Key: "$skip", Value: (req.Page - 1) * pageSize}}, + bson.D{{Key: "$limit", Value: pageSize}}, + }}, }}}, {{Key: "$project", Value: bson.D{ {Key: "total_records", Value: bson.D{{Key: "$arrayElemAt", Value: bson.A{"$totalRecords.count", 0}}}}, @@ -550,7 +636,7 @@ func (m *MongoDB) getTotalAppCost(req helper.GetCostAppListReq, app helper.CostA req.StartTime = time.Now().UTC().Add(-time.Hour * 24 * 30) req.EndTime = time.Now().UTC() } - match := bson.M{ + subConsumptionMatch := bson.M{ "owner": owner, "namespace": namespace, "app_costs.name": appName, @@ -560,7 +646,7 @@ func (m *MongoDB) getTotalAppCost(req helper.GetCostAppListReq, app helper.CostA "$lte": req.EndTime, }, } - appStoreMatch := bson.M{ + consumptionMatch := bson.M{ "owner": owner, "namespace": namespace, "app_name": appName, @@ -572,10 +658,10 @@ func (m *MongoDB) getTotalAppCost(req helper.GetCostAppListReq, app helper.CostA } var pipeline mongo.Pipeline - if appType == resources.AppType[resources.AppStore] { - // If appType is 8, match app_name and app_type directly + if appType == resources.AppType[resources.AppStore] || appType == resources.AppType[resources.LLMToken] { + // If appType is app-store || llm-token, match app_name and app_type directly pipeline = mongo.Pipeline{ - {{Key: "$match", Value: appStoreMatch}}, + {{Key: "$match", Value: consumptionMatch}}, {{Key: "$group", Value: bson.D{ {Key: "_id", Value: nil}, {Key: "totalAmount", Value: bson.D{{Key: "$sum", Value: "$amount"}}}, @@ -584,7 +670,7 @@ func (m *MongoDB) getTotalAppCost(req helper.GetCostAppListReq, app helper.CostA } else { // Otherwise, match inside app_costs pipeline = mongo.Pipeline{ - {{Key: "$match", Value: match}}, + {{Key: "$match", Value: subConsumptionMatch}}, {{Key: "$unwind", Value: "$app_costs"}}, {{Key: "$match", Value: bson.D{ {Key: "app_costs.name", Value: appName}, @@ -618,9 +704,6 @@ func (m *MongoDB) getTotalAppCost(req helper.GetCostAppListReq, app helper.CostA } func (m *MongoDB) GetCostAppList(req helper.GetCostAppListReq) (resp helper.CostAppListResp, rErr error) { - var ( - result []helper.CostApp - ) if req.PageSize <= 0 { req.PageSize = 10 } @@ -630,8 +713,8 @@ func (m *MongoDB) GetCostAppList(req helper.GetCostAppListReq) (resp helper.Cost pageSize := req.PageSize if strings.ToUpper(req.AppType) != resources.AppStore { match := bson.M{ - "owner": req.Owner, - // Exclude app store + "owner": req.Owner, + "type": accountv1.Consumption, "app_type": bson.M{"$ne": resources.AppType[resources.AppStore]}, } if req.Namespace != "" { @@ -640,9 +723,6 @@ func (m *MongoDB) GetCostAppList(req helper.GetCostAppListReq) (resp helper.Cost if req.AppType != "" { match["app_type"] = resources.AppType[strings.ToUpper(req.AppType)] } - if req.AppName != "" { - match["app_costs.name"] = req.AppName - } if req.StartTime.IsZero() { req.StartTime = time.Now().UTC().Add(-time.Hour * 24 * 30) req.EndTime = time.Now().UTC() @@ -651,52 +731,71 @@ func (m *MongoDB) GetCostAppList(req helper.GetCostAppListReq) (resp helper.Cost "$gte": req.StartTime, "$lte": req.EndTime, } - sort := bson.E{Key: "$sort", Value: bson.D{ - {Key: "time", Value: -1}, - }} + pipeline := mongo.Pipeline{ {{Key: "$match", Value: match}}, - {{Key: "$unwind", Value: "$app_costs"}}, - {{Key: "$match", Value: bson.D{ - {Key: "app_costs.name", Value: req.AppName}, + {{Key: "$facet", Value: bson.D{ + {Key: "withAppCosts", Value: bson.A{ + bson.D{{Key: "$match", Value: bson.D{{Key: "app_costs", Value: bson.M{"$exists": true}}}}}, + bson.D{{Key: "$unwind", Value: "$app_costs"}}, + bson.D{{Key: "$group", Value: bson.D{ + {Key: "_id", Value: bson.D{ + {Key: "app_type", Value: "$app_type"}, + {Key: "app_name", Value: "$app_costs.name"}, + {Key: "namespace", Value: "$namespace"}, + {Key: "owner", Value: "$owner"}, + }}, + }}}, + }}, + {Key: "withoutAppCosts", Value: bson.A{ + bson.D{{Key: "$match", Value: bson.D{ + {Key: "app_costs", Value: bson.M{"$exists": false}}, + {Key: "app_name", Value: bson.M{"$exists": true}}, + }}}, + bson.D{{Key: "$group", Value: bson.D{ + {Key: "_id", Value: bson.D{ + {Key: "app_type", Value: "$app_type"}, + {Key: "app_name", Value: "$app_name"}, + {Key: "namespace", Value: "$namespace"}, + {Key: "owner", Value: "$owner"}, + }}, + }}}, + }}, + }}}, + {{Key: "$project", Value: bson.D{ + {Key: "combined", Value: bson.D{{Key: "$concatArrays", Value: bson.A{"$withAppCosts", "$withoutAppCosts"}}}}, }}}, - {sort}, + {{Key: "$unwind", Value: "$combined"}}, + {{Key: "$replaceRoot", Value: bson.D{{Key: "newRoot", Value: "$combined._id"}}}}, } - if req.AppName == "" { - pipeline = mongo.Pipeline{ - {{Key: "$match", Value: match}}, - {{Key: "$unwind", Value: "$app_costs"}}, - {sort}, - } + + if req.AppName != "" { + pipeline = append(pipeline, bson.D{{Key: "$match", Value: bson.D{ + {Key: "app_name", Value: req.AppName}, + }}}) } - pipeline = append(pipeline, mongo.Pipeline{ - {{Key: "$group", Value: bson.D{ - {Key: "_id", Value: bson.D{ - {Key: "app_type", Value: "$app_type"}, - {Key: "app_name", Value: "$app_costs.name"}, - {Key: "namespace", Value: "$namespace"}, - {Key: "owner", Value: "$owner"}, - }}, - {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}, // 添加一个计数字段 - }}}, - {{Key: "$project", Value: bson.D{ + pipeline = append(pipeline, bson.D{ + {Key: "$project", Value: bson.D{ {Key: "_id", Value: 0}, - {Key: "namespace", Value: "$_id.namespace"}, - {Key: "appType", Value: "$_id.app_type"}, - {Key: "owner", Value: "$_id.owner"}, - {Key: "appName", Value: "$_id.app_name"}, - }}}, - }...) + {Key: "namespace", Value: "$namespace"}, + {Key: "appType", Value: "$app_type"}, + {Key: "owner", Value: "$owner"}, + {Key: "appName", Value: "$app_name"}, + }}, + }) - limitPipeline := append(pipeline, bson.D{{Key: "$skip", Value: (req.Page - 1) * req.PageSize}}, bson.D{{Key: "$limit", Value: req.PageSize}}) + pipeline = append(pipeline, bson.D{{Key: "$sort", Value: bson.D{ + {Key: "appName", Value: 1}, + {Key: "appType", Value: -1}, + {Key: "namespace", Value: 1}, + {Key: "amount", Value: 1}, + }}}) countPipeline := append(pipeline, bson.D{{Key: "$count", Value: "total"}}) - countCursor, err := m.getBillingCollection().Aggregate(context.Background(), countPipeline) if err != nil { - rErr = fmt.Errorf("failed to execute count aggregate query: %w", err) - return + return resp, fmt.Errorf("failed to execute count aggregate query: %w", err) } defer countCursor.Close(context.Background()) @@ -705,43 +804,45 @@ func (m *MongoDB) GetCostAppList(req helper.GetCostAppListReq) (resp helper.Cost Total int64 `bson:"total"` } if err := countCursor.Decode(&countResult); err != nil { - rErr = fmt.Errorf("failed to decode count result: %w", err) - return + return resp, fmt.Errorf("failed to decode count result: %w", err) } resp.Total = countResult.Total } + pipeline = append(pipeline, + bson.D{{Key: "$skip", Value: (req.Page - 1) * pageSize}}, + bson.D{{Key: "$limit", Value: pageSize}}, + ) - cursor, err := m.getBillingCollection().Aggregate(context.Background(), limitPipeline) + cursor, err := m.getBillingCollection().Aggregate(context.Background(), pipeline) if err != nil { - rErr = fmt.Errorf("failed to execute aggregate query: %w", err) - return + return resp, fmt.Errorf("failed to execute aggregate query: %w", err) } defer cursor.Close(context.Background()) + var result []helper.CostApp if err := cursor.All(context.Background(), &result); err != nil { - rErr = fmt.Errorf("failed to decode all billing record: %w", err) - return + return resp, fmt.Errorf("failed to decode all billing record: %w", err) } + resp.Apps = result } appStoreTotal, err := m.getAppStoreTotal(req) if err != nil { - rErr = fmt.Errorf("failed to get app store total: %w", err) - return + return resp, fmt.Errorf("failed to get app store total: %w", err) } if req.AppType == "" || strings.ToUpper(req.AppType) == resources.AppStore { - currentAppPageIsFull := len(result) == req.PageSize + currentAppPageIsFull := len(resp.Apps) == req.PageSize maxAppPageSize := (resp.Total + int64(req.PageSize) - 1) / int64(req.PageSize) completedNum := calculateComplement(int(resp.Total), req.PageSize) appPageSize := (resp.Total + int64(req.PageSize) - 1) / int64(req.PageSize) + if req.Page == int(maxAppPageSize) { if !currentAppPageIsFull { appStoreResp, err := m.getAppStoreList(req, 0, completedNum) if err != nil { - rErr = fmt.Errorf("failed to get app store list: %w", err) - return + return resp, fmt.Errorf("failed to get app store list: %w", err) } - result = append(result, appStoreResp.Apps...) + resp.Apps = append(resp.Apps, appStoreResp.Apps...) } } else if req.Page > int(maxAppPageSize) { skipPageSize := (req.Page - int(appPageSize) - 1) * req.PageSize @@ -750,16 +851,14 @@ func (m *MongoDB) GetCostAppList(req helper.GetCostAppListReq) (resp helper.Cost } appStoreResp, err := m.getAppStoreList(req, completedNum+skipPageSize, req.PageSize) if err != nil { - rErr = fmt.Errorf("failed to get app store list: %w", err) - return + return resp, fmt.Errorf("failed to get app store list: %w", err) } - result = append(result, appStoreResp.Apps...) + resp.Apps = append(resp.Apps, appStoreResp.Apps...) } resp.Total += appStoreTotal } resp.TotalPage = (resp.Total + int64(pageSize) - 1) / int64(pageSize) - resp.Apps = result return resp, nil } @@ -1220,19 +1319,20 @@ func NewAccountInterface(mongoURI, globalCockRoachURI, localCockRoachURI string) return nil, fmt.Errorf("failed to ping mongodb: %v", err) } mongodb := &MongoDB{ - Client: client, - AccountDBName: "sealos-resources", - BillingConn: "billing", - PropertiesConn: "properties", + Client: client, + AccountDBName: "sealos-resources", + BillingConn: "billing", + ActiveBillingConn: "active-billing", + PropertiesConn: "properties", } ck, err := cockroach.NewCockRoach(globalCockRoachURI, localCockRoachURI) if err != nil { return nil, fmt.Errorf("failed to connect cockroach: %v", err) } - if err = ck.InitTables(); err != nil { + account := &Account{MongoDB: mongodb, Cockroach: &Cockroach{ck: ck}} + if err = account.InitDB(); err != nil { return nil, fmt.Errorf("failed to init tables: %v", err) } - account := &Account{MongoDB: mongodb, Cockroach: &Cockroach{ck: ck}} return account, nil } @@ -1247,10 +1347,11 @@ func newAccountForTest(mongoURI, globalCockRoachURI, localCockRoachURI string) ( return nil, fmt.Errorf("failed to ping mongodb: %v", err) } account.MongoDB = &MongoDB{ - Client: client, - AccountDBName: "sealos-resources", - BillingConn: "billing", - PropertiesConn: "properties", + Client: client, + AccountDBName: "sealos-resources", + BillingConn: "billing", + ActiveBillingConn: "active-billing", + PropertiesConn: "properties", } } else { fmt.Printf("mongoURI is empty, skip connecting to mongodb\n") @@ -1338,6 +1439,10 @@ func (m *MongoDB) getMonitorCollection(collTime time.Time) *mongo.Collection { return m.Client.Database(m.AccountDBName).Collection(m.getMonitorCollectionName(collTime)) } +func (m *MongoDB) getActiveBillingCollection() *mongo.Collection { + return m.Client.Database(m.AccountDBName).Collection(m.ActiveBillingConn) +} + func (m *MongoDB) getMonitorCollectionName(collTime time.Time) string { // Calculate the suffix by day, for example, the suffix on the first day of 202012 is 20201201 return fmt.Sprintf("%s_%s", "monitor", collTime.Format("20060102")) @@ -1474,3 +1579,350 @@ func (m *Account) GetUserRealNameInfo(req *helper.GetRealNameInfoReq) (*types.Us return userRealNameInfo, nil } + +func (m *Account) ReconcileActiveBilling(startTime, endTime time.Time) error { + ctx := context.Background() + billings := make(map[uuid.UUID]*billingBatch) + + // Process billings in batches + if err := m.processBillingBatches(ctx, startTime, endTime, billings); err != nil { + helper.ErrorCounter.WithLabelValues("ReconcileActiveBilling", "processBillingBatches", "").Inc() + return fmt.Errorf("failed to process billing batches: %w", err) + } + + // Handle each user's billings + for uid, batch := range billings { + if err := m.reconcileUserBilling(ctx, uid, batch); err != nil { + helper.ErrorCounter.WithLabelValues("ReconcileActiveBilling", "reconcileUserBilling", uid.String()).Inc() + logrus.Errorf("failed to reconcile billing for user %s: %v", uid, err) + continue + } + } + + return nil +} + +type billingBatch struct { + IDs []primitive.ObjectID + Amount int64 +} + +func (m *Account) processBillingBatches(ctx context.Context, startTime, endTime time.Time, billings map[uuid.UUID]*billingBatch) error { + filter := bson.M{ + "time": bson.M{ + "$gte": startTime, + "$lte": endTime, + }, + "status": bson.M{"$nin": []resources.ConsumptionStatus{ + resources.Processing, + resources.Consumed, + }}, + } + + for { + var billing resources.ActiveBilling + err := m.MongoDB.getActiveBillingCollection().FindOneAndUpdate( + ctx, + filter, + bson.M{"$set": bson.M{"status": resources.Processing}}, + options.FindOneAndUpdate(). + SetReturnDocument(options.After). + SetSort(bson.M{"time": 1}), + ).Decode(&billing) + + if err == mongo.ErrNoDocuments { + break + } + // TODO error handling + if err != nil { + logrus.Errorf("failed to find and update billing: %v", err) + continue + } + + batch, ok := billings[billing.UserUID] + if !ok { + batch = &billingBatch{ + IDs: make([]primitive.ObjectID, 0), + Amount: 0, + } + billings[billing.UserUID] = batch + } + batch.IDs = append(batch.IDs, billing.ID) + batch.Amount += billing.Amount + } + + return nil +} + +func (m *Account) reconcileUserBilling(ctx context.Context, uid uuid.UUID, batch *billingBatch) error { + return m.ck.DB.Transaction(func(tx *gorm.DB) error { + // Deduct balance + if err := m.ck.AddDeductionBalanceWithDB(&types.UserQueryOpts{UID: uid}, batch.Amount, tx); err != nil { + return fmt.Errorf("failed to deduct balance: %w", err) + } + + // Update billing status + _, err := m.MongoDB.getActiveBillingCollection().UpdateMany( + ctx, + bson.M{"_id": bson.M{"$in": batch.IDs}}, + bson.M{"$set": bson.M{"status": resources.Consumed}}, + ) + if err != nil { + return fmt.Errorf("failed to update billing status: %w", err) + } + + return nil + }) +} + +func (m *Account) ChargeBilling(req *helper.AdminChargeBillingReq) error { + billing := &resources.ActiveBilling{ + Namespace: req.Namespace, + AppType: req.AppType, + AppName: req.AppName, + Amount: req.Amount, + //Owner: userCr.CrName, + Time: time.Now().UTC(), + Status: resources.Unconsumed, + UserUID: req.UserUID, + } + err := m.MongoDB.SaveActiveBillings(billing) + if err != nil { + return fmt.Errorf("save active monitor failed: %v", err) + } + return nil +} + +func (m *Account) ActiveBilling(req resources.ActiveBilling) error { + return m.ck.DB.Transaction(func(tx *gorm.DB) error { + if err := m.ck.AddDeductionBalanceWithDB(&types.UserQueryOpts{UID: req.UserUID}, req.Amount, tx); err != nil { + helper.ErrorCounter.WithLabelValues("ActiveBilling", "AddDeductionBalanceWithDB", req.UserUID.String()).Inc() + return fmt.Errorf("failed to deduct balance: %v", err) + } + req.Status = resources.Consumed + _, err := m.getActiveBillingCollection().InsertOne(context.Background(), req) + if err != nil { + helper.ErrorCounter.WithLabelValues("ActiveBilling", "InsertOne", req.UserUID.String()).Inc() + return fmt.Errorf("failed to insert (%v) monitor: %v", req, err) + } + return nil + }) +} + +func (m *MongoDB) UpdateBillingStatus(orderID string, status resources.BillingStatus) error { + filter := bson.M{"order_id": orderID} + update := bson.M{ + "$set": bson.M{ + "status": status, + }, + } + _, err := m.getBillingCollection().UpdateOne(context.Background(), filter, update) + if err != nil { + return fmt.Errorf("update error: %v", err) + } + return nil +} + +func (m *Account) ReconcileUnsettledLLMBilling(startTime, endTime time.Time) error { + unsettledAmounts, err := m.MongoDB.reconcileUnsettledLLMBilling(startTime, endTime) + if err != nil { + return fmt.Errorf("failed to get unsettled billing: %v", err) + } + for userUID, amount := range unsettledAmounts { + err = m.ck.DB.Transaction(func(tx *gorm.DB) error { + // 1. deduct balance + if err := m.ck.AddDeductionBalanceWithDB(&types.UserQueryOpts{UID: userUID}, amount, tx); err != nil { + return fmt.Errorf("failed to deduct balance: %v", err) + } + // 2. update billing status + filter := bson.M{ + "user_uid": userUID, + "type": accountv1.SubConsumption, + "status": resources.Unsettled, + "app_type": resources.AppType[resources.LLMToken], + "time": bson.M{ + "$gte": startTime, + "$lte": endTime, + }, + } + update := bson.M{ + "$set": bson.M{ + "status": resources.Settled, + }, + } + + _, err = m.MongoDB.getBillingCollection().UpdateMany(context.Background(), filter, update) + if err != nil { + return fmt.Errorf("failed to update billing status: %v", err) + } + + return nil + }) + + // If the transaction fails, roll back the billing state + //if err != nil { + // err = fmt.Errorf("failed to reconcile billing for user %s: %v", userUID, err) + // filter := bson.M{ + // "user_uid": userUID, + // "app_type": resources.AppType["LLM-TOKEN"], + // "time": bson.M{ + // "$gte": time.Now().Add(-time.Hour), + // }, + // } + // update := bson.M{ + // "$set": bson.M{ + // "status": resources.Unsettled, + // }, + // } + // if _, rollBackErr := m.MongoDB.getBillingCollection().UpdateMany(context.Background(), filter, update); rollBackErr != nil { + // return fmt.Errorf("%v; And failed to rollback billing status: %v", err, rollBackErr) + // } + // return err + //} + if err != nil { + return fmt.Errorf("failed to reconcile billing for user %s: %v", userUID, err) + } + } + return nil +} + +func (m *Account) ArchiveHourlyBilling(hourStart, hourEnd time.Time) error { + pipeline := mongo.Pipeline{ + {{Key: "$match", Value: bson.M{ + "time": bson.M{ + "$gte": hourStart, + "$lt": hourEnd, + }, + "status": resources.Consumed, + }}}, + {{Key: "$group", Value: bson.M{ + "_id": bson.M{ + "user_uid": "$user_uid", + "app_type": "$app_type", + "app_name": "$app_name", + //"owner": "$owner", + "namespace": "$namespace", + }, + "total_amount": bson.M{"$sum": "$amount"}, + }}}, + } + + cursor, err := m.MongoDB.getActiveBillingCollection().Aggregate(context.Background(), pipeline) + if err != nil { + helper.ErrorCounter.WithLabelValues("ArchiveHourlyBilling", "Aggregate", "").Inc() + return fmt.Errorf("failed to aggregate hourly billing: %v", err) + } + defer cursor.Close(context.Background()) + + var errs []error + for cursor.Next(context.Background()) { + var result struct { + ID struct { + UserUID uuid.UUID `bson:"user_uid"` + AppName string `bson:"app_name"` + AppType string `bson:"app_type"` + Owner string `bson:"owner,omitempty"` + Namespace string `bson:"namespace"` + } `bson:"_id"` + TotalAmount int64 `bson:"total_amount"` + } + + if err := cursor.Decode(&result); err != nil { + errs = append(errs, fmt.Errorf("failed to decode document: %v", err)) + continue + } + if result.ID.Owner == "" { + userCr, err := m.ck.GetUserCr(&types.UserQueryOpts{UID: result.ID.UserUID}) + if err != nil { + helper.ErrorCounter.WithLabelValues("ArchiveHourlyBilling", "GetUserCr", result.ID.UserUID.String()).Inc() + errs = append(errs, fmt.Errorf("failed to get user cr: %v", err)) + continue + } + result.ID.Owner = userCr.CrName + } + + filter := bson.M{ + "app_type": resources.AppType[result.ID.AppType], + "app_name": result.ID.AppName, + "namespace": result.ID.Namespace, + "owner": result.ID.Owner, + "time": hourStart, + "type": accountv1.Consumption, + } + + billing := bson.M{ + "order_id": gonanoid.Must(12), + "type": accountv1.Consumption, + "namespace": result.ID.Namespace, + "app_type": resources.AppType[result.ID.AppType], + "app_name": result.ID.AppName, + "amount": result.TotalAmount, + "owner": result.ID.Owner, + "time": hourStart, + "status": resources.Settled, + "user_uid": result.ID.UserUID, + } + + update := bson.M{ + "$setOnInsert": billing, + } + + opts := options.Update().SetUpsert(true) + _, err = m.MongoDB.getBillingCollection().UpdateOne( + context.Background(), + filter, + update, + opts, + ) + if err != nil { + helper.ErrorCounter.WithLabelValues("ArchiveHourlyBilling", "UpdateOne", result.ID.UserUID.String()).Inc() + errs = append(errs, fmt.Errorf("failed to upsert billing for user %s, app %s: %v", + result.ID.UserUID, result.ID.AppName, err)) + continue + } + } + if err = cursor.Err(); err != nil { + errs = append(errs, fmt.Errorf("cursor error: %v", err)) + } + if len(errs) > 0 { + return fmt.Errorf("encountered %d errors during archiving: %v", len(errs), errs) + } + return nil +} + +func (m *MongoDB) reconcileUnsettledLLMBilling(startTime, endTime time.Time) (map[uuid.UUID]int64, error) { + pipeline := mongo.Pipeline{ + {{Key: "$match", Value: bson.M{ + "time": bson.M{ + "$gte": startTime, + "$lte": endTime, + }, + "status": resources.Unsettled, + "app_type": resources.AppType[resources.LLMToken], + }}}, + {{Key: "$group", Value: bson.M{ + "_id": "$user_uid", + "total_amount": bson.M{"$sum": "$amount"}, + }}}, + } + cursor, err := m.getBillingCollection().Aggregate(context.Background(), pipeline) + if err != nil { + return nil, fmt.Errorf("failed to aggregate billing: %v", err) + } + defer cursor.Close(context.Background()) + result := make(map[uuid.UUID]int64) + for cursor.Next(context.Background()) { + var doc struct { + ID uuid.UUID `bson:"_id"` + Amount int64 `bson:"total_amount"` + } + if err := cursor.Decode(&doc); err != nil { + return nil, fmt.Errorf("failed to decode document: %v", err) + } + result[doc.ID] = doc.Amount + } + if err := cursor.Err(); err != nil { + return nil, fmt.Errorf("cursor error: %v", err) + } + return result, nil +} diff --git a/service/account/dao/interface_test.go b/service/account/dao/interface_test.go index 01ef8b704c2..cf6259a2071 100644 --- a/service/account/dao/interface_test.go +++ b/service/account/dao/interface_test.go @@ -668,3 +668,28 @@ func TestMongoDB_GetMonitorUniqueValues(t *testing.T) { t.Logf("monitor = %+v\n", monitor) } } + +func TestAccount_ReconcileUnsettledLLMBilling(t *testing.T) { + type fields struct { + MongoDB *MongoDB + Cockroach *Cockroach + } + tests := []struct { + name string + fields fields + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &Account{ + MongoDB: tt.fields.MongoDB, + Cockroach: tt.fields.Cockroach, + } + if err := m.ReconcileUnsettledLLMBilling(time.Now().Add(-1*time.Hour), time.Now()); (err != nil) != tt.wantErr { + t.Errorf("ReconcileUnsettledLLMBilling() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/service/account/deploy/manifests/deploy.yaml.tmpl b/service/account/deploy/manifests/deploy.yaml.tmpl index 6e9b3622fda..2b4e0b797d9 100644 --- a/service/account/deploy/manifests/deploy.yaml.tmpl +++ b/service/account/deploy/manifests/deploy.yaml.tmpl @@ -43,6 +43,17 @@ spec: containers: - name: account-service image: ghcr.io/labring/sealos-account-service:latest + env: + - name: POD_NAME + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: metadata.namespace envFrom: - configMapRef: name: account-manager-env diff --git a/service/account/helper/common.go b/service/account/helper/common.go index ba88c53a52c..2a1634876e7 100644 --- a/service/account/helper/common.go +++ b/service/account/helper/common.go @@ -32,6 +32,13 @@ const ( GetUserRealNameInfo = "/real-name-info" ) +const ( + AdminGroup = "/admin/v1alpha1" + AdminGetAccountWithWorkspace = "/account-with-workspace" + AdminChargeBilling = "/charge-billing" + AdminActiveBilling = "/active-billing" +) + // env const ( ConfigPath = "/config/config.json" diff --git a/service/account/helper/jwt.go b/service/account/helper/jwt.go index dfef164d3e9..c47a05e3007 100644 --- a/service/account/helper/jwt.go +++ b/service/account/helper/jwt.go @@ -23,6 +23,7 @@ type UserClaims struct { } type JwtUser struct { + Requester string `json:"requester,omitempty"` UserUID uuid.UUID `json:"userUid,omitempty"` UserCrUID string `json:"userCrUid,omitempty"` UserCrName string `json:"userCrName,omitempty"` diff --git a/service/account/helper/metrics.go b/service/account/helper/metrics.go new file mode 100644 index 00000000000..61d1080905b --- /dev/null +++ b/service/account/helper/metrics.go @@ -0,0 +1,35 @@ +package helper + +import ( + "os" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + podName = os.Getenv("POD_NAME") + namespace = os.Getenv("POD_NAMESPACE") + domain = os.Getenv("DOMAIN") + + registry = prometheus.WrapRegistererWith( + prometheus.Labels{"pod_name": podName, "namespace": namespace, "domain": domain}, + prometheus.DefaultRegisterer, + ) + // ErrorCounter is a counter for account service errors + ErrorCounter = promauto.With(registry).NewCounterVec( + prometheus.CounterOpts{ + Name: "sealos_account_errors_total", + Help: "account service error counter", + }, + []string{"error_type", "function", "user_uid"}, + ) + // CallCounter is a counter for account service calls + CallCounter = promauto.With(registry).NewCounterVec( + prometheus.CounterOpts{ + Name: "sealos_account_calls_total", + Help: "account service call counter", + }, + []string{"function", "user_uid"}, + ) +) diff --git a/service/account/helper/request.go b/service/account/helper/request.go index 4d2994de617..7b35b956718 100644 --- a/service/account/helper/request.go +++ b/service/account/helper/request.go @@ -594,3 +594,20 @@ func ParseUserUsageReq(c *gin.Context) (*UserUsageReq, error) { } return userUsage, nil } + +type AdminChargeBillingReq struct { + Amount int64 `json:"amount" bson:"amount" example:"100000000"` + Namespace string `json:"namespace" bson:"namespace" example:"ns-admin"` + Owner string `json:"owner" bson:"owner" example:"admin"` + AppType string `json:"appType" bson:"appType"` + AppName string `json:"appName" bson:"appName"` + UserUID uuid.UUID `json:"userUID" bson:"userUID"` +} + +func ParseAdminChargeBillingReq(c *gin.Context) (*AdminChargeBillingReq, error) { + rechargeBilling := &AdminChargeBillingReq{} + if err := c.ShouldBindJSON(rechargeBilling); err != nil { + return nil, fmt.Errorf("bind json error: %v", err) + } + return rechargeBilling, nil +} diff --git a/service/account/helper/task.go b/service/account/helper/task.go new file mode 100644 index 00000000000..6dfe23e54b8 --- /dev/null +++ b/service/account/helper/task.go @@ -0,0 +1,83 @@ +package helper + +import ( + "context" + "sync" + + "github.com/sirupsen/logrus" +) + +type Task interface { + Execute() error +} + +type TaskQueue struct { + ctx context.Context + cancel context.CancelFunc + queue chan Task + workers int + wg sync.WaitGroup + //mu sync.Mutex + started bool +} + +func NewTaskQueue(ctx context.Context, workerCount, queueSize int) *TaskQueue { + ctx, cancel := context.WithCancel(ctx) + return &TaskQueue{ + ctx: ctx, + queue: make(chan Task, queueSize), + workers: workerCount, + cancel: cancel, + } +} + +func (tq *TaskQueue) AddTask(task Task) { + //tq.mu.Lock() + //defer tq.mu.Unlock() + //if tq.started { + // tq.queue <- task + //} else { + // fmt.Println("TaskQueue has not been started yet") + //} + select { + case <-tq.ctx.Done(): + logrus.Info("TaskQueue has been stopped.") + case tq.queue <- task: + } +} + +func (tq *TaskQueue) Start() { + if tq.started { + return + } + tq.started = true + for i := 0; i < tq.workers; i++ { + tq.wg.Add(1) + go tq.worker(i) + } +} + +func (tq *TaskQueue) Stop() { + tq.cancel() + tq.wg.Wait() + close(tq.queue) + logrus.Info("TaskQueue stopped") +} + +func (tq *TaskQueue) worker(id int) { + defer tq.wg.Done() + for { + select { + case <-tq.ctx.Done(): + return + case task, ok := <-tq.queue: + if !ok { + return + } + if err := task.Execute(); err != nil { + // TODO handle task execution failures + logrus.Errorf("Worker %d failed to process task: %v", id, err) + } + } + } +} diff --git a/service/account/router/router.go b/service/account/router/router.go index 4c875f5447a..60e5c7e30b8 100644 --- a/service/account/router/router.go +++ b/service/account/router/router.go @@ -4,13 +4,19 @@ import ( "context" "fmt" "log" + "net/http" "os" "os/signal" + "sync/atomic" "syscall" "time" + "github.com/sirupsen/logrus" + "github.com/labring/sealos/controllers/pkg/utils/env" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/labring/sealos/service/account/docs" "github.com/labring/sealos/service/account/dao" @@ -27,16 +33,16 @@ import ( func RegisterPayRouter() { router := gin.Default() - - if err := dao.InitDB(); err != nil { + ctx := context.Background() + if err := dao.Init(ctx); err != nil { log.Fatalf("Error initializing database: %v", err) } - ctx := context.Background() defer func() { if err := dao.DBClient.Disconnect(ctx); err != nil { log.Fatalf("Error disconnecting database: %v", err) } }() + router.GET("/metrics", gin.WrapH(promhttp.Handler())) // /account/v1alpha1/{/namespaces | /properties | {/costs | /costs/recharge | /costs/consumption | /costs/properties}} router.Group(helper.GROUP). POST(helper.GetHistoryNamespaces, api.GetBillingHistoryNamespaceList). @@ -67,19 +73,25 @@ func RegisterPayRouter() { POST(helper.UserUsage, api.UserUsage). POST(helper.GetRechargeDiscount, api.GetRechargeDiscount). POST(helper.GetUserRealNameInfo, api.GetUserRealNameInfo) + router.Group(helper.AdminGroup). + GET(helper.AdminGetAccountWithWorkspace, api.AdminGetAccountWithWorkspaceID). + POST(helper.AdminChargeBilling, api.AdminChargeBilling) + //POST(helper.AdminActiveBilling, api.AdminActiveBilling) docs.SwaggerInfo.Host = env.GetEnvWithDefault("SWAGGER_HOST", "localhost:2333") router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) // Create a buffered channel interrupt and use the signal. - interrupt := make(chan os.Signal, 1) - signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) + rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() // Start the HTTP server to listen on port 2333. + srv := &http.Server{ + Addr: ":2333", + Handler: router, + } go func() { - err := router.Run(":2333") - fmt.Println("account service is running on port 2333") - if err != nil { - log.Fatalf("Error running server: %v", err) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %s\n", err) } }() @@ -88,10 +100,25 @@ func RegisterPayRouter() { fmt.Println("Start reward processing timer") go startRewardProcessingTimer(ctx) } + dao.BillingTask.Start() + // process llm task + go startReconcileBilling(ctx) + + // process hourly archive + go startHourlyBillingActiveArchive(ctx) // Wait for interrupt signal. - <-interrupt + <-rootCtx.Done() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Fatal("Server forced to shutdown: ", err) + } + dao.BillingTask.Stop() + + log.Println("Server exiting") // Terminate procedure. os.Exit(0) } @@ -111,3 +138,70 @@ func startRewardProcessingTimer(ctx context.Context) { } } } + +var lastReconcileTime atomic.Value + +func startReconcileBilling(ctx context.Context) { + // initialize to one hour ago + lastReconcileTime.Store(time.Now().UTC().Add(-time.Hour)) + + tickerTime, err := time.ParseDuration(env.GetEnvWithDefault("BILLING_RECONCILE_INTERVAL", "5s")) + if err != nil { + logrus.Errorf("Failed to parse LLM_BILLING_RECONCILE_INTERVAL: %v", err) + tickerTime = 5 * time.Second + } + // create a timer and execute it once every minute + ticker := time.NewTicker(tickerTime) + defer ticker.Stop() + + logrus.Info("Starting LLM billing reconciliation service, interval: ", tickerTime.String()) + + // This command is executed for the first time to process the data within the last hour + startTime, endTime := time.Now().UTC().Add(-time.Hour), time.Now().UTC() + dao.BillingTask.AddTask(&dao.ActiveBillingReconcile{ + StartTime: startTime, + EndTime: endTime, + }) + lastReconcileTime.Store(endTime) + + for { + select { + case <-ctx.Done(): + logrus.Info("Stopping LLM billing reconciliation service") + return + case t := <-ticker.C: + currentTime := t.UTC() + lastTime := lastReconcileTime.Load().(time.Time) + //doBillingReconcile(lastTime, currentTime) + dao.BillingTask.AddTask(&dao.ActiveBillingReconcile{ + StartTime: lastTime, + EndTime: currentTime, + }) + lastReconcileTime.Store(currentTime) + } + } +} + +func startHourlyBillingActiveArchive(ctx context.Context) { + logrus.Info("Starting hourly billing active archive service") + now := time.Now().UTC() + lastHourStart := time.Date(now.Year(), now.Month(), now.Day(), now.Hour()-1, 0, 0, 0, now.Location()) + + dao.BillingTask.AddTask(&dao.ArchiveBillingReconcile{ + StartTime: lastHourStart, + }) + nextHour := time.Date(now.Year(), now.Month(), now.Day(), now.Hour()+1, 0, 0, 0, now.Location()) + for { + select { + case <-ctx.Done(): + logrus.Info("Stopping hourly billing archive service") + return + case <-time.After(time.Until(nextHour)): + currentHour := nextHour.Add(-time.Hour) + dao.BillingTask.AddTask(&dao.ArchiveBillingReconcile{ + StartTime: currentHour, + }) + nextHour = nextHour.Add(time.Hour) + } + } +} diff --git a/service/aiproxy/.gitignore b/service/aiproxy/.gitignore new file mode 100644 index 00000000000..8df7a82da5e --- /dev/null +++ b/service/aiproxy/.gitignore @@ -0,0 +1,2 @@ +aiproxy.db* +aiproxy \ No newline at end of file diff --git a/service/aiproxy/Dockerfile b/service/aiproxy/Dockerfile new file mode 100644 index 00000000000..f078274204e --- /dev/null +++ b/service/aiproxy/Dockerfile @@ -0,0 +1,7 @@ +FROM gcr.io/distroless/static:nonroot +ARG TARGETARCH +COPY bin/service-aiproxy-$TARGETARCH /manager +EXPOSE 3000 +USER 65532:65532 + +ENTRYPOINT ["/manager"] \ No newline at end of file diff --git a/service/aiproxy/Makefile b/service/aiproxy/Makefile new file mode 100644 index 00000000000..fae59539d48 --- /dev/null +++ b/service/aiproxy/Makefile @@ -0,0 +1,53 @@ +IMG ?= ghcr.io/labring/sealos-aiproxy-service:latest + +# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) +ifeq (,$(shell go env GOBIN)) +GOBIN=$(shell go env GOPATH)/bin +else +GOBIN=$(shell go env GOBIN) +endif + +# only support linux, non cgo +PLATFORMS ?= linux_arm64 linux_amd64 +GOOS=linux +GOARCH=$(shell go env GOARCH) + +.PHONY: all +all: build + +##@ General + +# The help target prints out all targets with their descriptions organized +# beneath their categories. The categories are represented by '##@' and the +# target descriptions by '##'. The awk commands is responsible for reading the +# entire set of makefiles included in this invocation, looking for lines of the +# file as xyz: ## something, and then pretty-format the target and help. Then, +# if there's a line with ##@ something, that gets pretty-printed as a category. +# More info on the usage of ANSI control characters for terminal formatting: +# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters +# More info on the awk command: +# http://linuxcommand.org/lc3_adv_awk.php + +.PHONY: help +help: ## Display this help. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +##@ Build + +.PHONY: clean +clean: + rm -f $(SERVICE_NAME) + +.PHONY: build +build: ## Build service-hub binary. + LD_FLAGS="-s -w -extldflags '-static'"; \ + CGO_ENABLED=0 GOOS=linux go build -tags "jsoniter" -ldflags "$${LD_FLAGS}" -trimpath -o bin/manager main.go + +.PHONY: docker-build +docker-build: build + mv bin/manager bin/service-aiproxy-${TARGETARCH} + docker build -t $(IMG) . + +.PHONY: docker-push +docker-push: + docker push $(IMG) diff --git a/service/aiproxy/README.md b/service/aiproxy/README.md new file mode 100644 index 00000000000..442a287699c --- /dev/null +++ b/service/aiproxy/README.md @@ -0,0 +1,16 @@ +# Use Sealos to Deploy + +```bash +sealos run ghcr.io/labring/sealos-cloud-aiproxy-service:latest \ + -e ADMIN_KEY=<admin-key> \ + -e cloudDomain=<cloud-domain> +``` + +# Use One PostgreSQL + +```bash +sealos run ghcr.io/labring/sealos-cloud-aiproxy-service:latest \ + -e ADMIN_KEY=<admin-key> \ + -e cloudDomain=<cloud-domain> \ + -e LOG_SQL_DSN="" +``` diff --git a/service/aiproxy/common/balance/balance.go b/service/aiproxy/common/balance/balance.go new file mode 100644 index 00000000000..b1eac62231a --- /dev/null +++ b/service/aiproxy/common/balance/balance.go @@ -0,0 +1,14 @@ +package balance + +import "context" + +type GroupBalance interface { + GetGroupRemainBalance(ctx context.Context, group string) (float64, PostGroupConsumer, error) +} + +type PostGroupConsumer interface { + PostGroupConsume(ctx context.Context, tokenName string, usage float64) (float64, error) + GetBalance(ctx context.Context) (float64, error) +} + +var Default GroupBalance = NewMockGroupBalance() diff --git a/service/aiproxy/common/balance/mock.go b/service/aiproxy/common/balance/mock.go new file mode 100644 index 00000000000..8cb2ddc7e86 --- /dev/null +++ b/service/aiproxy/common/balance/mock.go @@ -0,0 +1,27 @@ +package balance + +import "context" + +var _ GroupBalance = (*MockGroupBalance)(nil) + +const ( + mockBalance = 10000000 +) + +type MockGroupBalance struct{} + +func NewMockGroupBalance() *MockGroupBalance { + return &MockGroupBalance{} +} + +func (q *MockGroupBalance) GetGroupRemainBalance(_ context.Context, _ string) (float64, PostGroupConsumer, error) { + return mockBalance, q, nil +} + +func (q *MockGroupBalance) PostGroupConsume(_ context.Context, _ string, usage float64) (float64, error) { + return usage, nil +} + +func (q *MockGroupBalance) GetBalance(_ context.Context) (float64, error) { + return mockBalance, nil +} diff --git a/service/aiproxy/common/balance/sealos.go b/service/aiproxy/common/balance/sealos.go new file mode 100644 index 00000000000..16ab8577911 --- /dev/null +++ b/service/aiproxy/common/balance/sealos.go @@ -0,0 +1,295 @@ +package balance + +import ( + "bytes" + "context" + "errors" + "fmt" + "math/rand/v2" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/conv" + "github.com/labring/sealos/service/aiproxy/common/env" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/redis/go-redis/v9" + "github.com/shopspring/decimal" +) + +const ( + defaultAccountURL = "http://account-service.account-system.svc.cluster.local:2333" + balancePrecision = 1000000 + appType = "LLM-TOKEN" + sealosRequester = "sealos-admin" + sealosGroupBalanceKey = "sealos:balance:%s" + getBalanceRetry = 3 +) + +var ( + _ GroupBalance = (*Sealos)(nil) + sealosHTTPClient = &http.Client{} + decimalBalancePrecision = decimal.NewFromInt(balancePrecision) + minConsumeAmount = decimal.NewFromInt(1) + jwtToken string + sealosRedisCacheEnable = env.Bool("BALANCE_SEALOS_REDIS_CACHE_ENABLE", true) + sealosCacheExpire = 3 * time.Minute +) + +type Sealos struct { + accountURL string +} + +// FIXME: 如果获取余额能成功,但是消费永远失败,需要加一个失败次数限制,如果失败次数超过一定阈值,暂停服务 +func InitSealos(jwtKey string, accountURL string) error { + token, err := newSealosToken(jwtKey) + if err != nil { + return fmt.Errorf("failed to generate sealos jwt token: %w", err) + } + jwtToken = token + Default = NewSealos(accountURL) + return nil +} + +func NewSealos(accountURL string) *Sealos { + if accountURL == "" { + accountURL = defaultAccountURL + } + return &Sealos{accountURL: accountURL} +} + +type sealosClaims struct { + Requester string `json:"requester"` + jwt.RegisteredClaims +} + +func newSealosToken(key string) (string, error) { + claims := &sealosClaims{ + Requester: sealosRequester, + RegisteredClaims: jwt.RegisteredClaims{ + NotBefore: jwt.NewNumericDate(time.Now()), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(conv.StringToBytes(key)) +} + +type sealosGetGroupBalanceResp struct { + UserUID string `json:"userUID"` + Error string `json:"error"` + Balance int64 `json:"balance"` +} + +type sealosPostGroupConsumeReq struct { + Namespace string `json:"namespace"` + AppType string `json:"appType"` + AppName string `json:"appName"` + UserUID string `json:"userUID"` + Amount int64 `json:"amount"` +} + +type sealosPostGroupConsumeResp struct { + Error string `json:"error"` +} + +type sealosCache struct { + UserUID string `redis:"u"` + Balance int64 `redis:"b"` +} + +func cacheSetGroupBalance(ctx context.Context, group string, balance int64, userUID string) error { + if !common.RedisEnabled || !sealosRedisCacheEnable { + return nil + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + pipe := common.RDB.Pipeline() + pipe.HSet(ctx, fmt.Sprintf(sealosGroupBalanceKey, group), sealosCache{ + Balance: balance, + UserUID: userUID, + }) + expireTime := sealosCacheExpire + time.Duration(rand.Int64N(10)-5)*time.Second + pipe.Expire(ctx, fmt.Sprintf(sealosGroupBalanceKey, group), expireTime) + _, err := pipe.Exec(ctx) + return err +} + +func cacheGetGroupBalance(ctx context.Context, group string) (*sealosCache, error) { + if !common.RedisEnabled || !sealosRedisCacheEnable { + return nil, redis.Nil + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + var cache sealosCache + if err := common.RDB.HGetAll(ctx, fmt.Sprintf(sealosGroupBalanceKey, group)).Scan(&cache); err != nil { + return nil, err + } + return &cache, nil +} + +var decreaseGroupBalanceScript = redis.NewScript(` + local balance = redis.call("HGet", KEYS[1], "balance") + if balance == false then + return redis.status_reply("ok") + end + redis.call("HSet", KEYS[1], "balance", balance - ARGV[1]) + return redis.status_reply("ok") +`) + +func cacheDecreaseGroupBalance(ctx context.Context, group string, amount int64) error { + if !common.RedisEnabled || !sealosRedisCacheEnable { + return nil + } + return decreaseGroupBalanceScript.Run(ctx, common.RDB, []string{fmt.Sprintf(sealosGroupBalanceKey, group)}, amount).Err() +} + +func (s *Sealos) GetGroupRemainBalance(ctx context.Context, group string) (float64, PostGroupConsumer, error) { + var errs []error + for i := 0; ; i++ { + balance, consumer, err := s.getGroupRemainBalance(ctx, group) + if err == nil { + return balance, consumer, nil + } + errs = append(errs, err) + if i == getBalanceRetry-1 { + return 0, nil, errors.Join(errs...) + } + time.Sleep(time.Second) + } +} + +// GroupBalance interface implementation +func (s *Sealos) getGroupRemainBalance(ctx context.Context, group string) (float64, PostGroupConsumer, error) { + if cache, err := cacheGetGroupBalance(ctx, group); err == nil && cache.UserUID != "" { + return decimal.NewFromInt(cache.Balance).Div(decimalBalancePrecision).InexactFloat64(), + newSealosPostGroupConsumer(s.accountURL, group, cache.UserUID, cache.Balance), nil + } else if err != nil && !errors.Is(err, redis.Nil) { + logger.SysErrorf("get group (%s) balance cache failed: %s", group, err) + } + + balance, userUID, err := s.fetchBalanceFromAPI(ctx, group) + if err != nil { + return 0, nil, err + } + + if err := cacheSetGroupBalance(ctx, group, balance, userUID); err != nil { + logger.SysErrorf("set group (%s) balance cache failed: %s", group, err) + } + + return decimal.NewFromInt(balance).Div(decimalBalancePrecision).InexactFloat64(), + newSealosPostGroupConsumer(s.accountURL, group, userUID, balance), nil +} + +func (s *Sealos) fetchBalanceFromAPI(ctx context.Context, group string) (balance int64, userUID string, err error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + fmt.Sprintf("%s/admin/v1alpha1/account-with-workspace?namespace=%s", s.accountURL, group), nil) + if err != nil { + return 0, "", err + } + + req.Header.Set("Authorization", "Bearer "+jwtToken) + resp, err := sealosHTTPClient.Do(req) + if err != nil { + return 0, "", err + } + defer resp.Body.Close() + + var sealosResp sealosGetGroupBalanceResp + if err := json.NewDecoder(resp.Body).Decode(&sealosResp); err != nil { + return 0, "", err + } + + if sealosResp.Error != "" { + return 0, "", errors.New(sealosResp.Error) + } + + if resp.StatusCode != http.StatusOK { + return 0, "", fmt.Errorf("get group (%s) balance failed with status code %d", group, resp.StatusCode) + } + + return sealosResp.Balance, sealosResp.UserUID, nil +} + +type SealosPostGroupConsumer struct { + accountURL string + group string + uid string + balance int64 +} + +func newSealosPostGroupConsumer(accountURL, group, uid string, balance int64) *SealosPostGroupConsumer { + return &SealosPostGroupConsumer{ + accountURL: accountURL, + group: group, + uid: uid, + balance: balance, + } +} + +func (s *SealosPostGroupConsumer) GetBalance(_ context.Context) (float64, error) { + return decimal.NewFromInt(s.balance).Div(decimalBalancePrecision).InexactFloat64(), nil +} + +func (s *SealosPostGroupConsumer) PostGroupConsume(ctx context.Context, tokenName string, usage float64) (float64, error) { + amount := s.calculateAmount(usage) + + if err := cacheDecreaseGroupBalance(ctx, s.group, amount.IntPart()); err != nil { + logger.SysErrorf("decrease group (%s) balance cache failed: %s", s.group, err) + } + + if err := s.postConsume(ctx, amount.IntPart(), tokenName); err != nil { + return 0, err + } + + return amount.Div(decimalBalancePrecision).InexactFloat64(), nil +} + +func (s *SealosPostGroupConsumer) calculateAmount(usage float64) decimal.Decimal { + amount := decimal.NewFromFloat(usage).Mul(decimalBalancePrecision).Ceil() + if amount.LessThan(minConsumeAmount) { + amount = minConsumeAmount + } + return amount +} + +func (s *SealosPostGroupConsumer) postConsume(ctx context.Context, amount int64, tokenName string) error { + reqBody, err := json.Marshal(sealosPostGroupConsumeReq{ + Namespace: s.group, + Amount: amount, + AppType: appType, + AppName: tokenName, + UserUID: s.uid, + }) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, + http.MethodPost, + s.accountURL+"/admin/v1alpha1/charge-billing", + bytes.NewBuffer(reqBody)) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+jwtToken) + resp, err := sealosHTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + var sealosResp sealosPostGroupConsumeResp + if err := json.NewDecoder(resp.Body).Decode(&sealosResp); err != nil { + return err + } + + if resp.StatusCode != http.StatusOK || sealosResp.Error != "" { + return fmt.Errorf("status code: %d, error: %s", resp.StatusCode, sealosResp.Error) + } + + return nil +} diff --git a/service/aiproxy/common/client/init.go b/service/aiproxy/common/client/init.go new file mode 100644 index 00000000000..37b040c441f --- /dev/null +++ b/service/aiproxy/common/client/init.go @@ -0,0 +1,63 @@ +package client + +import ( + "fmt" + "net/http" + "net/url" + "time" + + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/common/logger" +) + +var ( + HTTPClient *http.Client + ImpatientHTTPClient *http.Client + UserContentRequestHTTPClient *http.Client +) + +func Init() { + if config.UserContentRequestProxy != "" { + logger.SysLog(fmt.Sprintf("using %s as proxy to fetch user content", config.UserContentRequestProxy)) + proxyURL, err := url.Parse(config.UserContentRequestProxy) + if err != nil { + logger.FatalLog("USER_CONTENT_REQUEST_PROXY set but invalid: " + config.UserContentRequestProxy) + } + transport := &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + } + UserContentRequestHTTPClient = &http.Client{ + Transport: transport, + Timeout: time.Second * time.Duration(config.UserContentRequestTimeout), + } + } else { + UserContentRequestHTTPClient = &http.Client{} + } + var transport http.RoundTripper + if config.RelayProxy != "" { + logger.SysLog(fmt.Sprintf("using %s as api relay proxy", config.RelayProxy)) + proxyURL, err := url.Parse(config.RelayProxy) + if err != nil { + logger.FatalLog("USER_CONTENT_REQUEST_PROXY set but invalid: " + config.UserContentRequestProxy) + } + transport = &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + } + } + + if config.RelayTimeout == 0 { + HTTPClient = &http.Client{ + Transport: transport, + } + } else { + HTTPClient = &http.Client{ + Timeout: time.Duration(config.RelayTimeout) * time.Second, + Transport: transport, + } + } + + ImpatientHTTPClient = &http.Client{ + Timeout: 5 * time.Second, + Transport: transport, + } +} diff --git a/service/aiproxy/common/config/config.go b/service/aiproxy/common/config/config.go new file mode 100644 index 00000000000..94991567cd6 --- /dev/null +++ b/service/aiproxy/common/config/config.go @@ -0,0 +1,188 @@ +package config + +import ( + "os" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/labring/sealos/service/aiproxy/common/env" +) + +var ( + OptionMap map[string]string + OptionMapRWMutex sync.RWMutex +) + +var ( + DebugEnabled, _ = strconv.ParseBool(os.Getenv("DEBUG")) + DebugSQLEnabled, _ = strconv.ParseBool(os.Getenv("DEBUG_SQL")) +) + +var ( + // 当测试或请求的时候发生错误是否自动禁用渠道 + automaticDisableChannelEnabled atomic.Bool + // 当测试成功是否自动启用渠道 + automaticEnableChannelWhenTestSucceedEnabled atomic.Bool + // 是否近似计算token + approximateTokenEnabled atomic.Bool + // 重试次数 + retryTimes atomic.Int64 + // 暂停服务 + disableServe atomic.Bool +) + +func GetDisableServe() bool { + return disableServe.Load() +} + +func SetDisableServe(disabled bool) { + disableServe.Store(disabled) +} + +func GetAutomaticDisableChannelEnabled() bool { + return automaticDisableChannelEnabled.Load() +} + +func SetAutomaticDisableChannelEnabled(enabled bool) { + automaticDisableChannelEnabled.Store(enabled) +} + +func GetAutomaticEnableChannelWhenTestSucceedEnabled() bool { + return automaticEnableChannelWhenTestSucceedEnabled.Load() +} + +func SetAutomaticEnableChannelWhenTestSucceedEnabled(enabled bool) { + automaticEnableChannelWhenTestSucceedEnabled.Store(enabled) +} + +func GetApproximateTokenEnabled() bool { + return approximateTokenEnabled.Load() +} + +func SetApproximateTokenEnabled(enabled bool) { + approximateTokenEnabled.Store(enabled) +} + +func GetRetryTimes() int64 { + return retryTimes.Load() +} + +func SetRetryTimes(times int64) { + retryTimes.Store(times) +} + +var DisableAutoMigrateDB = os.Getenv("DISABLE_AUTO_MIGRATE_DB") == "true" + +var RelayTimeout = env.Int("RELAY_TIMEOUT", 0) // unit is second + +var RateLimitKeyExpirationDuration = 20 * time.Minute + +var ( + // 是否根据请求成功率禁用渠道,默认不开启 + EnableMetric = env.Bool("ENABLE_METRIC", false) + // 指标队列大小 + MetricQueueSize = env.Int("METRIC_QUEUE_SIZE", 10) + // 请求成功率阈值,默认80% + MetricSuccessRateThreshold = env.Float64("METRIC_SUCCESS_RATE_THRESHOLD", 0.8) + // 请求成功率指标队列大小 + MetricSuccessChanSize = env.Int("METRIC_SUCCESS_CHAN_SIZE", 1024) + // 请求失败率指标队列大小 + MetricFailChanSize = env.Int("METRIC_FAIL_CHAN_SIZE", 128) +) + +var OnlyOneLogFile = env.Bool("ONLY_ONE_LOG_FILE", false) + +var ( + // 代理地址 + RelayProxy = env.String("RELAY_PROXY", "") + // 用户内容请求代理地址 + UserContentRequestProxy = env.String("USER_CONTENT_REQUEST_PROXY", "") + // 用户内容请求超时时间,单位为秒 + UserContentRequestTimeout = env.Int("USER_CONTENT_REQUEST_TIMEOUT", 30) +) + +var AdminKey = env.String("ADMIN_KEY", "") + +var ( + globalAPIRateLimitNum atomic.Int64 + defaultChannelModels atomic.Value + defaultChannelModelMapping atomic.Value + defaultGroupQPM atomic.Int64 + groupMaxTokenNum atomic.Int32 +) + +func init() { + defaultChannelModels.Store(make(map[int][]string)) + defaultChannelModelMapping.Store(make(map[int]map[string]string)) +} + +// 全局qpm,不是根据ip限制,而是所有请求共享一个qpm +func GetGlobalAPIRateLimitNum() int64 { + return globalAPIRateLimitNum.Load() +} + +func SetGlobalAPIRateLimitNum(num int64) { + globalAPIRateLimitNum.Store(num) +} + +// group默认qpm,如果group没有设置qpm,则使用该qpm +func GetDefaultGroupQPM() int64 { + return defaultGroupQPM.Load() +} + +func SetDefaultGroupQPM(qpm int64) { + defaultGroupQPM.Store(qpm) +} + +func GetDefaultChannelModels() map[int][]string { + return defaultChannelModels.Load().(map[int][]string) +} + +func SetDefaultChannelModels(models map[int][]string) { + defaultChannelModels.Store(models) +} + +func GetDefaultChannelModelMapping() map[int]map[string]string { + return defaultChannelModelMapping.Load().(map[int]map[string]string) +} + +func SetDefaultChannelModelMapping(mapping map[int]map[string]string) { + defaultChannelModelMapping.Store(mapping) +} + +// 那个group最多可创建的token数量,0表示不限制 +func GetGroupMaxTokenNum() int32 { + return groupMaxTokenNum.Load() +} + +func SetGroupMaxTokenNum(num int32) { + groupMaxTokenNum.Store(num) +} + +var ( + geminiSafetySetting atomic.Value + geminiVersion atomic.Value +) + +func init() { + geminiSafetySetting.Store("BLOCK_NONE") + geminiVersion.Store("v1") +} + +func GetGeminiSafetySetting() string { + return geminiSafetySetting.Load().(string) +} + +func SetGeminiSafetySetting(setting string) { + geminiSafetySetting.Store(setting) +} + +func GetGeminiVersion() string { + return geminiVersion.Load().(string) +} + +func SetGeminiVersion(version string) { + geminiVersion.Store(version) +} diff --git a/service/aiproxy/common/constants.go b/service/aiproxy/common/constants.go new file mode 100644 index 00000000000..65d61413e40 --- /dev/null +++ b/service/aiproxy/common/constants.go @@ -0,0 +1,5 @@ +package common + +import "time" + +var StartTime = time.Now().UnixMilli() // unit: millisecond diff --git a/service/aiproxy/common/conv/any.go b/service/aiproxy/common/conv/any.go new file mode 100644 index 00000000000..ed6de0d1c12 --- /dev/null +++ b/service/aiproxy/common/conv/any.go @@ -0,0 +1,23 @@ +package conv + +import "unsafe" + +func AsString(v any) string { + str, _ := v.(string) + return str +} + +// The change of bytes will cause the change of string synchronously +func BytesToString(b []byte) string { + return *(*string)(unsafe.Pointer(&b)) +} + +// If string is readonly, modifying bytes will cause panic +func StringToBytes(s string) []byte { + return *(*[]byte)(unsafe.Pointer( + &struct { + string + Cap int + }{s, len(s)}, + )) +} diff --git a/service/aiproxy/common/ctxkey/key.go b/service/aiproxy/common/ctxkey/key.go new file mode 100644 index 00000000000..c2adec1a4e6 --- /dev/null +++ b/service/aiproxy/common/ctxkey/key.go @@ -0,0 +1,24 @@ +package ctxkey + +const ( + Config = "config" + Status = "status" + Channel = "channel" + ChannelID = "channel_id" + APIKey = "api_key" + SpecificChannelID = "specific_channel_id" + RequestModel = "request_model" + ConvertedRequest = "converted_request" + OriginalModel = "original_model" + Group = "group" + GroupQPM = "group_qpm" + ModelMapping = "model_mapping" + ChannelName = "channel_name" + TokenID = "token_id" + TokenName = "token_name" + TokenUsedAmount = "token_used_amount" + TokenQuota = "token_quota" + BaseURL = "base_url" + AvailableModels = "available_models" + KeyRequestBody = "key_request_body" +) diff --git a/service/aiproxy/common/custom-event.go b/service/aiproxy/common/custom-event.go new file mode 100644 index 00000000000..a7a76219fb9 --- /dev/null +++ b/service/aiproxy/common/custom-event.go @@ -0,0 +1,67 @@ +// Copyright 2014 Manu Martinez-Almeida. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package common + +import ( + "io" + "net/http" + "strings" + + "github.com/labring/sealos/service/aiproxy/common/conv" +) + +// Server-Sent Events +// W3C Working Draft 29 October 2009 +// http://www.w3.org/TR/2009/WD-eventsource-20091029/ + +var ( + contentType = []string{"text/event-stream"} + noCache = []string{"no-cache"} +) + +var dataReplacer = strings.NewReplacer( + "\n", "\ndata:", + "\r", "\\r") + +type CustomEvent struct { + Data string + Event string + ID string + Retry uint +} + +func encode(writer io.Writer, event CustomEvent) error { + return writeData(writer, event.Data) +} + +const nn = "\n\n" + +var nnBytes = conv.StringToBytes(nn) + +func writeData(w io.Writer, data string) error { + _, err := dataReplacer.WriteString(w, data) + if err != nil { + return err + } + if strings.HasPrefix(data, "data") { + _, err := w.Write(nnBytes) + return err + } + return nil +} + +func (r CustomEvent) Render(w http.ResponseWriter) error { + r.WriteContentType(w) + return encode(w, r) +} + +func (r CustomEvent) WriteContentType(w http.ResponseWriter) { + header := w.Header() + header["Content-Type"] = contentType + + if _, exist := header["Cache-Control"]; !exist { + header["Cache-Control"] = noCache + } +} diff --git a/service/aiproxy/common/database.go b/service/aiproxy/common/database.go new file mode 100644 index 00000000000..a164266c27a --- /dev/null +++ b/service/aiproxy/common/database.go @@ -0,0 +1,16 @@ +package common + +import ( + "github.com/labring/sealos/service/aiproxy/common/env" +) + +var ( + UsingSQLite = false + UsingPostgreSQL = false + UsingMySQL = false +) + +var ( + SQLitePath = "aiproxy.db" + SQLiteBusyTimeout = env.Int("SQLITE_BUSY_TIMEOUT", 3000) +) diff --git a/service/aiproxy/common/env/helper.go b/service/aiproxy/common/env/helper.go new file mode 100644 index 00000000000..fdb9f827ac2 --- /dev/null +++ b/service/aiproxy/common/env/helper.go @@ -0,0 +1,42 @@ +package env + +import ( + "os" + "strconv" +) + +func Bool(env string, defaultValue bool) bool { + if env == "" || os.Getenv(env) == "" { + return defaultValue + } + return os.Getenv(env) == "true" +} + +func Int(env string, defaultValue int) int { + if env == "" || os.Getenv(env) == "" { + return defaultValue + } + num, err := strconv.Atoi(os.Getenv(env)) + if err != nil { + return defaultValue + } + return num +} + +func Float64(env string, defaultValue float64) float64 { + if env == "" || os.Getenv(env) == "" { + return defaultValue + } + num, err := strconv.ParseFloat(os.Getenv(env), 64) + if err != nil { + return defaultValue + } + return num +} + +func String(env string, defaultValue string) string { + if env == "" || os.Getenv(env) == "" { + return defaultValue + } + return os.Getenv(env) +} diff --git a/service/aiproxy/common/fastJSONSerializer/fastJSONSerializer.go b/service/aiproxy/common/fastJSONSerializer/fastJSONSerializer.go new file mode 100644 index 00000000000..98a55ae32cf --- /dev/null +++ b/service/aiproxy/common/fastJSONSerializer/fastJSONSerializer.go @@ -0,0 +1,43 @@ +package fastjsonserializer + +import ( + "context" + "fmt" + "reflect" + + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common/conv" + + "gorm.io/gorm/schema" +) + +type JSONSerializer struct{} + +func (*JSONSerializer) Scan(ctx context.Context, field *schema.Field, dst reflect.Value, dbValue any) (err error) { + fieldValue := reflect.New(field.FieldType) + + if dbValue != nil { + var bytes []byte + switch v := dbValue.(type) { + case []byte: + bytes = v + case string: + bytes = conv.StringToBytes(v) + default: + return fmt.Errorf("failed to unmarshal JSONB value: %#v", dbValue) + } + + err = json.Unmarshal(bytes, fieldValue.Interface()) + } + + field.ReflectValueOf(ctx, dst).Set(fieldValue.Elem()) + return +} + +func (*JSONSerializer) Value(_ context.Context, _ *schema.Field, _ reflect.Value, fieldValue any) (any, error) { + return json.Marshal(fieldValue) +} + +func init() { + schema.RegisterSerializer("fastjson", new(JSONSerializer)) +} diff --git a/service/aiproxy/common/gin.go b/service/aiproxy/common/gin.go new file mode 100644 index 00000000000..f1027137533 --- /dev/null +++ b/service/aiproxy/common/gin.go @@ -0,0 +1,53 @@ +package common + +import ( + "bytes" + "fmt" + "io" + + "github.com/gin-gonic/gin" + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" +) + +func GetRequestBody(c *gin.Context) ([]byte, error) { + requestBody, ok := c.Get(ctxkey.KeyRequestBody) + if ok { + return requestBody.([]byte), nil + } + var buf []byte + var err error + defer func() { + c.Request.Body.Close() + if err == nil { + c.Request.Body = io.NopCloser(bytes.NewBuffer(buf)) + } + }() + if c.Request.ContentLength <= 0 || c.Request.Header.Get("Content-Type") != "application/json" { + buf, err = io.ReadAll(c.Request.Body) + } else { + buf = make([]byte, c.Request.ContentLength) + _, err = io.ReadFull(c.Request.Body, buf) + } + if err != nil { + return nil, fmt.Errorf("request body read failed: %w", err) + } + c.Set(ctxkey.KeyRequestBody, buf) + return buf, nil +} + +func UnmarshalBodyReusable(c *gin.Context, v any) error { + requestBody, err := GetRequestBody(c) + if err != nil { + return err + } + return json.Unmarshal(requestBody, &v) +} + +func SetEventStreamHeaders(c *gin.Context) { + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("Transfer-Encoding", "chunked") + c.Writer.Header().Set("X-Accel-Buffering", "no") +} diff --git a/service/aiproxy/common/helper/helper.go b/service/aiproxy/common/helper/helper.go new file mode 100644 index 00000000000..3a8f55e58a5 --- /dev/null +++ b/service/aiproxy/common/helper/helper.go @@ -0,0 +1,40 @@ +package helper + +import ( + "fmt" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common/random" +) + +func GenRequestID() string { + return GetTimeString() + random.GetRandomNumberString(8) +} + +func GetResponseID(c *gin.Context) string { + logID := c.GetString(string(RequestIDKey)) + return "chatcmpl-" + logID +} + +func AssignOrDefault(value string, defaultValue string) string { + if len(value) != 0 { + return value + } + return defaultValue +} + +func MessageWithRequestID(message string, id string) string { + return fmt.Sprintf("%s (request id: %s)", message, id) +} + +func String2Int(keyword string) int { + if keyword == "" { + return 0 + } + i, err := strconv.Atoi(keyword) + if err != nil { + return 0 + } + return i +} diff --git a/service/aiproxy/common/helper/key.go b/service/aiproxy/common/helper/key.go new file mode 100644 index 00000000000..bc9c949eb9c --- /dev/null +++ b/service/aiproxy/common/helper/key.go @@ -0,0 +1,7 @@ +package helper + +type Key string + +const ( + RequestIDKey Key = "X-Request-Id" +) diff --git a/service/aiproxy/common/helper/time.go b/service/aiproxy/common/helper/time.go new file mode 100644 index 00000000000..302746dbff9 --- /dev/null +++ b/service/aiproxy/common/helper/time.go @@ -0,0 +1,15 @@ +package helper + +import ( + "fmt" + "time" +) + +func GetTimestamp() int64 { + return time.Now().Unix() +} + +func GetTimeString() string { + now := time.Now() + return fmt.Sprintf("%s%d", now.Format("20060102150405"), now.UnixNano()%1e9) +} diff --git a/service/aiproxy/common/image/image.go b/service/aiproxy/common/image/image.go new file mode 100644 index 00000000000..4a768cef980 --- /dev/null +++ b/service/aiproxy/common/image/image.go @@ -0,0 +1,119 @@ +package image + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "image" + + // import gif decoder + _ "image/gif" + // import jpeg decoder + _ "image/jpeg" + // import png decoder + _ "image/png" + "io" + "net/http" + "regexp" + "strings" + + // import webp decoder + _ "golang.org/x/image/webp" + + "github.com/labring/sealos/service/aiproxy/common/client" +) + +// Regex to match data URL pattern +var dataURLPattern = regexp.MustCompile(`data:image/([^;]+);base64,(.*)`) + +func IsImageURL(resp *http.Response) bool { + return strings.HasPrefix(resp.Header.Get("Content-Type"), "image/") +} + +func GetImageSizeFromURL(url string) (width int, height int, err error) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) + if err != nil { + return 0, 0, err + } + resp, err := client.UserContentRequestHTTPClient.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return 0, 0, fmt.Errorf("status code: %d", resp.StatusCode) + } + + isImage := IsImageURL(resp) + if !isImage { + return + } + img, _, err := image.DecodeConfig(resp.Body) + if err != nil { + return + } + return img.Width, img.Height, nil +} + +func GetImageFromURL(url string) (string, string, error) { + // Check if the URL is a data URL + matches := dataURLPattern.FindStringSubmatch(url) + if len(matches) == 3 { + // URL is a data URL + return "image/" + matches[1], matches[2], nil + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) + if err != nil { + return "", "", err + } + resp, err := client.UserContentRequestHTTPClient.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("status code: %d", resp.StatusCode) + } + var buf []byte + if resp.ContentLength <= 0 { + buf, err = io.ReadAll(resp.Body) + } else { + buf = make([]byte, resp.ContentLength) + _, err = io.ReadFull(resp.Body, buf) + } + if err != nil { + return "", "", err + } + isImage := IsImageURL(resp) + if !isImage { + return "", "", errors.New("not an image") + } + return resp.Header.Get("Content-Type"), base64.StdEncoding.EncodeToString(buf), nil +} + +var reg = regexp.MustCompile(`data:image/([^;]+);base64,`) + +func GetImageSizeFromBase64(encoded string) (width int, height int, err error) { + decoded, err := base64.StdEncoding.DecodeString(reg.ReplaceAllString(encoded, "")) + if err != nil { + return 0, 0, err + } + + img, _, err := image.DecodeConfig(bytes.NewReader(decoded)) + if err != nil { + return 0, 0, err + } + + return img.Width, img.Height, nil +} + +func GetImageSize(image string) (width int, height int, err error) { + if strings.HasPrefix(image, "data:image/") { + return GetImageSizeFromBase64(image) + } + return GetImageSizeFromURL(image) +} diff --git a/service/aiproxy/common/image/image_test.go b/service/aiproxy/common/image/image_test.go new file mode 100644 index 00000000000..7dad94a0c5a --- /dev/null +++ b/service/aiproxy/common/image/image_test.go @@ -0,0 +1,176 @@ +package image_test + +import ( + "encoding/base64" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "strconv" + "strings" + "testing" + + "github.com/labring/sealos/service/aiproxy/common/client" + + img "github.com/labring/sealos/service/aiproxy/common/image" + + "github.com/stretchr/testify/assert" + _ "golang.org/x/image/webp" +) + +type CountingReader struct { + reader io.Reader + BytesRead int +} + +func (r *CountingReader) Read(p []byte) (n int, err error) { + n, err = r.reader.Read(p) + r.BytesRead += n + return n, err +} + +var cases = []struct { + url string + format string + width int + height int +}{ + {"https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", "jpeg", 2560, 1669}, + {"https://upload.wikimedia.org/wikipedia/commons/9/97/Basshunter_live_performances.png", "png", 4500, 2592}, + {"https://upload.wikimedia.org/wikipedia/commons/c/c6/TO_THE_ONE_SOMETHINGNESS.webp", "webp", 984, 985}, + {"https://upload.wikimedia.org/wikipedia/commons/d/d0/01_Das_Sandberg-Modell.gif", "gif", 1917, 1533}, + {"https://upload.wikimedia.org/wikipedia/commons/6/62/102Cervus.jpg", "jpeg", 270, 230}, +} + +func TestMain(m *testing.M) { + client.Init() + m.Run() +} + +func TestDecode(t *testing.T) { + // Bytes read: varies sometimes + // jpeg: 1063892 + // png: 294462 + // webp: 99529 + // gif: 956153 + // jpeg#01: 32805 + for _, c := range cases { + t.Run("Decode:"+c.format, func(t *testing.T) { + resp, err := http.Get(c.url) + assert.NoError(t, err) + defer resp.Body.Close() + reader := &CountingReader{reader: resp.Body} + img, format, err := image.Decode(reader) + assert.NoError(t, err) + size := img.Bounds().Size() + assert.Equal(t, c.format, format) + assert.Equal(t, c.width, size.X) + assert.Equal(t, c.height, size.Y) + t.Logf("Bytes read: %d", reader.BytesRead) + }) + } + + // Bytes read: + // jpeg: 4096 + // png: 4096 + // webp: 4096 + // gif: 4096 + // jpeg#01: 4096 + for _, c := range cases { + t.Run("DecodeConfig:"+c.format, func(t *testing.T) { + resp, err := http.Get(c.url) + assert.NoError(t, err) + defer resp.Body.Close() + reader := &CountingReader{reader: resp.Body} + config, format, err := image.DecodeConfig(reader) + assert.NoError(t, err) + assert.Equal(t, c.format, format) + assert.Equal(t, c.width, config.Width) + assert.Equal(t, c.height, config.Height) + t.Logf("Bytes read: %d", reader.BytesRead) + }) + } +} + +func TestBase64(t *testing.T) { + // Bytes read: + // jpeg: 1063892 + // png: 294462 + // webp: 99072 + // gif: 953856 + // jpeg#01: 32805 + for _, c := range cases { + t.Run("Decode:"+c.format, func(t *testing.T) { + resp, err := http.Get(c.url) + assert.NoError(t, err) + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + encoded := base64.StdEncoding.EncodeToString(data) + body := base64.NewDecoder(base64.StdEncoding, strings.NewReader(encoded)) + reader := &CountingReader{reader: body} + img, format, err := image.Decode(reader) + assert.NoError(t, err) + size := img.Bounds().Size() + assert.Equal(t, c.format, format) + assert.Equal(t, c.width, size.X) + assert.Equal(t, c.height, size.Y) + t.Logf("Bytes read: %d", reader.BytesRead) + }) + } + + // Bytes read: + // jpeg: 1536 + // png: 768 + // webp: 768 + // gif: 1536 + // jpeg#01: 3840 + for _, c := range cases { + t.Run("DecodeConfig:"+c.format, func(t *testing.T) { + resp, err := http.Get(c.url) + assert.NoError(t, err) + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + encoded := base64.StdEncoding.EncodeToString(data) + body := base64.NewDecoder(base64.StdEncoding, strings.NewReader(encoded)) + reader := &CountingReader{reader: body} + config, format, err := image.DecodeConfig(reader) + assert.NoError(t, err) + assert.Equal(t, c.format, format) + assert.Equal(t, c.width, config.Width) + assert.Equal(t, c.height, config.Height) + t.Logf("Bytes read: %d", reader.BytesRead) + }) + } +} + +func TestGetImageSize(t *testing.T) { + for i, c := range cases { + t.Run("Decode:"+strconv.Itoa(i), func(t *testing.T) { + width, height, err := img.GetImageSize(c.url) + assert.NoError(t, err) + assert.Equal(t, c.width, width) + assert.Equal(t, c.height, height) + }) + } +} + +func TestGetImageSizeFromBase64(t *testing.T) { + for i, c := range cases { + t.Run("Decode:"+strconv.Itoa(i), func(t *testing.T) { + resp, err := http.Get(c.url) + assert.NoError(t, err) + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + encoded := base64.StdEncoding.EncodeToString(data) + width, height, err := img.GetImageSizeFromBase64(encoded) + assert.NoError(t, err) + assert.Equal(t, c.width, width) + assert.Equal(t, c.height, height) + }) + } +} diff --git a/service/aiproxy/common/init.go b/service/aiproxy/common/init.go new file mode 100644 index 00000000000..7d26db36d6c --- /dev/null +++ b/service/aiproxy/common/init.go @@ -0,0 +1,37 @@ +package common + +import ( + "flag" + "log" + "os" + "path/filepath" + + "github.com/labring/sealos/service/aiproxy/common/logger" +) + +var ( + Port = flag.Int("port", 3000, "the listening port") + LogDir = flag.String("log-dir", "", "specify the log directory") +) + +func Init() { + flag.Parse() + + if os.Getenv("SQLITE_PATH") != "" { + SQLitePath = os.Getenv("SQLITE_PATH") + } + if *LogDir != "" { + var err error + *LogDir, err = filepath.Abs(*LogDir) + if err != nil { + log.Fatal(err) + } + if _, err := os.Stat(*LogDir); os.IsNotExist(err) { + err = os.Mkdir(*LogDir, 0o777) + if err != nil { + log.Fatal(err) + } + } + logger.LogDir = *LogDir + } +} diff --git a/service/aiproxy/common/logger/constants.go b/service/aiproxy/common/logger/constants.go new file mode 100644 index 00000000000..49df31ec715 --- /dev/null +++ b/service/aiproxy/common/logger/constants.go @@ -0,0 +1,3 @@ +package logger + +var LogDir string diff --git a/service/aiproxy/common/logger/logger.go b/service/aiproxy/common/logger/logger.go new file mode 100644 index 00000000000..ae777610f94 --- /dev/null +++ b/service/aiproxy/common/logger/logger.go @@ -0,0 +1,128 @@ +package logger + +import ( + "context" + "fmt" + "io" + "log" + "os" + "path/filepath" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/common/helper" +) + +const ( + loggerDEBUG = "DEBUG" + loggerINFO = "INFO" + loggerWarn = "WARN" + loggerError = "ERR" +) + +var setupLogOnce sync.Once + +func SetupLogger() { + setupLogOnce.Do(func() { + if LogDir != "" { + var logPath string + if config.OnlyOneLogFile { + logPath = filepath.Join(LogDir, "aiproxy.log") + } else { + logPath = filepath.Join(LogDir, fmt.Sprintf("aiproxy-%s.log", time.Now().Format("20060102"))) + } + fd, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + log.Fatal("failed to open log file") + } + gin.DefaultWriter = io.MultiWriter(os.Stdout, fd) + gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, fd) + } + }) +} + +func SysLog(s string) { + t := time.Now() + _, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s) +} + +func SysLogf(format string, a ...any) { + SysLog(fmt.Sprintf(format, a...)) +} + +func SysDebug(s string) { + if config.DebugEnabled { + SysLog(s) + } +} + +func SysDebugf(format string, a ...any) { + if config.DebugEnabled { + SysLogf(format, a...) + } +} + +func SysError(s string) { + t := time.Now() + _, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s) +} + +func SysErrorf(format string, a ...any) { + SysError(fmt.Sprintf(format, a...)) +} + +func Debug(ctx context.Context, msg string) { + if config.DebugEnabled { + logHelper(ctx, loggerDEBUG, msg) + } +} + +func Info(ctx context.Context, msg string) { + logHelper(ctx, loggerINFO, msg) +} + +func Warn(ctx context.Context, msg string) { + logHelper(ctx, loggerWarn, msg) +} + +func Error(ctx context.Context, msg string) { + logHelper(ctx, loggerError, msg) +} + +func Debugf(ctx context.Context, format string, a ...any) { + Debug(ctx, fmt.Sprintf(format, a...)) +} + +func Infof(ctx context.Context, format string, a ...any) { + Info(ctx, fmt.Sprintf(format, a...)) +} + +func Warnf(ctx context.Context, format string, a ...any) { + Warn(ctx, fmt.Sprintf(format, a...)) +} + +func Errorf(ctx context.Context, format string, a ...any) { + Error(ctx, fmt.Sprintf(format, a...)) +} + +func logHelper(ctx context.Context, level string, msg string) { + writer := gin.DefaultErrorWriter + if level == loggerINFO { + writer = gin.DefaultWriter + } + id := ctx.Value(helper.RequestIDKey) + if id == nil { + id = helper.GenRequestID() + } + now := time.Now() + _, _ = fmt.Fprintf(writer, "[%s] %v | %s | %s \n", level, now.Format("2006/01/02 - 15:04:05"), id, msg) + SetupLogger() +} + +func FatalLog(v ...any) { + t := time.Now() + _, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v) + os.Exit(1) +} diff --git a/service/aiproxy/common/network/ip.go b/service/aiproxy/common/network/ip.go new file mode 100644 index 00000000000..cb335ad642a --- /dev/null +++ b/service/aiproxy/common/network/ip.go @@ -0,0 +1,53 @@ +package network + +import ( + "context" + "fmt" + "net" + "strings" + + "github.com/labring/sealos/service/aiproxy/common/logger" +) + +func splitSubnets(subnets string) []string { + res := strings.Split(subnets, ",") + for i := 0; i < len(res); i++ { + res[i] = strings.TrimSpace(res[i]) + } + return res +} + +func isValidSubnet(subnet string) error { + _, _, err := net.ParseCIDR(subnet) + if err != nil { + return fmt.Errorf("failed to parse subnet: %w", err) + } + return nil +} + +func isIPInSubnet(ctx context.Context, ip string, subnet string) bool { + _, ipNet, err := net.ParseCIDR(subnet) + if err != nil { + logger.Errorf(ctx, "failed to parse subnet: %s", err.Error()) + return false + } + return ipNet.Contains(net.ParseIP(ip)) +} + +func IsValidSubnets(subnets string) error { + for _, subnet := range splitSubnets(subnets) { + if err := isValidSubnet(subnet); err != nil { + return err + } + } + return nil +} + +func IsIPInSubnets(ctx context.Context, ip string, subnets string) bool { + for _, subnet := range splitSubnets(subnets) { + if isIPInSubnet(ctx, ip, subnet) { + return true + } + } + return false +} diff --git a/service/aiproxy/common/network/ip_test.go b/service/aiproxy/common/network/ip_test.go new file mode 100644 index 00000000000..24a92d74f38 --- /dev/null +++ b/service/aiproxy/common/network/ip_test.go @@ -0,0 +1,19 @@ +package network + +import ( + "context" + "testing" + + "github.com/smartystreets/goconvey/convey" +) + +func TestIsIpInSubnet(t *testing.T) { + ctx := context.Background() + ip1 := "192.168.0.5" + ip2 := "125.216.250.89" + subnet := "192.168.0.0/24" + convey.Convey("TestIsIpInSubnet", t, func() { + convey.So(isIPInSubnet(ctx, ip1, subnet), convey.ShouldBeTrue) + convey.So(isIPInSubnet(ctx, ip2, subnet), convey.ShouldBeFalse) + }) +} diff --git a/service/aiproxy/common/random/main.go b/service/aiproxy/common/random/main.go new file mode 100644 index 00000000000..79ba35e39a7 --- /dev/null +++ b/service/aiproxy/common/random/main.go @@ -0,0 +1,57 @@ +package random + +import ( + "math/rand/v2" + "strings" + + "github.com/google/uuid" + "github.com/labring/sealos/service/aiproxy/common/conv" +) + +func GetUUID() string { + code := uuid.New().String() + code = strings.Replace(code, "-", "", -1) + return code +} + +const ( + keyChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + keyNumbers = "0123456789" +) + +func GenerateKey() string { + key := make([]byte, 48) + for i := 0; i < 16; i++ { + key[i] = keyChars[rand.IntN(len(keyChars))] + } + uuid := GetUUID() + for i := 0; i < 32; i++ { + c := uuid[i] + if i%2 == 0 && c >= 'a' && c <= 'z' { + c = c - 'a' + 'A' + } + key[i+16] = c + } + return conv.BytesToString(key) +} + +func GetRandomString(length int) string { + key := make([]byte, length) + for i := 0; i < length; i++ { + key[i] = keyChars[rand.IntN(len(keyChars))] + } + return conv.BytesToString(key) +} + +func GetRandomNumberString(length int) string { + key := make([]byte, length) + for i := 0; i < length; i++ { + key[i] = keyNumbers[rand.IntN(len(keyNumbers))] + } + return conv.BytesToString(key) +} + +// RandRange returns a random number between min and max (max is not included) +func RandRange(_min, _max int) int { + return _min + rand.IntN(_max-_min) +} diff --git a/service/aiproxy/common/rate-limit.go b/service/aiproxy/common/rate-limit.go new file mode 100644 index 00000000000..a94b6496fc2 --- /dev/null +++ b/service/aiproxy/common/rate-limit.go @@ -0,0 +1,93 @@ +package common + +import ( + "sync" + "time" +) + +type InMemoryRateLimiter struct { + store map[string]*RateLimitWindow + mutex sync.RWMutex + expirationDuration time.Duration +} + +type RateLimitWindow struct { + timestamps []int64 + lastAccess int64 +} + +func (l *InMemoryRateLimiter) Init(expirationDuration time.Duration) { + if l.store == nil { + l.mutex.Lock() + if l.store == nil { + l.store = make(map[string]*RateLimitWindow) + l.expirationDuration = expirationDuration + if expirationDuration > 0 { + go l.clearExpiredItems() + } + } + l.mutex.Unlock() + } +} + +func (l *InMemoryRateLimiter) clearExpiredItems() { + ticker := time.NewTicker(l.expirationDuration) + defer ticker.Stop() + + for range ticker.C { + l.mutex.Lock() + now := time.Now().Unix() + for key, window := range l.store { + if now-window.lastAccess > int64(l.expirationDuration.Seconds()) { + delete(l.store, key) + } + } + l.mutex.Unlock() + } +} + +// Request parameter duration's unit is seconds +func (l *InMemoryRateLimiter) Request(key string, maxRequestNum int, duration time.Duration) bool { + now := time.Now().Unix() + cutoff := now - int64(duration.Seconds()) + + l.mutex.RLock() + window, exists := l.store[key] + l.mutex.RUnlock() + + if !exists { + l.mutex.Lock() + window = &RateLimitWindow{ + timestamps: make([]int64, 0, maxRequestNum), + lastAccess: now, + } + l.store[key] = window + window.timestamps = append(window.timestamps, now) + l.mutex.Unlock() + return true + } + + l.mutex.Lock() + defer l.mutex.Unlock() + + // Update last access time + window.lastAccess = now + + // Remove expired timestamps + idx := 0 + for i, ts := range window.timestamps { + if ts > cutoff { + idx = i + break + } + } + window.timestamps = window.timestamps[idx:] + + // Check if we can add a new request + if len(window.timestamps) < maxRequestNum { + window.timestamps = append(window.timestamps, now) + return true + } + + return false +} diff --git a/service/aiproxy/common/redis.go b/service/aiproxy/common/redis.go new file mode 100644 index 00000000000..685d7dd671c --- /dev/null +++ b/service/aiproxy/common/redis.go @@ -0,0 +1,54 @@ +package common + +import ( + "context" + "os" + "time" + + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/redis/go-redis/v9" +) + +var ( + RDB *redis.Client + RedisEnabled = false +) + +// InitRedisClient This function is called after init() +func InitRedisClient() (err error) { + if os.Getenv("REDIS_CONN_STRING") == "" { + logger.SysLog("REDIS_CONN_STRING not set, redis is not enabled") + return nil + } + RedisEnabled = true + logger.SysLog("redis is enabled") + opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING")) + if err != nil { + logger.FatalLog("failed to parse redis connection string: " + err.Error()) + } + RDB = redis.NewClient(opt) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err = RDB.Ping(ctx).Result() + if err != nil { + logger.FatalLog("redis ping test failed: " + err.Error()) + } + return err +} + +func RedisSet(key string, value string, expiration time.Duration) error { + ctx := context.Background() + return RDB.Set(ctx, key, value, expiration).Err() +} + +func RedisGet(key string) (string, error) { + ctx := context.Background() + return RDB.Get(ctx, key).Result() +} + +func RedisDel(key string) error { + ctx := context.Background() + return RDB.Del(ctx, key).Err() +} diff --git a/service/aiproxy/common/render/render.go b/service/aiproxy/common/render/render.go new file mode 100644 index 00000000000..9a7d0fe0d68 --- /dev/null +++ b/service/aiproxy/common/render/render.go @@ -0,0 +1,33 @@ +package render + +import ( + "fmt" + "strings" + + "github.com/gin-gonic/gin" + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/conv" +) + +func StringData(c *gin.Context, str string) { + str = strings.TrimPrefix(str, "data:") + // str = strings.TrimSuffix(str, "\r") + c.Render(-1, common.CustomEvent{Data: "data: " + strings.TrimSpace(str)}) + c.Writer.Flush() +} + +func ObjectData(c *gin.Context, object any) error { + jsonData, err := json.Marshal(object) + if err != nil { + return fmt.Errorf("error marshalling object: %w", err) + } + StringData(c, conv.BytesToString(jsonData)) + return nil +} + +const DONE = "[DONE]" + +func Done(c *gin.Context) { + StringData(c, DONE) +} diff --git a/service/aiproxy/controller/channel-billing.go b/service/aiproxy/controller/channel-billing.go new file mode 100644 index 00000000000..5e5933b5c3b --- /dev/null +++ b/service/aiproxy/controller/channel-billing.go @@ -0,0 +1,413 @@ +package controller + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "time" + + json "github.com/json-iterator/go" + + "github.com/labring/sealos/service/aiproxy/common/balance" + "github.com/labring/sealos/service/aiproxy/common/client" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/model" + "github.com/labring/sealos/service/aiproxy/relay/channeltype" + + "github.com/gin-gonic/gin" +) + +// https://github.com/labring/sealos/service/aiproxy/issues/79 + +type OpenAISubscriptionResponse struct { + Object string `json:"object"` + HasPaymentMethod bool `json:"has_payment_method"` + SoftLimitUSD float64 `json:"soft_limit_usd"` + HardLimitUSD float64 `json:"hard_limit_usd"` + SystemHardLimitUSD float64 `json:"system_hard_limit_usd"` + AccessUntil int64 `json:"access_until"` +} + +type OpenAIUsageDailyCost struct { + LineItems []struct { + Name string `json:"name"` + Cost float64 `json:"cost"` + } + Timestamp float64 `json:"timestamp"` +} + +type OpenAICreditGrants struct { + Object string `json:"object"` + TotalGranted float64 `json:"total_granted"` + TotalUsed float64 `json:"total_used"` + TotalAvailable float64 `json:"total_available"` +} + +type OpenAIUsageResponse struct { + Object string `json:"object"` + // DailyCosts []OpenAIUsageDailyCost `json:"daily_costs"` + TotalUsage float64 `json:"total_usage"` // unit: 0.01 dollar +} + +type OpenAISBUsageResponse struct { + Data *struct { + Credit string `json:"credit"` + } `json:"data"` + Msg string `json:"msg"` +} + +type AIProxyUserOverviewResponse struct { + Message string `json:"message"` + ErrorCode int `json:"error_code"` + Data struct { + TotalPoints float64 `json:"totalPoints"` + } `json:"data"` + Success bool `json:"success"` +} + +type API2GPTUsageResponse struct { + Object string `json:"object"` + TotalGranted float64 `json:"total_granted"` + TotalUsed float64 `json:"total_used"` + TotalRemaining float64 `json:"total_remaining"` +} + +type APGC2DGPTUsageResponse struct { + // Grants interface{} `json:"grants"` + Object string `json:"object"` + TotalAvailable float64 `json:"total_available"` + TotalGranted float64 `json:"total_granted"` + TotalUsed float64 `json:"total_used"` +} + +type SiliconFlowUsageResponse struct { + Message string `json:"message"` + Data struct { + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + Email string `json:"email"` + Balance string `json:"balance"` + Status string `json:"status"` + Introduction string `json:"introduction"` + Role string `json:"role"` + ChargeBalance string `json:"chargeBalance"` + TotalBalance string `json:"totalBalance"` + Category string `json:"category"` + IsAdmin bool `json:"isAdmin"` + } `json:"data"` + Code int `json:"code"` + Status bool `json:"status"` +} + +// GetAuthHeader get auth header +func GetAuthHeader(token string) http.Header { + h := http.Header{} + h.Add("Authorization", "Bearer "+token) + return h +} + +func GetResponseBody(method, url string, _ *model.Channel, headers http.Header) ([]byte, error) { + req, err := http.NewRequestWithContext(context.Background(), method, url, nil) + if err != nil { + return nil, err + } + for k := range headers { + req.Header.Add(k, headers.Get(k)) + } + res, err := client.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status code: %d", res.StatusCode) + } + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + return body, nil +} + +func updateChannelCloseAIBalance(channel *model.Channel) (float64, error) { + url := channel.BaseURL + "/dashboard/billing/credit_grants" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + response := OpenAICreditGrants{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + channel.UpdateBalance(response.TotalAvailable) + return response.TotalAvailable, nil +} + +func updateChannelOpenAISBBalance(channel *model.Channel) (float64, error) { + url := "https://api.openai-sb.com/sb-api/user/status?api_key=" + channel.Key + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + response := OpenAISBUsageResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + if response.Data == nil { + return 0, errors.New(response.Msg) + } + balance, err := strconv.ParseFloat(response.Data.Credit, 64) + if err != nil { + return 0, err + } + channel.UpdateBalance(balance) + return balance, nil +} + +func updateChannelAIProxyBalance(channel *model.Channel) (float64, error) { + url := "https://aiproxy.io/api/report/getUserOverview" + headers := http.Header{} + headers.Add("Api-Key", channel.Key) + body, err := GetResponseBody("GET", url, channel, headers) + if err != nil { + return 0, err + } + response := AIProxyUserOverviewResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + if !response.Success { + return 0, fmt.Errorf("code: %d, message: %s", response.ErrorCode, response.Message) + } + channel.UpdateBalance(response.Data.TotalPoints) + return response.Data.TotalPoints, nil +} + +func updateChannelAPI2GPTBalance(channel *model.Channel) (float64, error) { + url := "https://api.api2gpt.com/dashboard/billing/credit_grants" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + response := API2GPTUsageResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + channel.UpdateBalance(response.TotalRemaining) + return response.TotalRemaining, nil +} + +func updateChannelAIGC2DBalance(channel *model.Channel) (float64, error) { + url := "https://api.aigc2d.com/dashboard/billing/credit_grants" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + response := APGC2DGPTUsageResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + channel.UpdateBalance(response.TotalAvailable) + return response.TotalAvailable, nil +} + +func updateChannelSiliconFlowBalance(channel *model.Channel) (float64, error) { + url := "https://api.siliconflow.cn/v1/user/info" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + response := SiliconFlowUsageResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + if response.Code != 20000 { + return 0, fmt.Errorf("code: %d, message: %s", response.Code, response.Message) + } + balance, err := strconv.ParseFloat(response.Data.Balance, 64) + if err != nil { + return 0, err + } + channel.UpdateBalance(balance) + return balance, nil +} + +func updateChannelBalance(channel *model.Channel) (float64, error) { + baseURL := channeltype.ChannelBaseURLs[channel.Type] + if channel.BaseURL == "" { + channel.BaseURL = baseURL + } + switch channel.Type { + case channeltype.OpenAI: + baseURL = channel.BaseURL + case channeltype.Azure: + return 0, errors.New("尚未实现") + case channeltype.Custom: + baseURL = channel.BaseURL + case channeltype.CloseAI: + return updateChannelCloseAIBalance(channel) + case channeltype.OpenAISB: + return updateChannelOpenAISBBalance(channel) + case channeltype.AIProxy: + return updateChannelAIProxyBalance(channel) + case channeltype.API2GPT: + return updateChannelAPI2GPTBalance(channel) + case channeltype.AIGC2D: + return updateChannelAIGC2DBalance(channel) + case channeltype.SiliconFlow: + return updateChannelSiliconFlowBalance(channel) + default: + return 0, errors.New("尚未实现") + } + url := baseURL + "/v1/dashboard/billing/subscription" + + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + subscription := OpenAISubscriptionResponse{} + err = json.Unmarshal(body, &subscription) + if err != nil { + return 0, err + } + now := time.Now() + startDate := now.Format("2006-01") + "-01" + endDate := now.Format("2006-01-02") + if !subscription.HasPaymentMethod { + startDate = now.AddDate(0, 0, -100).Format("2006-01-02") + } + url = baseURL + "/v1/dashboard/billing/usage?start_date=" + startDate + "&end_date=" + endDate + body, err = GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + usage := OpenAIUsageResponse{} + err = json.Unmarshal(body, &usage) + if err != nil { + return 0, err + } + balance := subscription.HardLimitUSD - usage.TotalUsage/100 + channel.UpdateBalance(balance) + return balance, nil +} + +func UpdateChannelBalance(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + channel, err := model.GetChannelByID(id, false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + balance, err := updateChannelBalance(channel) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "balance": balance, + }) +} + +func updateAllChannelsBalance() error { + channels, err := model.GetAllChannels(false, false) + if err != nil { + return err + } + for _, channel := range channels { + if channel.Status != model.ChannelStatusEnabled { + continue + } + // TODO: support Azure + if channel.Type != channeltype.OpenAI && channel.Type != channeltype.Custom { + continue + } + balance, err := updateChannelBalance(channel) + if err != nil { + continue + } + // err is nil & balance <= 0 means quota is used up + if balance <= 0 { + _ = model.DisableChannelByID(channel.ID) + } + time.Sleep(time.Second) + } + return nil +} + +func UpdateAllChannelsBalance(c *gin.Context) { + // err := updateAllChannelsBalance() + // if err != nil { + // c.JSON(http.StatusOK, gin.H{ + // "success": false, + // "message": err.Error(), + // }) + // return + // } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} + +func AutomaticallyUpdateChannels(frequency int) { + for { + time.Sleep(time.Duration(frequency) * time.Minute) + logger.SysLog("updating all channels") + _ = updateAllChannelsBalance() + logger.SysLog("channels update done") + } +} + +// subscription +func GetSubscription(c *gin.Context) { + group := c.GetString(ctxkey.Group) + b, _, err := balance.Default.GetGroupRemainBalance(c, group) + if err != nil { + logger.Errorf(c, "get group (%s) balance failed: %s", group, err) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": fmt.Sprintf("get group (%s) balance failed", group), + }) + return + } + quota := c.GetFloat64(ctxkey.TokenQuota) + if quota <= 0 { + quota = b + } + c.JSON(http.StatusOK, OpenAISubscriptionResponse{ + HardLimitUSD: quota / 7, + SoftLimitUSD: b / 7, + SystemHardLimitUSD: quota / 7, + }) +} + +func GetUsage(c *gin.Context) { + usedAmount := c.GetFloat64(ctxkey.TokenUsedAmount) + c.JSON(http.StatusOK, OpenAIUsageResponse{TotalUsage: usedAmount / 7 * 100}) +} diff --git a/service/aiproxy/controller/channel-test.go b/service/aiproxy/controller/channel-test.go new file mode 100644 index 00000000000..45c8ee54356 --- /dev/null +++ b/service/aiproxy/controller/channel-test.go @@ -0,0 +1,236 @@ +package controller + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strconv" + "sync" + "time" + + json "github.com/json-iterator/go" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/middleware" + "github.com/labring/sealos/service/aiproxy/model" + "github.com/labring/sealos/service/aiproxy/monitor" + relay "github.com/labring/sealos/service/aiproxy/relay" + "github.com/labring/sealos/service/aiproxy/relay/channeltype" + "github.com/labring/sealos/service/aiproxy/relay/controller" + "github.com/labring/sealos/service/aiproxy/relay/meta" + relaymodel "github.com/labring/sealos/service/aiproxy/relay/model" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" +) + +func buildTestRequest(model string) *relaymodel.GeneralOpenAIRequest { + if model == "" { + model = "gpt-3.5-turbo" + } + testRequest := &relaymodel.GeneralOpenAIRequest{ + MaxTokens: 2, + Model: model, + } + testMessage := relaymodel.Message{ + Role: "user", + Content: "hi", + } + testRequest.Messages = append(testRequest.Messages, testMessage) + return testRequest +} + +func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIRequest) (openaiErr *relaymodel.Error, err error) { + if len(channel.Models) == 0 { + channel.Models = config.GetDefaultChannelModels()[channel.Type] + if len(channel.Models) == 0 { + return nil, errors.New("no models") + } + } + modelName := request.Model + if modelName == "" { + modelName = channel.Models[0] + } else if !slices.Contains(channel.Models, modelName) { + return nil, fmt.Errorf("model %s not supported", modelName) + } + if v, ok := channel.ModelMapping[modelName]; ok { + modelName = v + } + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = &http.Request{ + Method: http.MethodPost, + URL: &url.URL{Path: "/v1/chat/completions"}, + Body: nil, + Header: make(http.Header), + } + c.Request.Header.Set("Authorization", "Bearer "+channel.Key) + c.Request.Header.Set("Content-Type", "application/json") + c.Set(ctxkey.Channel, channel.Type) + c.Set(ctxkey.BaseURL, channel.BaseURL) + c.Set(ctxkey.Config, channel.Config) + middleware.SetupContextForSelectedChannel(c, channel, "") + meta := meta.GetByContext(c) + apiType := channeltype.ToAPIType(channel.Type) + adaptor := relay.GetAdaptor(apiType) + if adaptor == nil { + return nil, fmt.Errorf("invalid api type: %d, adaptor is nil", apiType) + } + adaptor.Init(meta) + meta.OriginModelName, meta.ActualModelName = request.Model, modelName + request.Model = modelName + convertedRequest, err := adaptor.ConvertRequest(c, relaymode.ChatCompletions, request) + if err != nil { + return nil, err + } + jsonData, err := json.Marshal(convertedRequest) + if err != nil { + return nil, err + } + logger.SysLogf("testing channel #%d, request: \n%s", channel.ID, jsonData) + requestBody := bytes.NewBuffer(jsonData) + c.Request.Body = io.NopCloser(requestBody) + resp, err := adaptor.DoRequest(c, meta, requestBody) + if err != nil { + return nil, err + } + if resp != nil && resp.StatusCode != http.StatusOK { + err := controller.RelayErrorHandler(resp, meta.Mode) + return &err.Error, errors.New(err.Error.Message) + } + usage, respErr := adaptor.DoResponse(c, resp, meta) + if respErr != nil { + return &respErr.Error, errors.New(respErr.Error.Message) + } + if usage == nil { + return nil, errors.New("usage is nil") + } + result := w.Result() + // print result.Body + respBody, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + logger.SysLogf("testing channel #%d, response: \n%s", channel.ID, respBody) + return nil, nil +} + +func TestChannel(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + channel, err := model.GetChannelByID(id, false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + model := c.Query("model") + testRequest := buildTestRequest(model) + tik := time.Now() + _, err = testChannel(channel, testRequest) + tok := time.Now() + milliseconds := tok.Sub(tik).Milliseconds() + if err != nil { + milliseconds = 0 + } + go channel.UpdateResponseTime(milliseconds) + consumedTime := float64(milliseconds) / 1000.0 + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + "time": consumedTime, + "model": model, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "time": consumedTime, + "model": model, + }) +} + +var ( + testAllChannelsLock sync.Mutex + testAllChannelsRunning = false +) + +func testChannels(onlyDisabled bool) error { + testAllChannelsLock.Lock() + if testAllChannelsRunning { + testAllChannelsLock.Unlock() + return errors.New("测试已在运行中") + } + testAllChannelsRunning = true + testAllChannelsLock.Unlock() + channels, err := model.GetAllChannels(onlyDisabled, false) + if err != nil { + return err + } + go func() { + for _, channel := range channels { + isChannelEnabled := channel.Status == model.ChannelStatusEnabled + tik := time.Now() + testRequest := buildTestRequest("") + openaiErr, err := testChannel(channel, testRequest) + tok := time.Now() + milliseconds := tok.Sub(tik).Milliseconds() + if isChannelEnabled && monitor.ShouldDisableChannel(openaiErr, -1) { + _ = model.DisableChannelByID(channel.ID) + } + if !isChannelEnabled && monitor.ShouldEnableChannel(err, openaiErr) { + _ = model.EnableChannelByID(channel.ID) + } + channel.UpdateResponseTime(milliseconds) + time.Sleep(time.Second * 1) + } + testAllChannelsLock.Lock() + testAllChannelsRunning = false + testAllChannelsLock.Unlock() + }() + return nil +} + +func TestChannels(c *gin.Context) { + onlyDisabled := c.Query("only_disabled") == "true" + err := testChannels(onlyDisabled) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} + +func AutomaticallyTestChannels(frequency int) { + for { + time.Sleep(time.Duration(frequency) * time.Minute) + logger.SysLog("testing all channels") + err := testChannels(false) + if err != nil { + logger.SysLog("testing all channels failed: " + err.Error()) + } + logger.SysLog("channel test finished") + } +} diff --git a/service/aiproxy/controller/channel.go b/service/aiproxy/controller/channel.go new file mode 100644 index 00000000000..dff792e721d --- /dev/null +++ b/service/aiproxy/controller/channel.go @@ -0,0 +1,314 @@ +package controller + +import ( + "maps" + "net/http" + "slices" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/model" +) + +func GetChannels(c *gin.Context) { + p, _ := strconv.Atoi(c.Query("p")) + p-- + if p < 0 { + p = 0 + } + perPage, _ := strconv.Atoi(c.Query("per_page")) + if perPage <= 0 { + perPage = 10 + } else if perPage > 100 { + perPage = 100 + } + id, _ := strconv.Atoi(c.Query("id")) + name := c.Query("name") + key := c.Query("key") + channelType, _ := strconv.Atoi(c.Query("channel_type")) + baseURL := c.Query("base_url") + order := c.Query("order") + channels, total, err := model.GetChannels(p*perPage, perPage, false, false, id, name, key, channelType, baseURL, order) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "channels": channels, + "total": total, + }, + }) +} + +func GetAllChannels(c *gin.Context) { + channels, err := model.GetAllChannels(false, false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": channels, + }) +} + +func AddChannels(c *gin.Context) { + channels := make([]*AddChannelRequest, 0) + err := c.ShouldBindJSON(&channels) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + _channels := make([]*model.Channel, 0, len(channels)) + for _, channel := range channels { + _channels = append(_channels, channel.ToChannels()...) + } + err = model.BatchInsertChannels(_channels) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} + +func SearchChannels(c *gin.Context) { + keyword := c.Query("keyword") + p, _ := strconv.Atoi(c.Query("p")) + p-- + if p < 0 { + p = 0 + } + perPage, _ := strconv.Atoi(c.Query("per_page")) + if perPage <= 0 { + perPage = 10 + } else if perPage > 100 { + perPage = 100 + } + id, _ := strconv.Atoi(c.Query("id")) + name := c.Query("name") + key := c.Query("key") + channelType, _ := strconv.Atoi(c.Query("channel_type")) + baseURL := c.Query("base_url") + order := c.Query("order") + channels, total, err := model.SearchChannels(keyword, p*perPage, perPage, false, false, id, name, key, channelType, baseURL, order) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "channels": channels, + "total": total, + }, + }) +} + +func GetChannel(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + channel, err := model.GetChannelByID(id, false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": channel, + }) +} + +type AddChannelRequest struct { + ModelMapping map[string]string `json:"model_mapping"` + Config model.ChannelConfig `json:"config"` + Name string `json:"name"` + Key string `json:"key"` + BaseURL string `json:"base_url"` + Other string `json:"other"` + Models []string `json:"models"` + Type int `json:"type"` + Priority int32 `json:"priority"` + Status int `json:"status"` +} + +func (r *AddChannelRequest) ToChannel() *model.Channel { + return &model.Channel{ + Type: r.Type, + Name: r.Name, + Key: r.Key, + BaseURL: r.BaseURL, + Other: r.Other, + Models: slices.Clone(r.Models), + ModelMapping: maps.Clone(r.ModelMapping), + Config: r.Config, + Priority: r.Priority, + Status: r.Status, + } +} + +func (r *AddChannelRequest) ToChannels() []*model.Channel { + keys := strings.Split(r.Key, "\n") + channels := make([]*model.Channel, 0, len(keys)) + for _, key := range keys { + if key == "" { + continue + } + c := r.ToChannel() + c.Key = key + channels = append(channels, c) + } + return channels +} + +func AddChannel(c *gin.Context) { + channel := AddChannelRequest{} + err := c.ShouldBindJSON(&channel) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + err = model.BatchInsertChannels(channel.ToChannels()) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} + +func DeleteChannel(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + err := model.DeleteChannelByID(id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} + +type UpdateChannelRequest struct { + AddChannelRequest + ID int `json:"id"` +} + +func (r *UpdateChannelRequest) ToChannel() *model.Channel { + c := r.AddChannelRequest.ToChannel() + c.ID = r.ID + return c +} + +func UpdateChannel(c *gin.Context) { + channel := UpdateChannelRequest{} + err := c.ShouldBindJSON(&channel) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + ch := channel.ToChannel() + err = model.UpdateChannel(ch) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": UpdateChannelRequest{ + ID: ch.ID, + AddChannelRequest: AddChannelRequest{ + Type: ch.Type, + Name: ch.Name, + Key: ch.Key, + BaseURL: ch.BaseURL, + Other: ch.Other, + Models: ch.Models, + ModelMapping: ch.ModelMapping, + Priority: ch.Priority, + Config: ch.Config, + }, + }, + }) +} + +type UpdateChannelStatusRequest struct { + Status int `json:"status"` +} + +func UpdateChannelStatus(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + status := UpdateChannelStatusRequest{} + err := c.ShouldBindJSON(&status) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + err = model.UpdateChannelStatusByID(id, status.Status) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} diff --git a/service/aiproxy/controller/group.go b/service/aiproxy/controller/group.go new file mode 100644 index 00000000000..9a097da1775 --- /dev/null +++ b/service/aiproxy/controller/group.go @@ -0,0 +1,251 @@ +package controller + +import ( + "net/http" + "strconv" + "time" + + json "github.com/json-iterator/go" + + "github.com/labring/sealos/service/aiproxy/model" + + "github.com/gin-gonic/gin" +) + +func GetGroups(c *gin.Context) { + p, _ := strconv.Atoi(c.Query("p")) + p-- + if p < 0 { + p = 0 + } + perPage, _ := strconv.Atoi(c.Query("per_page")) + if perPage <= 0 { + perPage = 10 + } else if perPage > 100 { + perPage = 100 + } + + order := c.DefaultQuery("order", "") + groups, total, err := model.GetGroups(p*perPage, perPage, order, false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "groups": groups, + "total": total, + }, + }) +} + +func SearchGroups(c *gin.Context) { + keyword := c.Query("keyword") + p, _ := strconv.Atoi(c.Query("p")) + p-- + if p < 0 { + p = 0 + } + perPage, _ := strconv.Atoi(c.Query("per_page")) + if perPage <= 0 { + perPage = 10 + } else if perPage > 100 { + perPage = 100 + } + order := c.DefaultQuery("order", "") + status, _ := strconv.Atoi(c.Query("status")) + groups, total, err := model.SearchGroup(keyword, p*perPage, perPage, order, status) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "groups": groups, + "total": total, + }, + }) +} + +func GetGroup(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "group id is empty", + }) + return + } + group, err := model.GetGroupByID(id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": group, + }) +} + +func GetGroupDashboard(c *gin.Context) { + id := c.Param("id") + now := time.Now() + startOfDay := now.Truncate(24*time.Hour).AddDate(0, 0, -6).Unix() + endOfDay := now.Truncate(24 * time.Hour).Add(24*time.Hour - time.Second).Unix() + + dashboards, err := model.SearchLogsByDayAndModel(id, time.Unix(startOfDay, 0), time.Unix(endOfDay, 0)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "failed to get statistics", + "data": nil, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": dashboards, + }) +} + +type UpdateGroupQPMRequest struct { + QPM int64 `json:"qpm"` +} + +func UpdateGroupQPM(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "invalid parameter", + }) + return + } + req := UpdateGroupQPMRequest{} + err := json.NewDecoder(c.Request.Body).Decode(&req) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "invalid parameter", + }) + return + } + err = model.UpdateGroupQPM(id, req.QPM) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} + +type UpdateGroupStatusRequest struct { + Status int `json:"status"` +} + +func UpdateGroupStatus(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "invalid parameter", + }) + return + } + req := UpdateGroupStatusRequest{} + err := json.NewDecoder(c.Request.Body).Decode(&req) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "invalid parameter", + }) + return + } + err = model.UpdateGroupStatus(id, req.Status) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} + +func DeleteGroup(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "invalid parameter", + }) + return + } + err := model.DeleteGroupByID(id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} + +type CreateGroupRequest struct { + ID string `json:"id"` + QPM int64 `json:"qpm"` +} + +func CreateGroup(c *gin.Context) { + var group CreateGroupRequest + err := json.NewDecoder(c.Request.Body).Decode(&group) + if err != nil || group.ID == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "invalid parameter", + }) + return + } + if err := model.CreateGroup(&model.Group{ + ID: group.ID, + QPM: group.QPM, + }); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} diff --git a/service/aiproxy/controller/log.go b/service/aiproxy/controller/log.go new file mode 100644 index 00000000000..94279e66410 --- /dev/null +++ b/service/aiproxy/controller/log.go @@ -0,0 +1,325 @@ +package controller + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + "github.com/labring/sealos/service/aiproxy/model" +) + +func GetLogs(c *gin.Context) { + p, _ := strconv.Atoi(c.Query("p")) + p-- + if p < 0 { + p = 0 + } + perPage, _ := strconv.Atoi(c.Query("per_page")) + if perPage <= 0 { + perPage = 10 + } else if perPage > 100 { + perPage = 100 + } + code, _ := strconv.Atoi(c.Query("code")) + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + var startTimestampTime time.Time + if startTimestamp != 0 { + startTimestampTime = time.UnixMilli(startTimestamp) + } + var endTimestampTime time.Time + if endTimestamp != 0 { + endTimestampTime = time.UnixMilli(endTimestamp) + } + tokenName := c.Query("token_name") + modelName := c.Query("model_name") + channel, _ := strconv.Atoi(c.Query("channel")) + group := c.Query("group") + endpoint := c.Query("endpoint") + content := c.Query("content") + tokenID, _ := strconv.Atoi(c.Query("token_id")) + order := c.Query("order") + logs, total, err := model.GetLogs( + startTimestampTime, endTimestampTime, + code, modelName, group, tokenID, tokenName, p*perPage, perPage, channel, endpoint, content, order) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "logs": logs, + "total": total, + }, + }) +} + +func GetGroupLogs(c *gin.Context) { + p, _ := strconv.Atoi(c.Query("p")) + p-- + if p < 0 { + p = 0 + } + perPage, _ := strconv.Atoi(c.Query("per_page")) + if perPage <= 0 { + perPage = 10 + } else if perPage > 100 { + perPage = 100 + } + code, _ := strconv.Atoi(c.Query("code")) + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + var startTimestampTime time.Time + if startTimestamp != 0 { + startTimestampTime = time.UnixMilli(startTimestamp) + } + var endTimestampTime time.Time + if endTimestamp != 0 { + endTimestampTime = time.UnixMilli(endTimestamp) + } + tokenName := c.Query("token_name") + modelName := c.Query("model_name") + channel, _ := strconv.Atoi(c.Query("channel")) + group := c.Param("group") + endpoint := c.Query("endpoint") + content := c.Query("content") + tokenID, _ := strconv.Atoi(c.Query("token_id")) + order := c.Query("order") + logs, total, err := model.GetGroupLogs(group, + startTimestampTime, endTimestampTime, + code, modelName, tokenID, tokenName, p*perPage, perPage, channel, endpoint, content, order) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "logs": logs, + "total": total, + }, + }) +} + +func SearchLogs(c *gin.Context) { + keyword := c.Query("keyword") + p, _ := strconv.Atoi(c.Query("p")) + perPage, _ := strconv.Atoi(c.Query("per_page")) + if perPage <= 0 { + perPage = 10 + } else if perPage > 100 { + perPage = 100 + } + code, _ := strconv.Atoi(c.Query("code")) + endpoint := c.Query("endpoint") + tokenName := c.Query("token_name") + modelName := c.Query("model_name") + content := c.Query("content") + groupID := c.Query("group_id") + tokenID, _ := strconv.Atoi(c.Query("token_id")) + channel, _ := strconv.Atoi(c.Query("channel")) + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + var startTimestampTime time.Time + if startTimestamp != 0 { + startTimestampTime = time.UnixMilli(startTimestamp) + } + var endTimestampTime time.Time + if endTimestamp != 0 { + endTimestampTime = time.UnixMilli(endTimestamp) + } + order := c.Query("order") + logs, total, err := model.SearchLogs(keyword, p, perPage, code, endpoint, groupID, tokenID, tokenName, modelName, content, startTimestampTime, endTimestampTime, channel, order) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "logs": logs, + "total": total, + }, + }) +} + +func SearchGroupLogs(c *gin.Context) { + keyword := c.Query("keyword") + p, _ := strconv.Atoi(c.Query("p")) + perPage, _ := strconv.Atoi(c.Query("per_page")) + if perPage <= 0 { + perPage = 10 + } else if perPage > 100 { + perPage = 100 + } + group := c.Param("group") + code, _ := strconv.Atoi(c.Query("code")) + endpoint := c.Query("endpoint") + tokenName := c.Query("token_name") + modelName := c.Query("model_name") + content := c.Query("content") + tokenID, _ := strconv.Atoi(c.Query("token_id")) + channelID, _ := strconv.Atoi(c.Query("channel")) + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + var startTimestampTime time.Time + if startTimestamp != 0 { + startTimestampTime = time.UnixMilli(startTimestamp) + } + var endTimestampTime time.Time + if endTimestamp != 0 { + endTimestampTime = time.UnixMilli(endTimestamp) + } + order := c.Query("order") + logs, total, err := model.SearchGroupLogs(group, keyword, p, perPage, code, endpoint, tokenID, tokenName, modelName, content, startTimestampTime, endTimestampTime, channelID, order) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "logs": logs, + "total": total, + }, + }) +} + +func GetLogsStat(c *gin.Context) { + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + if endTimestamp < startTimestamp { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "end_timestamp must be greater than start_timestamp", + }) + return + } + tokenName := c.Query("token_name") + group := c.Query("group") + modelName := c.Query("model_name") + channel, _ := strconv.Atoi(c.Query("channel")) + endpoint := c.Query("endpoint") + var startTimestampTime time.Time + if startTimestamp != 0 { + startTimestampTime = time.UnixMilli(startTimestamp) + } + var endTimestampTime time.Time + if endTimestamp != 0 { + endTimestampTime = time.UnixMilli(endTimestamp) + } + quotaNum := model.SumUsedQuota(startTimestampTime, endTimestampTime, modelName, group, tokenName, channel, endpoint) + // tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, "") + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "quota": quotaNum, + // "token": tokenNum, + }, + }) +} + +func GetLogsSelfStat(c *gin.Context) { + group := c.GetString(ctxkey.Group) + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + tokenName := c.Query("token_name") + modelName := c.Query("model_name") + channel, _ := strconv.Atoi(c.Query("channel")) + endpoint := c.Query("endpoint") + var startTimestampTime time.Time + if startTimestamp != 0 { + startTimestampTime = time.UnixMilli(startTimestamp) + } + var endTimestampTime time.Time + if endTimestamp != 0 { + endTimestampTime = time.UnixMilli(endTimestamp) + } + quotaNum := model.SumUsedQuota(startTimestampTime, endTimestampTime, modelName, group, tokenName, channel, endpoint) + // tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, tokenName) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "quota": quotaNum, + // "token": tokenNum, + }, + }) +} + +func DeleteHistoryLogs(c *gin.Context) { + timestamp, _ := strconv.ParseInt(c.Query("timestamp"), 10, 64) + if timestamp == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "timestamp is required", + }) + return + } + count, err := model.DeleteOldLog(time.UnixMilli(timestamp)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": count, + }) +} + +func SearchConsumeError(c *gin.Context) { + keyword := c.Query("keyword") + group := c.Query("group") + tokenName := c.Query("token_name") + modelName := c.Query("model_name") + content := c.Query("content") + tokenID, _ := strconv.Atoi(c.Query("token_id")) + usedAmount, _ := strconv.ParseFloat(c.Query("used_amount"), 64) + page, _ := strconv.Atoi(c.Query("page")) + perPage, _ := strconv.Atoi(c.Query("per_page")) + if perPage <= 0 { + perPage = 10 + } else if perPage > 100 { + perPage = 100 + } + order := c.Query("order") + logs, total, err := model.SearchConsumeError(keyword, group, tokenName, modelName, content, usedAmount, tokenID, page, perPage, order) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "logs": logs, + "total": total, + }, + }) +} diff --git a/service/aiproxy/controller/misc.go b/service/aiproxy/controller/misc.go new file mode 100644 index 00000000000..06799d7cf1a --- /dev/null +++ b/service/aiproxy/controller/misc.go @@ -0,0 +1,23 @@ +package controller + +import ( + "net/http" + + "github.com/labring/sealos/service/aiproxy/common" + + "github.com/gin-gonic/gin" +) + +type StatusData struct { + StartTime int64 `json:"startTime"` +} + +func GetStatus(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": &StatusData{ + StartTime: common.StartTime, + }, + }) +} diff --git a/service/aiproxy/controller/model.go b/service/aiproxy/controller/model.go new file mode 100644 index 00000000000..8fd5b0f9966 --- /dev/null +++ b/service/aiproxy/controller/model.go @@ -0,0 +1,382 @@ +package controller + +import ( + "fmt" + "net/http" + "slices" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + "github.com/labring/sealos/service/aiproxy/model" + relay "github.com/labring/sealos/service/aiproxy/relay" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/apitype" + "github.com/labring/sealos/service/aiproxy/relay/channeltype" + "github.com/labring/sealos/service/aiproxy/relay/meta" + relaymodel "github.com/labring/sealos/service/aiproxy/relay/model" + billingprice "github.com/labring/sealos/service/aiproxy/relay/price" +) + +// https://platform.openai.com/docs/api-reference/models/list + +type OpenAIModelPermission struct { + Group *string `json:"group"` + ID string `json:"id"` + Object string `json:"object"` + Organization string `json:"organization"` + Created int `json:"created"` + AllowCreateEngine bool `json:"allow_create_engine"` + AllowSampling bool `json:"allow_sampling"` + AllowLogprobs bool `json:"allow_logprobs"` + AllowSearchIndices bool `json:"allow_search_indices"` + AllowView bool `json:"allow_view"` + AllowFineTuning bool `json:"allow_fine_tuning"` + IsBlocking bool `json:"is_blocking"` +} + +type OpenAIModels struct { + Parent *string `json:"parent"` + ID string `json:"id"` + Object string `json:"object"` + OwnedBy string `json:"owned_by"` + Root string `json:"root"` + Permission []OpenAIModelPermission `json:"permission"` + Created int `json:"created"` +} + +var ( + models []OpenAIModels + modelsMap map[string]OpenAIModels + channelID2Models map[int][]string +) + +func init() { + var permission []OpenAIModelPermission + permission = append(permission, OpenAIModelPermission{ + ID: "modelperm-LwHkVFn8AcMItP432fKKDIKJ", + Object: "model_permission", + Created: 1626777600, + AllowCreateEngine: true, + AllowSampling: true, + AllowLogprobs: true, + AllowSearchIndices: false, + AllowView: true, + AllowFineTuning: false, + Organization: "*", + Group: nil, + IsBlocking: false, + }) + // https://platform.openai.com/docs/models/model-endpoint-compatibility + for i := 0; i < apitype.Dummy; i++ { + if i == apitype.AIProxyLibrary { + continue + } + adaptor := relay.GetAdaptor(i) + adaptor.Init(&meta.Meta{ + ChannelType: i, + }) + channelName := adaptor.GetChannelName() + modelNames := adaptor.GetModelList() + for _, modelName := range modelNames { + models = append(models, OpenAIModels{ + ID: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: channelName, + Permission: permission, + Root: modelName, + Parent: nil, + }) + } + } + for _, channelType := range openai.CompatibleChannels { + if channelType == channeltype.Azure { + continue + } + channelName, channelModelList := openai.GetCompatibleChannelMeta(channelType) + for _, modelName := range channelModelList { + models = append(models, OpenAIModels{ + ID: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: channelName, + Permission: permission, + Root: modelName, + Parent: nil, + }) + } + } + modelsMap = make(map[string]OpenAIModels) + for _, model := range models { + modelsMap[model.ID] = model + } + channelID2Models = make(map[int][]string) + for i := 1; i < channeltype.Dummy; i++ { + adaptor := relay.GetAdaptor(channeltype.ToAPIType(i)) + meta := &meta.Meta{ + ChannelType: i, + } + adaptor.Init(meta) + channelID2Models[i] = adaptor.GetModelList() + } +} + +func BuiltinModels(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": channelID2Models, + }) +} + +type modelPrice struct { + Prompt float64 `json:"prompt"` + Completion float64 `json:"completion"` + Unset bool `json:"unset,omitempty"` +} + +func ModelPrice(c *gin.Context) { + bill := make(map[string]*modelPrice) + modelPriceMap := billingprice.GetModelPriceMap() + completionPriceMap := billingprice.GetCompletionPriceMap() + for model, price := range modelPriceMap { + bill[model] = &modelPrice{ + Prompt: price, + Completion: price, + } + if completionPrice, ok := completionPriceMap[model]; ok { + bill[model].Completion = completionPrice + } + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": bill, + }) +} + +func EnabledType2Models(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": model.CacheGetType2Models(), + }) +} + +func EnabledType2ModelsAndPrice(c *gin.Context) { + type2Models := model.CacheGetType2Models() + result := make(map[int]map[string]*modelPrice) + + modelPriceMap := billingprice.GetModelPriceMap() + completionPriceMap := billingprice.GetCompletionPriceMap() + + for channelType, models := range type2Models { + m := make(map[string]*modelPrice) + result[channelType] = m + for _, modelName := range models { + if price, ok := modelPriceMap[modelName]; ok { + m[modelName] = &modelPrice{ + Prompt: price, + Completion: price, + } + if completionPrice, ok := completionPriceMap[modelName]; ok { + m[modelName].Completion = completionPrice + } + } else { + m[modelName] = &modelPrice{ + Unset: true, + } + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": result, + }) +} + +func ChannelDefaultModels(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": config.GetDefaultChannelModels(), + }) +} + +func ChannelDefaultModelsByType(c *gin.Context) { + channelType := c.Param("type") + if channelType == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "type is required", + }) + return + } + channelTypeInt, err := strconv.Atoi(channelType) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid type", + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": config.GetDefaultChannelModels()[channelTypeInt], + }) +} + +func ChannelDefaultModelMapping(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": config.GetDefaultChannelModelMapping(), + }) +} + +func ChannelDefaultModelMappingByType(c *gin.Context) { + channelType := c.Param("type") + if channelType == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "type is required", + }) + return + } + channelTypeInt, err := strconv.Atoi(channelType) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid type", + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": config.GetDefaultChannelModelMapping()[channelTypeInt], + }) +} + +func ChannelDefaultModelsAndMapping(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "models": config.GetDefaultChannelModels(), + "mapping": config.GetDefaultChannelModelMapping(), + }, + }) +} + +func ChannelDefaultModelsAndMappingByType(c *gin.Context) { + channelType := c.Param("type") + if channelType == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "type is required", + }) + return + } + channelTypeInt, err := strconv.Atoi(channelType) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid type", + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "models": config.GetDefaultChannelModels()[channelTypeInt], + "mapping": config.GetDefaultChannelModelMapping()[channelTypeInt], + }, + }) +} + +func EnabledModels(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": model.CacheGetAllModels(), + }) +} + +func EnabledModelsAndPrice(c *gin.Context) { + enabledModels := model.CacheGetAllModels() + result := make(map[string]*modelPrice) + + modelPriceMap := billingprice.GetModelPriceMap() + completionPriceMap := billingprice.GetCompletionPriceMap() + + for _, modelName := range enabledModels { + if price, ok := modelPriceMap[modelName]; ok { + result[modelName] = &modelPrice{ + Prompt: price, + Completion: price, + } + if completionPrice, ok := completionPriceMap[modelName]; ok { + result[modelName].Completion = completionPrice + } + } else { + result[modelName] = &modelPrice{ + Unset: true, + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": result, + }) +} + +func ListModels(c *gin.Context) { + availableModels := c.GetStringSlice(ctxkey.AvailableModels) + availableOpenAIModels := make([]OpenAIModels, 0, len(availableModels)) + + for _, modelName := range availableModels { + if model, ok := modelsMap[modelName]; ok { + availableOpenAIModels = append(availableOpenAIModels, model) + continue + } + availableOpenAIModels = append(availableOpenAIModels, OpenAIModels{ + ID: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: "custom", + Root: modelName, + Parent: nil, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "object": "list", + "data": availableOpenAIModels, + }) +} + +func RetrieveModel(c *gin.Context) { + modelID := c.Param("model") + model, ok := modelsMap[modelID] + if !ok || !slices.Contains(c.GetStringSlice(ctxkey.AvailableModels), modelID) { + c.JSON(200, gin.H{ + "error": relaymodel.Error{ + Message: fmt.Sprintf("the model '%s' does not exist", modelID), + Type: "invalid_request_error", + Param: "model", + Code: "model_not_found", + }, + }) + return + } + c.JSON(200, model) +} diff --git a/service/aiproxy/controller/option.go b/service/aiproxy/controller/option.go new file mode 100644 index 00000000000..dd3c273ccec --- /dev/null +++ b/service/aiproxy/controller/option.go @@ -0,0 +1,74 @@ +package controller + +import ( + "net/http" + + json "github.com/json-iterator/go" + + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/model" + + "github.com/gin-gonic/gin" +) + +func GetOptions(c *gin.Context) { + options := make(map[string]string) + config.OptionMapRWMutex.RLock() + for k, v := range config.OptionMap { + options[k] = v + } + config.OptionMapRWMutex.RUnlock() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": options, + }) +} + +func UpdateOption(c *gin.Context) { + var option model.Option + err := json.NewDecoder(c.Request.Body).Decode(&option) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid parameter", + }) + return + } + err = model.UpdateOption(option.Key, option.Value) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} + +func UpdateOptions(c *gin.Context) { + var options map[string]string + err := json.NewDecoder(c.Request.Body).Decode(&options) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid parameter", + }) + return + } + err = model.UpdateOptions(options) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} diff --git a/service/aiproxy/controller/relay.go b/service/aiproxy/controller/relay.go new file mode 100644 index 00000000000..892861f1062 --- /dev/null +++ b/service/aiproxy/controller/relay.go @@ -0,0 +1,158 @@ +package controller + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/middleware" + dbmodel "github.com/labring/sealos/service/aiproxy/model" + "github.com/labring/sealos/service/aiproxy/monitor" + "github.com/labring/sealos/service/aiproxy/relay/controller" + "github.com/labring/sealos/service/aiproxy/relay/model" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" +) + +// https://platform.openai.com/docs/api-reference/chat + +func relayHelper(c *gin.Context, relayMode int) *model.ErrorWithStatusCode { + var err *model.ErrorWithStatusCode + switch relayMode { + case relaymode.ImagesGenerations: + err = controller.RelayImageHelper(c, relayMode) + case relaymode.AudioSpeech: + fallthrough + case relaymode.AudioTranslation: + fallthrough + case relaymode.AudioTranscription: + err = controller.RelayAudioHelper(c, relayMode) + case relaymode.Rerank: + err = controller.RerankHelper(c) + default: + err = controller.RelayTextHelper(c) + } + return err +} + +func Relay(c *gin.Context) { + ctx := c.Request.Context() + relayMode := relaymode.GetByPath(c.Request.URL.Path) + if config.DebugEnabled { + requestBody, _ := common.GetRequestBody(c) + logger.Debugf(ctx, "request body: %s", requestBody) + } + channelID := c.GetInt(ctxkey.ChannelID) + bizErr := relayHelper(c, relayMode) + if bizErr == nil { + monitor.Emit(channelID, true) + return + } + lastFailedChannelID := channelID + group := c.GetString(ctxkey.Group) + originalModel := c.GetString(ctxkey.OriginalModel) + go processChannelRelayError(ctx, group, channelID, bizErr) + requestID := c.GetString(string(helper.RequestIDKey)) + retryTimes := config.GetRetryTimes() + if !shouldRetry(c, bizErr.StatusCode) { + logger.Errorf(ctx, "relay error happen, status code is %d, won't retry in this case", bizErr.StatusCode) + retryTimes = 0 + } + for i := retryTimes; i > 0; i-- { + channel, err := dbmodel.CacheGetRandomSatisfiedChannel(originalModel) + if err != nil { + logger.Errorf(ctx, "get random satisfied channel failed: %+v", err) + break + } + logger.Infof(ctx, "using channel #%d to retry (remain times %d)", channel.ID, i) + if channel.ID == lastFailedChannelID { + continue + } + middleware.SetupContextForSelectedChannel(c, channel, originalModel) + requestBody, err := common.GetRequestBody(c) + if err != nil { + logger.Errorf(ctx, "GetRequestBody failed: %+v", err) + break + } + c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) + bizErr = relayHelper(c, relayMode) + if bizErr == nil { + return + } + channelID := c.GetInt(ctxkey.ChannelID) + lastFailedChannelID = channelID + // BUG: bizErr is in race condition + go processChannelRelayError(ctx, group, channelID, bizErr) + } + if bizErr != nil { + if bizErr.StatusCode == http.StatusTooManyRequests { + bizErr.Error.Message = "The upstream load of the current group is saturated, please try again later" + } + + // BUG: bizErr is in race condition + bizErr.Error.Message = helper.MessageWithRequestID(bizErr.Error.Message, requestID) + c.JSON(bizErr.StatusCode, gin.H{ + "error": bizErr.Error, + }) + } +} + +func shouldRetry(c *gin.Context, statusCode int) bool { + if _, ok := c.Get(ctxkey.SpecificChannelID); ok { + return false + } + if statusCode == http.StatusTooManyRequests { + return true + } + if statusCode/100 == 5 { + return true + } + if statusCode == http.StatusBadRequest { + return false + } + if statusCode/100 == 2 { + return false + } + return true +} + +func processChannelRelayError(ctx context.Context, group string, channelID int, err *model.ErrorWithStatusCode) { + logger.Errorf(ctx, "relay error (channel id %d, group: %s): %s", channelID, group, err) + // https://platform.openai.com/docs/guides/error-codes/api-errors + if monitor.ShouldDisableChannel(&err.Error, err.StatusCode) { + _ = dbmodel.DisableChannelByID(channelID) + } else { + monitor.Emit(channelID, false) + } +} + +func RelayNotImplemented(c *gin.Context) { + err := model.Error{ + Message: "API not implemented", + Type: "aiproxy_error", + Param: "", + Code: "api_not_implemented", + } + c.JSON(http.StatusNotImplemented, gin.H{ + "error": err, + }) +} + +func RelayNotFound(c *gin.Context) { + err := model.Error{ + Message: fmt.Sprintf("Invalid URL (%s %s)", c.Request.Method, c.Request.URL.Path), + Type: "invalid_request_error", + Param: "", + Code: "", + } + c.JSON(http.StatusNotFound, gin.H{ + "error": err, + }) +} diff --git a/service/aiproxy/controller/token.go b/service/aiproxy/controller/token.go new file mode 100644 index 00000000000..1abfb77b172 --- /dev/null +++ b/service/aiproxy/controller/token.go @@ -0,0 +1,624 @@ +package controller + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common/network" + "github.com/labring/sealos/service/aiproxy/common/random" + "github.com/labring/sealos/service/aiproxy/model" +) + +func GetTokens(c *gin.Context) { + p, _ := strconv.Atoi(c.Query("p")) + p-- + if p < 0 { + p = 0 + } + perPage, _ := strconv.Atoi(c.Query("per_page")) + if perPage <= 0 { + perPage = 10 + } else if perPage > 100 { + perPage = 100 + } + group := c.Query("group") + order := c.Query("order") + status, _ := strconv.Atoi(c.Query("status")) + tokens, total, err := model.GetTokens(p*perPage, perPage, order, group, status) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "tokens": tokens, + "total": total, + }, + }) +} + +func GetGroupTokens(c *gin.Context) { + p, _ := strconv.Atoi(c.Query("p")) + p-- + if p < 0 { + p = 0 + } + perPage, _ := strconv.Atoi(c.Query("per_page")) + if perPage <= 0 { + perPage = 10 + } else if perPage > 100 { + perPage = 100 + } + group := c.Param("group") + order := c.Query("order") + status, _ := strconv.Atoi(c.Query("status")) + tokens, total, err := model.GetGroupTokens(group, p*perPage, perPage, order, status) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "tokens": tokens, + "total": total, + }, + }) +} + +func SearchTokens(c *gin.Context) { + keyword := c.Query("keyword") + p, _ := strconv.Atoi(c.Query("p")) + p-- + if p < 0 { + p = 0 + } + perPage, _ := strconv.Atoi(c.Query("per_page")) + if perPage <= 0 { + perPage = 10 + } else if perPage > 100 { + perPage = 100 + } + order := c.Query("order") + name := c.Query("name") + key := c.Query("key") + status, _ := strconv.Atoi(c.Query("status")) + group := c.Query("group") + tokens, total, err := model.SearchTokens(keyword, p*perPage, perPage, order, status, name, key, group) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "tokens": tokens, + "total": total, + }, + }) +} + +func SearchGroupTokens(c *gin.Context) { + keyword := c.Query("keyword") + p, _ := strconv.Atoi(c.Query("p")) + p-- + if p < 0 { + p = 0 + } + perPage, _ := strconv.Atoi(c.Query("per_page")) + if perPage <= 0 { + perPage = 10 + } else if perPage > 100 { + perPage = 100 + } + group := c.Param("group") + order := c.Query("order") + name := c.Query("name") + key := c.Query("key") + status, _ := strconv.Atoi(c.Query("status")) + tokens, total, err := model.SearchGroupTokens(group, keyword, p*perPage, perPage, order, status, name, key) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "tokens": tokens, + "total": total, + }, + }) +} + +func GetToken(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + token, err := model.GetTokenByID(id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": token, + }) +} + +func GetGroupToken(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + group := c.Param("group") + token, err := model.GetGroupTokenByID(group, id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": token, + }) +} + +func validateToken(token AddTokenRequest) error { + if token.Name == "" { + return errors.New("token name cannot be empty") + } + if len(token.Name) > 30 { + return errors.New("token name is too long") + } + if token.Subnet != "" { + err := network.IsValidSubnets(token.Subnet) + if err != nil { + return fmt.Errorf("invalid subnet: %w", err) + } + } + return nil +} + +type AddTokenRequest struct { + Name string `json:"name"` + Subnet string `json:"subnet"` + Models []string `json:"models"` + ExpiredAt int64 `json:"expiredAt"` + Quota float64 `json:"quota"` +} + +func AddToken(c *gin.Context) { + group := c.Param("group") + token := AddTokenRequest{} + err := c.ShouldBindJSON(&token) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + err = validateToken(token) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "parameter error: " + err.Error(), + }) + return + } + + var expiredAt time.Time + if token.ExpiredAt == 0 { + expiredAt = time.Time{} + } else { + expiredAt = time.UnixMilli(token.ExpiredAt) + } + + cleanToken := &model.Token{ + GroupID: group, + Name: model.EmptyNullString(token.Name), + Key: random.GenerateKey(), + ExpiredAt: expiredAt, + Quota: token.Quota, + Models: token.Models, + Subnet: token.Subnet, + } + err = model.InsertToken(cleanToken, c.Query("auto_create_group") == "true") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": cleanToken, + }) +} + +func DeleteToken(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + err = model.DeleteTokenByID(id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} + +func DeleteGroupToken(c *gin.Context) { + group := c.Param("group") + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + err = model.DeleteTokenByIDAndGroupID(id, group) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} + +func UpdateToken(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + token := AddTokenRequest{} + err = c.ShouldBindJSON(&token) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + err = validateToken(token) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "parameter error: " + err.Error(), + }) + return + } + cleanToken, err := model.GetTokenByID(id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + expiredAt := time.Time{} + if token.ExpiredAt != 0 { + expiredAt = time.UnixMilli(token.ExpiredAt) + } + cleanToken.Name = model.EmptyNullString(token.Name) + cleanToken.ExpiredAt = expiredAt + cleanToken.Quota = token.Quota + cleanToken.Models = token.Models + cleanToken.Subnet = token.Subnet + err = model.UpdateToken(cleanToken) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": cleanToken, + }) +} + +func UpdateGroupToken(c *gin.Context) { + group := c.Param("group") + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + token := AddTokenRequest{} + err = c.ShouldBindJSON(&token) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + err = validateToken(token) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "parameter error: " + err.Error(), + }) + return + } + cleanToken, err := model.GetGroupTokenByID(group, id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + expiredAt := time.Time{} + if token.ExpiredAt != 0 { + expiredAt = time.UnixMilli(token.ExpiredAt) + } + cleanToken.Name = model.EmptyNullString(token.Name) + cleanToken.ExpiredAt = expiredAt + cleanToken.Quota = token.Quota + cleanToken.Models = token.Models + cleanToken.Subnet = token.Subnet + err = model.UpdateToken(cleanToken) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": cleanToken, + }) +} + +type UpdateTokenStatusRequest struct { + Status int `json:"status"` +} + +func UpdateTokenStatus(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + token := UpdateTokenStatusRequest{} + err = c.ShouldBindJSON(&token) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + cleanToken, err := model.GetTokenByID(id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + if token.Status == model.TokenStatusEnabled { + if cleanToken.Status == model.TokenStatusExpired && !cleanToken.ExpiredAt.IsZero() && cleanToken.ExpiredAt.Before(time.Now()) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期", + }) + return + } + if cleanToken.Status == model.TokenStatusExhausted && cleanToken.Quota > 0 && cleanToken.UsedAmount >= cleanToken.Quota { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度", + }) + return + } + } + err = model.UpdateTokenStatus(id, token.Status) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} + +type UpdateGroupTokenStatusRequest struct { + UpdateTokenStatusRequest +} + +func UpdateGroupTokenStatus(c *gin.Context) { + group := c.Param("group") + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + token := UpdateTokenStatusRequest{} + err = c.ShouldBindJSON(&token) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + cleanToken, err := model.GetGroupTokenByID(group, id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + if token.Status == model.TokenStatusEnabled { + if cleanToken.Status == model.TokenStatusExpired && !cleanToken.ExpiredAt.IsZero() && cleanToken.ExpiredAt.Before(time.Now()) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期", + }) + return + } + if cleanToken.Status == model.TokenStatusExhausted && cleanToken.Quota > 0 && cleanToken.UsedAmount >= cleanToken.Quota { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度", + }) + return + } + } + err = model.UpdateGroupTokenStatus(group, id, token.Status) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} + +type UpdateTokenNameRequest struct { + Name string `json:"name"` +} + +func UpdateTokenName(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + } + name := UpdateTokenNameRequest{} + err = c.ShouldBindJSON(&name) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + } + err = model.UpdateTokenName(id, name.Name) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} + +func UpdateGroupTokenName(c *gin.Context) { + group := c.Param("group") + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + } + name := UpdateTokenNameRequest{} + err = c.ShouldBindJSON(&name) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + } + err = model.UpdateGroupTokenName(group, id, name.Name) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} diff --git a/service/aiproxy/deploy/Kubefile b/service/aiproxy/deploy/Kubefile new file mode 100644 index 00000000000..9fc2c9a21f6 --- /dev/null +++ b/service/aiproxy/deploy/Kubefile @@ -0,0 +1,16 @@ +FROM scratch +COPY registry registry +COPY manifests manifests +COPY scripts scripts + +ENV cloudDomain="127.0.0.1.nip.io" +ENV cloudPort="" +ENV certSecretName="wildcard-cert" + +ENV ADMIN_KEY="" +ENV SEALOS_JWT_KEY="<sealos-jwt-key-placeholder>" +ENV SQL_DSN="<sql-placeholder>" +ENV LOG_SQL_DSN="<sql-log-placeholder>" +ENV REDIS_CONN_STRING="<redis-placeholder>" + +CMD ["bash scripts/init.sh"] diff --git a/service/aiproxy/deploy/manifests/aiproxy-config.yaml.tmpl b/service/aiproxy/deploy/manifests/aiproxy-config.yaml.tmpl new file mode 100644 index 00000000000..94214c61537 --- /dev/null +++ b/service/aiproxy/deploy/manifests/aiproxy-config.yaml.tmpl @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: aiproxy-env +data: + DEBUG: "false" + DEBUG_SQL: "false" + ADMIN_KEY: "{{ .ADMIN_KEY }}" + SEALOS_JWT_KEY: "{{ .SEALOS_JWT_KEY }}" + SQL_DSN: "{{ .SQL_DSN }}" + LOG_SQL_DSN: "{{ .LOG_SQL_DSN }}" + REDIS_CONN_STRING: "{{ .REDIS_CONN_STRING }}" diff --git a/service/aiproxy/deploy/manifests/deploy.yaml.tmpl b/service/aiproxy/deploy/manifests/deploy.yaml.tmpl new file mode 100644 index 00000000000..52031c24ac4 --- /dev/null +++ b/service/aiproxy/deploy/manifests/deploy.yaml.tmpl @@ -0,0 +1,69 @@ +apiVersion: v1 +kind: Service +metadata: + name: aiproxy + namespace: aiproxy-system + labels: + cloud.sealos.io/app-deploy-manager: aiproxy +spec: + ports: + - port: 3000 + targetPort: 3000 + selector: + app: aiproxy +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: aiproxy + namespace: aiproxy-system + annotations: + originImageName: ghcr.io/labring/sealos-aiproxy-service:latest + deploy.cloud.sealos.io/minReplicas: '3' + deploy.cloud.sealos.io/maxReplicas: '3' + labels: + cloud.sealos.io/app-deploy-manager: aiproxy + app: aiproxy +spec: + replicas: 3 + revisionHistoryLimit: 1 + selector: + matchLabels: + app: aiproxy + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + template: + metadata: + labels: + app: aiproxy + spec: + containers: + - name: aiproxy + image: ghcr.io/labring/sealos-aiproxy-service:latest + envFrom: + - configMapRef: + name: aiproxy-env + resources: + requests: + cpu: 50m + memory: 50Mi + limits: + cpu: 500m + memory: 512Mi + ports: + - containerPort: 3000 + imagePullPolicy: Always + startupProbe: + httpGet: + port: 3000 + path: /api/status + initialDelaySeconds: 5 + periodSeconds: 3 + failureThreshold: 30 + successThreshold: 1 + timeoutSeconds: 1 + serviceAccountName: default + automountServiceAccountToken: false diff --git a/service/aiproxy/deploy/manifests/ingress.yaml.tmpl b/service/aiproxy/deploy/manifests/ingress.yaml.tmpl new file mode 100644 index 00000000000..51d9009698c --- /dev/null +++ b/service/aiproxy/deploy/manifests/ingress.yaml.tmpl @@ -0,0 +1,37 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/enable-cors: "true" + nginx.ingress.kubernetes.io/cors-allow-methods: "PUT, GET, POST, DELETE, PATCH, OPTIONS" + nginx.ingress.kubernetes.io/cors-allow-origin: "https://{{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }}, https://*.{{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }}" + nginx.ingress.kubernetes.io/cors-allow-credentials: "true" + nginx.ingress.kubernetes.io/cors-max-age: "600" + nginx.ingress.kubernetes.io/backend-protocol: "HTTP" + nginx.ingress.kubernetes.io/configuration-snippet: | + more_clear_headers "X-Frame-Options:"; + more_set_headers "Content-Security-Policy: default-src * blob: data: *.{{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} {{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }}; img-src * data: blob: resource: *.{{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} {{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }}; connect-src * wss: blob: resource:; style-src 'self' 'unsafe-inline' blob: *.{{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} {{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} resource:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: *.{{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} {{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} resource: *.baidu.com *.bdstatic.com https://js.stripe.com; frame-src 'self' *.{{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} {{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} mailto: tel: weixin: mtt: *.baidu.com https://js.stripe.com; frame-ancestors 'self' https://{{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} https://*.{{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }}"; + more_set_headers "X-Xss-Protection: 1; mode=block"; + higress.io/response-header-control-remove: X-Frame-Options + higress.io/response-header-control-update: | + Content-Security-Policy "default-src * blob: data: *.{{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} {{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }}; img-src * data: blob: resource: *.{{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} {{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }}; connect-src * wss: blob: resource:; style-src 'self' 'unsafe-inline' blob: *.{{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} {{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} resource:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: *.{{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} {{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} resource: *.baidu.com *.bdstatic.com https://js.stripe.com; frame-src 'self' *.{{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} {{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} mailto: tel: weixin: mtt: *.baidu.com https://js.stripe.com; frame-ancestors 'self' https://{{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }} https://*.{{ .cloudDomain }}{{ if .cloudPort }}:{{ .cloudPort }}{{ end }}" + X-Xss-Protection "1; mode=block" + name: aiproxy + namespace: aiproxy-system +spec: + rules: + - host: aiproxy.{{ .cloudDomain }} + http: + paths: + - pathType: Prefix + path: /v1 + backend: + service: + name: aiproxy + port: + number: 3000 + tls: + - hosts: + - 'aiproxy.{{ .cloudDomain }}' + secretName: {{ .certSecretName }} diff --git a/service/aiproxy/deploy/manifests/pgsql-log.yaml b/service/aiproxy/deploy/manifests/pgsql-log.yaml new file mode 100644 index 00000000000..6f1e1c75230 --- /dev/null +++ b/service/aiproxy/deploy/manifests/pgsql-log.yaml @@ -0,0 +1,94 @@ +apiVersion: apps.kubeblocks.io/v1alpha1 +kind: Cluster +metadata: + finalizers: + - cluster.kubeblocks.io/finalizer + labels: + clusterdefinition.kubeblocks.io/name: postgresql + clusterversion.kubeblocks.io/name: postgresql-14.8.0 + sealos-db-provider-cr: aiproxy-log + annotations: {} + name: aiproxy-log + namespace: aiproxy-system +spec: + affinity: + nodeLabels: {} + podAntiAffinity: Preferred + tenancy: SharedNode + topologyKeys: + - kubernetes.io/hostname + clusterDefinitionRef: postgresql + clusterVersionRef: postgresql-14.8.0 + componentSpecs: + - componentDefRef: postgresql + monitor: true + name: postgresql + replicas: 2 + resources: + limits: + cpu: 1000m + memory: 1024Mi + requests: + cpu: 100m + memory: 102Mi + serviceAccountName: aiproxy-log + switchPolicy: + type: Noop + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + terminationPolicy: Delete + tolerations: [] + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + sealos-db-provider-cr: aiproxy-log + app.kubernetes.io/instance: aiproxy-log + app.kubernetes.io/managed-by: kbcli + name: aiproxy-log + namespace: aiproxy-system + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + sealos-db-provider-cr: aiproxy-log + app.kubernetes.io/instance: aiproxy-log + app.kubernetes.io/managed-by: kbcli + name: aiproxy-log + namespace: aiproxy-system +rules: + - apiGroups: + - '*' + resources: + - '*' + verbs: + - '*' + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + sealos-db-provider-cr: aiproxy-log + app.kubernetes.io/instance: aiproxy-log + app.kubernetes.io/managed-by: kbcli + name: aiproxy-log + namespace: aiproxy-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: aiproxy-log +subjects: + - kind: ServiceAccount + name: aiproxy-log + namespace: aiproxy-system diff --git a/service/aiproxy/deploy/manifests/pgsql.yaml b/service/aiproxy/deploy/manifests/pgsql.yaml new file mode 100644 index 00000000000..f3b76b8b9d2 --- /dev/null +++ b/service/aiproxy/deploy/manifests/pgsql.yaml @@ -0,0 +1,94 @@ +apiVersion: apps.kubeblocks.io/v1alpha1 +kind: Cluster +metadata: + finalizers: + - cluster.kubeblocks.io/finalizer + labels: + clusterdefinition.kubeblocks.io/name: postgresql + clusterversion.kubeblocks.io/name: postgresql-14.8.0 + sealos-db-provider-cr: aiproxy + annotations: {} + name: aiproxy + namespace: aiproxy-system +spec: + affinity: + nodeLabels: {} + podAntiAffinity: Preferred + tenancy: SharedNode + topologyKeys: + - kubernetes.io/hostname + clusterDefinitionRef: postgresql + clusterVersionRef: postgresql-14.8.0 + componentSpecs: + - componentDefRef: postgresql + monitor: true + name: postgresql + replicas: 2 + resources: + limits: + cpu: 1000m + memory: 1024Mi + requests: + cpu: 100m + memory: 102Mi + serviceAccountName: aiproxy + switchPolicy: + type: Noop + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 3Gi + terminationPolicy: Delete + tolerations: [] + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + sealos-db-provider-cr: aiproxy + app.kubernetes.io/instance: aiproxy + app.kubernetes.io/managed-by: kbcli + name: aiproxy + namespace: aiproxy-system + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + sealos-db-provider-cr: aiproxy + app.kubernetes.io/instance: aiproxy + app.kubernetes.io/managed-by: kbcli + name: aiproxy + namespace: aiproxy-system +rules: + - apiGroups: + - "*" + resources: + - "*" + verbs: + - "*" + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + sealos-db-provider-cr: aiproxy + app.kubernetes.io/instance: aiproxy + app.kubernetes.io/managed-by: kbcli + name: aiproxy + namespace: aiproxy-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: aiproxy +subjects: + - kind: ServiceAccount + name: aiproxy + namespace: aiproxy-system diff --git a/service/aiproxy/deploy/manifests/redis.yaml b/service/aiproxy/deploy/manifests/redis.yaml new file mode 100644 index 00000000000..a1148a2f750 --- /dev/null +++ b/service/aiproxy/deploy/manifests/redis.yaml @@ -0,0 +1,107 @@ +apiVersion: apps.kubeblocks.io/v1alpha1 +kind: Cluster +metadata: + finalizers: + - cluster.kubeblocks.io/finalizer + labels: + clusterdefinition.kubeblocks.io/name: redis + clusterversion.kubeblocks.io/name: redis-7.0.6 + sealos-db-provider-cr: aiproxy-redis + annotations: {} + name: aiproxy-redis + namespace: aiproxy-system +spec: + affinity: + nodeLabels: {} + podAntiAffinity: Preferred + tenancy: SharedNode + topologyKeys: + - kubernetes.io/hostname + clusterDefinitionRef: redis + clusterVersionRef: redis-7.0.6 + componentSpecs: + - componentDefRef: redis + monitor: true + name: redis + replicas: 3 + resources: + limits: + cpu: 1000m + memory: 1024Mi + requests: + cpu: 100m + memory: 102Mi + serviceAccountName: aiproxy-redis + switchPolicy: + type: Noop + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 3Gi + storageClassName: openebs-backup + - componentDefRef: redis-sentinel + monitor: true + name: redis-sentinel + replicas: 3 + resources: + limits: + cpu: 100m + memory: 100Mi + requests: + cpu: 100m + memory: 100Mi + serviceAccountName: aiproxy-redis + terminationPolicy: Delete + tolerations: [] + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + sealos-db-provider-cr: aiproxy-redis + app.kubernetes.io/instance: aiproxy-redis + app.kubernetes.io/managed-by: kbcli + name: aiproxy-redis + namespace: aiproxy-system + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + sealos-db-provider-cr: aiproxy-redis + app.kubernetes.io/instance: aiproxy-redis + app.kubernetes.io/managed-by: kbcli + name: aiproxy-redis + namespace: aiproxy-system +rules: + - apiGroups: + - '*' + resources: + - '*' + verbs: + - '*' + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + sealos-db-provider-cr: aiproxy-redis + app.kubernetes.io/instance: aiproxy-redis + app.kubernetes.io/managed-by: kbcli + name: aiproxy-redis + namespace: aiproxy-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: aiproxy-redis +subjects: + - kind: ServiceAccount + name: aiproxy-redis + namespace: aiproxy-system diff --git a/service/aiproxy/deploy/scripts/init.sh b/service/aiproxy/deploy/scripts/init.sh new file mode 100644 index 00000000000..ed1b3678d73 --- /dev/null +++ b/service/aiproxy/deploy/scripts/init.sh @@ -0,0 +1,111 @@ +#!/bin/bash +set -e + +# Create namespace +kubectl create ns aiproxy-system || true + +# Function to wait for secret +wait_for_secret() { + local secret_name=$1 + local retries=0 + while ! kubectl get secret -n aiproxy-system ${secret_name} >/dev/null 2>&1; do + sleep 3 + retries=$((retries + 1)) + if [ $retries -ge 30 ]; then + echo "Timeout waiting for secret ${secret_name}" + exit 1 + fi + done +} + +# Function to get secret value +get_secret_value() { + local secret_name=$1 + local key=$2 + base64_value=$(kubectl get secret -n aiproxy-system ${secret_name} -o jsonpath="{.data.${key}}") || return $? + echo "$base64_value" | base64 -d +} + +# Function to build postgres connection string +build_postgres_dsn() { + local secret_name=$1 + username=$(get_secret_value ${secret_name} "username") || return $? + password=$(get_secret_value ${secret_name} "password") || return $? + host=$(get_secret_value ${secret_name} "host") || return $? + port=$(get_secret_value ${secret_name} "port") || return $? + echo "postgres://${username}:${password}@${host}:${port}/postgres?sslmode=disable" +} + +build_redis_conn() { + local secret_name=$1 + username=$(get_secret_value ${secret_name} "username") || return $? + password=$(get_secret_value ${secret_name} "password") || return $? + host=$(get_secret_value ${secret_name} "host") || return $? + port=$(get_secret_value ${secret_name} "port") || return $? + echo "redis://${username}:${password}@${host}:${port}" +} + +# Handle JWT configuration +if grep "<sealos-jwt-key-placeholder>" manifests/aiproxy-config.yaml >/dev/null 2>&1; then + JWT_SECRET=$(kubectl get cm -n account-system account-manager-env -o jsonpath="{.data.ACCOUNT_API_JWT_SECRET}") || exit $? + sed -i "s|<sealos-jwt-key-placeholder>|${JWT_SECRET}|g" manifests/aiproxy-config.yaml +fi + +# Handle PostgreSQL configuration +if grep "<sql-placeholder>" manifests/aiproxy-config.yaml >/dev/null 2>&1; then + if grep "<sql-log-placeholder>" manifests/aiproxy-config.yaml >/dev/null 2>&1; then + # Deploy PostgreSQL resources + kubectl apply -f manifests/pgsql.yaml -n aiproxy-system + kubectl apply -f manifests/pgsql-log.yaml -n aiproxy-system + + # Wait for secrets + wait_for_secret "aiproxy-conn-credential" + wait_for_secret "aiproxy-log-conn-credential" + + # Build connection strings + SQL_DSN=$(build_postgres_dsn "aiproxy-conn-credential") || exit $? + LOG_SQL_DSN=$(build_postgres_dsn "aiproxy-log-conn-credential") || exit $? + + # Update config + sed -i "s|<sql-placeholder>|${SQL_DSN}|g" manifests/aiproxy-config.yaml + sed -i "s|<sql-log-placeholder>|${LOG_SQL_DSN}|g" manifests/aiproxy-config.yaml + elif grep "LOG_SQL_DSN: \"\"" manifests/aiproxy-config.yaml >/dev/null 2>&1; then + # Deploy PostgreSQL resources + kubectl apply -f manifests/pgsql.yaml -n aiproxy-system + + # Wait for secrets + wait_for_secret "aiproxy-conn-credential" + + # Build connection strings + SQL_DSN=$(build_postgres_dsn "aiproxy-conn-credential") || exit $? + + # Update config + sed -i "s|<sql-placeholder>|${SQL_DSN}|g" manifests/aiproxy-config.yaml + else + echo "Error: LOG_SQL_DSN is not allowed to be passed alone, please provide both SQL_DSN and LOG_SQL_DSN or provide SQL_DSN only or neither." + exit 1 + fi +elif grep "<sql-log-placeholder>" manifests/aiproxy-config.yaml >/dev/null 2>&1; then + sed -i 's/<sql-log-placeholder>//g' manifests/aiproxy-config.yaml +fi + +# Handle Redis configuration +if grep "<redis-placeholder>" manifests/aiproxy-config.yaml >/dev/null 2>&1; then + kubectl apply -f manifests/redis.yaml -n aiproxy-system + + wait_for_secret "aiproxy-redis-conn-credential" + + # Build redis connection string + REDIS_CONN=$(build_redis_conn "aiproxy-redis-conn-credential") || exit $? + + sed -i "s|<redis-placeholder>|${REDIS_CONN}|g" manifests/aiproxy-config.yaml +fi + +# Deploy application +kubectl apply -f manifests/aiproxy-config.yaml -n aiproxy-system +kubectl apply -f manifests/deploy.yaml -n aiproxy-system + +# Create ingress if domain is specified +if [[ -n "$cloudDomain" ]]; then + kubectl create -f manifests/ingress.yaml -n aiproxy-system || true +fi diff --git a/service/aiproxy/go.mod b/service/aiproxy/go.mod new file mode 100644 index 00000000000..c44cbcabc63 --- /dev/null +++ b/service/aiproxy/go.mod @@ -0,0 +1,114 @@ +module github.com/labring/sealos/service/aiproxy + +go 1.22.7 + +replace github.com/labring/sealos/service/aiproxy => ../aiproxy + +require ( + cloud.google.com/go/iam v1.2.2 + github.com/aws/aws-sdk-go-v2 v1.32.4 + github.com/aws/aws-sdk-go-v2/credentials v1.17.44 + github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.20.0 + github.com/gin-contrib/cors v1.7.2 + github.com/gin-contrib/gzip v1.0.1 + github.com/gin-gonic/gin v1.10.0 + github.com/glebarez/sqlite v1.11.0 + github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + github.com/jinzhu/copier v0.4.0 + github.com/joho/godotenv v1.5.1 + github.com/json-iterator/go v1.1.12 + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/pkg/errors v0.9.1 + github.com/pkoukk/tiktoken-go v0.1.7 + github.com/redis/go-redis/v9 v9.7.0 + github.com/shopspring/decimal v1.4.0 + github.com/smartystreets/goconvey v1.8.1 + github.com/stretchr/testify v1.9.0 + golang.org/x/image v0.22.0 + google.golang.org/api v0.205.0 + gorm.io/driver/mysql v1.5.7 + gorm.io/driver/postgres v1.5.9 + gorm.io/gorm v1.25.12 +) + +require ( + cloud.google.com/go/auth v0.10.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.5 // indirect + cloud.google.com/go/compute/metadata v0.5.2 // indirect + filippo.io/edwards25519 v1.1.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.23 // indirect + github.com/aws/smithy-go v1.22.0 // indirect + github.com/bytedance/sonic v1.12.4 // indirect + github.com/bytedance/sonic/loader v0.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/gabriel-vasile/mimetype v1.4.6 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/glebarez/go-sqlite v1.22.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.22.1 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/s2a-go v0.1.8 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect + github.com/googleapis/gax-go/v2 v2.14.0 // indirect + github.com/gopherjs/gopherjs v1.17.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/jtolds/gls v4.20.0+incompatible // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/smarty/assertions v1.15.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.57.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect + go.opentelemetry.io/otel v1.32.0 // indirect + go.opentelemetry.io/otel/metric v1.32.0 // indirect + go.opentelemetry.io/otel/trace v1.32.0 // indirect + golang.org/x/arch v0.12.0 // indirect + golang.org/x/crypto v0.29.0 // indirect + golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect + golang.org/x/net v0.31.0 // indirect + golang.org/x/oauth2 v0.24.0 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.20.0 // indirect + golang.org/x/time v0.8.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241113202542-65e8d215514f // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241113202542-65e8d215514f // indirect + google.golang.org/grpc v1.68.0 // indirect + google.golang.org/protobuf v1.35.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.61.0 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/sqlite v1.33.1 // indirect +) diff --git a/service/aiproxy/go.sum b/service/aiproxy/go.sum new file mode 100644 index 00000000000..e48ba2706ce --- /dev/null +++ b/service/aiproxy/go.sum @@ -0,0 +1,344 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go/auth v0.10.2 h1:oKF7rgBfSHdp/kuhXtqU/tNDr0mZqhYbEh+6SiqzkKo= +cloud.google.com/go/auth v0.10.2/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI= +cloud.google.com/go/auth/oauth2adapt v0.2.5 h1:2p29+dePqsCHPP1bqDJcKj4qxRyYCcbzKpFyKGt3MTk= +cloud.google.com/go/auth/oauth2adapt v0.2.5/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8= +cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo= +cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k= +cloud.google.com/go/iam v1.2.2 h1:ozUSofHUGf/F4tCNy/mu9tHLTaxZFLOUiKzjcgWHGIA= +cloud.google.com/go/iam v1.2.2/go.mod h1:0Ys8ccaZHdI1dEUilwzqng/6ps2YB6vRsjIe00/+6JY= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/aws/aws-sdk-go-v2 v1.32.4 h1:S13INUiTxgrPueTmrm5DZ+MiAo99zYzHEFh1UNkOxNE= +github.com/aws/aws-sdk-go-v2 v1.32.4/go.mod h1:2SK5n0a2karNTv5tbP1SjsX0uhttou00v/HpXKM1ZUo= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 h1:pT3hpW0cOHRJx8Y0DfJUEQuqPild8jRGmSFmBgvydr0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6/go.mod h1:j/I2++U0xX+cr44QjHay4Cvxj6FUbnxrgmqN3H1jTZA= +github.com/aws/aws-sdk-go-v2/credentials v1.17.44 h1:qqfs5kulLUHUEXlHEZXLJkgGoF3kkUeFUTVA585cFpU= +github.com/aws/aws-sdk-go-v2/credentials v1.17.44/go.mod h1:0Lm2YJ8etJdEdw23s+q/9wTpOeo2HhNE97XcRa7T8MA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.23 h1:A2w6m6Tmr+BNXjDsr7M90zkWjsu4JXHwrzPg235STs4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.23/go.mod h1:35EVp9wyeANdujZruvHiQUAo9E3vbhnIO1mTCAxMlY0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.23 h1:pgYW9FCabt2M25MoHYCfMrVY2ghiiBKYWUVXfwZs+sU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.23/go.mod h1:c48kLgzO19wAu3CPkDWC28JbaJ+hfQlsdl7I2+oqIbk= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.20.0 h1:c/2Lv0Nq/I+UeWKqUKR/LS9rO8McuXc5CzIfK2aBlhg= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.20.0/go.mod h1:Kh/nzScDldU7Ti7MyFMCA+0Po+LZ4iNjWwl7H1DWYtU= +github.com/aws/smithy-go v1.22.0 h1:uunKnWlcoL3zO7q+gG2Pk53joueEOsnNB28QdMsmiMM= +github.com/aws/smithy-go v1.22.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/sonic v1.12.4 h1:9Csb3c9ZJhfUWeMtpCDCq6BUoH5ogfDFLUgQ/jG+R0k= +github.com/bytedance/sonic v1.12.4/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= +github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +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/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc= +github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc= +github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= +github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= +github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE= +github.com/gin-contrib/gzip v1.0.1/go.mod h1:njt428fdUNRvjuJf16tZMYZ2Yl+WQB53X5wmhDwXvC4= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= +github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +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-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +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/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= +github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= +github.com/google/uuid v1.1.2/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/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/googleapis/gax-go/v2 v2.14.0 h1:f+jMrjBPl+DL9nI4IQzLUxMq7XrAqFYB7hBPqMNIe8o= +github.com/googleapis/gax-go/v2 v2.14.0/go.mod h1:lhBCnjdLrWRaPvLWhmc8IS24m9mr07qSYnHncrgo+zk= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= +github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= +github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw= +github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +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/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +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/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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.57.0 h1:qtFISDHKolvIxzSs0gIaiPUPR0Cucb0F2coHC7ZLdps= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.57.0/go.mod h1:Y+Pop1Q6hCOnETWTW4NROK/q1hv50hM7yDaUTjG8lp8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94= +go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= +go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= +go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= +go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= +go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= +go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= +golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= +golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= +golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= +golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g= +golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= +golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o= +golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.205.0 h1:LFaxkAIpDb/GsrWV20dMMo5MR0h8UARTbn24LmD+0Pg= +google.golang.org/api v0.205.0/go.mod h1:NrK1EMqO8Xk6l6QwRAmrXXg2v6dzukhlOyvkYtnvUuc= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/api v0.0.0-20241113202542-65e8d215514f h1:M65LEviCfuZTfrfzwwEoxVtgvfkFkBUbFnRbxCXuXhU= +google.golang.org/genproto/googleapis/api v0.0.0-20241113202542-65e8d215514f/go.mod h1:Yo94eF2nj7igQt+TiJ49KxjIH8ndLYPZMIRSiRcEbg0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241113202542-65e8d215514f h1:C1QccEa9kUwvMgEUORqQD9S17QesQijxjZ84sO82mfo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241113202542-65e8d215514f/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0= +google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +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/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= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= +gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4= +modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M= +modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE= +modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0= +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.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= +modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +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= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/service/aiproxy/main.go b/service/aiproxy/main.go new file mode 100644 index 00000000000..fb4d5e0ae63 --- /dev/null +++ b/service/aiproxy/main.go @@ -0,0 +1,124 @@ +package main + +import ( + "context" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/gin-gonic/gin" + _ "github.com/joho/godotenv/autoload" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/balance" + "github.com/labring/sealos/service/aiproxy/common/client" + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/controller" + "github.com/labring/sealos/service/aiproxy/middleware" + "github.com/labring/sealos/service/aiproxy/model" + relaycontroller "github.com/labring/sealos/service/aiproxy/relay/controller" + "github.com/labring/sealos/service/aiproxy/router" +) + +func main() { + common.Init() + logger.SetupLogger() + + sealosJwtKey := os.Getenv("SEALOS_JWT_KEY") + if sealosJwtKey == "" { + logger.SysLog("SEALOS_JWT_KEY is not set, balance will not be enabled") + } else { + logger.SysLog("SEALOS_JWT_KEY is set, balance will be enabled") + err := balance.InitSealos(sealosJwtKey, os.Getenv("SEALOS_ACCOUNT_URL")) + if err != nil { + logger.FatalLog("failed to initialize sealos balance: " + err.Error()) + } + } + + if os.Getenv("GIN_MODE") != gin.DebugMode { + gin.SetMode(gin.ReleaseMode) + } + if config.DebugEnabled { + logger.SysLog("running in debug mode") + } + + // Initialize SQL Database + model.InitDB() + model.InitLogDB() + + defer func() { + err := model.CloseDB() + if err != nil { + logger.FatalLog("failed to close database: " + err.Error()) + } + }() + + // Initialize Redis + err := common.InitRedisClient() + if err != nil { + logger.FatalLog("failed to initialize Redis: " + err.Error()) + } + + // Initialize options + model.InitOptionMap() + model.InitChannelCache() + go model.SyncOptions(time.Second * 5) + go model.SyncChannelCache(time.Second * 5) + if os.Getenv("CHANNEL_TEST_FREQUENCY") != "" { + frequency, err := strconv.Atoi(os.Getenv("CHANNEL_TEST_FREQUENCY")) + if err != nil { + logger.FatalLog("failed to parse CHANNEL_TEST_FREQUENCY: " + err.Error()) + } + go controller.AutomaticallyTestChannels(frequency) + } + if config.EnableMetric { + logger.SysLog("metric enabled, will disable channel if too much request failed") + } + client.Init() + + // Initialize HTTP server + server := gin.New() + server.Use(gin.Recovery()) + server.Use(middleware.RequestID) + middleware.SetUpLogger(server) + + router.SetRouter(server) + port := os.Getenv("PORT") + if port == "" { + port = strconv.Itoa(*common.Port) + } + + // Create HTTP server + srv := &http.Server{ + Addr: ":" + port, + ReadHeaderTimeout: 10 * time.Second, + Handler: server, + } + + // Graceful shutdown setup + go func() { + logger.SysLogf("server started on http://localhost:%s", port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.FatalLog("failed to start HTTP server: " + err.Error()) + } + }() + + // Wait for interrupt signal to gracefully shutdown the server + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + <-quit + logger.SysLog("shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + logger.SysError("server forced to shutdown: " + err.Error()) + } + + relaycontroller.ConsumeWaitGroup.Wait() + + logger.SysLog("server exiting") +} diff --git a/service/aiproxy/middleware/auth.go b/service/aiproxy/middleware/auth.go new file mode 100644 index 00000000000..a278eddc902 --- /dev/null +++ b/service/aiproxy/middleware/auth.go @@ -0,0 +1,137 @@ +package middleware + +import ( + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + "github.com/labring/sealos/service/aiproxy/common/network" + "github.com/labring/sealos/service/aiproxy/model" +) + +func AdminAuth(c *gin.Context) { + accessToken := c.Request.Header.Get("Authorization") + if config.AdminKey != "" && (accessToken == "" || strings.TrimPrefix(accessToken, "Bearer ") != config.AdminKey) { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "unauthorized, no access token provided", + }) + c.Abort() + return + } + c.Next() +} + +func TokenAuth(c *gin.Context) { + ctx := c.Request.Context() + key := c.Request.Header.Get("Authorization") + key = strings.TrimPrefix( + strings.TrimPrefix(key, "Bearer "), + "sk-", + ) + parts := strings.Split(key, "-") + key = parts[0] + token, err := model.ValidateAndGetToken(key) + if err != nil { + abortWithMessage(c, http.StatusUnauthorized, err.Error()) + return + } + if token.Subnet != "" { + if !network.IsIPInSubnets(ctx, c.ClientIP(), token.Subnet) { + abortWithMessage(c, http.StatusForbidden, + fmt.Sprintf("token (%s[%d]) can only be used in the specified subnet: %s, current ip: %s", + token.Name, + token.ID, + token.Subnet, + c.ClientIP(), + ), + ) + return + } + } + group, err := model.CacheGetGroup(token.Group) + if err != nil { + abortWithMessage(c, http.StatusInternalServerError, err.Error()) + return + } + requestModel, err := getRequestModel(c) + if err != nil && shouldCheckModel(c) { + abortWithMessage(c, http.StatusBadRequest, err.Error()) + return + } + c.Set(ctxkey.RequestModel, requestModel) + if len(token.Models) == 0 { + token.Models = model.CacheGetAllModels() + if requestModel != "" && len(token.Models) == 0 { + abortWithMessage(c, + http.StatusForbidden, + fmt.Sprintf("token (%s[%d]) has no permission to use any model", + token.Name, token.ID, + ), + ) + return + } + } + c.Set(ctxkey.AvailableModels, []string(token.Models)) + if requestModel != "" && !slices.Contains(token.Models, requestModel) { + abortWithMessage(c, + http.StatusForbidden, + fmt.Sprintf("token (%s[%d]) has no permission to use model: %s", + token.Name, token.ID, requestModel, + ), + ) + return + } + + if group.QPM <= 0 { + group.QPM = config.GetDefaultGroupQPM() + } + + if group.QPM > 0 { + ok := ForceRateLimit(ctx, "group_qpm:"+group.ID, int(group.QPM), time.Minute) + if !ok { + abortWithMessage(c, http.StatusTooManyRequests, + group.ID+" is requesting too frequently", + ) + return + } + } + + c.Set(ctxkey.Group, token.Group) + c.Set(ctxkey.GroupQPM, group.QPM) + c.Set(ctxkey.TokenID, token.ID) + c.Set(ctxkey.TokenName, token.Name) + c.Set(ctxkey.TokenUsedAmount, token.UsedAmount) + c.Set(ctxkey.TokenQuota, token.Quota) + // if len(parts) > 1 { + // c.Set(ctxkey.SpecificChannelId, parts[1]) + // } + + // set channel id for proxy relay + if channelID := c.Param("channelid"); channelID != "" { + c.Set(ctxkey.SpecificChannelID, channelID) + } + + c.Next() +} + +func shouldCheckModel(c *gin.Context) bool { + if strings.HasPrefix(c.Request.URL.Path, "/v1/completions") { + return true + } + if strings.HasPrefix(c.Request.URL.Path, "/v1/chat/completions") { + return true + } + if strings.HasPrefix(c.Request.URL.Path, "/v1/images") { + return true + } + if strings.HasPrefix(c.Request.URL.Path, "/v1/audio") { + return true + } + return false +} diff --git a/service/aiproxy/middleware/cors.go b/service/aiproxy/middleware/cors.go new file mode 100644 index 00000000000..d2a109abece --- /dev/null +++ b/service/aiproxy/middleware/cors.go @@ -0,0 +1,15 @@ +package middleware + +import ( + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func CORS() gin.HandlerFunc { + config := cors.DefaultConfig() + config.AllowAllOrigins = true + config.AllowCredentials = true + config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} + config.AllowHeaders = []string{"*"} + return cors.New(config) +} diff --git a/service/aiproxy/middleware/distributor.go b/service/aiproxy/middleware/distributor.go new file mode 100644 index 00000000000..826f1c34568 --- /dev/null +++ b/service/aiproxy/middleware/distributor.go @@ -0,0 +1,90 @@ +package middleware + +import ( + "net/http" + "slices" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + "github.com/labring/sealos/service/aiproxy/model" + "github.com/labring/sealos/service/aiproxy/relay/channeltype" +) + +type ModelRequest struct { + Model string `form:"model" json:"model"` +} + +func Distribute(c *gin.Context) { + if config.GetDisableServe() { + abortWithMessage(c, http.StatusServiceUnavailable, "service is under maintenance") + return + } + requestModel := c.GetString(ctxkey.RequestModel) + if requestModel == "" { + abortWithMessage(c, http.StatusBadRequest, "no model provided") + return + } + var channel *model.Channel + channelID, ok := c.Get(ctxkey.SpecificChannelID) + if ok { + id, err := strconv.Atoi(channelID.(string)) + if err != nil { + abortWithMessage(c, http.StatusBadRequest, "invalid channel ID") + return + } + channel, ok = model.CacheGetChannelByID(id) + if !ok { + abortWithMessage(c, http.StatusBadRequest, "invalid channel ID") + return + } + if !slices.Contains(channel.Models, requestModel) { + abortWithMessage(c, http.StatusServiceUnavailable, channel.Name+" does not support "+requestModel) + return + } + } else { + var err error + channel, err = model.CacheGetRandomSatisfiedChannel(requestModel) + if err != nil { + message := requestModel + " is not available" + abortWithMessage(c, http.StatusServiceUnavailable, message) + return + } + } + SetupContextForSelectedChannel(c, channel, requestModel) + c.Next() +} + +func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) { + c.Set(ctxkey.Channel, channel.Type) + c.Set(ctxkey.ChannelID, channel.ID) + c.Set(ctxkey.APIKey, channel.Key) + c.Set(ctxkey.ChannelName, channel.Name) + c.Set(ctxkey.ModelMapping, channel.ModelMapping) + c.Set(ctxkey.OriginalModel, modelName) // for retry + c.Set(ctxkey.BaseURL, channel.BaseURL) + cfg := channel.Config + // this is for backward compatibility + if channel.Other != "" { + switch channel.Type { + case channeltype.Azure: + if cfg.APIVersion == "" { + cfg.APIVersion = channel.Other + } + case channeltype.Gemini: + if cfg.APIVersion == "" { + cfg.APIVersion = channel.Other + } + case channeltype.AIProxyLibrary: + if cfg.LibraryID == "" { + cfg.LibraryID = channel.Other + } + case channeltype.Ali: + if cfg.Plugin == "" { + cfg.Plugin = channel.Other + } + } + } + c.Set(ctxkey.Config, cfg) +} diff --git a/service/aiproxy/middleware/logger.go b/service/aiproxy/middleware/logger.go new file mode 100644 index 00000000000..75c3fa66dbc --- /dev/null +++ b/service/aiproxy/middleware/logger.go @@ -0,0 +1,26 @@ +package middleware + +import ( + "fmt" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common/helper" +) + +func SetUpLogger(server *gin.Engine) { + server.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { + var requestID string + if param.Keys != nil { + requestID = param.Keys[string(helper.RequestIDKey)].(string) + } + return fmt.Sprintf("[GIN] %s | %s | %3d | %13v | %15s | %7s %s\n", + param.TimeStamp.Format("2006/01/02 - 15:04:05"), + requestID, + param.StatusCode, + param.Latency, + param.ClientIP, + param.Method, + param.Path, + ) + })) +} diff --git a/service/aiproxy/middleware/rate-limit.go b/service/aiproxy/middleware/rate-limit.go new file mode 100644 index 00000000000..2783ab458b1 --- /dev/null +++ b/service/aiproxy/middleware/rate-limit.go @@ -0,0 +1,101 @@ +package middleware + +import ( + "context" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/common/logger" +) + +var inMemoryRateLimiter common.InMemoryRateLimiter + +// 1. 使用Redis列表存储请求时间戳 +// 2. 列表长度代表当前窗口内的请求数 +// 3. 如果请求数未达到限制,直接添加新请求并返回成功 +// 4. 如果达到限制,则检查最老的请求是否已经过期 +// 5. 如果最老的请求已过期,移除它并添加新请求,否则拒绝新请求 +// 6. 通过EXPIRE命令设置键的过期时间,自动清理过期数据 +var luaScript = ` +local key = KEYS[1] +local max_requests = tonumber(ARGV[1]) +local window = tonumber(ARGV[2]) +local current_time = tonumber(ARGV[3]) + +local count = redis.call('LLEN', key) + +if count < max_requests then + redis.call('LPUSH', key, current_time) + redis.call('PEXPIRE', key, window) + return 1 +else + local oldest = redis.call('LINDEX', key, -1) + if current_time - tonumber(oldest) >= window then + redis.call('LPUSH', key, current_time) + redis.call('LTRIM', key, 0, max_requests - 1) + redis.call('PEXPIRE', key, window) + return 1 + else + return 0 + end +end +` + +func redisRateLimitRequest(ctx context.Context, key string, maxRequestNum int, duration time.Duration) (bool, error) { + rdb := common.RDB + currentTime := time.Now().UnixMilli() + result, err := rdb.Eval(ctx, luaScript, []string{key}, maxRequestNum, duration.Milliseconds(), currentTime).Int64() + if err != nil { + return false, err + } + return result == 1, nil +} + +func RateLimit(ctx context.Context, key string, maxRequestNum int, duration time.Duration) (bool, error) { + if maxRequestNum == 0 { + return true, nil + } + if common.RedisEnabled { + return redisRateLimitRequest(ctx, key, maxRequestNum, duration) + } + return MemoryRateLimit(ctx, key, maxRequestNum, duration), nil +} + +// ignore redis error +func ForceRateLimit(ctx context.Context, key string, maxRequestNum int, duration time.Duration) bool { + if maxRequestNum == 0 { + return true + } + if common.RedisEnabled { + ok, err := redisRateLimitRequest(ctx, key, maxRequestNum, duration) + if err == nil { + return ok + } + logger.Error(ctx, "rate limit error: "+err.Error()) + } + return MemoryRateLimit(ctx, key, maxRequestNum, duration) +} + +func MemoryRateLimit(_ context.Context, key string, maxRequestNum int, duration time.Duration) bool { + // It's safe to call multi times. + inMemoryRateLimiter.Init(config.RateLimitKeyExpirationDuration) + return inMemoryRateLimiter.Request(key, maxRequestNum, duration) +} + +func GlobalAPIRateLimit(c *gin.Context) { + globalAPIRateLimitNum := config.GetGlobalAPIRateLimitNum() + if globalAPIRateLimitNum <= 0 { + c.Next() + return + } + ok := ForceRateLimit(c.Request.Context(), "global_qpm", int(globalAPIRateLimitNum), time.Minute) + if !ok { + c.Status(http.StatusTooManyRequests) + c.Abort() + return + } + c.Next() +} diff --git a/service/aiproxy/middleware/recover.go b/service/aiproxy/middleware/recover.go new file mode 100644 index 00000000000..d76c1792ccb --- /dev/null +++ b/service/aiproxy/middleware/recover.go @@ -0,0 +1,32 @@ +package middleware + +import ( + "fmt" + "net/http" + "runtime/debug" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/logger" +) + +func RelayPanicRecover(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + ctx := c.Request.Context() + logger.Errorf(ctx, "panic detected: %v", err) + logger.Errorf(ctx, "stacktrace from panic: %s", debug.Stack()) + logger.Errorf(ctx, "request: %s %s", c.Request.Method, c.Request.URL.Path) + body, _ := common.GetRequestBody(c) + logger.Errorf(ctx, "request body: %s", body) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "message": fmt.Sprintf("Panic detected, error: %v.", err), + "type": "aiproxy_panic", + }, + }) + c.Abort() + } + }() + c.Next() +} diff --git a/service/aiproxy/middleware/request-id.go b/service/aiproxy/middleware/request-id.go new file mode 100644 index 00000000000..aabca3a04e3 --- /dev/null +++ b/service/aiproxy/middleware/request-id.go @@ -0,0 +1,17 @@ +package middleware + +import ( + "context" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common/helper" +) + +func RequestID(c *gin.Context) { + id := helper.GenRequestID() + c.Set(string(helper.RequestIDKey), id) + ctx := context.WithValue(c.Request.Context(), helper.RequestIDKey, id) + c.Request = c.Request.WithContext(ctx) + c.Header(string(helper.RequestIDKey), id) + c.Next() +} diff --git a/service/aiproxy/middleware/utils.go b/service/aiproxy/middleware/utils.go new file mode 100644 index 00000000000..91eedb46c03 --- /dev/null +++ b/service/aiproxy/middleware/utils.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "fmt" + "strings" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/logger" +) + +func abortWithMessage(c *gin.Context, statusCode int, message string) { + c.JSON(statusCode, gin.H{ + "error": gin.H{ + "message": helper.MessageWithRequestID(message, c.GetString(string(helper.RequestIDKey))), + "type": "aiproxy_error", + }, + }) + c.Abort() + logger.Error(c.Request.Context(), message) +} + +func getRequestModel(c *gin.Context) (string, error) { + path := c.Request.URL.Path + switch { + case strings.HasPrefix(path, "/v1/moderations"): + return "text-moderation-stable", nil + case strings.HasPrefix(path, "/v1/images/generations"): + return "dall-e-2", nil + case strings.HasPrefix(path, "/v1/audio/transcriptions"), strings.HasPrefix(path, "/v1/audio/translations"): + return c.Request.FormValue("model"), nil + default: + var modelRequest ModelRequest + err := common.UnmarshalBodyReusable(c, &modelRequest) + if err != nil { + return "", fmt.Errorf("get request model failed: %w", err) + } + return modelRequest.Model, nil + } +} diff --git a/service/aiproxy/model/cache.go b/service/aiproxy/model/cache.go new file mode 100644 index 00000000000..be58a19cb20 --- /dev/null +++ b/service/aiproxy/model/cache.go @@ -0,0 +1,399 @@ +package model + +import ( + "context" + "encoding" + "errors" + "fmt" + "math/rand/v2" + "sort" + "sync" + "time" + + json "github.com/json-iterator/go" + "github.com/redis/go-redis/v9" + + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/common/conv" + "github.com/labring/sealos/service/aiproxy/common/logger" +) + +const ( + SyncFrequency = time.Minute * 3 + TokenCacheKey = "token:%s" + GroupCacheKey = "group:%s" +) + +var ( + _ encoding.BinaryMarshaler = (*redisStringSlice)(nil) + _ redis.Scanner = (*redisStringSlice)(nil) +) + +type redisStringSlice []string + +func (r *redisStringSlice) ScanRedis(value string) error { + return json.Unmarshal(conv.StringToBytes(value), r) +} + +func (r redisStringSlice) MarshalBinary() ([]byte, error) { + return json.Marshal(r) +} + +type redisTime time.Time + +func (t *redisTime) ScanRedis(value string) error { + return (*time.Time)(t).UnmarshalBinary(conv.StringToBytes(value)) +} + +func (t redisTime) MarshalBinary() ([]byte, error) { + return time.Time(t).MarshalBinary() +} + +type TokenCache struct { + ExpiredAt redisTime `json:"expired_at" redis:"e"` + Group string `json:"group" redis:"g"` + Key string `json:"-" redis:"-"` + Name string `json:"name" redis:"n"` + Subnet string `json:"subnet" redis:"s"` + Models redisStringSlice `json:"models" redis:"m"` + ID int `json:"id" redis:"i"` + Status int `json:"status" redis:"st"` + Quota float64 `json:"quota" redis:"q"` + UsedAmount float64 `json:"used_amount" redis:"u"` +} + +func (t *Token) ToTokenCache() *TokenCache { + return &TokenCache{ + ID: t.ID, + Group: t.GroupID, + Name: t.Name.String(), + Models: t.Models, + Subnet: t.Subnet, + Status: t.Status, + ExpiredAt: redisTime(t.ExpiredAt), + Quota: t.Quota, + UsedAmount: t.UsedAmount, + } +} + +func CacheDeleteToken(key string) error { + if !common.RedisEnabled { + return nil + } + return common.RedisDel(fmt.Sprintf(TokenCacheKey, key)) +} + +func CacheSetToken(token *Token) error { + if !common.RedisEnabled { + return nil + } + key := fmt.Sprintf(TokenCacheKey, token.Key) + pipe := common.RDB.Pipeline() + pipe.HSet(context.Background(), key, token.ToTokenCache()) + expireTime := SyncFrequency + time.Duration(rand.Int64N(60)-30)*time.Second + pipe.Expire(context.Background(), key, expireTime) + _, err := pipe.Exec(context.Background()) + return err +} + +func CacheGetTokenByKey(key string) (*TokenCache, error) { + if !common.RedisEnabled { + token, err := GetTokenByKey(key) + if err != nil { + return nil, err + } + return token.ToTokenCache(), nil + } + + cacheKey := fmt.Sprintf(TokenCacheKey, key) + tokenCache := &TokenCache{} + err := common.RDB.HGetAll(context.Background(), cacheKey).Scan(tokenCache) + if err == nil && tokenCache.ID != 0 { + tokenCache.Key = key + return tokenCache, nil + } else if err != nil && err != redis.Nil { + logger.SysLogf("get token (%s) from redis error: %s", key, err.Error()) + } + + token, err := GetTokenByKey(key) + if err != nil { + return nil, err + } + + if err := CacheSetToken(token); err != nil { + logger.SysError("redis set token error: " + err.Error()) + } + + return token.ToTokenCache(), nil +} + +var updateTokenUsedAmountScript = redis.NewScript(` + if redis.call("HExists", KEYS[1], "used_amount") then + redis.call("HSet", KEYS[1], "used_amount", ARGV[1]) + end + return redis.status_reply("ok") +`) + +var updateTokenUsedAmountOnlyIncreaseScript = redis.NewScript(` + local used_amount = redis.call("HGet", KEYS[1], "used_amount") + if used_amount == false then + return redis.status_reply("ok") + end + if ARGV[1] < used_amount then + return redis.status_reply("ok") + end + redis.call("HSet", KEYS[1], "used_amount", ARGV[1]) + return redis.status_reply("ok") +`) + +var increaseTokenUsedAmountScript = redis.NewScript(` + local used_amount = redis.call("HGet", KEYS[1], "used_amount") + if used_amount == false then + return redis.status_reply("ok") + end + redis.call("HSet", KEYS[1], "used_amount", used_amount + ARGV[1]) + return redis.status_reply("ok") +`) + +func CacheUpdateTokenUsedAmount(key string, amount float64) error { + if !common.RedisEnabled { + return nil + } + return updateTokenUsedAmountScript.Run(context.Background(), common.RDB, []string{fmt.Sprintf(TokenCacheKey, key)}, amount).Err() +} + +func CacheUpdateTokenUsedAmountOnlyIncrease(key string, amount float64) error { + if !common.RedisEnabled { + return nil + } + return updateTokenUsedAmountOnlyIncreaseScript.Run(context.Background(), common.RDB, []string{fmt.Sprintf(TokenCacheKey, key)}, amount).Err() +} + +func CacheIncreaseTokenUsedAmount(key string, amount float64) error { + if !common.RedisEnabled { + return nil + } + return increaseTokenUsedAmountScript.Run(context.Background(), common.RDB, []string{fmt.Sprintf(TokenCacheKey, key)}, amount).Err() +} + +type GroupCache struct { + ID string `json:"-" redis:"-"` + Status int `json:"status" redis:"st"` + QPM int64 `json:"qpm" redis:"q"` +} + +func (g *Group) ToGroupCache() *GroupCache { + return &GroupCache{ + ID: g.ID, + Status: g.Status, + QPM: g.QPM, + } +} + +func CacheDeleteGroup(id string) error { + if !common.RedisEnabled { + return nil + } + return common.RedisDel(fmt.Sprintf(GroupCacheKey, id)) +} + +var updateGroupQPMScript = redis.NewScript(` + if redis.call("HExists", KEYS[1], "qpm") then + redis.call("HSet", KEYS[1], "qpm", ARGV[1]) + end + return redis.status_reply("ok") +`) + +func CacheUpdateGroupQPM(id string, qpm int64) error { + if !common.RedisEnabled { + return nil + } + return updateGroupQPMScript.Run(context.Background(), common.RDB, []string{fmt.Sprintf(GroupCacheKey, id)}, qpm).Err() +} + +var updateGroupStatusScript = redis.NewScript(` + if redis.call("HExists", KEYS[1], "status") then + redis.call("HSet", KEYS[1], "status", ARGV[1]) + end + return redis.status_reply("ok") +`) + +func CacheUpdateGroupStatus(id string, status int) error { + if !common.RedisEnabled { + return nil + } + return updateGroupStatusScript.Run(context.Background(), common.RDB, []string{fmt.Sprintf(GroupCacheKey, id)}, status).Err() +} + +func CacheSetGroup(group *Group) error { + if !common.RedisEnabled { + return nil + } + key := fmt.Sprintf(GroupCacheKey, group.ID) + pipe := common.RDB.Pipeline() + pipe.HSet(context.Background(), key, group.ToGroupCache()) + expireTime := SyncFrequency + time.Duration(rand.Int64N(60)-30)*time.Second + pipe.Expire(context.Background(), key, expireTime) + _, err := pipe.Exec(context.Background()) + return err +} + +func CacheGetGroup(id string) (*GroupCache, error) { + if !common.RedisEnabled { + group, err := GetGroupByID(id) + if err != nil { + return nil, err + } + return group.ToGroupCache(), nil + } + + cacheKey := fmt.Sprintf(GroupCacheKey, id) + groupCache := &GroupCache{} + err := common.RDB.HGetAll(context.Background(), cacheKey).Scan(groupCache) + if err == nil && groupCache.Status != 0 { + groupCache.ID = id + return groupCache, nil + } else if err != nil && !errors.Is(err, redis.Nil) { + logger.SysLogf("get group (%s) from redis error: %s", id, err.Error()) + } + + group, err := GetGroupByID(id) + if err != nil { + return nil, err + } + + if err := CacheSetGroup(group); err != nil { + logger.SysError("redis set group error: " + err.Error()) + } + + return group.ToGroupCache(), nil +} + +var ( + model2channels map[string][]*Channel + allModels []string + type2Models map[int][]string + channelID2channel map[int]*Channel + channelSyncLock sync.RWMutex +) + +func CacheGetAllModels() []string { + channelSyncLock.RLock() + defer channelSyncLock.RUnlock() + return allModels +} + +func CacheGetType2Models() map[int][]string { + channelSyncLock.RLock() + defer channelSyncLock.RUnlock() + return type2Models +} + +func CacheGetModelsByType(channelType int) []string { + return CacheGetType2Models()[channelType] +} + +func InitChannelCache() { + newChannelID2channel := make(map[int]*Channel) + var channels []*Channel + DB.Where("status = ?", ChannelStatusEnabled).Find(&channels) + for _, channel := range channels { + if len(channel.Models) == 0 { + channel.Models = config.GetDefaultChannelModels()[channel.Type] + } + if len(channel.ModelMapping) == 0 { + channel.ModelMapping = config.GetDefaultChannelModelMapping()[channel.Type] + } + newChannelID2channel[channel.ID] = channel + } + newModel2channels := make(map[string][]*Channel) + for _, channel := range channels { + for _, model := range channel.Models { + newModel2channels[model] = append(newModel2channels[model], channel) + } + } + + // sort by priority + for _, channels := range newModel2channels { + sort.Slice(channels, func(i, j int) bool { + return channels[i].Priority > channels[j].Priority + }) + } + + models := make([]string, 0, len(newModel2channels)) + for model := range newModel2channels { + models = append(models, model) + } + + newType2ModelsMap := make(map[int]map[string]struct{}) + for _, channel := range channels { + newType2ModelsMap[channel.Type] = make(map[string]struct{}) + for _, model := range channel.Models { + newType2ModelsMap[channel.Type][model] = struct{}{} + } + } + newType2Models := make(map[int][]string) + for k, v := range newType2ModelsMap { + newType2Models[k] = make([]string, 0, len(v)) + for model := range v { + newType2Models[k] = append(newType2Models[k], model) + } + } + + channelSyncLock.Lock() + model2channels = newModel2channels + allModels = models + type2Models = newType2Models + channelID2channel = newChannelID2channel + channelSyncLock.Unlock() + logger.SysDebug("channels synced from database") +} + +func SyncChannelCache(frequency time.Duration) { + ticker := time.NewTicker(frequency) + defer ticker.Stop() + for range ticker.C { + logger.SysDebug("syncing channels from database") + InitChannelCache() + } +} + +func CacheGetRandomSatisfiedChannel(model string) (*Channel, error) { + channelSyncLock.RLock() + channels := model2channels[model] + channelSyncLock.RUnlock() + if len(channels) == 0 { + return nil, errors.New("model not found") + } + + if len(channels) == 1 { + return channels[0], nil + } + + var totalWeight int32 + for _, ch := range channels { + totalWeight += ch.Priority + } + + if totalWeight == 0 { + return channels[rand.IntN(len(channels))], nil + } + + r := rand.Int32N(totalWeight) + for _, ch := range channels { + r -= ch.Priority + if r < 0 { + return ch, nil + } + } + + return channels[rand.IntN(len(channels))], nil +} + +func CacheGetChannelByID(id int) (*Channel, bool) { + channelSyncLock.RLock() + channel, ok := channelID2channel[id] + channelSyncLock.RUnlock() + return channel, ok +} diff --git a/service/aiproxy/model/channel.go b/service/aiproxy/model/channel.go new file mode 100644 index 00000000000..28dd6a133f5 --- /dev/null +++ b/service/aiproxy/model/channel.go @@ -0,0 +1,334 @@ +package model + +import ( + "fmt" + "strings" + "time" + + json "github.com/json-iterator/go" + + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/logger" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + ErrChannelNotFound = "channel" +) + +const ( + ChannelStatusUnknown = 0 + ChannelStatusEnabled = 1 // don't use 0, 0 is the default value! + ChannelStatusManuallyDisabled = 2 // also don't use 0 + ChannelStatusAutoDisabled = 3 +) + +type Channel struct { + CreatedAt time.Time `gorm:"index" json:"created_at"` + AccessedAt time.Time `json:"accessed_at"` + TestAt time.Time `json:"test_at"` + BalanceUpdatedAt time.Time `json:"balance_updated_at"` + ModelMapping map[string]string `gorm:"serializer:fastjson;type:text" json:"model_mapping"` + Config ChannelConfig `gorm:"serializer:fastjson;type:text" json:"config"` + Other string `json:"other"` + Key string `gorm:"type:text;index" json:"key"` + Name string `gorm:"index" json:"name"` + BaseURL string `gorm:"index" json:"base_url"` + Models []string `gorm:"serializer:fastjson;type:text" json:"models"` + Balance float64 `json:"balance"` + ResponseDuration int64 `gorm:"index" json:"response_duration"` + ID int `gorm:"primaryKey" json:"id"` + UsedAmount float64 `gorm:"index" json:"used_amount"` + RequestCount int `gorm:"index" json:"request_count"` + Status int `gorm:"default:1;index" json:"status"` + Type int `gorm:"default:0;index" json:"type"` + Priority int32 `json:"priority"` +} + +func (c *Channel) MarshalJSON() ([]byte, error) { + type Alias Channel + return json.Marshal(&struct { + *Alias + CreatedAt int64 `json:"created_at"` + AccessedAt int64 `json:"accessed_at"` + TestAt int64 `json:"test_at"` + BalanceUpdatedAt int64 `json:"balance_updated_at"` + }{ + Alias: (*Alias)(c), + CreatedAt: c.CreatedAt.UnixMilli(), + AccessedAt: c.AccessedAt.UnixMilli(), + TestAt: c.TestAt.UnixMilli(), + BalanceUpdatedAt: c.BalanceUpdatedAt.UnixMilli(), + }) +} + +//nolint:goconst +func getChannelOrder(order string) string { + switch order { + case "name": + return "name asc" + case "name-desc": + return "name desc" + case "type": + return "type asc" + case "type-desc": + return "type desc" + case "created_at": + return "created_at asc" + case "created_at-desc": + return "created_at desc" + case "accessed_at": + return "accessed_at asc" + case "accessed_at-desc": + return "accessed_at desc" + case "status": + return "status asc" + case "status-desc": + return "status desc" + case "test_at": + return "test_at asc" + case "test_at-desc": + return "test_at desc" + case "balance_updated_at": + return "balance_updated_at asc" + case "balance_updated_at-desc": + return "balance_updated_at desc" + case "used_amount": + return "used_amount asc" + case "used_amount-desc": + return "used_amount desc" + case "request_count": + return "request_count asc" + case "request_count-desc": + return "request_count desc" + case "priority": + return "priority asc" + case "priority-desc": + return "priority desc" + case "id": + return "id asc" + default: + return "id desc" + } +} + +type ChannelConfig struct { + Region string `json:"region,omitempty"` + SK string `json:"sk,omitempty"` + AK string `json:"ak,omitempty"` + UserID string `json:"user_id,omitempty"` + APIVersion string `json:"api_version,omitempty"` + LibraryID string `json:"library_id,omitempty"` + Plugin string `json:"plugin,omitempty"` + VertexAIProjectID string `json:"vertex_ai_project_id,omitempty"` + VertexAIADC string `json:"vertex_ai_adc,omitempty"` +} + +func GetAllChannels(onlyDisabled bool, omitKey bool) (channels []*Channel, err error) { + tx := DB.Model(&Channel{}) + if onlyDisabled { + tx = tx.Where("status = ? or status = ?", ChannelStatusAutoDisabled, ChannelStatusManuallyDisabled) + } + if omitKey { + tx = tx.Omit("key") + } + err = tx.Order("id desc").Find(&channels).Error + return channels, err +} + +func GetChannels(startIdx int, num int, onlyDisabled bool, omitKey bool, id int, name string, key string, channelType int, baseURL string, order string) (channels []*Channel, total int64, err error) { + tx := DB.Model(&Channel{}) + if onlyDisabled { + tx = tx.Where("status = ? or status = ?", ChannelStatusAutoDisabled, ChannelStatusManuallyDisabled) + } + if id != 0 { + tx = tx.Where("id = ?", id) + } + if name != "" { + tx = tx.Where("name = ?", name) + } + if key != "" { + tx = tx.Where("key = ?", key) + } + if channelType != 0 { + tx = tx.Where("type = ?", channelType) + } + if baseURL != "" { + tx = tx.Where("base_url = ?", baseURL) + } + err = tx.Count(&total).Error + if err != nil { + return nil, 0, err + } + if omitKey { + tx = tx.Omit("key") + } + if total <= 0 { + return nil, 0, nil + } + err = tx.Order(getChannelOrder(order)).Limit(num).Offset(startIdx).Find(&channels).Error + return channels, total, err +} + +func SearchChannels(keyword string, startIdx int, num int, onlyDisabled bool, omitKey bool, id int, name string, key string, channelType int, baseURL string, order string) (channels []*Channel, total int64, err error) { + tx := DB.Model(&Channel{}) + if onlyDisabled { + tx = tx.Where("status = ? or status = ?", ChannelStatusAutoDisabled, ChannelStatusManuallyDisabled) + } + + // Handle exact match conditions for non-zero values + if id != 0 { + tx = tx.Where("id = ?", id) + } + if name != "" { + tx = tx.Where("name = ?", name) + } + if key != "" { + tx = tx.Where("key = ?", key) + } + if channelType != 0 { + tx = tx.Where("type = ?", channelType) + } + if baseURL != "" { + tx = tx.Where("base_url = ?", baseURL) + } + + // Handle keyword search for zero value fields + if keyword != "" { + var conditions []string + var values []interface{} + + if id == 0 { + conditions = append(conditions, "id = ?") + values = append(values, helper.String2Int(keyword)) + } + if name == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "name ILIKE ?") + } else { + conditions = append(conditions, "name LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + if key == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "key ILIKE ?") + } else { + conditions = append(conditions, "key LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + if channelType == 0 { + conditions = append(conditions, "type = ?") + values = append(values, helper.String2Int(keyword)) + } + if baseURL == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "base_url ILIKE ?") + } else { + conditions = append(conditions, "base_url LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + + if len(conditions) > 0 { + tx = tx.Where(fmt.Sprintf("(%s)", strings.Join(conditions, " OR ")), values...) + } + } + + err = tx.Count(&total).Error + if err != nil { + return nil, 0, err + } + if omitKey { + tx = tx.Omit("key") + } + if total <= 0 { + return nil, 0, nil + } + err = tx.Order(getChannelOrder(order)).Limit(num).Offset(startIdx).Find(&channels).Error + return channels, total, err +} + +func GetChannelByID(id int, omitKey bool) (*Channel, error) { + channel := Channel{ID: id} + var err error + if omitKey { + err = DB.Omit("key").First(&channel, "id = ?", id).Error + } else { + err = DB.First(&channel, "id = ?", id).Error + } + if err != nil { + return nil, err + } + return &channel, nil +} + +func BatchInsertChannels(channels []*Channel) error { + return DB.Transaction(func(tx *gorm.DB) error { + return tx.Create(&channels).Error + }) +} + +func UpdateChannel(channel *Channel) error { + result := DB. + Model(channel). + Omit("accessed_at", "used_amount", "request_count", "balance_updated_at", "created_at", "balance", "test_at", "balance_updated_at"). + Clauses(clause.Returning{}). + Updates(channel) + return HandleUpdateResult(result, ErrChannelNotFound) +} + +func (c *Channel) UpdateResponseTime(responseTime int64) { + err := DB.Model(c).Select("test_at", "response_duration").Updates(Channel{ + TestAt: time.Now(), + ResponseDuration: responseTime, + }).Error + if err != nil { + logger.SysError("failed to update response time: " + err.Error()) + } +} + +func (c *Channel) UpdateBalance(balance float64) { + err := DB.Model(c).Select("balance_updated_at", "balance").Updates(Channel{ + BalanceUpdatedAt: time.Now(), + Balance: balance, + }).Error + if err != nil { + logger.SysError("failed to update balance: " + err.Error()) + } +} + +func DeleteChannelByID(id int) error { + result := DB.Delete(&Channel{ID: id}) + return HandleUpdateResult(result, ErrChannelNotFound) +} + +func UpdateChannelStatusByID(id int, status int) error { + result := DB.Model(&Channel{}).Where("id = ?", id).Update("status", status) + return HandleUpdateResult(result, ErrChannelNotFound) +} + +func DisableChannelByID(id int) error { + return UpdateChannelStatusByID(id, ChannelStatusAutoDisabled) +} + +func EnableChannelByID(id int) error { + return UpdateChannelStatusByID(id, ChannelStatusEnabled) +} + +func UpdateChannelUsedAmount(id int, amount float64, requestCount int) error { + result := DB.Model(&Channel{}).Where("id = ?", id).Updates(map[string]interface{}{ + "used_amount": gorm.Expr("used_amount + ?", amount), + "request_count": gorm.Expr("request_count + ?", requestCount), + "accessed_at": time.Now(), + }) + return HandleUpdateResult(result, ErrChannelNotFound) +} + +func DeleteDisabledChannel() error { + result := DB.Where("status = ? or status = ?", ChannelStatusAutoDisabled, ChannelStatusManuallyDisabled).Delete(&Channel{}) + return HandleUpdateResult(result, ErrChannelNotFound) +} diff --git a/service/aiproxy/model/consumeerr.go b/service/aiproxy/model/consumeerr.go new file mode 100644 index 00000000000..7bf76b9b49e --- /dev/null +++ b/service/aiproxy/model/consumeerr.go @@ -0,0 +1,133 @@ +package model + +import ( + "fmt" + "strings" + "time" + + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/helper" +) + +type ConsumeError struct { + CreatedAt time.Time `gorm:"index" json:"created_at"` + GroupID string `gorm:"index" json:"group_id"` + TokenName EmptyNullString `gorm:"index;not null" json:"token_name"` + Model string `gorm:"index" json:"model"` + Content string `gorm:"type:text" json:"content"` + ID int `gorm:"primaryKey" json:"id"` + UsedAmount float64 `gorm:"index" json:"used_amount"` + TokenID int `gorm:"index" json:"token_id"` +} + +func (c *ConsumeError) MarshalJSON() ([]byte, error) { + type Alias ConsumeError + return json.Marshal(&struct { + *Alias + CreatedAt int64 `json:"created_at"` + }{ + Alias: (*Alias)(c), + CreatedAt: c.CreatedAt.UnixMilli(), + }) +} + +func CreateConsumeError(group string, tokenName string, model string, content string, usedAmount float64, tokenID int) error { + return LogDB.Create(&ConsumeError{ + GroupID: group, + TokenName: EmptyNullString(tokenName), + Model: model, + Content: content, + UsedAmount: usedAmount, + TokenID: tokenID, + }).Error +} + +func SearchConsumeError(keyword string, group string, tokenName string, model string, content string, usedAmount float64, tokenID int, page int, perPage int, order string) ([]*ConsumeError, int64, error) { + tx := LogDB.Model(&ConsumeError{}) + + // Handle exact match conditions for non-zero values + if group != "" { + tx = tx.Where("group_id = ?", group) + } + if tokenName != "" { + tx = tx.Where("token_name = ?", tokenName) + } + if model != "" { + tx = tx.Where("model = ?", model) + } + if content != "" { + tx = tx.Where("content = ?", content) + } + if usedAmount > 0 { + tx = tx.Where("used_amount = ?", usedAmount) + } + if tokenID != 0 { + tx = tx.Where("token_id = ?", tokenID) + } + + // Handle keyword search for zero value fields + if keyword != "" { + var conditions []string + var values []interface{} + + if tokenID == 0 { + conditions = append(conditions, "token_id = ?") + values = append(values, helper.String2Int(keyword)) + } + if group == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "group_id ILIKE ?") + } else { + conditions = append(conditions, "group_id LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + if tokenName == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "token_name ILIKE ?") + } else { + conditions = append(conditions, "token_name LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + if model == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "model ILIKE ?") + } else { + conditions = append(conditions, "model LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + if content == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "content ILIKE ?") + } else { + conditions = append(conditions, "content LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + + if len(conditions) > 0 { + tx = tx.Where(fmt.Sprintf("(%s)", strings.Join(conditions, " OR ")), values...) + } + } + + var total int64 + err := tx.Count(&total).Error + if err != nil { + return nil, 0, err + } + if total <= 0 { + return nil, 0, nil + } + + page-- + if page < 0 { + page = 0 + } + + var errors []*ConsumeError + err = tx.Order(getLogOrder(order)).Limit(perPage).Offset(page * perPage).Find(&errors).Error + return errors, total, err +} diff --git a/service/aiproxy/model/group.go b/service/aiproxy/model/group.go new file mode 100644 index 00000000000..aaf7523f1e3 --- /dev/null +++ b/service/aiproxy/model/group.go @@ -0,0 +1,219 @@ +package model + +import ( + "errors" + "fmt" + "strings" + "time" + + json "github.com/json-iterator/go" + + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/logger" + "gorm.io/gorm" +) + +const ( + ErrGroupNotFound = "group" +) + +const ( + GroupStatusEnabled = 1 // don't use 0, 0 is the default value! + GroupStatusDisabled = 2 // also don't use 0 +) + +type Group struct { + CreatedAt time.Time `json:"created_at"` + AccessedAt time.Time `json:"accessed_at"` + ID string `gorm:"primaryKey" json:"id"` + Tokens []*Token `gorm:"foreignKey:GroupID" json:"-"` + Status int `gorm:"default:1;index" json:"status"` + UsedAmount float64 `gorm:"index" json:"used_amount"` + QPM int64 `gorm:"index" json:"qpm"` + RequestCount int `gorm:"index" json:"request_count"` +} + +func (g *Group) AfterDelete(tx *gorm.DB) (err error) { + return tx.Model(&Token{}).Where("group_id = ?", g.ID).Delete(&Token{}).Error +} + +func (g *Group) MarshalJSON() ([]byte, error) { + type Alias Group + return json.Marshal(&struct { + *Alias + CreatedAt int64 `json:"created_at"` + AccessedAt int64 `json:"accessed_at"` + }{ + Alias: (*Alias)(g), + CreatedAt: g.CreatedAt.UnixMilli(), + AccessedAt: g.AccessedAt.UnixMilli(), + }) +} + +//nolint:goconst +func getGroupOrder(order string) string { + switch order { + case "id-desc": + return "id desc" + case "request_count": + return "request_count asc" + case "request_count-desc": + return "request_count desc" + case "accessed_at": + return "accessed_at asc" + case "accessed_at-desc": + return "accessed_at desc" + case "status": + return "status asc" + case "status-desc": + return "status desc" + case "created_at": + return "created_at asc" + case "created_at-desc": + return "created_at desc" + case "used_amount": + return "used_amount asc" + case "used_amount-desc": + return "used_amount desc" + case "id": + return "id asc" + default: + return "id desc" + } +} + +func GetGroups(startIdx int, num int, order string, onlyDisabled bool) (groups []*Group, total int64, err error) { + tx := DB.Model(&Group{}) + if onlyDisabled { + tx = tx.Where("status = ?", GroupStatusDisabled) + } + + err = tx.Count(&total).Error + if err != nil { + return nil, 0, err + } + + if total <= 0 { + return nil, 0, nil + } + + err = tx.Order(getGroupOrder(order)).Limit(num).Offset(startIdx).Find(&groups).Error + return groups, total, err +} + +func GetGroupByID(id string) (*Group, error) { + if id == "" { + return nil, errors.New("id 为空!") + } + group := Group{ID: id} + err := DB.First(&group, "id = ?", id).Error + return &group, HandleNotFound(err, ErrGroupNotFound) +} + +func DeleteGroupByID(id string) (err error) { + if id == "" { + return errors.New("id 为空!") + } + defer func() { + if err == nil { + if err := CacheDeleteGroup(id); err != nil { + logger.SysError("CacheDeleteGroup failed: " + err.Error()) + } + if _, err := DeleteGroupLogs(id); err != nil { + logger.SysError("DeleteGroupLogs failed: " + err.Error()) + } + } + }() + result := DB. + Delete(&Group{ + ID: id, + }) + return HandleUpdateResult(result, ErrGroupNotFound) +} + +func UpdateGroupUsedAmountAndRequestCount(id string, amount float64, count int) error { + result := DB.Model(&Group{}).Where("id = ?", id).Updates(map[string]interface{}{ + "used_amount": gorm.Expr("used_amount + ?", amount), + "request_count": gorm.Expr("request_count + ?", count), + "accessed_at": time.Now(), + }) + return HandleUpdateResult(result, ErrGroupNotFound) +} + +func UpdateGroupUsedAmount(id string, amount float64) error { + result := DB.Model(&Group{}).Where("id = ?", id).Updates(map[string]interface{}{ + "used_amount": gorm.Expr("used_amount + ?", amount), + "accessed_at": time.Now(), + }) + return HandleUpdateResult(result, ErrGroupNotFound) +} + +func UpdateGroupRequestCount(id string, count int) error { + result := DB.Model(&Group{}).Where("id = ?", id).Updates(map[string]interface{}{ + "request_count": gorm.Expr("request_count + ?", count), + "accessed_at": time.Now(), + }) + return HandleUpdateResult(result, ErrGroupNotFound) +} + +func UpdateGroupQPM(id string, qpm int64) (err error) { + defer func() { + if err == nil { + if err := CacheUpdateGroupQPM(id, qpm); err != nil { + logger.SysError("CacheUpdateGroupQPM failed: " + err.Error()) + } + } + }() + result := DB.Model(&Group{}).Where("id = ?", id).Update("qpm", qpm) + return HandleUpdateResult(result, ErrGroupNotFound) +} + +func UpdateGroupStatus(id string, status int) (err error) { + defer func() { + if err == nil { + if err := CacheUpdateGroupStatus(id, status); err != nil { + logger.SysError("CacheUpdateGroupStatus failed: " + err.Error()) + } + } + }() + result := DB.Model(&Group{}).Where("id = ?", id).Update("status", status) + return HandleUpdateResult(result, ErrGroupNotFound) +} + +func SearchGroup(keyword string, startIdx int, num int, order string, status int) (groups []*Group, total int64, err error) { + tx := DB.Model(&Group{}) + if status != 0 { + tx = tx.Where("status = ?", status) + } + if common.UsingPostgreSQL { + tx = tx.Where("id ILIKE ?", "%"+keyword+"%") + } else { + tx = tx.Where("id LIKE ?", "%"+keyword+"%") + } + if keyword != "" { + var conditions []string + var values []interface{} + + if status == 0 { + conditions = append(conditions, "status = ?") + values = append(values, 1) + } + + if len(conditions) > 0 { + tx = tx.Where(fmt.Sprintf("(%s)", strings.Join(conditions, " OR ")), values...) + } + } + err = tx.Count(&total).Error + if err != nil { + return nil, 0, err + } + if total <= 0 { + return nil, 0, nil + } + err = tx.Order(getGroupOrder(order)).Limit(num).Offset(startIdx).Find(&groups).Error + return groups, total, err +} + +func CreateGroup(group *Group) error { + return DB.Create(group).Error +} diff --git a/service/aiproxy/model/log.go b/service/aiproxy/model/log.go new file mode 100644 index 00000000000..15fe1f6807b --- /dev/null +++ b/service/aiproxy/model/log.go @@ -0,0 +1,516 @@ +package model + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + json "github.com/json-iterator/go" + + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/helper" +) + +type Log struct { + CreatedAt time.Time `gorm:"index" json:"created_at"` + TokenName string `gorm:"index" json:"token_name"` + Endpoint string `gorm:"index" json:"endpoint"` + Content string `gorm:"type:text" json:"content"` + GroupID string `gorm:"index" json:"group"` + Model string `gorm:"index" json:"model"` + Price float64 `json:"price"` + ID int `gorm:"primaryKey" json:"id"` + CompletionPrice float64 `json:"completion_price"` + TokenID int `gorm:"index" json:"token_id"` + UsedAmount float64 `gorm:"index" json:"used_amount"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + ChannelID int `gorm:"index" json:"channel"` + Code int `gorm:"index" json:"code"` +} + +func (l *Log) MarshalJSON() ([]byte, error) { + type Alias Log + return json.Marshal(&struct { + *Alias + CreatedAt int64 `json:"created_at"` + }{ + Alias: (*Alias)(l), + CreatedAt: l.CreatedAt.UnixMilli(), + }) +} + +func RecordConsumeLog(_ context.Context, group string, code int, channelID int, promptTokens int, completionTokens int, modelName string, tokenID int, tokenName string, amount float64, price float64, completionPrice float64, endpoint string, content string) error { + log := &Log{ + GroupID: group, + CreatedAt: time.Now(), + Code: code, + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + TokenID: tokenID, + TokenName: tokenName, + Model: modelName, + UsedAmount: amount, + Price: price, + CompletionPrice: completionPrice, + ChannelID: channelID, + Endpoint: endpoint, + Content: content, + } + return LogDB.Create(log).Error +} + +//nolint:goconst +func getLogOrder(order string) string { + switch order { + case "id-desc": + return "id desc" + case "used_amount": + return "used_amount asc" + case "used_amount-desc": + return "used_amount desc" + case "price": + return "price asc" + case "price-desc": + return "price desc" + case "completion_price": + return "completion_price asc" + case "completion_price-desc": + return "completion_price desc" + case "token_id": + return "token_id asc" + case "token_id-desc": + return "token_id desc" + case "token_name": + return "token_name asc" + case "token_name-desc": + return "token_name desc" + case "prompt_tokens": + return "prompt_tokens asc" + case "prompt_tokens-desc": + return "prompt_tokens desc" + case "completion_tokens": + return "completion_tokens asc" + case "completion_tokens-desc": + return "completion_tokens desc" + case "endpoint": + return "endpoint asc" + case "endpoint-desc": + return "endpoint desc" + case "group": + return "group_id asc" + case "group-desc": + return "group_id desc" + case "created_at": + return "created_at asc" + case "created_at-desc": + return "created_at desc" + case "id": + return "id asc" + default: + return "id desc" + } +} + +func GetLogs(startTimestamp time.Time, endTimestamp time.Time, code int, modelName string, group string, tokenID int, tokenName string, startIdx int, num int, channelID int, endpoint string, content string, order string) (logs []*Log, total int64, err error) { + tx := LogDB.Model(&Log{}) + if modelName != "" { + tx = tx.Where("model = ?", modelName) + } + if group != "" { + tx = tx.Where("group_id = ?", group) + } + if tokenID != 0 { + tx = tx.Where("token_id = ?", tokenID) + } + if tokenName != "" { + tx = tx.Where("token_name = ?", tokenName) + } + if !startTimestamp.IsZero() { + tx = tx.Where("created_at >= ?", startTimestamp) + } + if !endTimestamp.IsZero() { + tx = tx.Where("created_at <= ?", endTimestamp) + } + if channelID != 0 { + tx = tx.Where("channel_id = ?", channelID) + } + if endpoint != "" { + tx = tx.Where("endpoint = ?", endpoint) + } + if content != "" { + tx = tx.Where("content = ?", content) + } + if code != 0 { + tx = tx.Where("code = ?", code) + } + err = tx.Count(&total).Error + if err != nil { + return nil, 0, err + } + if total <= 0 { + return nil, 0, nil + } + + err = tx.Order(getLogOrder(order)).Limit(num).Offset(startIdx).Find(&logs).Error + return logs, total, err +} + +func GetGroupLogs(group string, startTimestamp time.Time, endTimestamp time.Time, code int, modelName string, tokenID int, tokenName string, startIdx int, num int, channelID int, endpoint string, content string, order string) (logs []*Log, total int64, err error) { + tx := LogDB.Model(&Log{}).Where("group_id = ?", group) + if modelName != "" { + tx = tx.Where("model = ?", modelName) + } + if tokenID != 0 { + tx = tx.Where("token_id = ?", tokenID) + } + if tokenName != "" { + tx = tx.Where("token_name = ?", tokenName) + } + if !startTimestamp.IsZero() { + tx = tx.Where("created_at >= ?", startTimestamp) + } + if !endTimestamp.IsZero() { + tx = tx.Where("created_at <= ?", endTimestamp) + } + if channelID != 0 { + tx = tx.Where("channel_id = ?", channelID) + } + if endpoint != "" { + tx = tx.Where("endpoint = ?", endpoint) + } + if content != "" { + tx = tx.Where("content = ?", content) + } + if code != 0 { + tx = tx.Where("code = ?", code) + } + err = tx.Count(&total).Error + if err != nil { + return nil, 0, err + } + if total <= 0 { + return nil, 0, nil + } + + err = tx.Order(getLogOrder(order)).Limit(num).Offset(startIdx).Omit("id").Find(&logs).Error + return logs, total, err +} + +func SearchLogs(keyword string, page int, perPage int, code int, endpoint string, groupID string, tokenID int, tokenName string, modelName string, content string, startTimestamp time.Time, endTimestamp time.Time, channelID int, order string) (logs []*Log, total int64, err error) { + tx := LogDB.Model(&Log{}) + + // Handle exact match conditions for non-zero values + if code != 0 { + tx = tx.Where("code = ?", code) + } + if endpoint != "" { + tx = tx.Where("endpoint = ?", endpoint) + } + if groupID != "" { + tx = tx.Where("group_id = ?", groupID) + } + if tokenID != 0 { + tx = tx.Where("token_id = ?", tokenID) + } + if tokenName != "" { + tx = tx.Where("token_name = ?", tokenName) + } + if modelName != "" { + tx = tx.Where("model = ?", modelName) + } + if content != "" { + tx = tx.Where("content = ?", content) + } + if !startTimestamp.IsZero() { + tx = tx.Where("created_at >= ?", startTimestamp) + } + if !endTimestamp.IsZero() { + tx = tx.Where("created_at <= ?", endTimestamp) + } + if channelID != 0 { + tx = tx.Where("channel_id = ?", channelID) + } + + // Handle keyword search for zero value fields + if keyword != "" { + var conditions []string + var values []interface{} + + if code == 0 { + conditions = append(conditions, "code = ?") + values = append(values, helper.String2Int(keyword)) + } + if channelID == 0 { + conditions = append(conditions, "channel_id = ?") + values = append(values, helper.String2Int(keyword)) + } + if endpoint == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "endpoint ILIKE ?") + } else { + conditions = append(conditions, "endpoint LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + if groupID == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "group_id ILIKE ?") + } else { + conditions = append(conditions, "group_id LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + if tokenName == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "token_name ILIKE ?") + } else { + conditions = append(conditions, "token_name LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + if modelName == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "model ILIKE ?") + } else { + conditions = append(conditions, "model LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + if content == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "content ILIKE ?") + } else { + conditions = append(conditions, "content LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + + if len(conditions) > 0 { + tx = tx.Where(fmt.Sprintf("(%s)", strings.Join(conditions, " OR ")), values...) + } + } + + err = tx.Count(&total).Error + if err != nil { + return nil, 0, err + } + if total <= 0 { + return nil, 0, nil + } + + page-- + if page < 0 { + page = 0 + } + err = tx.Order(getLogOrder(order)).Limit(perPage).Offset(page * perPage).Find(&logs).Error + return logs, total, err +} + +func SearchGroupLogs(group string, keyword string, page int, perPage int, code int, endpoint string, tokenID int, tokenName string, modelName string, content string, startTimestamp time.Time, endTimestamp time.Time, channelID int, order string) (logs []*Log, total int64, err error) { + if group == "" { + return nil, 0, errors.New("group is empty") + } + tx := LogDB.Model(&Log{}).Where("group_id = ?", group) + + // Handle exact match conditions for non-zero values + if code != 0 { + tx = tx.Where("code = ?", code) + } + if endpoint != "" { + tx = tx.Where("endpoint = ?", endpoint) + } + if tokenID != 0 { + tx = tx.Where("token_id = ?", tokenID) + } + if tokenName != "" { + tx = tx.Where("token_name = ?", tokenName) + } + if modelName != "" { + tx = tx.Where("model = ?", modelName) + } + if content != "" { + tx = tx.Where("content = ?", content) + } + if !startTimestamp.IsZero() { + tx = tx.Where("created_at >= ?", startTimestamp) + } + if !endTimestamp.IsZero() { + tx = tx.Where("created_at <= ?", endTimestamp) + } + if channelID != 0 { + tx = tx.Where("channel_id = ?", channelID) + } + + // Handle keyword search for zero value fields + if keyword != "" { + var conditions []string + var values []interface{} + + if code == 0 { + conditions = append(conditions, "code = ?") + values = append(values, helper.String2Int(keyword)) + } + if channelID == 0 { + conditions = append(conditions, "channel_id = ?") + values = append(values, helper.String2Int(keyword)) + } + if endpoint == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "endpoint ILIKE ?") + } else { + conditions = append(conditions, "endpoint LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + if tokenName == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "token_name ILIKE ?") + } else { + conditions = append(conditions, "token_name LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + if modelName == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "model ILIKE ?") + } else { + conditions = append(conditions, "model LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + if content == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "content ILIKE ?") + } else { + conditions = append(conditions, "content LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + + if len(conditions) > 0 { + tx = tx.Where(fmt.Sprintf("(%s)", strings.Join(conditions, " OR ")), values...) + } + } + + err = tx.Count(&total).Error + if err != nil { + return nil, 0, err + } + if total <= 0 { + return nil, 0, nil + } + + page-- + if page < 0 { + page = 0 + } + + err = tx.Order(getLogOrder(order)).Limit(perPage).Offset(page * perPage).Find(&logs).Error + return logs, total, err +} + +func SumUsedQuota(startTimestamp time.Time, endTimestamp time.Time, modelName string, group string, tokenName string, channel int, endpoint string) (quota int64) { + ifnull := "ifnull" + if common.UsingPostgreSQL { + ifnull = "COALESCE" + } + tx := LogDB.Table("logs").Select(ifnull + "(sum(quota),0)") + if group != "" { + tx = tx.Where("group_id = ?", group) + } + if tokenName != "" { + tx = tx.Where("token_name = ?", tokenName) + } + if !startTimestamp.IsZero() { + tx = tx.Where("created_at >= ?", startTimestamp) + } + if !endTimestamp.IsZero() { + tx = tx.Where("created_at <= ?", endTimestamp) + } + if modelName != "" { + tx = tx.Where("model = ?", modelName) + } + if channel != 0 { + tx = tx.Where("channel_id = ?", channel) + } + if endpoint != "" { + tx = tx.Where("endpoint = ?", endpoint) + } + tx.Scan("a) + return quota +} + +func SumUsedToken(startTimestamp time.Time, endTimestamp time.Time, modelName string, group string, tokenName string, endpoint string) (token int) { + ifnull := "ifnull" + if common.UsingPostgreSQL { + ifnull = "COALESCE" + } + tx := LogDB.Table("logs").Select(fmt.Sprintf("%s(sum(prompt_tokens),0) + %s(sum(completion_tokens),0)", ifnull, ifnull)) + if group != "" { + tx = tx.Where("group_id = ?", group) + } + if tokenName != "" { + tx = tx.Where("token_name = ?", tokenName) + } + if !startTimestamp.IsZero() { + tx = tx.Where("created_at >= ?", startTimestamp) + } + if !endTimestamp.IsZero() { + tx = tx.Where("created_at <= ?", endTimestamp) + } + if modelName != "" { + tx = tx.Where("model = ?", modelName) + } + if endpoint != "" { + tx = tx.Where("endpoint = ?", endpoint) + } + tx.Scan(&token) + return token +} + +func DeleteOldLog(timestamp time.Time) (int64, error) { + result := LogDB.Where("created_at < ?", timestamp).Delete(&Log{}) + return result.RowsAffected, result.Error +} + +func DeleteGroupLogs(groupID string) (int64, error) { + result := LogDB.Where("group_id = ?", groupID).Delete(&Log{}) + return result.RowsAffected, result.Error +} + +type LogStatistic struct { + Day string `gorm:"column:day"` + Model string `gorm:"column:model"` + RequestCount int `gorm:"column:request_count"` + PromptTokens int `gorm:"column:prompt_tokens"` + CompletionTokens int `gorm:"column:completion_tokens"` +} + +func SearchLogsByDayAndModel(group string, start time.Time, end time.Time) (logStatistics []*LogStatistic, err error) { + groupSelect := "DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%m-%d') as day" + + if common.UsingPostgreSQL { + groupSelect = "TO_CHAR(date_trunc('day', to_timestamp(created_at)), 'YYYY-MM-DD') as day" + } + + if common.UsingSQLite { + groupSelect = "strftime('%Y-%m-%d', datetime(created_at, 'unixepoch')) as day" + } + + err = LogDB.Raw(` + SELECT `+groupSelect+`, + model, count(1) as request_count, + sum(prompt_tokens) as prompt_tokens, + sum(completion_tokens) as completion_tokens + FROM logs + WHERE group_id = ? + AND created_at BETWEEN ? AND ? + GROUP BY day, model + ORDER BY day, model + `, group, start, end).Scan(&logStatistics).Error + + return logStatistics, err +} diff --git a/service/aiproxy/model/main.go b/service/aiproxy/model/main.go new file mode 100644 index 00000000000..2009f9a08ae --- /dev/null +++ b/service/aiproxy/model/main.go @@ -0,0 +1,217 @@ +package model + +import ( + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/glebarez/sqlite" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/common/env" + + // import fastjson serializer + _ "github.com/labring/sealos/service/aiproxy/common/fastJSONSerializer" + "github.com/labring/sealos/service/aiproxy/common/logger" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/gorm" + gormLogger "gorm.io/gorm/logger" +) + +var ( + DB *gorm.DB + LogDB *gorm.DB +) + +func chooseDB(envName string) (*gorm.DB, error) { + dsn := os.Getenv(envName) + + switch { + case strings.HasPrefix(dsn, "postgres"): + // Use PostgreSQL + return openPostgreSQL(dsn) + case dsn != "": + // Use MySQL + return openMySQL(dsn) + default: + // Use SQLite + return openSQLite() + } +} + +func newDBLogger() gormLogger.Interface { + var logLevel gormLogger.LogLevel + if config.DebugSQLEnabled { + logLevel = gormLogger.Info + } else { + logLevel = gormLogger.Warn + } + return gormLogger.New( + log.New(os.Stdout, "", log.LstdFlags), + gormLogger.Config{ + SlowThreshold: time.Second, + LogLevel: logLevel, + IgnoreRecordNotFoundError: true, + ParameterizedQueries: !config.DebugSQLEnabled, + Colorful: true, + }, + ) +} + +func openPostgreSQL(dsn string) (*gorm.DB, error) { + logger.SysLog("using PostgreSQL as database") + common.UsingPostgreSQL = true + return gorm.Open(postgres.New(postgres.Config{ + DSN: dsn, + PreferSimpleProtocol: true, // disables implicit prepared statement usage + }), &gorm.Config{ + PrepareStmt: true, // precompile SQL + TranslateError: true, + Logger: newDBLogger(), + DisableForeignKeyConstraintWhenMigrating: false, + IgnoreRelationshipsWhenMigrating: false, + }) +} + +func openMySQL(dsn string) (*gorm.DB, error) { + logger.SysLog("using MySQL as database") + common.UsingMySQL = true + return gorm.Open(mysql.Open(dsn), &gorm.Config{ + PrepareStmt: true, // precompile SQL + TranslateError: true, + Logger: newDBLogger(), + DisableForeignKeyConstraintWhenMigrating: false, + IgnoreRelationshipsWhenMigrating: false, + }) +} + +func openSQLite() (*gorm.DB, error) { + logger.SysLog("SQL_DSN not set, using SQLite as database") + common.UsingSQLite = true + dsn := fmt.Sprintf("%s?_busy_timeout=%d", common.SQLitePath, common.SQLiteBusyTimeout) + return gorm.Open(sqlite.Open(dsn), &gorm.Config{ + PrepareStmt: true, // precompile SQL + TranslateError: true, + Logger: newDBLogger(), + DisableForeignKeyConstraintWhenMigrating: false, + IgnoreRelationshipsWhenMigrating: false, + }) +} + +func InitDB() { + var err error + DB, err = chooseDB("SQL_DSN") + if err != nil { + logger.FatalLog("failed to initialize database: " + err.Error()) + return + } + + setDBConns(DB) + + if config.DisableAutoMigrateDB { + return + } + + logger.SysLog("database migration started") + if err = migrateDB(); err != nil { + logger.FatalLog("failed to migrate database: " + err.Error()) + return + } + logger.SysLog("database migrated") +} + +func migrateDB() error { + err := DB.AutoMigrate( + &Channel{}, + &Token{}, + &Group{}, + &Option{}, + ) + if err != nil { + return err + } + return nil +} + +func InitLogDB() { + if os.Getenv("LOG_SQL_DSN") == "" { + LogDB = DB + if config.DisableAutoMigrateDB { + return + } + err := migrateLOGDB() + if err != nil { + logger.FatalLog("failed to migrate secondary database: " + err.Error()) + return + } + logger.SysLog("secondary database migrated") + return + } + + logger.SysLog("using secondary database for table logs") + var err error + LogDB, err = chooseDB("LOG_SQL_DSN") + if err != nil { + logger.FatalLog("failed to initialize secondary database: " + err.Error()) + return + } + + setDBConns(LogDB) + + if config.DisableAutoMigrateDB { + return + } + + logger.SysLog("secondary database migration started") + err = migrateLOGDB() + if err != nil { + logger.FatalLog("failed to migrate secondary database: " + err.Error()) + return + } + logger.SysLog("secondary database migrated") +} + +func migrateLOGDB() error { + return LogDB.AutoMigrate( + &Log{}, + &ConsumeError{}, + ) +} + +func setDBConns(db *gorm.DB) { + if config.DebugSQLEnabled { + db = db.Debug() + } + + sqlDB, err := db.DB() + if err != nil { + logger.FatalLog("failed to connect database: " + err.Error()) + return + } + + sqlDB.SetMaxIdleConns(env.Int("SQL_MAX_IDLE_CONNS", 100)) + sqlDB.SetMaxOpenConns(env.Int("SQL_MAX_OPEN_CONNS", 1000)) + sqlDB.SetConnMaxLifetime(time.Second * time.Duration(env.Int("SQL_MAX_LIFETIME", 60))) +} + +func closeDB(db *gorm.DB) error { + sqlDB, err := db.DB() + if err != nil { + return err + } + err = sqlDB.Close() + return err +} + +func CloseDB() error { + if LogDB != DB { + err := closeDB(LogDB) + if err != nil { + return err + } + } + return closeDB(DB) +} diff --git a/service/aiproxy/model/option.go b/service/aiproxy/model/option.go new file mode 100644 index 00000000000..d530d63286d --- /dev/null +++ b/service/aiproxy/model/option.go @@ -0,0 +1,176 @@ +package model + +import ( + "errors" + "strconv" + "time" + + json "github.com/json-iterator/go" + + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/common/conv" + "github.com/labring/sealos/service/aiproxy/common/logger" + billingprice "github.com/labring/sealos/service/aiproxy/relay/price" +) + +type Option struct { + Key string `gorm:"primaryKey" json:"key"` + Value string `json:"value"` +} + +func AllOption() ([]*Option, error) { + var options []*Option + err := DB.Find(&options).Error + return options, err +} + +func InitOptionMap() { + config.OptionMapRWMutex.Lock() + config.OptionMap = make(map[string]string) + config.OptionMap["DisableServe"] = strconv.FormatBool(config.GetDisableServe()) + config.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(config.GetAutomaticDisableChannelEnabled()) + config.OptionMap["AutomaticEnableChannelWhenTestSucceedEnabled"] = strconv.FormatBool(config.GetAutomaticEnableChannelWhenTestSucceedEnabled()) + config.OptionMap["ApproximateTokenEnabled"] = strconv.FormatBool(config.GetApproximateTokenEnabled()) + config.OptionMap["BillingEnabled"] = strconv.FormatBool(billingprice.GetBillingEnabled()) + config.OptionMap["ModelPrice"] = billingprice.ModelPrice2JSONString() + config.OptionMap["CompletionPrice"] = billingprice.CompletionPrice2JSONString() + config.OptionMap["RetryTimes"] = strconv.FormatInt(config.GetRetryTimes(), 10) + config.OptionMap["GlobalApiRateLimitNum"] = strconv.FormatInt(config.GetGlobalAPIRateLimitNum(), 10) + config.OptionMap["DefaultGroupQPM"] = strconv.FormatInt(config.GetDefaultGroupQPM(), 10) + defaultChannelModelsJSON, _ := json.Marshal(config.GetDefaultChannelModels()) + config.OptionMap["DefaultChannelModels"] = conv.BytesToString(defaultChannelModelsJSON) + defaultChannelModelMappingJSON, _ := json.Marshal(config.GetDefaultChannelModelMapping()) + config.OptionMap["DefaultChannelModelMapping"] = conv.BytesToString(defaultChannelModelMappingJSON) + config.OptionMap["GeminiSafetySetting"] = config.GetGeminiSafetySetting() + config.OptionMap["GeminiVersion"] = config.GetGeminiVersion() + config.OptionMap["GroupMaxTokenNum"] = strconv.FormatInt(int64(config.GetGroupMaxTokenNum()), 10) + config.OptionMapRWMutex.Unlock() + loadOptionsFromDatabase() +} + +func loadOptionsFromDatabase() { + options, _ := AllOption() + for _, option := range options { + if option.Key == "ModelPrice" { + option.Value = billingprice.AddNewMissingPrice(option.Value) + } + err := updateOptionMap(option.Key, option.Value) + if err != nil { + logger.SysError("failed to update option map: " + err.Error()) + } + } + logger.SysDebug("options synced from database") +} + +func SyncOptions(frequency time.Duration) { + ticker := time.NewTicker(frequency) + defer ticker.Stop() + for range ticker.C { + logger.SysDebug("syncing options from database") + loadOptionsFromDatabase() + } +} + +func UpdateOption(key string, value string) error { + err := updateOptionMap(key, value) + if err != nil { + return err + } + // Save to database first + option := Option{ + Key: key, + } + err = DB.Assign(Option{Key: key, Value: value}).FirstOrCreate(&option).Error + if err != nil { + return err + } + return nil +} + +func UpdateOptions(options map[string]string) error { + errs := make([]error, 0) + for key, value := range options { + err := UpdateOption(key, value) + if err != nil && !errors.Is(err, ErrUnknownOptionKey) { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} + +var ErrUnknownOptionKey = errors.New("unknown option key") + +func isTrue(value string) bool { + result, _ := strconv.ParseBool(value) + return result +} + +func updateOptionMap(key string, value string) (err error) { + config.OptionMapRWMutex.Lock() + defer config.OptionMapRWMutex.Unlock() + config.OptionMap[key] = value + switch key { + case "DisableServe": + config.SetDisableServe(isTrue(value)) + case "AutomaticDisableChannelEnabled": + config.SetAutomaticDisableChannelEnabled(isTrue(value)) + case "AutomaticEnableChannelWhenTestSucceedEnabled": + config.SetAutomaticEnableChannelWhenTestSucceedEnabled(isTrue(value)) + case "ApproximateTokenEnabled": + config.SetApproximateTokenEnabled(isTrue(value)) + case "BillingEnabled": + billingprice.SetBillingEnabled(isTrue(value)) + case "GroupMaxTokenNum": + groupMaxTokenNum, err := strconv.ParseInt(value, 10, 32) + if err != nil { + return err + } + config.SetGroupMaxTokenNum(int32(groupMaxTokenNum)) + case "GeminiSafetySetting": + config.SetGeminiSafetySetting(value) + case "GeminiVersion": + config.SetGeminiVersion(value) + case "GlobalApiRateLimitNum": + globalAPIRateLimitNum, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return err + } + config.SetGlobalAPIRateLimitNum(globalAPIRateLimitNum) + case "DefaultGroupQPM": + defaultGroupQPM, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return err + } + config.SetDefaultGroupQPM(defaultGroupQPM) + case "DefaultChannelModels": + var newModules map[int][]string + err := json.Unmarshal(conv.StringToBytes(value), &newModules) + if err != nil { + return err + } + config.SetDefaultChannelModels(newModules) + case "DefaultChannelModelMapping": + var newMapping map[int]map[string]string + err := json.Unmarshal(conv.StringToBytes(value), &newMapping) + if err != nil { + return err + } + config.SetDefaultChannelModelMapping(newMapping) + case "RetryTimes": + retryTimes, err := strconv.ParseInt(value, 10, 32) + if err != nil { + return err + } + config.SetRetryTimes(retryTimes) + case "ModelPrice": + err = billingprice.UpdateModelPriceByJSONString(value) + case "CompletionPrice": + err = billingprice.UpdateCompletionPriceByJSONString(value) + default: + return ErrUnknownOptionKey + } + return err +} diff --git a/service/aiproxy/model/token.go b/service/aiproxy/model/token.go new file mode 100644 index 00000000000..66485de4fd9 --- /dev/null +++ b/service/aiproxy/model/token.go @@ -0,0 +1,604 @@ +package model + +import ( + "errors" + "fmt" + "strings" + "time" + + json "github.com/json-iterator/go" + + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/common/logger" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + ErrTokenNotFound = "token" +) + +const ( + TokenStatusEnabled = 1 // don't use 0, 0 is the default value! + TokenStatusDisabled = 2 // also don't use 0 + TokenStatusExpired = 3 + TokenStatusExhausted = 4 +) + +type Token struct { + CreatedAt time.Time `json:"created_at"` + ExpiredAt time.Time `json:"expired_at"` + AccessedAt time.Time `gorm:"index" json:"accessed_at"` + Group *Group `gorm:"foreignKey:GroupID" json:"-"` + Key string `gorm:"type:char(48);uniqueIndex" json:"key"` + Name EmptyNullString `gorm:"index;uniqueIndex:idx_group_name;not null" json:"name"` + GroupID string `gorm:"index;uniqueIndex:idx_group_name" json:"group"` + Subnet string `json:"subnet"` + Models []string `gorm:"serializer:fastjson;type:text" json:"models"` + Status int `gorm:"default:1;index" json:"status"` + ID int `gorm:"primaryKey" json:"id"` + Quota float64 `json:"quota"` + UsedAmount float64 `gorm:"index" json:"used_amount"` + RequestCount int `gorm:"index" json:"request_count"` +} + +func (t *Token) MarshalJSON() ([]byte, error) { + type Alias Token + return json.Marshal(&struct { + *Alias + CreatedAt int64 `json:"created_at"` + AccessedAt int64 `json:"accessed_at"` + ExpiredAt int64 `json:"expired_at"` + }{ + Alias: (*Alias)(t), + CreatedAt: t.CreatedAt.UnixMilli(), + AccessedAt: t.AccessedAt.UnixMilli(), + ExpiredAt: t.ExpiredAt.UnixMilli(), + }) +} + +//nolint:goconst +func getTokenOrder(order string) string { + switch order { + case "name": + return "name asc" + case "name-desc": + return "name desc" + case "accessed_at": + return "accessed_at asc" + case "accessed_at-desc": + return "accessed_at desc" + case "expired_at": + return "expired_at asc" + case "expired_at-desc": + return "expired_at desc" + case "group": + return "group_id asc" + case "group-desc": + return "group_id desc" + case "used_amount": + return "used_amount asc" + case "used_amount-desc": + return "used_amount desc" + case "request_count": + return "request_count asc" + case "request_count-desc": + return "request_count desc" + case "id": + return "id asc" + default: + return "id desc" + } +} + +func InsertToken(token *Token, autoCreateGroup bool) error { + if autoCreateGroup { + group := &Group{ + ID: token.GroupID, + } + if err := OnConflictDoNothing().Create(group).Error; err != nil { + return err + } + } + maxTokenNum := config.GetGroupMaxTokenNum() + err := DB.Transaction(func(tx *gorm.DB) error { + if maxTokenNum > 0 { + var count int64 + err := tx.Model(&Token{}).Where("group_id = ?", token.GroupID).Count(&count).Error + if err != nil { + return err + } + if count >= int64(maxTokenNum) { + return errors.New("group max token num reached") + } + } + return tx.Create(token).Error + }) + if err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return errors.New("token name already exists in this group") + } + return err + } + return nil +} + +func GetTokens(startIdx int, num int, order string, group string, status int) (tokens []*Token, total int64, err error) { + tx := DB.Model(&Token{}) + + if group != "" { + tx = tx.Where("group_id = ?", group) + } + if status != 0 { + tx = tx.Where("status = ?", status) + } + + err = tx.Count(&total).Error + if err != nil { + return nil, 0, err + } + + if total <= 0 { + return nil, 0, nil + } + err = tx.Order(getTokenOrder(order)).Limit(num).Offset(startIdx).Find(&tokens).Error + return tokens, total, err +} + +func GetGroupTokens(group string, startIdx int, num int, order string, status int) (tokens []*Token, total int64, err error) { + if group == "" { + return nil, 0, errors.New("group is empty") + } + + tx := DB.Model(&Token{}).Where("group_id = ?", group) + + if status != 0 { + tx = tx.Where("status = ?", status) + } + + err = tx.Count(&total).Error + if err != nil { + return nil, 0, err + } + + if total <= 0 { + return nil, 0, nil + } + err = tx.Order(getTokenOrder(order)).Limit(num).Offset(startIdx).Find(&tokens).Error + return tokens, total, err +} + +func SearchTokens(keyword string, startIdx int, num int, order string, status int, name string, key string, group string) (tokens []*Token, total int64, err error) { + tx := DB.Model(&Token{}) + if group != "" { + tx = tx.Where("group_id = ?", group) + } + if status != 0 { + tx = tx.Where("status = ?", status) + } + if name != "" { + tx = tx.Where("name = ?", name) + } + if key != "" { + tx = tx.Where("key = ?", key) + } + + if keyword != "" { + var conditions []string + var values []interface{} + if status == 0 { + conditions = append(conditions, "status = ?") + values = append(values, 1) + } + if group == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "group_id ILIKE ?") + } else { + conditions = append(conditions, "group_id LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + if name == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "name ILIKE ?") + } else { + conditions = append(conditions, "name LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + if key == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "key ILIKE ?") + } else { + conditions = append(conditions, "key LIKE ?") + } + values = append(values, keyword) + } + if len(conditions) > 0 { + tx = tx.Where(fmt.Sprintf("(%s)", strings.Join(conditions, " OR ")), values...) + } + } + + err = tx.Count(&total).Error + if err != nil { + return nil, 0, err + } + if total <= 0 { + return nil, 0, nil + } + err = tx.Order(getTokenOrder(order)).Limit(num).Offset(startIdx).Find(&tokens).Error + return tokens, total, err +} + +func SearchGroupTokens(group string, keyword string, startIdx int, num int, order string, status int, name string, key string) (tokens []*Token, total int64, err error) { + if group == "" { + return nil, 0, errors.New("group is empty") + } + tx := DB.Model(&Token{}).Where("group_id = ?", group) + if status != 0 { + tx = tx.Where("status = ?", status) + } + if name != "" { + tx = tx.Where("name = ?", name) + } + if key != "" { + tx = tx.Where("key = ?", key) + } + + if keyword != "" { + var conditions []string + var values []interface{} + if status == 0 { + conditions = append(conditions, "status = ?") + values = append(values, 1) + } + if name == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "name ILIKE ?") + } else { + conditions = append(conditions, "name LIKE ?") + } + values = append(values, "%"+keyword+"%") + } + if key == "" { + if common.UsingPostgreSQL { + conditions = append(conditions, "key ILIKE ?") + } else { + conditions = append(conditions, "key LIKE ?") + } + values = append(values, keyword) + } + if len(conditions) > 0 { + tx = tx.Where(fmt.Sprintf("(%s)", strings.Join(conditions, " OR ")), values...) + } + } + + err = tx.Count(&total).Error + if err != nil { + return nil, 0, err + } + if total <= 0 { + return nil, 0, nil + } + err = tx.Order(getTokenOrder(order)).Limit(num).Offset(startIdx).Find(&tokens).Error + return tokens, total, err +} + +func GetTokenByKey(key string) (*Token, error) { + var token Token + err := DB.Where("key = ?", key).First(&token).Error + return &token, HandleNotFound(err, ErrTokenNotFound) +} + +func GetTokenUsedAmount(id int) (float64, error) { + var amount float64 + err := DB.Model(&Token{}).Where("id = ?", id).Select("used_amount").Scan(&amount).Error + return amount, HandleNotFound(err, ErrTokenNotFound) +} + +func GetTokenUsedAmountByKey(key string) (float64, error) { + var amount float64 + err := DB.Model(&Token{}).Where("key = ?", key).Select("used_amount").Scan(&amount).Error + return amount, HandleNotFound(err, ErrTokenNotFound) +} + +func ValidateAndGetToken(key string) (token *TokenCache, err error) { + if key == "" { + return nil, errors.New("no token provided") + } + token, err = CacheGetTokenByKey(key) + if err != nil { + logger.SysError("get token from cache failed: " + err.Error()) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("invalid token") + } + return nil, errors.New("token validation failed") + } + switch token.Status { + case TokenStatusExhausted: + return nil, fmt.Errorf("token (%s[%d]) quota is exhausted", token.Name, token.ID) + case TokenStatusExpired: + return nil, fmt.Errorf("token (%s[%d]) is expired", token.Name, token.ID) + } + if token.Status != TokenStatusEnabled { + return nil, fmt.Errorf("token (%s[%d]) is not available", token.Name, token.ID) + } + if !time.Time(token.ExpiredAt).IsZero() && time.Time(token.ExpiredAt).Before(time.Now()) { + err := UpdateTokenStatusAndAccessedAt(token.ID, TokenStatusExpired) + if err != nil { + logger.SysError("failed to update token status" + err.Error()) + } + return nil, fmt.Errorf("token (%s[%d]) is expired", token.Name, token.ID) + } + if token.Quota > 0 && token.UsedAmount >= token.Quota { + // in this case, we can make sure the token is exhausted + err := UpdateTokenStatusAndAccessedAt(token.ID, TokenStatusExhausted) + if err != nil { + logger.SysError("failed to update token status" + err.Error()) + } + return nil, fmt.Errorf("token (%s[%d]) quota is exhausted", token.Name, token.ID) + } + return token, nil +} + +func GetGroupTokenByID(group string, id int) (*Token, error) { + if id == 0 || group == "" { + return nil, errors.New("id or group is empty") + } + token := Token{} + err := DB. + Where("id = ? and group_id = ?", id, group). + First(&token).Error + return &token, HandleNotFound(err, ErrTokenNotFound) +} + +func GetTokenByID(id int) (*Token, error) { + if id == 0 { + return nil, errors.New("id is empty") + } + token := Token{ID: id} + err := DB.First(&token, "id = ?", id).Error + return &token, HandleNotFound(err, ErrTokenNotFound) +} + +func UpdateTokenStatus(id int, status int) (err error) { + token := Token{ID: id} + defer func() { + if err == nil { + if err := CacheDeleteToken(token.Key); err != nil { + logger.SysError("delete token from cache failed: " + err.Error()) + } + } + }() + result := DB. + Model(&token). + Clauses(clause.Returning{ + Columns: []clause.Column{ + {Name: "key"}, + }, + }). + Where("id = ?", id). + Updates( + map[string]interface{}{ + "status": status, + }, + ) + return HandleUpdateResult(result, ErrTokenNotFound) +} + +func UpdateTokenStatusAndAccessedAt(id int, status int) (err error) { + token := Token{ID: id} + defer func() { + if err == nil { + if err := CacheDeleteToken(token.Key); err != nil { + logger.SysError("delete token from cache failed: " + err.Error()) + } + } + }() + result := DB. + Model(&token). + Clauses(clause.Returning{ + Columns: []clause.Column{ + {Name: "key"}, + }, + }). + Where("id = ?", id).Updates( + map[string]interface{}{ + "status": status, + "accessed_at": time.Now(), + }, + ) + return HandleUpdateResult(result, ErrTokenNotFound) +} + +func UpdateGroupTokenStatusAndAccessedAt(group string, id int, status int) (err error) { + token := Token{} + defer func() { + if err == nil { + if err := CacheDeleteToken(token.Key); err != nil { + logger.SysError("delete token from cache failed: " + err.Error()) + } + } + }() + result := DB. + Model(&token). + Clauses(clause.Returning{ + Columns: []clause.Column{ + {Name: "key"}, + }, + }). + Where("id = ? and group_id = ?", id, group). + Updates( + map[string]interface{}{ + "status": status, + "accessed_at": time.Now(), + }, + ) + return HandleUpdateResult(result, ErrTokenNotFound) +} + +func UpdateGroupTokenStatus(group string, id int, status int) (err error) { + token := Token{} + defer func() { + if err == nil { + if err := CacheDeleteToken(token.Key); err != nil { + logger.SysError("delete token from cache failed: " + err.Error()) + } + } + }() + result := DB. + Model(&token). + Clauses(clause.Returning{ + Columns: []clause.Column{ + {Name: "key"}, + }, + }). + Where("id = ? and group_id = ?", id, group). + Updates( + map[string]interface{}{ + "status": status, + }, + ) + return HandleUpdateResult(result, ErrTokenNotFound) +} + +func DeleteTokenByIDAndGroupID(id int, groupID string) (err error) { + if id == 0 || groupID == "" { + return errors.New("id 或 group 为空!") + } + token := Token{ID: id, GroupID: groupID} + defer func() { + if err == nil { + if err := CacheDeleteToken(token.Key); err != nil { + logger.SysError("delete token from cache failed: " + err.Error()) + } + } + }() + result := DB. + Clauses(clause.Returning{ + Columns: []clause.Column{ + {Name: "key"}, + }, + }). + Where(token). + Delete(&token) + return HandleUpdateResult(result, ErrTokenNotFound) +} + +func DeleteTokenByID(id int) (err error) { + if id == 0 { + return errors.New("id 为空!") + } + token := Token{ID: id} + defer func() { + if err == nil { + if err := CacheDeleteToken(token.Key); err != nil { + logger.SysError("delete token from cache failed: " + err.Error()) + } + } + }() + result := DB. + Clauses(clause.Returning{ + Columns: []clause.Column{ + {Name: "key"}, + }, + }). + Where(token). + Delete(&token) + return HandleUpdateResult(result, ErrTokenNotFound) +} + +func UpdateToken(token *Token) (err error) { + defer func() { + if err == nil { + if err := CacheDeleteToken(token.Key); err != nil { + logger.SysError("delete token from cache failed: " + err.Error()) + } + } + }() + result := DB.Omit("created_at", "status", "key", "group_id", "used_amount", "request_count").Save(token) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrDuplicatedKey) { + return errors.New("token name already exists in this group") + } + } + return HandleUpdateResult(result, ErrTokenNotFound) +} + +func UpdateTokenUsedAmount(id int, amount float64, requestCount int) (err error) { + token := &Token{ID: id} + defer func() { + if amount > 0 && err == nil && token.Quota > 0 { + if err := CacheUpdateTokenUsedAmountOnlyIncrease(token.Key, token.UsedAmount); err != nil { + logger.SysError("update token used amount in cache failed: " + err.Error()) + } + } + }() + result := DB. + Model(token). + Clauses(clause.Returning{ + Columns: []clause.Column{ + {Name: "key"}, + {Name: "quota"}, + {Name: "used_amount"}, + }, + }). + Where("id = ?", id). + Updates( + map[string]interface{}{ + "used_amount": gorm.Expr("used_amount + ?", amount), + "request_count": gorm.Expr("request_count + ?", requestCount), + "accessed_at": time.Now(), + }, + ) + return HandleUpdateResult(result, ErrTokenNotFound) +} + +func UpdateTokenName(id int, name string) (err error) { + token := &Token{ID: id} + defer func() { + if err == nil { + if err := CacheDeleteToken(token.Key); err != nil { + logger.SysError("delete token from cache failed: " + err.Error()) + } + } + }() + result := DB. + Model(token). + Clauses(clause.Returning{ + Columns: []clause.Column{ + {Name: "key"}, + }, + }). + Where("id = ?", id). + Update("name", name) + if result.Error != nil && errors.Is(result.Error, gorm.ErrDuplicatedKey) { + return errors.New("token name already exists in this group") + } + return HandleUpdateResult(result, ErrTokenNotFound) +} + +func UpdateGroupTokenName(group string, id int, name string) (err error) { + token := &Token{ID: id, GroupID: group} + defer func() { + if err == nil { + if err := CacheDeleteToken(token.Key); err != nil { + logger.SysError("delete token from cache failed: " + err.Error()) + } + } + }() + result := DB. + Model(token). + Clauses(clause.Returning{ + Columns: []clause.Column{ + {Name: "key"}, + }, + }). + Where("id = ? and group_id = ?", id, group). + Update("name", name) + if result.Error != nil && errors.Is(result.Error, gorm.ErrDuplicatedKey) { + return errors.New("token name already exists in this group") + } + return HandleUpdateResult(result, ErrTokenNotFound) +} diff --git a/service/aiproxy/model/utils.go b/service/aiproxy/model/utils.go new file mode 100644 index 00000000000..7868ef2338d --- /dev/null +++ b/service/aiproxy/model/utils.go @@ -0,0 +1,97 @@ +package model + +import ( + "context" + "database/sql/driver" + "errors" + "fmt" + "strings" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type NotFoundError string + +func (e NotFoundError) Error() string { + return string(e) + " not found" +} + +func HandleNotFound(err error, errMsg ...string) error { + if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { + return NotFoundError(strings.Join(errMsg, " ")) + } + return err +} + +// Helper function to handle update results +func HandleUpdateResult(result *gorm.DB, entityName string) error { + if result.Error != nil { + return HandleNotFound(result.Error, entityName) + } + if result.RowsAffected == 0 { + return NotFoundError(entityName) + } + return nil +} + +func OnConflictDoNothing() *gorm.DB { + return DB.Clauses(clause.OnConflict{ + DoNothing: true, + }) +} + +func BatchRecordConsume(ctx context.Context, group string, code int, channelID int, promptTokens int, completionTokens int, modelName string, tokenID int, tokenName string, amount float64, price float64, completionPrice float64, endpoint string, content string) error { + errs := []error{} + err := RecordConsumeLog(ctx, group, code, channelID, promptTokens, completionTokens, modelName, tokenID, tokenName, amount, price, completionPrice, endpoint, content) + if err != nil { + errs = append(errs, fmt.Errorf("failed to record log: %w", err)) + } + err = UpdateGroupUsedAmountAndRequestCount(group, amount, 1) + if err != nil { + errs = append(errs, fmt.Errorf("failed to update group used amount and request count: %w", err)) + } + err = UpdateTokenUsedAmount(tokenID, amount, 1) + if err != nil { + errs = append(errs, fmt.Errorf("failed to update token used amount: %w", err)) + } + err = UpdateChannelUsedAmount(channelID, amount, 1) + if err != nil { + errs = append(errs, fmt.Errorf("failed to update channel used amount: %w", err)) + } + if len(errs) == 0 { + return nil + } + return errors.Join(errs...) +} + +type EmptyNullString string + +func (ns EmptyNullString) String() string { + return string(ns) +} + +// Scan implements the [Scanner] interface. +func (ns *EmptyNullString) Scan(value any) error { + if value == nil { + *ns = "" + return nil + } + switch v := value.(type) { + case []byte: + *ns = EmptyNullString(v) + case string: + *ns = EmptyNullString(v) + default: + return fmt.Errorf("unsupported type: %T", v) + } + return nil +} + +// Value implements the [driver.Valuer] interface. +func (ns EmptyNullString) Value() (driver.Value, error) { + if ns == "" { + return nil, nil + } + return string(ns), nil +} diff --git a/service/aiproxy/monitor/manage.go b/service/aiproxy/monitor/manage.go new file mode 100644 index 00000000000..15d58110057 --- /dev/null +++ b/service/aiproxy/monitor/manage.go @@ -0,0 +1,55 @@ +package monitor + +import ( + "net/http" + "strings" + + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +func ShouldDisableChannel(err *model.Error, statusCode int) bool { + if !config.GetAutomaticDisableChannelEnabled() { + return false + } + if err == nil { + return false + } + if statusCode == http.StatusUnauthorized { + return true + } + switch err.Type { + case "insufficient_quota", "authentication_error", "permission_error", "forbidden": + return true + } + if err.Code == "invalid_api_key" || err.Code == "account_deactivated" { + return true + } + + lowerMessage := strings.ToLower(err.Message) + if strings.Contains(lowerMessage, "your access was terminated") || + strings.Contains(lowerMessage, "violation of our policies") || + strings.Contains(lowerMessage, "your credit balance is too low") || + strings.Contains(lowerMessage, "organization has been disabled") || + strings.Contains(lowerMessage, "credit") || + strings.Contains(lowerMessage, "balance") || + strings.Contains(lowerMessage, "permission denied") || + strings.Contains(lowerMessage, "organization has been restricted") || // groq + strings.Contains(lowerMessage, "已欠费") { + return true + } + return false +} + +func ShouldEnableChannel(err error, openAIErr *model.Error) bool { + if !config.GetAutomaticEnableChannelWhenTestSucceedEnabled() { + return false + } + if err != nil { + return false + } + if openAIErr != nil { + return false + } + return true +} diff --git a/service/aiproxy/monitor/metric.go b/service/aiproxy/monitor/metric.go new file mode 100644 index 00000000000..bd7b9914606 --- /dev/null +++ b/service/aiproxy/monitor/metric.go @@ -0,0 +1,76 @@ +package monitor + +import ( + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/model" +) + +var ( + store = make(map[int][]bool) + metricSuccessChan = make(chan int, config.MetricSuccessChanSize) + metricFailChan = make(chan int, config.MetricFailChanSize) +) + +func consumeSuccess(channelID int) { + if len(store[channelID]) > config.MetricQueueSize { + store[channelID] = store[channelID][1:] + } + store[channelID] = append(store[channelID], true) +} + +func consumeFail(channelID int) (bool, float64) { + if len(store[channelID]) > config.MetricQueueSize { + store[channelID] = store[channelID][1:] + } + store[channelID] = append(store[channelID], false) + successCount := 0 + for _, success := range store[channelID] { + if success { + successCount++ + } + } + successRate := float64(successCount) / float64(len(store[channelID])) + if len(store[channelID]) < config.MetricQueueSize { + return false, successRate + } + if successRate < config.MetricSuccessRateThreshold { + store[channelID] = make([]bool, 0) + return true, successRate + } + return false, successRate +} + +func metricSuccessConsumer() { + for channelID := range metricSuccessChan { + consumeSuccess(channelID) + } +} + +func metricFailConsumer() { + for channelID := range metricFailChan { + disable, _ := consumeFail(channelID) + if disable { + _ = model.DisableChannelByID(channelID) + } + } +} + +func init() { + if config.EnableMetric { + go metricSuccessConsumer() + go metricFailConsumer() + } +} + +func Emit(channelID int, success bool) { + if !config.EnableMetric { + return + } + go func() { + if success { + metricSuccessChan <- channelID + } else { + metricFailChan <- channelID + } + }() +} diff --git a/service/aiproxy/relay/adaptor.go b/service/aiproxy/relay/adaptor.go new file mode 100644 index 00000000000..11669d62d06 --- /dev/null +++ b/service/aiproxy/relay/adaptor.go @@ -0,0 +1,63 @@ +package relay + +import ( + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/aiproxy" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/ali" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/anthropic" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/aws" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/baidu" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/cloudflare" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/cohere" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/coze" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/deepl" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/gemini" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/ollama" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/palm" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/tencent" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/vertexai" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/xunfei" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/zhipu" + "github.com/labring/sealos/service/aiproxy/relay/apitype" +) + +func GetAdaptor(apiType int) adaptor.Adaptor { + switch apiType { + case apitype.AIProxyLibrary: + return &aiproxy.Adaptor{} + case apitype.Ali: + return &ali.Adaptor{} + case apitype.Anthropic: + return &anthropic.Adaptor{} + case apitype.AwsClaude: + return &aws.Adaptor{} + case apitype.Baidu: + return &baidu.Adaptor{} + case apitype.Gemini: + return &gemini.Adaptor{} + case apitype.OpenAI: + return &openai.Adaptor{} + case apitype.PaLM: + return &palm.Adaptor{} + case apitype.Tencent: + return &tencent.Adaptor{} + case apitype.Xunfei: + return &xunfei.Adaptor{} + case apitype.Zhipu: + return &zhipu.Adaptor{} + case apitype.Ollama: + return &ollama.Adaptor{} + case apitype.Coze: + return &coze.Adaptor{} + case apitype.Cohere: + return &cohere.Adaptor{} + case apitype.Cloudflare: + return &cloudflare.Adaptor{} + case apitype.DeepL: + return &deepl.Adaptor{} + case apitype.VertexAI: + return &vertexai.Adaptor{} + } + return nil +} diff --git a/service/aiproxy/relay/adaptor/ai360/constants.go b/service/aiproxy/relay/adaptor/ai360/constants.go new file mode 100644 index 00000000000..cfc3cb2833f --- /dev/null +++ b/service/aiproxy/relay/adaptor/ai360/constants.go @@ -0,0 +1,8 @@ +package ai360 + +var ModelList = []string{ + "360GPT_S2_V9", + "embedding-bert-512-v1", + "embedding_s1_v1", + "semantic_similarity_s1_v1", +} diff --git a/service/aiproxy/relay/adaptor/aiproxy/adaptor.go b/service/aiproxy/relay/adaptor/aiproxy/adaptor.go new file mode 100644 index 00000000000..dcc15255878 --- /dev/null +++ b/service/aiproxy/relay/adaptor/aiproxy/adaptor.go @@ -0,0 +1,75 @@ +package aiproxy + +import ( + "errors" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +type Adaptor struct { + meta *meta.Meta +} + +func (a *Adaptor) Init(meta *meta.Meta) { + a.meta = meta +} + +func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { + return meta.BaseURL + "/api/library/ask", nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { + adaptor.SetupCommonRequestHeader(c, req, meta) + req.Header.Set("Authorization", "Bearer "+meta.APIKey) + return nil +} + +func (a *Adaptor) ConvertRequest(_ *gin.Context, _ int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + aiProxyLibraryRequest := ConvertRequest(request) + aiProxyLibraryRequest.LibraryID = a.meta.Config.LibraryID + return aiProxyLibraryRequest, nil +} + +func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + return adaptor.DoRequestHelper(a, c, meta, requestBody) +} + +func (a *Adaptor) ConvertSTTRequest(*http.Request) (io.ReadCloser, error) { + return nil, nil +} + +func (a *Adaptor) ConvertTTSRequest(*model.TextToSpeechRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + err, usage = StreamHandler(c, resp) + } else { + err, usage = Handler(c, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return "aiproxy" +} diff --git a/service/aiproxy/relay/adaptor/aiproxy/constants.go b/service/aiproxy/relay/adaptor/aiproxy/constants.go new file mode 100644 index 00000000000..1bdad8b1711 --- /dev/null +++ b/service/aiproxy/relay/adaptor/aiproxy/constants.go @@ -0,0 +1,9 @@ +package aiproxy + +import "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + +var ModelList = []string{""} + +func init() { + ModelList = openai.ModelList +} diff --git a/service/aiproxy/relay/adaptor/aiproxy/main.go b/service/aiproxy/relay/adaptor/aiproxy/main.go new file mode 100644 index 00000000000..a06b0ab10b0 --- /dev/null +++ b/service/aiproxy/relay/adaptor/aiproxy/main.go @@ -0,0 +1,185 @@ +package aiproxy + +import ( + "bufio" + "fmt" + "net/http" + "slices" + "strconv" + + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common/conv" + "github.com/labring/sealos/service/aiproxy/common/render" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/common/random" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/constant" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +// https://docs.aiproxy.io/dev/library#使用已经定制好的知识库进行对话问答 + +func ConvertRequest(request *model.GeneralOpenAIRequest) *LibraryRequest { + query := "" + if len(request.Messages) != 0 { + query = request.Messages[len(request.Messages)-1].StringContent() + } + return &LibraryRequest{ + Model: request.Model, + Stream: request.Stream, + Query: query, + } +} + +func aiProxyDocuments2Markdown(documents []LibraryDocument) string { + if len(documents) == 0 { + return "" + } + content := "\n\n参考文档:\n" + for i, document := range documents { + content += fmt.Sprintf("%d. [%s](%s)\n", i+1, document.Title, document.URL) + } + return content +} + +func responseAIProxyLibrary2OpenAI(response *LibraryResponse) *openai.TextResponse { + content := response.Answer + aiProxyDocuments2Markdown(response.Documents) + choice := openai.TextResponseChoice{ + Index: 0, + Message: model.Message{ + Role: "assistant", + Content: content, + }, + FinishReason: "stop", + } + fullTextResponse := openai.TextResponse{ + ID: "chatcmpl-" + random.GetUUID(), + Object: "chat.completion", + Created: helper.GetTimestamp(), + Choices: []openai.TextResponseChoice{choice}, + } + return &fullTextResponse +} + +func documentsAIProxyLibrary(documents []LibraryDocument) *openai.ChatCompletionsStreamResponse { + var choice openai.ChatCompletionsStreamResponseChoice + choice.Delta.Content = aiProxyDocuments2Markdown(documents) + choice.FinishReason = &constant.StopFinishReason + return &openai.ChatCompletionsStreamResponse{ + ID: "chatcmpl-" + random.GetUUID(), + Object: "chat.completion.chunk", + Created: helper.GetTimestamp(), + Model: "", + Choices: []openai.ChatCompletionsStreamResponseChoice{choice}, + } +} + +func streamResponseAIProxyLibrary2OpenAI(response *LibraryStreamResponse) *openai.ChatCompletionsStreamResponse { + var choice openai.ChatCompletionsStreamResponseChoice + choice.Delta.Content = response.Content + return &openai.ChatCompletionsStreamResponse{ + ID: "chatcmpl-" + random.GetUUID(), + Object: "chat.completion.chunk", + Created: helper.GetTimestamp(), + Model: response.Model, + Choices: []openai.ChatCompletionsStreamResponseChoice{choice}, + } +} + +func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var usage model.Usage + var documents []LibraryDocument + scanner := bufio.NewScanner(resp.Body) + scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + if i := slices.Index(data, '\n'); i >= 0 { + return i + 1, data[0:i], nil + } + if atEOF { + return len(data), data, nil + } + return 0, nil, nil + }) + + common.SetEventStreamHeaders(c) + + for scanner.Scan() { + data := scanner.Bytes() + if len(data) < 6 || conv.BytesToString(data[:6]) != "data: " { + continue + } + data = data[6:] + + if conv.BytesToString(data) == "[DONE]" { + break + } + + var AIProxyLibraryResponse LibraryStreamResponse + err := json.Unmarshal(data, &AIProxyLibraryResponse) + if err != nil { + logger.SysError("error unmarshalling stream response: " + err.Error()) + continue + } + if len(AIProxyLibraryResponse.Documents) != 0 { + documents = AIProxyLibraryResponse.Documents + } + response := streamResponseAIProxyLibrary2OpenAI(&AIProxyLibraryResponse) + err = render.ObjectData(c, response) + if err != nil { + logger.SysError(err.Error()) + } + } + + if err := scanner.Err(); err != nil { + logger.SysError("error reading stream: " + err.Error()) + } + + response := documentsAIProxyLibrary(documents) + err := render.ObjectData(c, response) + if err != nil { + logger.SysError(err.Error()) + } + render.Done(c) + + return nil, &usage +} + +func Handler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var AIProxyLibraryResponse LibraryResponse + err := json.NewDecoder(resp.Body).Decode(&AIProxyLibraryResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if AIProxyLibraryResponse.ErrCode != 0 { + return &model.ErrorWithStatusCode{ + Error: model.Error{ + Message: AIProxyLibraryResponse.Message, + Type: strconv.Itoa(AIProxyLibraryResponse.ErrCode), + Code: AIProxyLibraryResponse.ErrCode, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := responseAIProxyLibrary2OpenAI(&AIProxyLibraryResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + if err != nil { + return openai.ErrorWrapper(err, "write_response_body_failed", http.StatusInternalServerError), nil + } + return nil, &fullTextResponse.Usage +} diff --git a/service/aiproxy/relay/adaptor/aiproxy/model.go b/service/aiproxy/relay/adaptor/aiproxy/model.go new file mode 100644 index 00000000000..4030e5fbc15 --- /dev/null +++ b/service/aiproxy/relay/adaptor/aiproxy/model.go @@ -0,0 +1,32 @@ +package aiproxy + +type LibraryRequest struct { + Model string `json:"model"` + Query string `json:"query"` + LibraryID string `json:"libraryId"` + Stream bool `json:"stream"` +} + +type LibraryError struct { + Message string `json:"message"` + ErrCode int `json:"errCode"` +} + +type LibraryDocument struct { + Title string `json:"title"` + URL string `json:"url"` +} + +type LibraryResponse struct { + LibraryError + Answer string `json:"answer"` + Documents []LibraryDocument `json:"documents"` + Success bool `json:"success"` +} + +type LibraryStreamResponse struct { + Content string `json:"content"` + Model string `json:"model"` + Documents []LibraryDocument `json:"documents"` + Finish bool `json:"finish"` +} diff --git a/service/aiproxy/relay/adaptor/ali/adaptor.go b/service/aiproxy/relay/adaptor/ali/adaptor.go new file mode 100644 index 00000000000..40de93dc11e --- /dev/null +++ b/service/aiproxy/relay/adaptor/ali/adaptor.go @@ -0,0 +1,111 @@ +package ali + +import ( + "errors" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" +) + +// https://help.aliyun.com/zh/dashscope/developer-reference/api-details + +type Adaptor struct { + meta *meta.Meta +} + +func (a *Adaptor) Init(meta *meta.Meta) { + a.meta = meta +} + +func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { + switch meta.Mode { + case relaymode.Embeddings: + return meta.BaseURL + "/api/v1/services/embeddings/text-embedding/text-embedding", nil + case relaymode.ImagesGenerations: + return meta.BaseURL + "/api/v1/services/aigc/text2image/image-synthesis", nil + default: + return meta.BaseURL + "/compatible-mode/v1/chat/completions", nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { + adaptor.SetupCommonRequestHeader(c, req, meta) + if meta.IsStream { + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("X-Dashscope-Sse", "enable") + } + req.Header.Set("Authorization", "Bearer "+meta.APIKey) + + if meta.Mode == relaymode.ImagesGenerations { + req.Header.Set("X-Dashscope-Async", "enable") + } + if a.meta.Config.Plugin != "" { + req.Header.Set("X-Dashscope-Plugin", a.meta.Config.Plugin) + } + return nil +} + +func (a *Adaptor) ConvertRequest(_ *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + switch relayMode { + case relaymode.Embeddings: + aliEmbeddingRequest := ConvertEmbeddingRequest(request) + return aliEmbeddingRequest, nil + default: + aliRequest := ConvertRequest(request) + return aliRequest, nil + } +} + +func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + + aliRequest := ConvertImageRequest(*request) + return aliRequest, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + return adaptor.DoRequestHelper(a, c, meta, requestBody) +} + +func (a *Adaptor) ConvertSTTRequest(*http.Request) (io.ReadCloser, error) { + return nil, nil +} + +func (a *Adaptor) ConvertTTSRequest(*model.TextToSpeechRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + err, _, usage = openai.StreamHandler(c, resp, meta.Mode) + } else { + switch meta.Mode { + case relaymode.Embeddings: + err, usage = EmbeddingHandler(c, resp) + case relaymode.ImagesGenerations: + err, usage = ImageHandler(c, resp, meta.APIKey) + default: + err, usage = openai.Handler(c, resp, meta.PromptTokens, meta.ActualModelName) + } + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return "ali" +} diff --git a/service/aiproxy/relay/adaptor/ali/constants.go b/service/aiproxy/relay/adaptor/ali/constants.go new file mode 100644 index 00000000000..3f24ce2e141 --- /dev/null +++ b/service/aiproxy/relay/adaptor/ali/constants.go @@ -0,0 +1,7 @@ +package ali + +var ModelList = []string{ + "qwen-turbo", "qwen-plus", "qwen-max", "qwen-max-longcontext", + "text-embedding-v1", + "ali-stable-diffusion-xl", "ali-stable-diffusion-v1.5", "wanx-v1", +} diff --git a/service/aiproxy/relay/adaptor/ali/image.go b/service/aiproxy/relay/adaptor/ali/image.go new file mode 100644 index 00000000000..d6b01a3b40a --- /dev/null +++ b/service/aiproxy/relay/adaptor/ali/image.go @@ -0,0 +1,188 @@ +package ali + +import ( + "context" + "encoding/base64" + "errors" + "io" + "net/http" + "time" + + "github.com/gin-gonic/gin" + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +func ImageHandler(c *gin.Context, resp *http.Response, apiKey string) (*model.ErrorWithStatusCode, *model.Usage) { + responseFormat := c.GetString("response_format") + + var aliTaskResponse TaskResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return openai.ErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + err = resp.Body.Close() + if err != nil { + return openai.ErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + err = json.Unmarshal(responseBody, &aliTaskResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + + if aliTaskResponse.Message != "" { + logger.SysErrorf("aliAsyncTask err: %s", responseBody) + return openai.ErrorWrapper(errors.New(aliTaskResponse.Message), "ali_async_task_failed", http.StatusInternalServerError), nil + } + + aliResponse, err := asyncTaskWait(c, aliTaskResponse.Output.TaskID, apiKey) + if err != nil { + return openai.ErrorWrapper(err, "ali_async_task_wait_failed", http.StatusInternalServerError), nil + } + + if aliResponse.Output.TaskStatus != "SUCCEEDED" { + return &model.ErrorWithStatusCode{ + Error: model.Error{ + Message: aliResponse.Output.Message, + Type: "ali_error", + Param: "", + Code: aliResponse.Output.Code, + }, + StatusCode: resp.StatusCode, + }, nil + } + + fullTextResponse := responseAli2OpenAIImage(aliResponse, responseFormat) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil, nil +} + +func asyncTask(ctx context.Context, taskID string, key string) (*TaskResponse, error) { + url := "https://dashscope.aliyuncs.com/api/v1/tasks/" + taskID + + var aliResponse TaskResponse + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return &aliResponse, err + } + + req.Header.Set("Authorization", "Bearer "+key) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + logger.SysError("aliAsyncTask client.Do err: " + err.Error()) + return &aliResponse, err + } + defer resp.Body.Close() + + var response TaskResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + logger.SysError("aliAsyncTask NewDecoder err: " + err.Error()) + return &aliResponse, err + } + + return &response, nil +} + +func asyncTaskWait(ctx context.Context, taskID string, key string) (*TaskResponse, error) { + waitSeconds := 2 + step := 0 + maxStep := 20 + + for { + step++ + rsp, err := asyncTask(ctx, taskID, key) + if err != nil { + return nil, err + } + + if rsp.Output.TaskStatus == "" { + return rsp, nil + } + + switch rsp.Output.TaskStatus { + case "FAILED": + fallthrough + case "CANCELED": + fallthrough + case "SUCCEEDED": + fallthrough + case "UNKNOWN": + return rsp, nil + } + if step >= maxStep { + break + } + time.Sleep(time.Duration(waitSeconds) * time.Second) + } + + return nil, errors.New("aliAsyncTaskWait timeout") +} + +func responseAli2OpenAIImage(response *TaskResponse, responseFormat string) *openai.ImageResponse { + imageResponse := openai.ImageResponse{ + Created: helper.GetTimestamp(), + } + + for _, data := range response.Output.Results { + var b64Json string + if responseFormat == "b64_json" { + // 读取 data.Url 的图片数据并转存到 b64Json + imageData, err := getImageData(data.URL) + if err != nil { + // 处理获取图片数据失败的情况 + logger.SysError("getImageData Error getting image data: " + err.Error()) + continue + } + + // 将图片数据转为 Base64 编码的字符串 + b64Json = Base64Encode(imageData) + } else { + // 如果 responseFormat 不是 "b64_json",则直接使用 data.B64Image + b64Json = data.B64Image + } + + imageResponse.Data = append(imageResponse.Data, openai.ImageData{ + URL: data.URL, + B64Json: b64Json, + RevisedPrompt: "", + }) + } + return &imageResponse +} + +func getImageData(url string) ([]byte, error) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) + if err != nil { + return nil, err + } + response, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer response.Body.Close() + + imageData, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + return imageData, nil +} + +func Base64Encode(data []byte) string { + b64Json := base64.StdEncoding.EncodeToString(data) + return b64Json +} diff --git a/service/aiproxy/relay/adaptor/ali/main.go b/service/aiproxy/relay/adaptor/ali/main.go new file mode 100644 index 00000000000..83ae8bea420 --- /dev/null +++ b/service/aiproxy/relay/adaptor/ali/main.go @@ -0,0 +1,106 @@ +package ali + +import ( + "net/http" + "strings" + + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +// https://help.aliyun.com/document_detail/613695.html?spm=a2c4g.2399480.0.0.1adb778fAdzP9w#341800c0f8w0r + +const EnableSearchModelSuffix = "-internet" + +func ConvertRequest(request *model.GeneralOpenAIRequest) *model.GeneralOpenAIRequest { + if request.TopP != nil && *request.TopP >= 1 { + *request.TopP = 0.9999 + } + if request.Stream { + if request.StreamOptions == nil { + request.StreamOptions = &model.StreamOptions{} + } + request.StreamOptions.IncludeUsage = true + } + return request +} + +func ConvertEmbeddingRequest(request *model.GeneralOpenAIRequest) *EmbeddingRequest { + return &EmbeddingRequest{ + Model: request.Model, + Input: struct { + Texts []string `json:"texts"` + }{ + Texts: request.ParseInput(), + }, + } +} + +func ConvertImageRequest(request model.ImageRequest) *ImageRequest { + var imageRequest ImageRequest + imageRequest.Input.Prompt = request.Prompt + imageRequest.Model = request.Model + imageRequest.Parameters.Size = strings.Replace(request.Size, "x", "*", -1) + imageRequest.Parameters.N = request.N + imageRequest.ResponseFormat = request.ResponseFormat + + return &imageRequest +} + +func EmbeddingHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { + var aliResponse EmbeddingResponse + err := json.NewDecoder(resp.Body).Decode(&aliResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + + err = resp.Body.Close() + if err != nil { + return openai.ErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + + if aliResponse.Code != "" { + return &model.ErrorWithStatusCode{ + Error: model.Error{ + Message: aliResponse.Message, + Type: aliResponse.Code, + Param: aliResponse.RequestID, + Code: aliResponse.Code, + }, + StatusCode: resp.StatusCode, + }, nil + } + requestModel := c.GetString(ctxkey.RequestModel) + fullTextResponse := embeddingResponseAli2OpenAI(&aliResponse) + fullTextResponse.Model = requestModel + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} + +func embeddingResponseAli2OpenAI(response *EmbeddingResponse) *openai.EmbeddingResponse { + openAIEmbeddingResponse := openai.EmbeddingResponse{ + Object: "list", + Data: make([]openai.EmbeddingResponseItem, 0, len(response.Output.Embeddings)), + Model: "text-embedding-v1", + Usage: model.Usage{TotalTokens: response.Usage.TotalTokens}, + } + + for _, item := range response.Output.Embeddings { + openAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, openai.EmbeddingResponseItem{ + Object: `embedding`, + Index: item.TextIndex, + Embedding: item.Embedding, + }) + } + return &openAIEmbeddingResponse +} diff --git a/service/aiproxy/relay/adaptor/ali/model.go b/service/aiproxy/relay/adaptor/ali/model.go new file mode 100644 index 00000000000..d1f1344670b --- /dev/null +++ b/service/aiproxy/relay/adaptor/ali/model.go @@ -0,0 +1,76 @@ +package ali + +type ImageRequest struct { + Input struct { + Prompt string `json:"prompt"` + NegativePrompt string `json:"negative_prompt,omitempty"` + } `json:"input"` + Model string `json:"model"` + ResponseFormat string `json:"response_format,omitempty"` + Parameters struct { + Size string `json:"size,omitempty"` + Steps string `json:"steps,omitempty"` + Scale string `json:"scale,omitempty"` + N int `json:"n,omitempty"` + } `json:"parameters,omitempty"` +} + +type TaskResponse struct { + RequestID string `json:"request_id,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Output struct { + TaskID string `json:"task_id,omitempty"` + TaskStatus string `json:"task_status,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Results []struct { + B64Image string `json:"b64_image,omitempty"` + URL string `json:"url,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + } `json:"results,omitempty"` + TaskMetrics struct { + Total int `json:"TOTAL,omitempty"` + Succeeded int `json:"SUCCEEDED,omitempty"` + Failed int `json:"FAILED,omitempty"` + } `json:"task_metrics,omitempty"` + } `json:"output,omitempty"` + Usage Usage `json:"usage"` + StatusCode int `json:"status_code,omitempty"` +} + +type EmbeddingRequest struct { + Parameters *struct { + TextType string `json:"text_type,omitempty"` + } `json:"parameters,omitempty"` + Model string `json:"model"` + Input struct { + Texts []string `json:"texts"` + } `json:"input"` +} + +type Embedding struct { + Embedding []float64 `json:"embedding"` + TextIndex int `json:"text_index"` +} + +type EmbeddingResponse struct { + Error + Output struct { + Embeddings []Embedding `json:"embeddings"` + } `json:"output"` + Usage Usage `json:"usage"` +} + +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + RequestID string `json:"request_id"` +} + +type Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` +} diff --git a/service/aiproxy/relay/adaptor/anthropic/adaptor.go b/service/aiproxy/relay/adaptor/anthropic/adaptor.go new file mode 100644 index 00000000000..d3a0b85d0b9 --- /dev/null +++ b/service/aiproxy/relay/adaptor/anthropic/adaptor.go @@ -0,0 +1,84 @@ +package anthropic + +import ( + "errors" + "io" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +type Adaptor struct{} + +func (a *Adaptor) Init(_ *meta.Meta) { +} + +func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { + return meta.BaseURL + "/v1/messages", nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { + adaptor.SetupCommonRequestHeader(c, req, meta) + req.Header.Set("X-Api-Key", meta.APIKey) + anthropicVersion := c.Request.Header.Get("Anthropic-Version") + if anthropicVersion == "" { + anthropicVersion = "2023-06-01" + } + req.Header.Set("Anthropic-Version", anthropicVersion) + req.Header.Set("Anthropic-Beta", "messages-2023-12-15") + + // https://x.com/alexalbert__/status/1812921642143900036 + // claude-3-5-sonnet can support 8k context + if strings.HasPrefix(meta.ActualModelName, "claude-3-5-sonnet") { + req.Header.Set("Anthropic-Beta", "max-tokens-3-5-sonnet-2024-07-15") + } + + return nil +} + +func (a *Adaptor) ConvertRequest(_ *gin.Context, _ int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return ConvertRequest(request), nil +} + +func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + return adaptor.DoRequestHelper(a, c, meta, requestBody) +} + +func (a *Adaptor) ConvertSTTRequest(*http.Request) (io.ReadCloser, error) { + return nil, nil +} + +func (a *Adaptor) ConvertTTSRequest(*model.TextToSpeechRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + err, usage = StreamHandler(c, resp) + } else { + err, usage = Handler(c, resp, meta.PromptTokens, meta.ActualModelName) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return "anthropic" +} diff --git a/service/aiproxy/relay/adaptor/anthropic/constants.go b/service/aiproxy/relay/adaptor/anthropic/constants.go new file mode 100644 index 00000000000..cb574706d48 --- /dev/null +++ b/service/aiproxy/relay/adaptor/anthropic/constants.go @@ -0,0 +1,13 @@ +package anthropic + +var ModelList = []string{ + "claude-instant-1.2", "claude-2.0", "claude-2.1", + "claude-3-haiku-20240307", + "claude-3-5-haiku-20241022", + "claude-3-sonnet-20240229", + "claude-3-opus-20240229", + "claude-3-5-sonnet-20240620", + "claude-3-5-sonnet-20241022", + "claude-3-5-sonnet-latest", + "claude-3-5-haiku-20241022", +} diff --git a/service/aiproxy/relay/adaptor/anthropic/main.go b/service/aiproxy/relay/adaptor/anthropic/main.go new file mode 100644 index 00000000000..f0efdfa7a7a --- /dev/null +++ b/service/aiproxy/relay/adaptor/anthropic/main.go @@ -0,0 +1,377 @@ +package anthropic + +import ( + "bufio" + "net/http" + "slices" + + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common/conv" + "github.com/labring/sealos/service/aiproxy/common/render" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/image" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +const toolUseType = "tool_use" + +func stopReasonClaude2OpenAI(reason *string) string { + if reason == nil { + return "" + } + switch *reason { + case "end_turn": + return "stop" + case "stop_sequence": + return "stop" + case "max_tokens": + return "length" + case toolUseType: + return "tool_calls" + default: + return *reason + } +} + +func ConvertRequest(textRequest *model.GeneralOpenAIRequest) *Request { + claudeTools := make([]Tool, 0, len(textRequest.Tools)) + + for _, tool := range textRequest.Tools { + if params, ok := tool.Function.Parameters.(map[string]any); ok { + claudeTools = append(claudeTools, Tool{ + Name: tool.Function.Name, + Description: tool.Function.Description, + InputSchema: InputSchema{ + Type: params["type"].(string), + Properties: params["properties"], + Required: params["required"], + }, + }) + } + } + + claudeRequest := Request{ + Model: textRequest.Model, + MaxTokens: textRequest.MaxTokens, + Temperature: textRequest.Temperature, + TopP: textRequest.TopP, + TopK: textRequest.TopK, + Stream: textRequest.Stream, + Tools: claudeTools, + } + if len(claudeTools) > 0 { + claudeToolChoice := struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + }{Type: "auto"} // default value https://docs.anthropic.com/en/docs/build-with-claude/tool-use#controlling-claudes-output + if choice, ok := textRequest.ToolChoice.(map[string]any); ok { + if function, ok := choice["function"].(map[string]any); ok { + claudeToolChoice.Type = "tool" + claudeToolChoice.Name = function["name"].(string) + } + } else if toolChoiceType, ok := textRequest.ToolChoice.(string); ok { + if toolChoiceType == "any" { + claudeToolChoice.Type = toolChoiceType + } + } + claudeRequest.ToolChoice = claudeToolChoice + } + if claudeRequest.MaxTokens == 0 { + claudeRequest.MaxTokens = 4096 + } + // legacy model name mapping + switch claudeRequest.Model { + case "claude-instant-1": + claudeRequest.Model = "claude-instant-1.1" + case "claude-2": + claudeRequest.Model = "claude-2.1" + } + for _, message := range textRequest.Messages { + if message.Role == "system" && claudeRequest.System == "" { + claudeRequest.System = message.StringContent() + continue + } + claudeMessage := Message{ + Role: message.Role, + } + var content Content + if message.IsStringContent() { + content.Type = "text" + content.Text = message.StringContent() + if message.Role == "tool" { + claudeMessage.Role = "user" + content.Type = "tool_result" + content.Content = content.Text + content.Text = "" + content.ToolUseID = message.ToolCallID + } + claudeMessage.Content = append(claudeMessage.Content, content) + for i := range message.ToolCalls { + inputParam := make(map[string]any) + _ = json.Unmarshal(conv.StringToBytes(message.ToolCalls[i].Function.Arguments), &inputParam) + claudeMessage.Content = append(claudeMessage.Content, Content{ + Type: toolUseType, + ID: message.ToolCalls[i].ID, + Name: message.ToolCalls[i].Function.Name, + Input: inputParam, + }) + } + claudeRequest.Messages = append(claudeRequest.Messages, claudeMessage) + continue + } + var contents []Content + openaiContent := message.ParseContent() + for _, part := range openaiContent { + var content Content + switch part.Type { + case model.ContentTypeText: + content.Type = "text" + content.Text = part.Text + case model.ContentTypeImageURL: + content.Type = "image" + content.Source = &ImageSource{ + Type: "base64", + } + mimeType, data, _ := image.GetImageFromURL(part.ImageURL.URL) + content.Source.MediaType = mimeType + content.Source.Data = data + } + contents = append(contents, content) + } + claudeMessage.Content = contents + claudeRequest.Messages = append(claudeRequest.Messages, claudeMessage) + } + return &claudeRequest +} + +// https://docs.anthropic.com/claude/reference/messages-streaming +func StreamResponseClaude2OpenAI(claudeResponse *StreamResponse) (*openai.ChatCompletionsStreamResponse, *Response) { + var response *Response + var responseText string + var stopReason string + tools := make([]model.Tool, 0) + + switch claudeResponse.Type { + case "message_start": + return nil, claudeResponse.Message + case "content_block_start": + if claudeResponse.ContentBlock != nil { + responseText = claudeResponse.ContentBlock.Text + if claudeResponse.ContentBlock.Type == toolUseType { + tools = append(tools, model.Tool{ + ID: claudeResponse.ContentBlock.ID, + Type: "function", + Function: model.Function{ + Name: claudeResponse.ContentBlock.Name, + Arguments: "", + }, + }) + } + } + case "content_block_delta": + if claudeResponse.Delta != nil { + responseText = claudeResponse.Delta.Text + if claudeResponse.Delta.Type == "input_json_delta" { + tools = append(tools, model.Tool{ + Function: model.Function{ + Arguments: claudeResponse.Delta.PartialJSON, + }, + }) + } + } + case "message_delta": + if claudeResponse.Usage != nil { + response = &Response{ + Usage: *claudeResponse.Usage, + } + } + if claudeResponse.Delta != nil && claudeResponse.Delta.StopReason != nil { + stopReason = *claudeResponse.Delta.StopReason + } + } + var choice openai.ChatCompletionsStreamResponseChoice + choice.Delta.Content = responseText + if len(tools) > 0 { + choice.Delta.Content = nil // compatible with other OpenAI derivative applications, like LobeOpenAICompatibleFactory ... + choice.Delta.ToolCalls = tools + } + choice.Delta.Role = "assistant" + finishReason := stopReasonClaude2OpenAI(&stopReason) + if finishReason != "null" { + choice.FinishReason = &finishReason + } + var openaiResponse openai.ChatCompletionsStreamResponse + openaiResponse.Object = "chat.completion.chunk" + openaiResponse.Choices = []openai.ChatCompletionsStreamResponseChoice{choice} + return &openaiResponse, response +} + +func ResponseClaude2OpenAI(claudeResponse *Response) *openai.TextResponse { + var responseText string + if len(claudeResponse.Content) > 0 { + responseText = claudeResponse.Content[0].Text + } + tools := make([]model.Tool, 0) + for _, v := range claudeResponse.Content { + if v.Type == toolUseType { + args, _ := json.Marshal(v.Input) + tools = append(tools, model.Tool{ + ID: v.ID, + Type: "function", // compatible with other OpenAI derivative applications + Function: model.Function{ + Name: v.Name, + Arguments: conv.BytesToString(args), + }, + }) + } + } + choice := openai.TextResponseChoice{ + Index: 0, + Message: model.Message{ + Role: "assistant", + Content: responseText, + Name: nil, + ToolCalls: tools, + }, + FinishReason: stopReasonClaude2OpenAI(claudeResponse.StopReason), + } + fullTextResponse := openai.TextResponse{ + ID: "chatcmpl-" + claudeResponse.ID, + Model: claudeResponse.Model, + Object: "chat.completion", + Created: helper.GetTimestamp(), + Choices: []openai.TextResponseChoice{choice}, + } + return &fullTextResponse +} + +func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + createdTime := helper.GetTimestamp() + scanner := bufio.NewScanner(resp.Body) + scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + if i := slices.Index(data, '\n'); i >= 0 { + return i + 1, data[0:i], nil + } + if atEOF { + return len(data), data, nil + } + return 0, nil, nil + }) + + common.SetEventStreamHeaders(c) + + var usage model.Usage + var modelName string + var id string + var lastToolCallChoice openai.ChatCompletionsStreamResponseChoice + + for scanner.Scan() { + data := scanner.Bytes() + if len(data) < 6 || conv.BytesToString(data[:6]) != "data: " { + continue + } + data = data[6:] + + if conv.BytesToString(data) == "[DONE]" { + break + } + + var claudeResponse StreamResponse + err := json.Unmarshal(data, &claudeResponse) + if err != nil { + logger.SysErrorf("error unmarshalling stream response: %s, data: %s", err.Error(), conv.BytesToString(data)) + continue + } + + response, meta := StreamResponseClaude2OpenAI(&claudeResponse) + if response == nil { + continue + } + if meta != nil { + usage.PromptTokens += meta.Usage.InputTokens + usage.CompletionTokens += meta.Usage.OutputTokens + if len(meta.ID) > 0 { // only message_start has an id, otherwise it's a finish_reason event. + modelName = meta.Model + id = "chatcmpl-" + meta.ID + continue + } + if len(lastToolCallChoice.Delta.ToolCalls) > 0 { + lastArgs := &lastToolCallChoice.Delta.ToolCalls[len(lastToolCallChoice.Delta.ToolCalls)-1].Function + if len(lastArgs.Arguments) == 0 { // compatible with OpenAI sending an empty object `{}` when no arguments. + lastArgs.Arguments = "{}" + response.Choices[len(response.Choices)-1].Delta.Content = nil + response.Choices[len(response.Choices)-1].Delta.ToolCalls = lastToolCallChoice.Delta.ToolCalls + } + } + } + + response.ID = id + response.Model = modelName + response.Created = createdTime + + for _, choice := range response.Choices { + if len(choice.Delta.ToolCalls) > 0 { + lastToolCallChoice = choice + } + } + err = render.ObjectData(c, response) + if err != nil { + logger.SysError(err.Error()) + } + } + + if err := scanner.Err(); err != nil { + logger.SysError("error reading stream: " + err.Error()) + } + + render.Done(c) + + return nil, &usage +} + +func Handler(c *gin.Context, resp *http.Response, _ int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var claudeResponse Response + err := json.NewDecoder(resp.Body).Decode(&claudeResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if claudeResponse.Error.Type != "" { + return &model.ErrorWithStatusCode{ + Error: model.Error{ + Message: claudeResponse.Error.Message, + Type: claudeResponse.Error.Type, + Param: "", + Code: claudeResponse.Error.Type, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := ResponseClaude2OpenAI(&claudeResponse) + fullTextResponse.Model = modelName + usage := model.Usage{ + PromptTokens: claudeResponse.Usage.InputTokens, + CompletionTokens: claudeResponse.Usage.OutputTokens, + TotalTokens: claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens, + } + fullTextResponse.Usage = usage + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil, &usage +} diff --git a/service/aiproxy/relay/adaptor/anthropic/model.go b/service/aiproxy/relay/adaptor/anthropic/model.go new file mode 100644 index 00000000000..a4102886da6 --- /dev/null +++ b/service/aiproxy/relay/adaptor/anthropic/model.go @@ -0,0 +1,95 @@ +package anthropic + +// https://docs.anthropic.com/claude/reference/messages_post + +type Metadata struct { + UserID string `json:"user_id"` +} + +type ImageSource struct { + Type string `json:"type"` + MediaType string `json:"media_type"` + Data string `json:"data"` +} + +type Content struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Source *ImageSource `json:"source,omitempty"` + // tool_calls + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input any `json:"input,omitempty"` + Content string `json:"content,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` +} + +type Message struct { + Role string `json:"role"` + Content []Content `json:"content"` +} + +type Tool struct { + InputSchema InputSchema `json:"input_schema"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +type InputSchema struct { + Properties any `json:"properties,omitempty"` + Required any `json:"required,omitempty"` + Type string `json:"type"` +} + +type Request struct { + ToolChoice any `json:"tool_choice,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Model string `json:"model"` + System string `json:"system,omitempty"` + Messages []Message `json:"messages"` + StopSequences []string `json:"stop_sequences,omitempty"` + Tools []Tool `json:"tools,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + TopK int `json:"top_k,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +type Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +type Error struct { + Type string `json:"type"` + Message string `json:"message"` +} + +type Response struct { + StopReason *string `json:"stop_reason"` + StopSequence *string `json:"stop_sequence"` + Error Error `json:"error"` + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Model string `json:"model"` + Content []Content `json:"content"` + Usage Usage `json:"usage"` +} + +type Delta struct { + StopReason *string `json:"stop_reason"` + StopSequence *string `json:"stop_sequence"` + Type string `json:"type"` + Text string `json:"text"` + PartialJSON string `json:"partial_json,omitempty"` +} + +type StreamResponse struct { + Message *Response `json:"message"` + ContentBlock *Content `json:"content_block"` + Delta *Delta `json:"delta"` + Usage *Usage `json:"usage"` + Type string `json:"type"` + Index int `json:"index"` +} diff --git a/service/aiproxy/relay/adaptor/aws/adaptor.go b/service/aiproxy/relay/adaptor/aws/adaptor.go new file mode 100644 index 00000000000..68fa986857d --- /dev/null +++ b/service/aiproxy/relay/adaptor/aws/adaptor.go @@ -0,0 +1,92 @@ +package aws + +import ( + "errors" + "io" + "net/http" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/aws/utils" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +var _ adaptor.Adaptor = new(Adaptor) + +type Adaptor struct { + awsAdapter utils.AwsAdapter + + Meta *meta.Meta + AwsClient *bedrockruntime.Client +} + +func (a *Adaptor) ConvertSTTRequest(*http.Request) (io.ReadCloser, error) { + return nil, nil +} + +func (a *Adaptor) ConvertTTSRequest(*model.TextToSpeechRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) Init(meta *meta.Meta) { + a.Meta = meta + a.AwsClient = bedrockruntime.New(bedrockruntime.Options{ + Region: meta.Config.Region, + Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(meta.Config.AK, meta.Config.SK, "")), + }) +} + +func (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + + adaptor := GetAdaptor(request.Model) + if adaptor == nil { + return nil, errors.New("adaptor not found") + } + + a.awsAdapter = adaptor + return adaptor.ConvertRequest(c, relayMode, request) +} + +func (a *Adaptor) DoResponse(c *gin.Context, _ *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if a.awsAdapter == nil { + return nil, utils.WrapErr(errors.New("awsAdapter is nil")) + } + return a.awsAdapter.DoResponse(c, a.AwsClient, meta) +} + +func (a *Adaptor) GetModelList() (models []string) { + for model := range adaptors { + models = append(models, model) + } + return +} + +func (a *Adaptor) GetChannelName() string { + return "aws" +} + +func (a *Adaptor) GetRequestURL(_ *meta.Meta) (string, error) { + return "", nil +} + +func (a *Adaptor) SetupRequestHeader(_ *gin.Context, _ *http.Request, _ *meta.Meta) error { + return nil +} + +func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) DoRequest(_ *gin.Context, _ *meta.Meta, _ io.Reader) (*http.Response, error) { + return nil, nil +} diff --git a/service/aiproxy/relay/adaptor/aws/claude/adapter.go b/service/aiproxy/relay/adaptor/aws/claude/adapter.go new file mode 100644 index 00000000000..94e451f9ea5 --- /dev/null +++ b/service/aiproxy/relay/adaptor/aws/claude/adapter.go @@ -0,0 +1,36 @@ +package aws + +import ( + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/anthropic" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/aws/utils" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" + "github.com/pkg/errors" +) + +var _ utils.AwsAdapter = new(Adaptor) + +type Adaptor struct{} + +func (a *Adaptor) ConvertRequest(c *gin.Context, _ int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + + claudeReq := anthropic.ConvertRequest(request) + c.Set(ctxkey.RequestModel, request.Model) + c.Set(ctxkey.ConvertedRequest, claudeReq) + return claudeReq, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, awsCli *bedrockruntime.Client, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + err, usage = StreamHandler(c, awsCli) + } else { + err, usage = Handler(c, awsCli, meta.ActualModelName) + } + return +} diff --git a/service/aiproxy/relay/adaptor/aws/claude/main.go b/service/aiproxy/relay/adaptor/aws/claude/main.go new file mode 100644 index 00000000000..bc7fb873f5a --- /dev/null +++ b/service/aiproxy/relay/adaptor/aws/claude/main.go @@ -0,0 +1,205 @@ +// Package aws provides the AWS adaptor for the relay service. +package aws + +import ( + "io" + "net/http" + + json "github.com/json-iterator/go" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types" + "github.com/gin-gonic/gin" + "github.com/jinzhu/copier" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/common/render" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/anthropic" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/aws/utils" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + relaymodel "github.com/labring/sealos/service/aiproxy/relay/model" + "github.com/pkg/errors" +) + +// AwsModelIDMap maps internal model identifiers to AWS model identifiers. +// For more details, see: https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html +var AwsModelIDMap = map[string]string{ + "claude-instant-1.2": "anthropic.claude-instant-v1", + "claude-2.0": "anthropic.claude-v2", + "claude-2.1": "anthropic.claude-v2:1", + "claude-3-haiku-20240307": "anthropic.claude-3-haiku-20240307-v1:0", + "claude-3-sonnet-20240229": "anthropic.claude-3-sonnet-20240229-v1:0", + "claude-3-opus-20240229": "anthropic.claude-3-opus-20240229-v1:0", + "claude-3-5-sonnet-20240620": "anthropic.claude-3-5-sonnet-20240620-v1:0", + "claude-3-5-sonnet-20241022": "anthropic.claude-3-5-sonnet-20241022-v2:0", + "claude-3-5-sonnet-latest": "anthropic.claude-3-5-sonnet-20241022-v2:0", + "claude-3-5-haiku-20241022": "anthropic.claude-3-5-haiku-20241022-v1:0", +} + +func awsModelID(requestModel string) (string, error) { + if awsModelID, ok := AwsModelIDMap[requestModel]; ok { + return awsModelID, nil + } + + return "", errors.Errorf("model %s not found", requestModel) +} + +func Handler(c *gin.Context, awsCli *bedrockruntime.Client, modelName string) (*relaymodel.ErrorWithStatusCode, *relaymodel.Usage) { + awsModelID, err := awsModelID(c.GetString(ctxkey.RequestModel)) + if err != nil { + return utils.WrapErr(errors.Wrap(err, "awsModelID")), nil + } + + awsReq := &bedrockruntime.InvokeModelInput{ + ModelId: aws.String(awsModelID), + Accept: aws.String("application/json"), + ContentType: aws.String("application/json"), + } + + convReq, ok := c.Get(ctxkey.ConvertedRequest) + if !ok { + return utils.WrapErr(errors.New("request not found")), nil + } + claudeReq := convReq.(*anthropic.Request) + awsClaudeReq := &Request{ + AnthropicVersion: "bedrock-2023-05-31", + } + if err = copier.Copy(awsClaudeReq, claudeReq); err != nil { + return utils.WrapErr(errors.Wrap(err, "copy request")), nil + } + + awsReq.Body, err = json.Marshal(awsClaudeReq) + if err != nil { + return utils.WrapErr(errors.Wrap(err, "marshal request")), nil + } + + awsResp, err := awsCli.InvokeModel(c.Request.Context(), awsReq) + if err != nil { + return utils.WrapErr(errors.Wrap(err, "InvokeModel")), nil + } + + claudeResponse := new(anthropic.Response) + err = json.Unmarshal(awsResp.Body, claudeResponse) + if err != nil { + return utils.WrapErr(errors.Wrap(err, "unmarshal response")), nil + } + + openaiResp := anthropic.ResponseClaude2OpenAI(claudeResponse) + openaiResp.Model = modelName + usage := relaymodel.Usage{ + PromptTokens: claudeResponse.Usage.InputTokens, + CompletionTokens: claudeResponse.Usage.OutputTokens, + TotalTokens: claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens, + } + openaiResp.Usage = usage + + c.JSON(http.StatusOK, openaiResp) + return nil, &usage +} + +func StreamHandler(c *gin.Context, awsCli *bedrockruntime.Client) (*relaymodel.ErrorWithStatusCode, *relaymodel.Usage) { + createdTime := helper.GetTimestamp() + awsModelID, err := awsModelID(c.GetString(ctxkey.RequestModel)) + if err != nil { + return utils.WrapErr(errors.Wrap(err, "awsModelID")), nil + } + + awsReq := &bedrockruntime.InvokeModelWithResponseStreamInput{ + ModelId: aws.String(awsModelID), + Accept: aws.String("application/json"), + ContentType: aws.String("application/json"), + } + + convReq, ok := c.Get(ctxkey.ConvertedRequest) + if !ok { + return utils.WrapErr(errors.New("request not found")), nil + } + claudeReq := convReq.(*anthropic.Request) + + awsClaudeReq := &Request{ + AnthropicVersion: "bedrock-2023-05-31", + } + if err = copier.Copy(awsClaudeReq, claudeReq); err != nil { + return utils.WrapErr(errors.Wrap(err, "copy request")), nil + } + awsReq.Body, err = json.Marshal(awsClaudeReq) + if err != nil { + return utils.WrapErr(errors.Wrap(err, "marshal request")), nil + } + + awsResp, err := awsCli.InvokeModelWithResponseStream(c.Request.Context(), awsReq) + if err != nil { + return utils.WrapErr(errors.Wrap(err, "InvokeModelWithResponseStream")), nil + } + stream := awsResp.GetStream() + defer stream.Close() + + c.Writer.Header().Set("Content-Type", "text/event-stream") + var usage relaymodel.Usage + var id string + var lastToolCallChoice openai.ChatCompletionsStreamResponseChoice + + c.Stream(func(_ io.Writer) bool { + event, ok := <-stream.Events() + if !ok { + render.StringData(c, "[DONE]") + return false + } + + switch v := event.(type) { + case *types.ResponseStreamMemberChunk: + claudeResp := anthropic.StreamResponse{} + err := json.Unmarshal(v.Value.Bytes, &claudeResp) + if err != nil { + logger.SysError("error unmarshalling stream response: " + err.Error()) + return false + } + + response, meta := anthropic.StreamResponseClaude2OpenAI(&claudeResp) + if response == nil { + return true + } + if meta != nil { + usage.PromptTokens += meta.Usage.InputTokens + usage.CompletionTokens += meta.Usage.OutputTokens + if len(meta.ID) > 0 { // only message_start has an id, otherwise it's a finish_reason event. + id = "chatcmpl-" + meta.ID + return true + } + if len(lastToolCallChoice.Delta.ToolCalls) > 0 { + lastArgs := &lastToolCallChoice.Delta.ToolCalls[len(lastToolCallChoice.Delta.ToolCalls)-1].Function + if len(lastArgs.Arguments) == 0 { // compatible with OpenAI sending an empty object `{}` when no arguments. + lastArgs.Arguments = "{}" + response.Choices[len(response.Choices)-1].Delta.Content = nil + response.Choices[len(response.Choices)-1].Delta.ToolCalls = lastToolCallChoice.Delta.ToolCalls + } + } + } + response.ID = id + response.Model = c.GetString(ctxkey.OriginalModel) + response.Created = createdTime + + for _, choice := range response.Choices { + if len(choice.Delta.ToolCalls) > 0 { + lastToolCallChoice = choice + } + } + err = render.ObjectData(c, response) + if err != nil { + logger.SysError("error stream response: " + err.Error()) + return false + } + return true + case *types.UnknownUnionMember: + logger.SysErrorf("unknown tag: %s", v.Tag) + return false + default: + logger.SysErrorf("union is nil or unknown type: %v", v) + return false + } + }) + + return nil, &usage +} diff --git a/service/aiproxy/relay/adaptor/aws/claude/model.go b/service/aiproxy/relay/adaptor/aws/claude/model.go new file mode 100644 index 00000000000..d8a14dd6200 --- /dev/null +++ b/service/aiproxy/relay/adaptor/aws/claude/model.go @@ -0,0 +1,19 @@ +package aws + +import "github.com/labring/sealos/service/aiproxy/relay/adaptor/anthropic" + +// Request is the request to AWS Claude +// +// https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages.html +type Request struct { + ToolChoice any `json:"tool_choice,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + AnthropicVersion string `json:"anthropic_version"` + System string `json:"system,omitempty"` + Messages []anthropic.Message `json:"messages"` + StopSequences []string `json:"stop_sequences,omitempty"` + Tools []anthropic.Tool `json:"tools,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + TopK int `json:"top_k,omitempty"` +} diff --git a/service/aiproxy/relay/adaptor/aws/llama3/adapter.go b/service/aiproxy/relay/adaptor/aws/llama3/adapter.go new file mode 100644 index 00000000000..3fcef4b8fab --- /dev/null +++ b/service/aiproxy/relay/adaptor/aws/llama3/adapter.go @@ -0,0 +1,36 @@ +package aws + +import ( + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/aws/utils" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" + "github.com/pkg/errors" +) + +var _ utils.AwsAdapter = new(Adaptor) + +type Adaptor struct{} + +func (a *Adaptor) ConvertRequest(c *gin.Context, _ int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + + llamaReq := ConvertRequest(request) + c.Set(ctxkey.RequestModel, request.Model) + c.Set(ctxkey.ConvertedRequest, llamaReq) + return llamaReq, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, awsCli *bedrockruntime.Client, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + err, usage = StreamHandler(c, awsCli) + } else { + err, usage = Handler(c, awsCli, meta.ActualModelName) + } + return +} diff --git a/service/aiproxy/relay/adaptor/aws/llama3/main.go b/service/aiproxy/relay/adaptor/aws/llama3/main.go new file mode 100644 index 00000000000..10b56fde851 --- /dev/null +++ b/service/aiproxy/relay/adaptor/aws/llama3/main.go @@ -0,0 +1,233 @@ +// Package aws provides the AWS adaptor for the relay service. +package aws + +import ( + "bytes" + "fmt" + "io" + "net/http" + "text/template" + + json "github.com/json-iterator/go" + + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + "github.com/labring/sealos/service/aiproxy/common/random" + "github.com/labring/sealos/service/aiproxy/common/render" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types" + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/aws/utils" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + relaymodel "github.com/labring/sealos/service/aiproxy/relay/model" + "github.com/pkg/errors" +) + +// AwsModelIDMap maps internal model identifiers to AWS model identifiers. +// It currently supports only llama-3-8b and llama-3-70b instruction models. +// For more details, see: https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html +var AwsModelIDMap = map[string]string{ + "llama3-8b-8192": "meta.llama3-8b-instruct-v1:0", + "llama3-70b-8192": "meta.llama3-70b-instruct-v1:0", +} + +func awsModelID(requestModel string) (string, error) { + if awsModelID, ok := AwsModelIDMap[requestModel]; ok { + return awsModelID, nil + } + + return "", errors.Errorf("model %s not found", requestModel) +} + +// promptTemplate with range +const promptTemplate = `<|begin_of_text|>{{range .Messages}}<|start_header_id|>{{.Role}}<|end_header_id|>{{.StringContent}}<|eot_id|>{{end}}<|start_header_id|>assistant<|end_header_id|> +` + +var promptTpl = template.Must(template.New("llama3-chat").Parse(promptTemplate)) + +func RenderPrompt(messages []relaymodel.Message) string { + var buf bytes.Buffer + err := promptTpl.Execute(&buf, struct{ Messages []relaymodel.Message }{messages}) + if err != nil { + logger.SysError("error rendering prompt messages: " + err.Error()) + } + return buf.String() +} + +func ConvertRequest(textRequest *relaymodel.GeneralOpenAIRequest) *Request { + llamaRequest := Request{ + MaxGenLen: textRequest.MaxTokens, + Temperature: textRequest.Temperature, + TopP: textRequest.TopP, + } + if llamaRequest.MaxGenLen == 0 { + llamaRequest.MaxGenLen = 2048 + } + prompt := RenderPrompt(textRequest.Messages) + llamaRequest.Prompt = prompt + return &llamaRequest +} + +func Handler(c *gin.Context, awsCli *bedrockruntime.Client, modelName string) (*relaymodel.ErrorWithStatusCode, *relaymodel.Usage) { + awsModelID, err := awsModelID(c.GetString(ctxkey.RequestModel)) + if err != nil { + return utils.WrapErr(errors.Wrap(err, "awsModelID")), nil + } + + awsReq := &bedrockruntime.InvokeModelInput{ + ModelId: aws.String(awsModelID), + Accept: aws.String("application/json"), + ContentType: aws.String("application/json"), + } + + llamaReq, ok := c.Get(ctxkey.ConvertedRequest) + if !ok { + return utils.WrapErr(errors.New("request not found")), nil + } + + awsReq.Body, err = json.Marshal(llamaReq) + if err != nil { + return utils.WrapErr(errors.Wrap(err, "marshal request")), nil + } + + awsResp, err := awsCli.InvokeModel(c.Request.Context(), awsReq) + if err != nil { + return utils.WrapErr(errors.Wrap(err, "InvokeModel")), nil + } + + var llamaResponse Response + err = json.Unmarshal(awsResp.Body, &llamaResponse) + if err != nil { + return utils.WrapErr(errors.Wrap(err, "unmarshal response")), nil + } + + openaiResp := ResponseLlama2OpenAI(&llamaResponse) + openaiResp.Model = modelName + usage := relaymodel.Usage{ + PromptTokens: llamaResponse.PromptTokenCount, + CompletionTokens: llamaResponse.GenerationTokenCount, + TotalTokens: llamaResponse.PromptTokenCount + llamaResponse.GenerationTokenCount, + } + openaiResp.Usage = usage + + c.JSON(http.StatusOK, openaiResp) + return nil, &usage +} + +func ResponseLlama2OpenAI(llamaResponse *Response) *openai.TextResponse { + var responseText string + if len(llamaResponse.Generation) > 0 { + responseText = llamaResponse.Generation + } + choice := openai.TextResponseChoice{ + Index: 0, + Message: relaymodel.Message{ + Role: "assistant", + Content: responseText, + Name: nil, + }, + FinishReason: llamaResponse.StopReason, + } + fullTextResponse := openai.TextResponse{ + ID: "chatcmpl-" + random.GetUUID(), + Object: "chat.completion", + Created: helper.GetTimestamp(), + Choices: []openai.TextResponseChoice{choice}, + } + return &fullTextResponse +} + +func StreamHandler(c *gin.Context, awsCli *bedrockruntime.Client) (*relaymodel.ErrorWithStatusCode, *relaymodel.Usage) { + createdTime := helper.GetTimestamp() + awsModelID, err := awsModelID(c.GetString(ctxkey.RequestModel)) + if err != nil { + return utils.WrapErr(errors.Wrap(err, "awsModelID")), nil + } + + awsReq := &bedrockruntime.InvokeModelWithResponseStreamInput{ + ModelId: aws.String(awsModelID), + Accept: aws.String("application/json"), + ContentType: aws.String("application/json"), + } + + llamaReq, ok := c.Get(ctxkey.ConvertedRequest) + if !ok { + return utils.WrapErr(errors.New("request not found")), nil + } + + awsReq.Body, err = json.Marshal(llamaReq) + if err != nil { + return utils.WrapErr(errors.Wrap(err, "marshal request")), nil + } + + awsResp, err := awsCli.InvokeModelWithResponseStream(c.Request.Context(), awsReq) + if err != nil { + return utils.WrapErr(errors.Wrap(err, "InvokeModelWithResponseStream")), nil + } + stream := awsResp.GetStream() + defer stream.Close() + + c.Writer.Header().Set("Content-Type", "text/event-stream") + var usage relaymodel.Usage + c.Stream(func(_ io.Writer) bool { + event, ok := <-stream.Events() + if !ok { + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + + switch v := event.(type) { + case *types.ResponseStreamMemberChunk: + var llamaResp StreamResponse + err := json.Unmarshal(v.Value.Bytes, &llamaResp) + if err != nil { + logger.SysError("error unmarshalling stream response: " + err.Error()) + return false + } + + if llamaResp.PromptTokenCount > 0 { + usage.PromptTokens = llamaResp.PromptTokenCount + } + if llamaResp.StopReason == "stop" { + usage.CompletionTokens = llamaResp.GenerationTokenCount + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + response := StreamResponseLlama2OpenAI(&llamaResp) + response.ID = "chatcmpl-" + random.GetUUID() + response.Model = c.GetString(ctxkey.OriginalModel) + response.Created = createdTime + err = render.ObjectData(c, response) + if err != nil { + logger.SysError("error stream response: " + err.Error()) + return true + } + return true + case *types.UnknownUnionMember: + fmt.Println("unknown tag:", v.Tag) + return false + default: + fmt.Println("union is nil or unknown type") + return false + } + }) + + return nil, &usage +} + +func StreamResponseLlama2OpenAI(llamaResponse *StreamResponse) *openai.ChatCompletionsStreamResponse { + var choice openai.ChatCompletionsStreamResponseChoice + choice.Delta.Content = llamaResponse.Generation + choice.Delta.Role = "assistant" + finishReason := llamaResponse.StopReason + if finishReason != "null" { + choice.FinishReason = &finishReason + } + var openaiResponse openai.ChatCompletionsStreamResponse + openaiResponse.Object = "chat.completion.chunk" + openaiResponse.Choices = []openai.ChatCompletionsStreamResponseChoice{choice} + return &openaiResponse +} diff --git a/service/aiproxy/relay/adaptor/aws/llama3/main_test.go b/service/aiproxy/relay/adaptor/aws/llama3/main_test.go new file mode 100644 index 00000000000..22b7aa1ca7d --- /dev/null +++ b/service/aiproxy/relay/adaptor/aws/llama3/main_test.go @@ -0,0 +1,45 @@ +package aws_test + +import ( + "testing" + + aws "github.com/labring/sealos/service/aiproxy/relay/adaptor/aws/llama3" + relaymodel "github.com/labring/sealos/service/aiproxy/relay/model" + "github.com/stretchr/testify/assert" +) + +func TestRenderPrompt(t *testing.T) { + messages := []relaymodel.Message{ + { + Role: "user", + Content: "What's your name?", + }, + } + prompt := aws.RenderPrompt(messages) + expected := `<|begin_of_text|><|start_header_id|>user<|end_header_id|>What's your name?<|eot_id|><|start_header_id|>assistant<|end_header_id|> +` + assert.Equal(t, expected, prompt) + + messages = []relaymodel.Message{ + { + Role: "system", + Content: "Your name is Kat. You are a detective.", + }, + { + Role: "user", + Content: "What's your name?", + }, + { + Role: "assistant", + Content: "Kat", + }, + { + Role: "user", + Content: "What's your job?", + }, + } + prompt = aws.RenderPrompt(messages) + expected = `<|begin_of_text|><|start_header_id|>system<|end_header_id|>Your name is Kat. You are a detective.<|eot_id|><|start_header_id|>user<|end_header_id|>What's your name?<|eot_id|><|start_header_id|>assistant<|end_header_id|>Kat<|eot_id|><|start_header_id|>user<|end_header_id|>What's your job?<|eot_id|><|start_header_id|>assistant<|end_header_id|> +` + assert.Equal(t, expected, prompt) +} diff --git a/service/aiproxy/relay/adaptor/aws/llama3/model.go b/service/aiproxy/relay/adaptor/aws/llama3/model.go new file mode 100644 index 00000000000..3d8ab8e0957 --- /dev/null +++ b/service/aiproxy/relay/adaptor/aws/llama3/model.go @@ -0,0 +1,29 @@ +package aws + +// Request is the request to AWS Llama3 +// +// https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-meta.html +type Request struct { + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Prompt string `json:"prompt"` + MaxGenLen int `json:"max_gen_len,omitempty"` +} + +// Response is the response from AWS Llama3 +// +// https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-meta.html +type Response struct { + Generation string `json:"generation"` + StopReason string `json:"stop_reason"` + PromptTokenCount int `json:"prompt_token_count"` + GenerationTokenCount int `json:"generation_token_count"` +} + +// {'generation': 'Hi', 'prompt_token_count': 15, 'generation_token_count': 1, 'stop_reason': None} +type StreamResponse struct { + Generation string `json:"generation"` + StopReason string `json:"stop_reason"` + PromptTokenCount int `json:"prompt_token_count"` + GenerationTokenCount int `json:"generation_token_count"` +} diff --git a/service/aiproxy/relay/adaptor/aws/registry.go b/service/aiproxy/relay/adaptor/aws/registry.go new file mode 100644 index 00000000000..32083fad9fa --- /dev/null +++ b/service/aiproxy/relay/adaptor/aws/registry.go @@ -0,0 +1,37 @@ +package aws + +import ( + claude "github.com/labring/sealos/service/aiproxy/relay/adaptor/aws/claude" + llama3 "github.com/labring/sealos/service/aiproxy/relay/adaptor/aws/llama3" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/aws/utils" +) + +type ModelType int + +const ( + AwsClaude ModelType = iota + 1 + AwsLlama3 +) + +var adaptors = map[string]ModelType{} + +func init() { + for model := range claude.AwsModelIDMap { + adaptors[model] = AwsClaude + } + for model := range llama3.AwsModelIDMap { + adaptors[model] = AwsLlama3 + } +} + +func GetAdaptor(model string) utils.AwsAdapter { + adaptorType := adaptors[model] + switch adaptorType { + case AwsClaude: + return &claude.Adaptor{} + case AwsLlama3: + return &llama3.Adaptor{} + default: + return nil + } +} diff --git a/service/aiproxy/relay/adaptor/aws/utils/adaptor.go b/service/aiproxy/relay/adaptor/aws/utils/adaptor.go new file mode 100644 index 00000000000..1af4579e967 --- /dev/null +++ b/service/aiproxy/relay/adaptor/aws/utils/adaptor.go @@ -0,0 +1,51 @@ +package utils + +import ( + "errors" + "io" + "net/http" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +type AwsAdapter interface { + ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) + DoResponse(c *gin.Context, awsCli *bedrockruntime.Client, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) +} + +type Adaptor struct { + Meta *meta.Meta + AwsClient *bedrockruntime.Client +} + +func (a *Adaptor) Init(meta *meta.Meta) { + a.Meta = meta + a.AwsClient = bedrockruntime.New(bedrockruntime.Options{ + Region: meta.Config.Region, + Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(meta.Config.AK, meta.Config.SK, "")), + }) +} + +func (a *Adaptor) GetRequestURL(_ *meta.Meta) (string, error) { + return "", nil +} + +func (a *Adaptor) SetupRequestHeader(_ *gin.Context, _ *http.Request, _ *meta.Meta) error { + return nil +} + +func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) DoRequest(_ *gin.Context, _ *meta.Meta, _ io.Reader) (*http.Response, error) { + return nil, nil +} diff --git a/service/aiproxy/relay/adaptor/aws/utils/utils.go b/service/aiproxy/relay/adaptor/aws/utils/utils.go new file mode 100644 index 00000000000..0323f8c2195 --- /dev/null +++ b/service/aiproxy/relay/adaptor/aws/utils/utils.go @@ -0,0 +1,16 @@ +package utils + +import ( + "net/http" + + relaymodel "github.com/labring/sealos/service/aiproxy/relay/model" +) + +func WrapErr(err error) *relaymodel.ErrorWithStatusCode { + return &relaymodel.ErrorWithStatusCode{ + StatusCode: http.StatusInternalServerError, + Error: relaymodel.Error{ + Message: err.Error(), + }, + } +} diff --git a/service/aiproxy/relay/adaptor/baichuan/constants.go b/service/aiproxy/relay/adaptor/baichuan/constants.go new file mode 100644 index 00000000000..cb20a1ffe16 --- /dev/null +++ b/service/aiproxy/relay/adaptor/baichuan/constants.go @@ -0,0 +1,7 @@ +package baichuan + +var ModelList = []string{ + "Baichuan2-Turbo", + "Baichuan2-Turbo-192k", + "Baichuan-Text-Embedding", +} diff --git a/service/aiproxy/relay/adaptor/baidu/adaptor.go b/service/aiproxy/relay/adaptor/baidu/adaptor.go new file mode 100644 index 00000000000..6befae42296 --- /dev/null +++ b/service/aiproxy/relay/adaptor/baidu/adaptor.go @@ -0,0 +1,142 @@ +package baidu + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +type Adaptor struct{} + +func (a *Adaptor) Init(_ *meta.Meta) { +} + +func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { + // https://cloud.baidu.com/doc/WENXINWORKSHOP/s/clntwmv7t + suffix := "chat/" + if strings.HasPrefix(meta.ActualModelName, "Embedding") || + strings.HasPrefix(meta.ActualModelName, "bge-large") || + strings.HasPrefix(meta.ActualModelName, "tao-8k") { + suffix = "embeddings/" + } + switch meta.ActualModelName { + case "ERNIE-4.0-8K", "ERNIE-4.0", "ERNIE-Bot-4": + suffix += "completions_pro" + case "ERNIE-Bot": + suffix += "completions" + case "ERNIE-Bot-turbo": + suffix += "eb-instant" + case "ERNIE-Speed": + suffix += "ernie_speed" + case "ERNIE-3.5-8K": + suffix += "completions" + case "ERNIE-3.5-8K-0205": + suffix += "ernie-3.5-8k-0205" + case "ERNIE-3.5-8K-1222": + suffix += "ernie-3.5-8k-1222" + case "ERNIE-Bot-8K": + suffix += "ernie_bot_8k" + case "ERNIE-3.5-4K-0205": + suffix += "ernie-3.5-4k-0205" + case "ERNIE-Speed-8K": + suffix += "ernie_speed" + case "ERNIE-Speed-128K": + suffix += "ernie-speed-128k" + case "ERNIE-Lite-8K-0922": + suffix += "eb-instant" + case "ERNIE-Lite-8K-0308": + suffix += "ernie-lite-8k" + case "ERNIE-Tiny-8K": + suffix += "ernie-tiny-8k" + case "BLOOMZ-7B": + suffix += "bloomz_7b1" + case "Embedding-V1": + suffix += "embedding-v1" + case "bge-large-zh": + suffix += "bge_large_zh" + case "bge-large-en": + suffix += "bge_large_en" + case "tao-8k": + suffix += "tao_8k" + default: + suffix += strings.ToLower(meta.ActualModelName) + } + fullRequestURL := fmt.Sprintf("%s/rpc/2.0/ai_custom/v1/wenxinworkshop/%s", meta.BaseURL, suffix) + var accessToken string + var err error + if accessToken, err = GetAccessToken(meta.APIKey); err != nil { + return "", err + } + fullRequestURL += "?access_token=" + accessToken + return fullRequestURL, nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { + adaptor.SetupCommonRequestHeader(c, req, meta) + req.Header.Set("Authorization", "Bearer "+meta.APIKey) + return nil +} + +func (a *Adaptor) ConvertRequest(_ *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + switch relayMode { + case relaymode.Embeddings: + baiduEmbeddingRequest := ConvertEmbeddingRequest(request) + return baiduEmbeddingRequest, nil + default: + baiduRequest := ConvertRequest(request) + return baiduRequest, nil + } +} + +func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + return adaptor.DoRequestHelper(a, c, meta, requestBody) +} + +func (a *Adaptor) ConvertSTTRequest(*http.Request) (io.ReadCloser, error) { + return nil, nil +} + +func (a *Adaptor) ConvertTTSRequest(*model.TextToSpeechRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + err, usage = StreamHandler(c, resp) + } else { + switch meta.Mode { + case relaymode.Embeddings: + err, usage = EmbeddingHandler(c, resp) + default: + err, usage = Handler(c, resp) + } + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return "baidu" +} diff --git a/service/aiproxy/relay/adaptor/baidu/constants.go b/service/aiproxy/relay/adaptor/baidu/constants.go new file mode 100644 index 00000000000..f952adc6b90 --- /dev/null +++ b/service/aiproxy/relay/adaptor/baidu/constants.go @@ -0,0 +1,20 @@ +package baidu + +var ModelList = []string{ + "ERNIE-4.0-8K", + "ERNIE-3.5-8K", + "ERNIE-3.5-8K-0205", + "ERNIE-3.5-8K-1222", + "ERNIE-Bot-8K", + "ERNIE-3.5-4K-0205", + "ERNIE-Speed-8K", + "ERNIE-Speed-128K", + "ERNIE-Lite-8K-0922", + "ERNIE-Lite-8K-0308", + "ERNIE-Tiny-8K", + "BLOOMZ-7B", + "Embedding-V1", + "bge-large-zh", + "bge-large-en", + "tao-8k", +} diff --git a/service/aiproxy/relay/adaptor/baidu/main.go b/service/aiproxy/relay/adaptor/baidu/main.go new file mode 100644 index 00000000000..b9ddf8b869b --- /dev/null +++ b/service/aiproxy/relay/adaptor/baidu/main.go @@ -0,0 +1,317 @@ +package baidu + +import ( + "bufio" + "context" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common/conv" + "github.com/labring/sealos/service/aiproxy/common/render" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/client" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/constant" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +// https://cloud.baidu.com/doc/WENXINWORKSHOP/s/flfmc9do2 + +type TokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` +} + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type ChatRequest struct { + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + PenaltyScore *float64 `json:"penalty_score,omitempty"` + System string `json:"system,omitempty"` + UserID string `json:"user_id,omitempty"` + Messages []model.Message `json:"messages"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` + DisableSearch bool `json:"disable_search,omitempty"` + EnableCitation bool `json:"enable_citation,omitempty"` +} + +type Error struct { + ErrorMsg string `json:"error_msg"` + ErrorCode int `json:"error_code"` +} + +var baiduTokenStore sync.Map + +func ConvertRequest(request *model.GeneralOpenAIRequest) *ChatRequest { + baiduRequest := ChatRequest{ + Messages: request.Messages, + Temperature: request.Temperature, + TopP: request.TopP, + Stream: request.Stream, + DisableSearch: false, + EnableCitation: false, + MaxOutputTokens: request.MaxTokens, + UserID: request.User, + } + // Convert frequency penalty to penalty score range [1.0, 2.0] + if request.FrequencyPenalty != nil { + penaltyScore := *request.FrequencyPenalty + if penaltyScore < -2.0 { + penaltyScore = -2.0 + } + if penaltyScore > 2.0 { + penaltyScore = 2.0 + } + // Map [-2.0, 2.0] to [1.0, 2.0] + mappedScore := (penaltyScore+2.0)/4.0 + 1.0 + baiduRequest.PenaltyScore = &mappedScore + } + + for i, message := range request.Messages { + if message.Role == "system" { + baiduRequest.System = message.StringContent() + request.Messages = append(request.Messages[:i], request.Messages[i+1:]...) + break + } + } + return &baiduRequest +} + +func responseBaidu2OpenAI(response *ChatResponse) *openai.TextResponse { + choice := openai.TextResponseChoice{ + Index: 0, + Message: model.Message{ + Role: "assistant", + Content: response.Result, + }, + FinishReason: "stop", + } + fullTextResponse := openai.TextResponse{ + ID: response.ID, + Object: "chat.completion", + Created: response.Created, + Choices: []openai.TextResponseChoice{choice}, + Usage: response.Usage, + } + return &fullTextResponse +} + +func streamResponseBaidu2OpenAI(baiduResponse *ChatStreamResponse) *openai.ChatCompletionsStreamResponse { + var choice openai.ChatCompletionsStreamResponseChoice + choice.Delta.Content = baiduResponse.Result + if baiduResponse.IsEnd { + choice.FinishReason = &constant.StopFinishReason + } + response := openai.ChatCompletionsStreamResponse{ + ID: baiduResponse.ID, + Object: "chat.completion.chunk", + Created: baiduResponse.Created, + Model: "ernie-bot", + Choices: []openai.ChatCompletionsStreamResponseChoice{choice}, + } + return &response +} + +func ConvertEmbeddingRequest(request *model.GeneralOpenAIRequest) *EmbeddingRequest { + return &EmbeddingRequest{ + Input: request.ParseInput(), + } +} + +func embeddingResponseBaidu2OpenAI(response *EmbeddingResponse) *openai.EmbeddingResponse { + openAIEmbeddingResponse := openai.EmbeddingResponse{ + Object: "list", + Data: make([]openai.EmbeddingResponseItem, 0, len(response.Data)), + Model: "baidu-embedding", + Usage: response.Usage, + } + for _, item := range response.Data { + openAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, openai.EmbeddingResponseItem{ + Object: item.Object, + Index: item.Index, + Embedding: item.Embedding, + }) + } + return &openAIEmbeddingResponse +} + +func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var usage model.Usage + scanner := bufio.NewScanner(resp.Body) + scanner.Split(bufio.ScanLines) + + common.SetEventStreamHeaders(c) + + for scanner.Scan() { + data := scanner.Bytes() + if len(data) < 6 || conv.BytesToString(data[:6]) != "data: " { + continue + } + data = data[6:] + + if conv.BytesToString(data) == "[DONE]" { + break + } + + var baiduResponse ChatStreamResponse + err := json.Unmarshal(data, &baiduResponse) + if err != nil { + logger.SysErrorf("error unmarshalling stream response: %s, data: %s", err.Error(), conv.BytesToString(data)) + continue + } + if baiduResponse.Usage.TotalTokens != 0 { + usage.TotalTokens = baiduResponse.Usage.TotalTokens + usage.PromptTokens = baiduResponse.Usage.PromptTokens + usage.CompletionTokens = baiduResponse.Usage.TotalTokens - baiduResponse.Usage.PromptTokens + } + response := streamResponseBaidu2OpenAI(&baiduResponse) + err = render.ObjectData(c, response) + if err != nil { + logger.SysError(err.Error()) + } + } + + if err := scanner.Err(); err != nil { + logger.SysError("error reading stream: " + err.Error()) + } + + render.Done(c) + + return nil, &usage +} + +func Handler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var baiduResponse ChatResponse + err := json.NewDecoder(resp.Body).Decode(&baiduResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if baiduResponse.ErrorMsg != "" { + return &model.ErrorWithStatusCode{ + Error: model.Error{ + Message: baiduResponse.ErrorMsg, + Type: "baidu_error", + Param: "", + Code: baiduResponse.ErrorCode, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := responseBaidu2OpenAI(&baiduResponse) + fullTextResponse.Model = "ernie-bot" + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} + +func EmbeddingHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var baiduResponse EmbeddingResponse + err := json.NewDecoder(resp.Body).Decode(&baiduResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if baiduResponse.ErrorMsg != "" { + return &model.ErrorWithStatusCode{ + Error: model.Error{ + Message: baiduResponse.ErrorMsg, + Type: "baidu_error", + Param: "", + Code: baiduResponse.ErrorCode, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := embeddingResponseBaidu2OpenAI(&baiduResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} + +func GetAccessToken(apiKey string) (string, error) { + if val, ok := baiduTokenStore.Load(apiKey); ok { + var accessToken AccessToken + if accessToken, ok = val.(AccessToken); ok { + // soon this will expire + if time.Now().Add(time.Hour).After(accessToken.ExpiresAt) { + go func() { + _, _ = getBaiduAccessTokenHelper(apiKey) + }() + } + return accessToken.AccessToken, nil + } + } + accessToken, err := getBaiduAccessTokenHelper(apiKey) + if err != nil { + return "", err + } + if accessToken == nil { + return "", errors.New("GetAccessToken return a nil token") + } + return accessToken.AccessToken, nil +} + +func getBaiduAccessTokenHelper(apiKey string) (*AccessToken, error) { + parts := strings.Split(apiKey, "|") + if len(parts) != 2 { + return nil, errors.New("invalid baidu apikey") + } + req, err := http.NewRequestWithContext(context.Background(), + http.MethodPost, + fmt.Sprintf("https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=%s&client_secret=%s", + parts[0], parts[1]), + nil) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "application/json") + res, err := client.ImpatientHTTPClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + var accessToken AccessToken + err = json.NewDecoder(res.Body).Decode(&accessToken) + if err != nil { + return nil, err + } + if accessToken.Error != "" { + return nil, errors.New(accessToken.Error + ": " + accessToken.ErrorDescription) + } + if accessToken.AccessToken == "" { + return nil, errors.New("getBaiduAccessTokenHelper get empty access token") + } + accessToken.ExpiresAt = time.Now().Add(time.Duration(accessToken.ExpiresIn) * time.Second) + baiduTokenStore.Store(apiKey, accessToken) + return &accessToken, nil +} diff --git a/service/aiproxy/relay/adaptor/baidu/model.go b/service/aiproxy/relay/adaptor/baidu/model.go new file mode 100644 index 00000000000..0f0d52a298c --- /dev/null +++ b/service/aiproxy/relay/adaptor/baidu/model.go @@ -0,0 +1,51 @@ +package baidu + +import ( + "time" + + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +type ChatResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Result string `json:"result"` + Error + Usage model.Usage `json:"usage"` + Created int64 `json:"created"` + IsTruncated bool `json:"is_truncated"` + NeedClearHistory bool `json:"need_clear_history"` +} + +type ChatStreamResponse struct { + ChatResponse + SentenceID int `json:"sentence_id"` + IsEnd bool `json:"is_end"` +} + +type EmbeddingRequest struct { + Input []string `json:"input"` +} + +type EmbeddingData struct { + Object string `json:"object"` + Embedding []float64 `json:"embedding"` + Index int `json:"index"` +} + +type EmbeddingResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Data []EmbeddingData `json:"data"` + Error + Usage model.Usage `json:"usage"` + Created int64 `json:"created"` +} + +type AccessToken struct { + ExpiresAt time.Time `json:"-"` + AccessToken string `json:"access_token"` + Error string `json:"error,omitempty"` + ErrorDescription string `json:"error_description,omitempty"` + ExpiresIn int64 `json:"expires_in,omitempty"` +} diff --git a/service/aiproxy/relay/adaptor/cloudflare/adaptor.go b/service/aiproxy/relay/adaptor/cloudflare/adaptor.go new file mode 100644 index 00000000000..694ce44c5c2 --- /dev/null +++ b/service/aiproxy/relay/adaptor/cloudflare/adaptor.go @@ -0,0 +1,108 @@ +package cloudflare + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" +) + +type Adaptor struct { + meta *meta.Meta +} + +// ConvertImageRequest implements adaptor.Adaptor. +func (*Adaptor) ConvertImageRequest(_ *model.ImageRequest) (any, error) { + return nil, errors.New("not implemented") +} + +// ConvertImageRequest implements adaptor.Adaptor. + +func (a *Adaptor) Init(meta *meta.Meta) { + a.meta = meta +} + +// WorkerAI cannot be used across accounts with AIGateWay +// https://developers.cloudflare.com/ai-gateway/providers/workersai/#openai-compatible-endpoints +// https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/workers-ai +func (a *Adaptor) isAIGateWay(baseURL string) bool { + return strings.HasPrefix(baseURL, "https://gateway.ai.cloudflare.com") && strings.HasSuffix(baseURL, "/workers-ai") +} + +func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { + isAIGateWay := a.isAIGateWay(meta.BaseURL) + var urlPrefix string + if isAIGateWay { + urlPrefix = meta.BaseURL + } else { + urlPrefix = fmt.Sprintf("%s/client/v4/accounts/%s/ai", meta.BaseURL, meta.Config.UserID) + } + + switch meta.Mode { + case relaymode.ChatCompletions: + return urlPrefix + "/v1/chat/completions", nil + case relaymode.Embeddings: + return urlPrefix + "/v1/embeddings", nil + default: + if isAIGateWay { + return fmt.Sprintf("%s/%s", urlPrefix, meta.ActualModelName), nil + } + return fmt.Sprintf("%s/run/%s", urlPrefix, meta.ActualModelName), nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { + adaptor.SetupCommonRequestHeader(c, req, meta) + req.Header.Set("Authorization", "Bearer "+meta.APIKey) + return nil +} + +func (a *Adaptor) ConvertRequest(_ *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + switch relayMode { + case relaymode.Completions: + return ConvertCompletionsRequest(request), nil + case relaymode.ChatCompletions, relaymode.Embeddings: + return request, nil + default: + return nil, errors.New("not implemented") + } +} + +func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + return adaptor.DoRequestHelper(a, c, meta, requestBody) +} + +func (a *Adaptor) ConvertSTTRequest(*http.Request) (io.ReadCloser, error) { + return nil, nil +} + +func (a *Adaptor) ConvertTTSRequest(*model.TextToSpeechRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + err, usage = StreamHandler(c, resp, meta.PromptTokens, meta.ActualModelName) + } else { + err, usage = Handler(c, resp, meta.PromptTokens, meta.ActualModelName) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return "cloudflare" +} diff --git a/service/aiproxy/relay/adaptor/cloudflare/constant.go b/service/aiproxy/relay/adaptor/cloudflare/constant.go new file mode 100644 index 00000000000..54052aa6ca1 --- /dev/null +++ b/service/aiproxy/relay/adaptor/cloudflare/constant.go @@ -0,0 +1,37 @@ +package cloudflare + +var ModelList = []string{ + "@cf/meta/llama-3.1-8b-instruct", + "@cf/meta/llama-2-7b-chat-fp16", + "@cf/meta/llama-2-7b-chat-int8", + "@cf/mistral/mistral-7b-instruct-v0.1", + "@hf/thebloke/deepseek-coder-6.7b-base-awq", + "@hf/thebloke/deepseek-coder-6.7b-instruct-awq", + "@cf/deepseek-ai/deepseek-math-7b-base", + "@cf/deepseek-ai/deepseek-math-7b-instruct", + "@cf/thebloke/discolm-german-7b-v1-awq", + "@cf/tiiuae/falcon-7b-instruct", + "@cf/google/gemma-2b-it-lora", + "@hf/google/gemma-7b-it", + "@cf/google/gemma-7b-it-lora", + "@hf/nousresearch/hermes-2-pro-mistral-7b", + "@hf/thebloke/llama-2-13b-chat-awq", + "@cf/meta-llama/llama-2-7b-chat-hf-lora", + "@cf/meta/llama-3-8b-instruct", + "@hf/thebloke/llamaguard-7b-awq", + "@hf/thebloke/mistral-7b-instruct-v0.1-awq", + "@hf/mistralai/mistral-7b-instruct-v0.2", + "@cf/mistral/mistral-7b-instruct-v0.2-lora", + "@hf/thebloke/neural-chat-7b-v3-1-awq", + "@cf/openchat/openchat-3.5-0106", + "@hf/thebloke/openhermes-2.5-mistral-7b-awq", + "@cf/microsoft/phi-2", + "@cf/qwen/qwen1.5-0.5b-chat", + "@cf/qwen/qwen1.5-1.8b-chat", + "@cf/qwen/qwen1.5-14b-chat-awq", + "@cf/qwen/qwen1.5-7b-chat-awq", + "@cf/defog/sqlcoder-7b-2", + "@hf/nexusflow/starling-lm-7b-beta", + "@cf/tinyllama/tinyllama-1.1b-chat-v1.0", + "@hf/thebloke/zephyr-7b-beta-awq", +} diff --git a/service/aiproxy/relay/adaptor/cloudflare/main.go b/service/aiproxy/relay/adaptor/cloudflare/main.go new file mode 100644 index 00000000000..87679da7489 --- /dev/null +++ b/service/aiproxy/relay/adaptor/cloudflare/main.go @@ -0,0 +1,106 @@ +package cloudflare + +import ( + "bufio" + "net/http" + + json "github.com/json-iterator/go" + + "github.com/labring/sealos/service/aiproxy/common/conv" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + "github.com/labring/sealos/service/aiproxy/common/render" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +func ConvertCompletionsRequest(textRequest *model.GeneralOpenAIRequest) *Request { + p, _ := textRequest.Prompt.(string) + return &Request{ + Prompt: p, + MaxTokens: textRequest.MaxTokens, + Stream: textRequest.Stream, + Temperature: textRequest.Temperature, + } +} + +func StreamHandler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + scanner := bufio.NewScanner(resp.Body) + scanner.Split(bufio.ScanLines) + + common.SetEventStreamHeaders(c) + id := helper.GetResponseID(c) + responseModel := c.GetString(ctxkey.OriginalModel) + var responseText string + + for scanner.Scan() { + data := scanner.Bytes() + if len(data) < 6 || conv.BytesToString(data[:6]) != "data: " { + continue + } + data = data[6:] + + if conv.BytesToString(data) == "[DONE]" { + break + } + + var response openai.ChatCompletionsStreamResponse + err := json.Unmarshal(data, &response) + if err != nil { + logger.SysErrorf("error unmarshalling stream response: %s, data: %s", err.Error(), conv.BytesToString(data)) + continue + } + for _, v := range response.Choices { + v.Delta.Role = "assistant" + responseText += v.Delta.StringContent() + } + response.ID = id + response.Model = modelName + err = render.ObjectData(c, response) + if err != nil { + logger.SysError(err.Error()) + } + } + + if err := scanner.Err(); err != nil { + logger.SysError("error reading stream: " + err.Error()) + } + + render.Done(c) + + usage := openai.ResponseText2Usage(responseText, responseModel, promptTokens) + return nil, usage +} + +func Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var response openai.TextResponse + err := json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + + response.Model = modelName + var responseText string + for _, v := range response.Choices { + responseText += v.Message.Content.(string) + } + usage := openai.ResponseText2Usage(responseText, modelName, promptTokens) + response.Usage = *usage + response.ID = helper.GetResponseID(c) + jsonResponse, err := json.Marshal(response) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil, usage +} diff --git a/service/aiproxy/relay/adaptor/cloudflare/model.go b/service/aiproxy/relay/adaptor/cloudflare/model.go new file mode 100644 index 00000000000..8d1b480192f --- /dev/null +++ b/service/aiproxy/relay/adaptor/cloudflare/model.go @@ -0,0 +1,13 @@ +package cloudflare + +import "github.com/labring/sealos/service/aiproxy/relay/model" + +type Request struct { + Temperature *float64 `json:"temperature,omitempty"` + Lora string `json:"lora,omitempty"` + Prompt string `json:"prompt,omitempty"` + Messages []model.Message `json:"messages,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Raw bool `json:"raw,omitempty"` + Stream bool `json:"stream,omitempty"` +} diff --git a/service/aiproxy/relay/adaptor/cohere/adaptor.go b/service/aiproxy/relay/adaptor/cohere/adaptor.go new file mode 100644 index 00000000000..6815c6bb098 --- /dev/null +++ b/service/aiproxy/relay/adaptor/cohere/adaptor.go @@ -0,0 +1,75 @@ +package cohere + +import ( + "errors" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" +) + +type Adaptor struct{} + +// ConvertImageRequest implements adaptor.Adaptor. +func (*Adaptor) ConvertImageRequest(_ *model.ImageRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(_ *meta.Meta) { +} + +func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { + return meta.BaseURL + "/v1/chat", nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { + adaptor.SetupCommonRequestHeader(c, req, meta) + req.Header.Set("Authorization", "Bearer "+meta.APIKey) + return nil +} + +func (a *Adaptor) ConvertRequest(_ *gin.Context, _ int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return ConvertRequest(request), nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + return adaptor.DoRequestHelper(a, c, meta, requestBody) +} + +func (a *Adaptor) ConvertSTTRequest(*http.Request) (io.ReadCloser, error) { + return nil, nil +} + +func (a *Adaptor) ConvertTTSRequest(*model.TextToSpeechRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + err, usage = StreamHandler(c, resp) + return + } + switch meta.Mode { + case relaymode.Rerank: + err, usage = openai.RerankHandler(c, resp, meta.PromptTokens, meta) + default: + err, usage = Handler(c, resp, meta.PromptTokens, meta.ActualModelName) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return "Cohere" +} diff --git a/service/aiproxy/relay/adaptor/cohere/constant.go b/service/aiproxy/relay/adaptor/cohere/constant.go new file mode 100644 index 00000000000..9e70652ccb9 --- /dev/null +++ b/service/aiproxy/relay/adaptor/cohere/constant.go @@ -0,0 +1,14 @@ +package cohere + +var ModelList = []string{ + "command", "command-nightly", + "command-light", "command-light-nightly", + "command-r", "command-r-plus", +} + +func init() { + num := len(ModelList) + for i := 0; i < num; i++ { + ModelList = append(ModelList, ModelList[i]+"-internet") + } +} diff --git a/service/aiproxy/relay/adaptor/cohere/main.go b/service/aiproxy/relay/adaptor/cohere/main.go new file mode 100644 index 00000000000..d40e0056e0a --- /dev/null +++ b/service/aiproxy/relay/adaptor/cohere/main.go @@ -0,0 +1,219 @@ +package cohere + +import ( + "bufio" + "fmt" + "net/http" + "strings" + + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common/conv" + "github.com/labring/sealos/service/aiproxy/common/render" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +var WebSearchConnector = Connector{ID: "web-search"} + +func stopReasonCohere2OpenAI(reason *string) string { + if reason == nil { + return "" + } + switch *reason { + case "COMPLETE": + return "stop" + default: + return *reason + } +} + +func ConvertRequest(textRequest *model.GeneralOpenAIRequest) *Request { + cohereRequest := Request{ + Model: textRequest.Model, + Message: "", + MaxTokens: textRequest.MaxTokens, + Temperature: textRequest.Temperature, + P: textRequest.TopP, + K: textRequest.TopK, + Stream: textRequest.Stream, + FrequencyPenalty: textRequest.FrequencyPenalty, + PresencePenalty: textRequest.PresencePenalty, + Seed: int(textRequest.Seed), + } + if cohereRequest.Model == "" { + cohereRequest.Model = "command-r" + } + if strings.HasSuffix(cohereRequest.Model, "-internet") { + cohereRequest.Model = strings.TrimSuffix(cohereRequest.Model, "-internet") + cohereRequest.Connectors = append(cohereRequest.Connectors, WebSearchConnector) + } + for _, message := range textRequest.Messages { + if message.Role == "user" { + cohereRequest.Message = message.Content.(string) + } else { + var role string + switch message.Role { + case "assistant": + role = "CHATBOT" + case "system": + role = "SYSTEM" + default: + role = "USER" + } + cohereRequest.ChatHistory = append(cohereRequest.ChatHistory, ChatMessage{ + Role: role, + Message: message.Content.(string), + }) + } + } + return &cohereRequest +} + +func StreamResponseCohere2OpenAI(cohereResponse *StreamResponse) (*openai.ChatCompletionsStreamResponse, *Response) { + var response *Response + var responseText string + var finishReason string + + switch cohereResponse.EventType { + case "stream-start": + return nil, nil + case "text-generation": + responseText += cohereResponse.Text + case "stream-end": + usage := cohereResponse.Response.Meta.Tokens + response = &Response{ + Meta: Meta{ + Tokens: Usage{ + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + }, + }, + } + finishReason = *cohereResponse.Response.FinishReason + default: + return nil, nil + } + + var choice openai.ChatCompletionsStreamResponseChoice + choice.Delta.Content = responseText + choice.Delta.Role = "assistant" + if finishReason != "" { + choice.FinishReason = &finishReason + } + var openaiResponse openai.ChatCompletionsStreamResponse + openaiResponse.Object = "chat.completion.chunk" + openaiResponse.Choices = []openai.ChatCompletionsStreamResponseChoice{choice} + return &openaiResponse, response +} + +func ResponseCohere2OpenAI(cohereResponse *Response) *openai.TextResponse { + choice := openai.TextResponseChoice{ + Index: 0, + Message: model.Message{ + Role: "assistant", + Content: cohereResponse.Text, + Name: nil, + }, + FinishReason: stopReasonCohere2OpenAI(cohereResponse.FinishReason), + } + fullTextResponse := openai.TextResponse{ + ID: "chatcmpl-" + cohereResponse.ResponseID, + Model: "model", + Object: "chat.completion", + Created: helper.GetTimestamp(), + Choices: []openai.TextResponseChoice{choice}, + } + return &fullTextResponse +} + +func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + createdTime := helper.GetTimestamp() + scanner := bufio.NewScanner(resp.Body) + scanner.Split(bufio.ScanLines) + + common.SetEventStreamHeaders(c) + var usage model.Usage + + for scanner.Scan() { + data := scanner.Text() + data = strings.TrimSuffix(data, "\r") + + var cohereResponse StreamResponse + err := json.Unmarshal(conv.StringToBytes(data), &cohereResponse) + if err != nil { + logger.SysError("error unmarshalling stream response: " + err.Error()) + continue + } + + response, meta := StreamResponseCohere2OpenAI(&cohereResponse) + if meta != nil { + usage.PromptTokens += meta.Meta.Tokens.InputTokens + usage.CompletionTokens += meta.Meta.Tokens.OutputTokens + continue + } + if response == nil { + continue + } + + response.ID = fmt.Sprintf("chatcmpl-%d", createdTime) + response.Model = c.GetString("original_model") + response.Created = createdTime + + err = render.ObjectData(c, response) + if err != nil { + logger.SysError(err.Error()) + } + } + + if err := scanner.Err(); err != nil { + logger.SysError("error reading stream: " + err.Error()) + } + + render.Done(c) + + return nil, &usage +} + +func Handler(c *gin.Context, resp *http.Response, _ int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var cohereResponse Response + err := json.NewDecoder(resp.Body).Decode(&cohereResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if cohereResponse.ResponseID == "" { + return &model.ErrorWithStatusCode{ + Error: model.Error{ + Message: cohereResponse.Message, + Type: cohereResponse.Message, + Param: "", + Code: resp.StatusCode, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := ResponseCohere2OpenAI(&cohereResponse) + fullTextResponse.Model = modelName + usage := model.Usage{ + PromptTokens: cohereResponse.Meta.Tokens.InputTokens, + CompletionTokens: cohereResponse.Meta.Tokens.OutputTokens, + TotalTokens: cohereResponse.Meta.Tokens.InputTokens + cohereResponse.Meta.Tokens.OutputTokens, + } + fullTextResponse.Usage = usage + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil, &usage +} diff --git a/service/aiproxy/relay/adaptor/cohere/model.go b/service/aiproxy/relay/adaptor/cohere/model.go new file mode 100644 index 00000000000..64e1ccc8f5e --- /dev/null +++ b/service/aiproxy/relay/adaptor/cohere/model.go @@ -0,0 +1,147 @@ +package cohere + +type Request struct { + P *float64 `json:"p,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` + Model string `json:"model,omitempty"` + Message string `json:"message" required:"true"` + Preamble string `json:"preamble,omitempty"` + PromptTruncation string `json:"prompt_truncation,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Tools []Tool `json:"tools,omitempty"` + ToolResults []ToolResult `json:"tool_results,omitempty"` + Documents []Document `json:"documents,omitempty"` + Connectors []Connector `json:"connectors,omitempty"` + ChatHistory []ChatMessage `json:"chat_history,omitempty"` + K int `json:"k,omitempty"` + MaxInputTokens int `json:"max_input_tokens,omitempty"` + Seed int `json:"seed,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +type ChatMessage struct { + Role string `json:"role" required:"true"` + Message string `json:"message" required:"true"` +} + +type Tool struct { + ParameterDefinitions map[string]ParameterSpec `json:"parameter_definitions"` + Name string `json:"name" required:"true"` + Description string `json:"description" required:"true"` +} + +type ParameterSpec struct { + Description string `json:"description"` + Type string `json:"type" required:"true"` + Required bool `json:"required"` +} + +type ToolResult struct { + Call ToolCall `json:"call"` + Outputs []map[string]interface{} `json:"outputs"` +} + +type ToolCall struct { + Parameters map[string]interface{} `json:"parameters" required:"true"` + Name string `json:"name" required:"true"` +} + +type StreamResponse struct { + Response *Response `json:"response,omitempty"` + EventType string `json:"event_type"` + GenerationID string `json:"generation_id,omitempty"` + Text string `json:"text,omitempty"` + FinishReason string `json:"finish_reason,omitempty"` + SearchQueries []*SearchQuery `json:"search_queries,omitempty"` + SearchResults []*SearchResult `json:"search_results,omitempty"` + Documents []*Document `json:"documents,omitempty"` + Citations []*Citation `json:"citations,omitempty"` + IsFinished bool `json:"is_finished"` +} + +type SearchQuery struct { + Text string `json:"text"` + GenerationID string `json:"generation_id"` +} + +type SearchResult struct { + SearchQuery *SearchQuery `json:"search_query"` + Connector *Connector `json:"connector"` + DocumentIDs []string `json:"document_ids"` +} + +type Connector struct { + ID string `json:"id"` +} + +type Document struct { + ID string `json:"id"` + Snippet string `json:"snippet"` + Timestamp string `json:"timestamp"` + Title string `json:"title"` + URL string `json:"url"` +} + +type Citation struct { + Text string `json:"text"` + DocumentIDs []string `json:"document_ids"` + Start int `json:"start"` + End int `json:"end"` +} + +type Response struct { + FinishReason *string `json:"finish_reason"` + ResponseID string `json:"response_id"` + Text string `json:"text"` + GenerationID string `json:"generation_id"` + Message string `json:"message"` + ChatHistory []*Message `json:"chat_history"` + Citations []*Citation `json:"citations"` + Documents []*Document `json:"documents"` + SearchResults []*SearchResult `json:"search_results"` + SearchQueries []*SearchQuery `json:"search_queries"` + Meta Meta `json:"meta"` +} + +type Message struct { + Role string `json:"role"` + Message string `json:"message"` +} + +type Version struct { + Version string `json:"version"` +} + +type Units struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +type ChatEntry struct { + Role string `json:"role"` + Message string `json:"message"` +} + +type Meta struct { + APIVersion APIVersion `json:"api_version"` + BilledUnits BilledUnits `json:"billed_units"` + Tokens Usage `json:"tokens"` +} + +type APIVersion struct { + Version string `json:"version"` +} + +type BilledUnits struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +type Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} diff --git a/service/aiproxy/relay/adaptor/common.go b/service/aiproxy/relay/adaptor/common.go new file mode 100644 index 00000000000..d4d369bb2c1 --- /dev/null +++ b/service/aiproxy/relay/adaptor/common.go @@ -0,0 +1,47 @@ +package adaptor + +import ( + "fmt" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common/client" + "github.com/labring/sealos/service/aiproxy/relay/meta" +) + +func SetupCommonRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) { + req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) + req.Header.Set("Accept", c.Request.Header.Get("Accept")) + if meta.IsStream && c.Request.Header.Get("Accept") == "" { + req.Header.Set("Accept", "text/event-stream") + } +} + +func DoRequestHelper(a Adaptor, c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + fullRequestURL, err := a.GetRequestURL(meta) + if err != nil { + return nil, fmt.Errorf("get request url failed: %w", err) + } + req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, fullRequestURL, requestBody) + if err != nil { + return nil, fmt.Errorf("new request failed: %w", err) + } + err = a.SetupRequestHeader(c, req, meta) + if err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + resp, err := DoRequest(c, req) + if err != nil { + return nil, fmt.Errorf("do request failed: %w", err) + } + return resp, nil +} + +func DoRequest(_ *gin.Context, req *http.Request) (*http.Response, error) { + resp, err := client.HTTPClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/service/aiproxy/relay/adaptor/coze/adaptor.go b/service/aiproxy/relay/adaptor/coze/adaptor.go new file mode 100644 index 00000000000..4bb92b3285d --- /dev/null +++ b/service/aiproxy/relay/adaptor/coze/adaptor.go @@ -0,0 +1,83 @@ +package coze + +import ( + "errors" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +type Adaptor struct { + meta *meta.Meta +} + +func (a *Adaptor) Init(meta *meta.Meta) { + a.meta = meta +} + +func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { + return meta.BaseURL + "/open_api/v2/chat", nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { + adaptor.SetupCommonRequestHeader(c, req, meta) + req.Header.Set("Authorization", "Bearer "+meta.APIKey) + return nil +} + +func (a *Adaptor) ConvertRequest(_ *gin.Context, _ int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + request.User = a.meta.Config.UserID + return ConvertRequest(request), nil +} + +func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + return adaptor.DoRequestHelper(a, c, meta, requestBody) +} + +func (a *Adaptor) ConvertSTTRequest(*http.Request) (io.ReadCloser, error) { + return nil, nil +} + +func (a *Adaptor) ConvertTTSRequest(*model.TextToSpeechRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + var responseText *string + if meta.IsStream { + err, responseText = StreamHandler(c, resp) + } else { + err, responseText = Handler(c, resp, meta.PromptTokens, meta.ActualModelName) + } + if responseText != nil { + usage = openai.ResponseText2Usage(*responseText, meta.ActualModelName, meta.PromptTokens) + } else { + usage = &model.Usage{} + } + usage.PromptTokens = meta.PromptTokens + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return "coze" +} diff --git a/service/aiproxy/relay/adaptor/coze/constant/contenttype/define.go b/service/aiproxy/relay/adaptor/coze/constant/contenttype/define.go new file mode 100644 index 00000000000..69c876bc4c4 --- /dev/null +++ b/service/aiproxy/relay/adaptor/coze/constant/contenttype/define.go @@ -0,0 +1,5 @@ +package contenttype + +const ( + Text = "text" +) diff --git a/service/aiproxy/relay/adaptor/coze/constant/event/define.go b/service/aiproxy/relay/adaptor/coze/constant/event/define.go new file mode 100644 index 00000000000..c03e8c173ec --- /dev/null +++ b/service/aiproxy/relay/adaptor/coze/constant/event/define.go @@ -0,0 +1,7 @@ +package event + +const ( + Message = "message" + Done = "done" + Error = "error" +) diff --git a/service/aiproxy/relay/adaptor/coze/constant/messagetype/define.go b/service/aiproxy/relay/adaptor/coze/constant/messagetype/define.go new file mode 100644 index 00000000000..6c1c25db4c8 --- /dev/null +++ b/service/aiproxy/relay/adaptor/coze/constant/messagetype/define.go @@ -0,0 +1,6 @@ +package messagetype + +const ( + Answer = "answer" + FollowUp = "follow_up" +) diff --git a/service/aiproxy/relay/adaptor/coze/constants.go b/service/aiproxy/relay/adaptor/coze/constants.go new file mode 100644 index 00000000000..d20fd875804 --- /dev/null +++ b/service/aiproxy/relay/adaptor/coze/constants.go @@ -0,0 +1,3 @@ +package coze + +var ModelList = []string{} diff --git a/service/aiproxy/relay/adaptor/coze/main.go b/service/aiproxy/relay/adaptor/coze/main.go new file mode 100644 index 00000000000..da8e57e222d --- /dev/null +++ b/service/aiproxy/relay/adaptor/coze/main.go @@ -0,0 +1,195 @@ +package coze + +import ( + "bufio" + "net/http" + "strings" + + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common/render" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/conv" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/coze/constant/messagetype" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +// https://www.coze.com/open + +func stopReasonCoze2OpenAI(reason *string) string { + if reason == nil { + return "" + } + switch *reason { + case "end_turn": + return "stop" + case "stop_sequence": + return "stop" + case "max_tokens": + return "length" + default: + return *reason + } +} + +func ConvertRequest(textRequest *model.GeneralOpenAIRequest) *Request { + cozeRequest := Request{ + Stream: textRequest.Stream, + User: textRequest.User, + BotID: strings.TrimPrefix(textRequest.Model, "bot-"), + } + for i, message := range textRequest.Messages { + if i == len(textRequest.Messages)-1 { + cozeRequest.Query = message.StringContent() + continue + } + cozeMessage := Message{ + Role: message.Role, + Content: message.StringContent(), + } + cozeRequest.ChatHistory = append(cozeRequest.ChatHistory, cozeMessage) + } + return &cozeRequest +} + +func StreamResponseCoze2OpenAI(cozeResponse *StreamResponse) (*openai.ChatCompletionsStreamResponse, *Response) { + var response *Response + var stopReason string + var choice openai.ChatCompletionsStreamResponseChoice + + if cozeResponse.Message != nil { + if cozeResponse.Message.Type != messagetype.Answer { + return nil, nil + } + choice.Delta.Content = cozeResponse.Message.Content + } + choice.Delta.Role = "assistant" + finishReason := stopReasonCoze2OpenAI(&stopReason) + if finishReason != "null" { + choice.FinishReason = &finishReason + } + var openaiResponse openai.ChatCompletionsStreamResponse + openaiResponse.Object = "chat.completion.chunk" + openaiResponse.Choices = []openai.ChatCompletionsStreamResponseChoice{choice} + openaiResponse.ID = cozeResponse.ConversationID + return &openaiResponse, response +} + +func ResponseCoze2OpenAI(cozeResponse *Response) *openai.TextResponse { + var responseText string + for _, message := range cozeResponse.Messages { + if message.Type == messagetype.Answer { + responseText = message.Content + break + } + } + choice := openai.TextResponseChoice{ + Index: 0, + Message: model.Message{ + Role: "assistant", + Content: responseText, + Name: nil, + }, + FinishReason: "stop", + } + fullTextResponse := openai.TextResponse{ + ID: "chatcmpl-" + cozeResponse.ConversationID, + Model: "coze-bot", + Object: "chat.completion", + Created: helper.GetTimestamp(), + Choices: []openai.TextResponseChoice{choice}, + } + return &fullTextResponse +} + +func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *string) { + defer resp.Body.Close() + + var responseText string + createdTime := helper.GetTimestamp() + scanner := bufio.NewScanner(resp.Body) + scanner.Split(bufio.ScanLines) + + common.SetEventStreamHeaders(c) + var modelName string + + for scanner.Scan() { + data := scanner.Bytes() + if len(data) < 6 || conv.BytesToString(data[:6]) != "data: " { + continue + } + data = data[6:] + + if conv.BytesToString(data) == "[DONE]" { + break + } + + var cozeResponse StreamResponse + err := json.Unmarshal(data, &cozeResponse) + if err != nil { + logger.SysErrorf("error unmarshalling stream response: %s, data: %s", err.Error(), conv.BytesToString(data)) + continue + } + + response, _ := StreamResponseCoze2OpenAI(&cozeResponse) + if response == nil { + continue + } + + for _, choice := range response.Choices { + responseText += conv.AsString(choice.Delta.Content) + } + response.Model = modelName + response.Created = createdTime + + err = render.ObjectData(c, response) + if err != nil { + logger.SysError(err.Error()) + } + } + + if err := scanner.Err(); err != nil { + logger.SysError("error reading stream: " + err.Error()) + } + + render.Done(c) + + return nil, &responseText +} + +func Handler(c *gin.Context, resp *http.Response, _ int, modelName string) (*model.ErrorWithStatusCode, *string) { + defer resp.Body.Close() + + var cozeResponse Response + err := json.NewDecoder(resp.Body).Decode(&cozeResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if cozeResponse.Code != 0 { + return &model.ErrorWithStatusCode{ + Error: model.Error{ + Message: cozeResponse.Msg, + Code: cozeResponse.Code, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := ResponseCoze2OpenAI(&cozeResponse) + fullTextResponse.Model = modelName + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + var responseText string + if len(fullTextResponse.Choices) > 0 { + responseText = fullTextResponse.Choices[0].Message.StringContent() + } + return nil, &responseText +} diff --git a/service/aiproxy/relay/adaptor/coze/model.go b/service/aiproxy/relay/adaptor/coze/model.go new file mode 100644 index 00000000000..a43adf8902f --- /dev/null +++ b/service/aiproxy/relay/adaptor/coze/model.go @@ -0,0 +1,38 @@ +package coze + +type Message struct { + Role string `json:"role"` + Type string `json:"type"` + Content string `json:"content"` + ContentType string `json:"content_type"` +} + +type ErrorInformation struct { + Msg string `json:"msg"` + Code int `json:"code"` +} + +type Request struct { + ConversationID string `json:"conversation_id,omitempty"` + BotID string `json:"bot_id"` + User string `json:"user"` + Query string `json:"query"` + ChatHistory []Message `json:"chat_history,omitempty"` + Stream bool `json:"stream"` +} + +type Response struct { + ConversationID string `json:"conversation_id,omitempty"` + Msg string `json:"msg,omitempty"` + Messages []Message `json:"messages,omitempty"` + Code int `json:"code,omitempty"` +} + +type StreamResponse struct { + Message *Message `json:"message,omitempty"` + ErrorInformation *ErrorInformation `json:"error_information,omitempty"` + Event string `json:"event,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` + Index int `json:"index,omitempty"` + IsFinish bool `json:"is_finish,omitempty"` +} diff --git a/service/aiproxy/relay/adaptor/deepl/adaptor.go b/service/aiproxy/relay/adaptor/deepl/adaptor.go new file mode 100644 index 00000000000..3973d8bb3c6 --- /dev/null +++ b/service/aiproxy/relay/adaptor/deepl/adaptor.go @@ -0,0 +1,81 @@ +package deepl + +import ( + "errors" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +type Adaptor struct { + meta *meta.Meta + promptText string +} + +func (a *Adaptor) Init(meta *meta.Meta) { + a.meta = meta +} + +func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { + return meta.BaseURL + "/v2/translate", nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { + adaptor.SetupCommonRequestHeader(c, req, meta) + req.Header.Set("Authorization", "DeepL-Auth-Key "+meta.APIKey) + return nil +} + +func (a *Adaptor) ConvertRequest(_ *gin.Context, _ int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + convertedRequest, text := ConvertRequest(request) + a.promptText = text + return convertedRequest, nil +} + +func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + return adaptor.DoRequestHelper(a, c, meta, requestBody) +} + +func (a *Adaptor) ConvertSTTRequest(*http.Request) (io.ReadCloser, error) { + return nil, nil +} + +func (a *Adaptor) ConvertTTSRequest(*model.TextToSpeechRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + err = StreamHandler(c, resp, meta.ActualModelName) + } else { + err = Handler(c, resp, meta.ActualModelName) + } + promptTokens := len(a.promptText) + usage = &model.Usage{ + PromptTokens: promptTokens, + TotalTokens: promptTokens, + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return "deepl" +} diff --git a/service/aiproxy/relay/adaptor/deepl/constants.go b/service/aiproxy/relay/adaptor/deepl/constants.go new file mode 100644 index 00000000000..6a4f25454ab --- /dev/null +++ b/service/aiproxy/relay/adaptor/deepl/constants.go @@ -0,0 +1,9 @@ +package deepl + +// https://developers.deepl.com/docs/api-reference/glossaries + +var ModelList = []string{ + "deepl-zh", + "deepl-en", + "deepl-ja", +} diff --git a/service/aiproxy/relay/adaptor/deepl/helper.go b/service/aiproxy/relay/adaptor/deepl/helper.go new file mode 100644 index 00000000000..6d3a914b922 --- /dev/null +++ b/service/aiproxy/relay/adaptor/deepl/helper.go @@ -0,0 +1,11 @@ +package deepl + +import "strings" + +func parseLangFromModelName(modelName string) string { + parts := strings.Split(modelName, "-") + if len(parts) == 1 { + return "ZH" + } + return parts[1] +} diff --git a/service/aiproxy/relay/adaptor/deepl/main.go b/service/aiproxy/relay/adaptor/deepl/main.go new file mode 100644 index 00000000000..2ae86e13f97 --- /dev/null +++ b/service/aiproxy/relay/adaptor/deepl/main.go @@ -0,0 +1,117 @@ +package deepl + +import ( + "net/http" + + "github.com/gin-gonic/gin" + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/render" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/constant" + "github.com/labring/sealos/service/aiproxy/relay/constant/finishreason" + "github.com/labring/sealos/service/aiproxy/relay/constant/role" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +// https://developers.deepl.com/docs/getting-started/your-first-api-request + +func ConvertRequest(textRequest *model.GeneralOpenAIRequest) (*Request, string) { + var text string + if len(textRequest.Messages) != 0 { + text = textRequest.Messages[len(textRequest.Messages)-1].StringContent() + } + deeplRequest := Request{ + TargetLang: parseLangFromModelName(textRequest.Model), + Text: []string{text}, + } + return &deeplRequest, text +} + +func StreamResponseDeepL2OpenAI(deeplResponse *Response) *openai.ChatCompletionsStreamResponse { + var choice openai.ChatCompletionsStreamResponseChoice + if len(deeplResponse.Translations) != 0 { + choice.Delta.Content = deeplResponse.Translations[0].Text + } + choice.Delta.Role = role.Assistant + choice.FinishReason = &constant.StopFinishReason + openaiResponse := openai.ChatCompletionsStreamResponse{ + Object: constant.StreamObject, + Created: helper.GetTimestamp(), + Choices: []openai.ChatCompletionsStreamResponseChoice{choice}, + } + return &openaiResponse +} + +func ResponseDeepL2OpenAI(deeplResponse *Response) *openai.TextResponse { + var responseText string + if len(deeplResponse.Translations) != 0 { + responseText = deeplResponse.Translations[0].Text + } + choice := openai.TextResponseChoice{ + Index: 0, + Message: model.Message{ + Role: role.Assistant, + Content: responseText, + Name: nil, + }, + FinishReason: finishreason.Stop, + } + fullTextResponse := openai.TextResponse{ + Object: constant.NonStreamObject, + Created: helper.GetTimestamp(), + Choices: []openai.TextResponseChoice{choice}, + } + return &fullTextResponse +} + +func StreamHandler(c *gin.Context, resp *http.Response, modelName string) *model.ErrorWithStatusCode { + defer resp.Body.Close() + + var deeplResponse Response + err := json.NewDecoder(resp.Body).Decode(&deeplResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError) + } + fullTextResponse := StreamResponseDeepL2OpenAI(&deeplResponse) + fullTextResponse.Model = modelName + fullTextResponse.ID = helper.GetResponseID(c) + common.SetEventStreamHeaders(c) + err = render.ObjectData(c, fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "render_response_body_failed", http.StatusInternalServerError) + } + render.Done(c) + return nil +} + +func Handler(c *gin.Context, resp *http.Response, modelName string) *model.ErrorWithStatusCode { + defer resp.Body.Close() + + var deeplResponse Response + err := json.NewDecoder(resp.Body).Decode(&deeplResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError) + } + if deeplResponse.Message != "" { + return &model.ErrorWithStatusCode{ + Error: model.Error{ + Message: deeplResponse.Message, + Code: "deepl_error", + }, + StatusCode: resp.StatusCode, + } + } + fullTextResponse := ResponseDeepL2OpenAI(&deeplResponse) + fullTextResponse.Model = modelName + fullTextResponse.ID = helper.GetResponseID(c) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil +} diff --git a/service/aiproxy/relay/adaptor/deepl/model.go b/service/aiproxy/relay/adaptor/deepl/model.go new file mode 100644 index 00000000000..4f3a3e01d58 --- /dev/null +++ b/service/aiproxy/relay/adaptor/deepl/model.go @@ -0,0 +1,16 @@ +package deepl + +type Request struct { + TargetLang string `json:"target_lang"` + Text []string `json:"text"` +} + +type Translation struct { + DetectedSourceLanguage string `json:"detected_source_language,omitempty"` + Text string `json:"text,omitempty"` +} + +type Response struct { + Message string `json:"message,omitempty"` + Translations []Translation `json:"translations,omitempty"` +} diff --git a/service/aiproxy/relay/adaptor/deepseek/constants.go b/service/aiproxy/relay/adaptor/deepseek/constants.go new file mode 100644 index 00000000000..ad840bc2cc0 --- /dev/null +++ b/service/aiproxy/relay/adaptor/deepseek/constants.go @@ -0,0 +1,6 @@ +package deepseek + +var ModelList = []string{ + "deepseek-chat", + "deepseek-coder", +} diff --git a/service/aiproxy/relay/adaptor/doubao/constants.go b/service/aiproxy/relay/adaptor/doubao/constants.go new file mode 100644 index 00000000000..dbe819dd511 --- /dev/null +++ b/service/aiproxy/relay/adaptor/doubao/constants.go @@ -0,0 +1,13 @@ +package doubao + +// https://console.volcengine.com/ark/region:ark+cn-beijing/model + +var ModelList = []string{ + "Doubao-pro-128k", + "Doubao-pro-32k", + "Doubao-pro-4k", + "Doubao-lite-128k", + "Doubao-lite-32k", + "Doubao-lite-4k", + "Doubao-embedding", +} diff --git a/service/aiproxy/relay/adaptor/doubao/main.go b/service/aiproxy/relay/adaptor/doubao/main.go new file mode 100644 index 00000000000..9e3cb858574 --- /dev/null +++ b/service/aiproxy/relay/adaptor/doubao/main.go @@ -0,0 +1,23 @@ +package doubao + +import ( + "fmt" + "strings" + + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" +) + +func GetRequestURL(meta *meta.Meta) (string, error) { + switch meta.Mode { + case relaymode.ChatCompletions: + if strings.HasPrefix(meta.ActualModelName, "bot-") { + return meta.BaseURL + "/api/v3/bots/chat/completions", nil + } + return meta.BaseURL + "/api/v3/chat/completions", nil + case relaymode.Embeddings: + return meta.BaseURL + "/api/v3/embeddings", nil + default: + return "", fmt.Errorf("unsupported relay mode %d for doubao", meta.Mode) + } +} diff --git a/service/aiproxy/relay/adaptor/gemini/adaptor.go b/service/aiproxy/relay/adaptor/gemini/adaptor.go new file mode 100644 index 00000000000..09b9e1296ff --- /dev/null +++ b/service/aiproxy/relay/adaptor/gemini/adaptor.go @@ -0,0 +1,101 @@ +package gemini + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/common/helper" + channelhelper "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" +) + +type Adaptor struct{} + +func (a *Adaptor) Init(_ *meta.Meta) { +} + +func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { + version := helper.AssignOrDefault(meta.Config.APIVersion, config.GetGeminiVersion()) + var action string + switch meta.Mode { + case relaymode.Embeddings: + action = "batchEmbedContents" + default: + action = "generateContent" + } + + if meta.IsStream { + action = "streamGenerateContent?alt=sse" + } + return fmt.Sprintf("%s/%s/models/%s:%s", meta.BaseURL, version, meta.ActualModelName, action), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { + channelhelper.SetupCommonRequestHeader(c, req, meta) + req.Header.Set("X-Goog-Api-Key", meta.APIKey) + return nil +} + +func (a *Adaptor) ConvertRequest(_ *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + switch relayMode { + case relaymode.Embeddings: + geminiEmbeddingRequest := ConvertEmbeddingRequest(request) + return geminiEmbeddingRequest, nil + default: + geminiRequest := ConvertRequest(request) + return geminiRequest, nil + } +} + +func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + return channelhelper.DoRequestHelper(a, c, meta, requestBody) +} + +func (a *Adaptor) ConvertSTTRequest(*http.Request) (io.ReadCloser, error) { + return nil, nil +} + +func (a *Adaptor) ConvertTTSRequest(*model.TextToSpeechRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + var responseText string + err, responseText = StreamHandler(c, resp) + usage = openai.ResponseText2Usage(responseText, meta.ActualModelName, meta.PromptTokens) + } else { + switch meta.Mode { + case relaymode.Embeddings: + err, usage = EmbeddingHandler(c, resp) + default: + err, usage = Handler(c, resp, meta.PromptTokens, meta.ActualModelName) + } + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return "google gemini" +} diff --git a/service/aiproxy/relay/adaptor/gemini/constants.go b/service/aiproxy/relay/adaptor/gemini/constants.go new file mode 100644 index 00000000000..b0f84dfc556 --- /dev/null +++ b/service/aiproxy/relay/adaptor/gemini/constants.go @@ -0,0 +1,7 @@ +package gemini + +// https://ai.google.dev/models/gemini + +var ModelList = []string{ + "gemini-pro", "gemini-1.0-pro", "gemini-1.5-flash", "gemini-1.5-pro", "text-embedding-004", "aqa", +} diff --git a/service/aiproxy/relay/adaptor/gemini/main.go b/service/aiproxy/relay/adaptor/gemini/main.go new file mode 100644 index 00000000000..87922183c80 --- /dev/null +++ b/service/aiproxy/relay/adaptor/gemini/main.go @@ -0,0 +1,405 @@ +package gemini + +import ( + "bufio" + "net/http" + + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common/conv" + "github.com/labring/sealos/service/aiproxy/common/render" + + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/image" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/common/random" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/constant" + "github.com/labring/sealos/service/aiproxy/relay/model" + + "github.com/gin-gonic/gin" +) + +// https://ai.google.dev/docs/gemini_api_overview?hl=zh-cn + +const ( + VisionMaxImageNum = 16 +) + +var mimeTypeMap = map[string]string{ + "json_object": "application/json", + "text": "text/plain", +} + +// Setting safety to the lowest possible values since Gemini is already powerless enough +func ConvertRequest(textRequest *model.GeneralOpenAIRequest) *ChatRequest { + safetySetting := config.GetGeminiSafetySetting() + geminiRequest := ChatRequest{ + Contents: make([]ChatContent, 0, len(textRequest.Messages)), + SafetySettings: []ChatSafetySettings{ + { + Category: "HARM_CATEGORY_HARASSMENT", + Threshold: safetySetting, + }, + { + Category: "HARM_CATEGORY_HATE_SPEECH", + Threshold: safetySetting, + }, + { + Category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", + Threshold: safetySetting, + }, + { + Category: "HARM_CATEGORY_DANGEROUS_CONTENT", + Threshold: safetySetting, + }, + }, + GenerationConfig: ChatGenerationConfig{ + Temperature: textRequest.Temperature, + TopP: textRequest.TopP, + MaxOutputTokens: textRequest.MaxTokens, + }, + } + if textRequest.ResponseFormat != nil { + if mimeType, ok := mimeTypeMap[textRequest.ResponseFormat.Type]; ok { + geminiRequest.GenerationConfig.ResponseMimeType = mimeType + } + if textRequest.ResponseFormat.JSONSchema != nil { + geminiRequest.GenerationConfig.ResponseSchema = textRequest.ResponseFormat.JSONSchema.Schema + geminiRequest.GenerationConfig.ResponseMimeType = mimeTypeMap["json_object"] + } + } + if textRequest.Tools != nil { + functions := make([]model.Function, 0, len(textRequest.Tools)) + for _, tool := range textRequest.Tools { + functions = append(functions, tool.Function) + } + geminiRequest.Tools = []ChatTools{ + { + FunctionDeclarations: functions, + }, + } + } else if textRequest.Functions != nil { + geminiRequest.Tools = []ChatTools{ + { + FunctionDeclarations: textRequest.Functions, + }, + } + } + shouldAddDummyModelMessage := false + for _, message := range textRequest.Messages { + content := ChatContent{ + Role: message.Role, + Parts: []Part{ + { + Text: message.StringContent(), + }, + }, + } + openaiContent := message.ParseContent() + var parts []Part + imageNum := 0 + for _, part := range openaiContent { + if part.Type == model.ContentTypeText { + parts = append(parts, Part{ + Text: part.Text, + }) + } else if part.Type == model.ContentTypeImageURL { + imageNum++ + if imageNum > VisionMaxImageNum { + continue + } + mimeType, data, _ := image.GetImageFromURL(part.ImageURL.URL) + parts = append(parts, Part{ + InlineData: &InlineData{ + MimeType: mimeType, + Data: data, + }, + }) + } + } + content.Parts = parts + + // there's no assistant role in gemini and API shall vomit if Role is not user or model + if content.Role == "assistant" { + content.Role = "model" + } + // Converting system prompt to prompt from user for the same reason + if content.Role == "system" { + content.Role = "user" + shouldAddDummyModelMessage = true + } + geminiRequest.Contents = append(geminiRequest.Contents, content) + + // If a system message is the last message, we need to add a dummy model message to make gemini happy + if shouldAddDummyModelMessage { + geminiRequest.Contents = append(geminiRequest.Contents, ChatContent{ + Role: "model", + Parts: []Part{ + { + Text: "Okay", + }, + }, + }) + shouldAddDummyModelMessage = false + } + } + + return &geminiRequest +} + +func ConvertEmbeddingRequest(request *model.GeneralOpenAIRequest) *BatchEmbeddingRequest { + inputs := request.ParseInput() + requests := make([]EmbeddingRequest, len(inputs)) + model := "models/" + request.Model + + for i, input := range inputs { + requests[i] = EmbeddingRequest{ + Model: model, + Content: ChatContent{ + Parts: []Part{ + { + Text: input, + }, + }, + }, + } + } + + return &BatchEmbeddingRequest{ + Requests: requests, + } +} + +type ChatResponse struct { + Candidates []ChatCandidate `json:"candidates"` + PromptFeedback ChatPromptFeedback `json:"promptFeedback"` +} + +func (g *ChatResponse) GetResponseText() string { + if g == nil { + return "" + } + if len(g.Candidates) > 0 && len(g.Candidates[0].Content.Parts) > 0 { + return g.Candidates[0].Content.Parts[0].Text + } + return "" +} + +type ChatCandidate struct { + FinishReason string `json:"finishReason"` + Content ChatContent `json:"content"` + SafetyRatings []ChatSafetyRating `json:"safetyRatings"` + Index int64 `json:"index"` +} + +type ChatSafetyRating struct { + Category string `json:"category"` + Probability string `json:"probability"` +} + +type ChatPromptFeedback struct { + SafetyRatings []ChatSafetyRating `json:"safetyRatings"` +} + +func getToolCalls(candidate *ChatCandidate) []model.Tool { + var toolCalls []model.Tool + + item := candidate.Content.Parts[0] + if item.FunctionCall == nil { + return toolCalls + } + argsBytes, err := json.Marshal(item.FunctionCall.Arguments) + if err != nil { + logger.FatalLog("getToolCalls failed: " + err.Error()) + return toolCalls + } + toolCall := model.Tool{ + ID: "call_" + random.GetUUID(), + Type: "function", + Function: model.Function{ + Arguments: conv.BytesToString(argsBytes), + Name: item.FunctionCall.FunctionName, + }, + } + toolCalls = append(toolCalls, toolCall) + return toolCalls +} + +func responseGeminiChat2OpenAI(response *ChatResponse) *openai.TextResponse { + fullTextResponse := openai.TextResponse{ + ID: "chatcmpl-" + random.GetUUID(), + Object: "chat.completion", + Created: helper.GetTimestamp(), + Choices: make([]openai.TextResponseChoice, 0, len(response.Candidates)), + } + for i, candidate := range response.Candidates { + choice := openai.TextResponseChoice{ + Index: i, + Message: model.Message{ + Role: "assistant", + }, + FinishReason: constant.StopFinishReason, + } + if len(candidate.Content.Parts) > 0 { + if candidate.Content.Parts[0].FunctionCall != nil { + choice.Message.ToolCalls = getToolCalls(&candidate) + } else { + choice.Message.Content = candidate.Content.Parts[0].Text + } + } else { + choice.Message.Content = "" + choice.FinishReason = candidate.FinishReason + } + fullTextResponse.Choices = append(fullTextResponse.Choices, choice) + } + return &fullTextResponse +} + +func streamResponseGeminiChat2OpenAI(geminiResponse *ChatResponse) *openai.ChatCompletionsStreamResponse { + var choice openai.ChatCompletionsStreamResponseChoice + choice.Delta.Content = geminiResponse.GetResponseText() + // choice.FinishReason = &constant.StopFinishReason + var response openai.ChatCompletionsStreamResponse + response.ID = "chatcmpl-" + random.GetUUID() + response.Created = helper.GetTimestamp() + response.Object = "chat.completion.chunk" + response.Model = "gemini" + response.Choices = []openai.ChatCompletionsStreamResponseChoice{choice} + return &response +} + +func embeddingResponseGemini2OpenAI(response *EmbeddingResponse) *openai.EmbeddingResponse { + openAIEmbeddingResponse := openai.EmbeddingResponse{ + Object: "list", + Data: make([]openai.EmbeddingResponseItem, 0, len(response.Embeddings)), + Model: "gemini-embedding", + Usage: model.Usage{TotalTokens: 0}, + } + for _, item := range response.Embeddings { + openAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, openai.EmbeddingResponseItem{ + Object: `embedding`, + Index: 0, + Embedding: item.Values, + }) + } + return &openAIEmbeddingResponse +} + +func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, string) { + defer resp.Body.Close() + + responseText := "" + scanner := bufio.NewScanner(resp.Body) + scanner.Split(bufio.ScanLines) + + common.SetEventStreamHeaders(c) + + for scanner.Scan() { + data := scanner.Bytes() + if len(data) < 6 || conv.BytesToString(data[:6]) != "data: " { + continue + } + data = data[6:] + + if conv.BytesToString(data) == "[DONE]" { + break + } + + var geminiResponse ChatResponse + err := json.Unmarshal(data, &geminiResponse) + if err != nil { + logger.SysErrorf("error unmarshalling stream response: %s, data: %s", err.Error(), conv.BytesToString(data)) + continue + } + + response := streamResponseGeminiChat2OpenAI(&geminiResponse) + if response == nil { + continue + } + + responseText += response.Choices[0].Delta.StringContent() + + err = render.ObjectData(c, response) + if err != nil { + logger.SysError(err.Error()) + } + } + + if err := scanner.Err(); err != nil { + logger.SysError("error reading stream: " + err.Error()) + } + + render.Done(c) + + return nil, responseText +} + +func Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var geminiResponse ChatResponse + err := json.NewDecoder(resp.Body).Decode(&geminiResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if len(geminiResponse.Candidates) == 0 { + return &model.ErrorWithStatusCode{ + Error: model.Error{ + Message: "No candidates returned", + Type: "server_error", + Param: "", + Code: 500, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := responseGeminiChat2OpenAI(&geminiResponse) + fullTextResponse.Model = modelName + completionTokens := openai.CountTokenText(geminiResponse.GetResponseText(), modelName) + usage := model.Usage{ + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + TotalTokens: promptTokens + completionTokens, + } + fullTextResponse.Usage = usage + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil, &usage +} + +func EmbeddingHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var geminiEmbeddingResponse EmbeddingResponse + err := json.NewDecoder(resp.Body).Decode(&geminiEmbeddingResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if geminiEmbeddingResponse.Error != nil { + return &model.ErrorWithStatusCode{ + Error: model.Error{ + Message: geminiEmbeddingResponse.Error.Message, + Type: "gemini_error", + Param: "", + Code: geminiEmbeddingResponse.Error.Code, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := embeddingResponseGemini2OpenAI(&geminiEmbeddingResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} diff --git a/service/aiproxy/relay/adaptor/gemini/model.go b/service/aiproxy/relay/adaptor/gemini/model.go new file mode 100644 index 00000000000..b69352d16d5 --- /dev/null +++ b/service/aiproxy/relay/adaptor/gemini/model.go @@ -0,0 +1,76 @@ +package gemini + +type ChatRequest struct { + Contents []ChatContent `json:"contents"` + SafetySettings []ChatSafetySettings `json:"safety_settings,omitempty"` + Tools []ChatTools `json:"tools,omitempty"` + GenerationConfig ChatGenerationConfig `json:"generation_config,omitempty"` +} + +type EmbeddingRequest struct { + Model string `json:"model"` + TaskType string `json:"taskType,omitempty"` + Title string `json:"title,omitempty"` + Content ChatContent `json:"content"` + OutputDimensionality int `json:"outputDimensionality,omitempty"` +} + +type BatchEmbeddingRequest struct { + Requests []EmbeddingRequest `json:"requests"` +} + +type EmbeddingData struct { + Values []float64 `json:"values"` +} + +type EmbeddingResponse struct { + Error *Error `json:"error,omitempty"` + Embeddings []EmbeddingData `json:"embeddings"` +} + +type Error struct { + Message string `json:"message,omitempty"` + Status string `json:"status,omitempty"` + Code int `json:"code,omitempty"` +} + +type InlineData struct { + MimeType string `json:"mimeType"` + Data string `json:"data"` +} + +type FunctionCall struct { + Arguments any `json:"args"` + FunctionName string `json:"name"` +} + +type Part struct { + InlineData *InlineData `json:"inlineData,omitempty"` + FunctionCall *FunctionCall `json:"functionCall,omitempty"` + Text string `json:"text,omitempty"` +} + +type ChatContent struct { + Role string `json:"role,omitempty"` + Parts []Part `json:"parts"` +} + +type ChatSafetySettings struct { + Category string `json:"category"` + Threshold string `json:"threshold"` +} + +type ChatTools struct { + FunctionDeclarations any `json:"function_declarations,omitempty"` +} + +type ChatGenerationConfig struct { + ResponseSchema any `json:"responseSchema,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"topP,omitempty"` + ResponseMimeType string `json:"responseMimeType,omitempty"` + StopSequences []string `json:"stopSequences,omitempty"` + TopK float64 `json:"topK,omitempty"` + MaxOutputTokens int `json:"maxOutputTokens,omitempty"` + CandidateCount int `json:"candidateCount,omitempty"` +} diff --git a/service/aiproxy/relay/adaptor/groq/constants.go b/service/aiproxy/relay/adaptor/groq/constants.go new file mode 100644 index 00000000000..0864ebe75e3 --- /dev/null +++ b/service/aiproxy/relay/adaptor/groq/constants.go @@ -0,0 +1,27 @@ +package groq + +// https://console.groq.com/docs/models + +var ModelList = []string{ + "gemma-7b-it", + "gemma2-9b-it", + "llama-3.1-70b-versatile", + "llama-3.1-8b-instant", + "llama-3.2-11b-text-preview", + "llama-3.2-11b-vision-preview", + "llama-3.2-1b-preview", + "llama-3.2-3b-preview", + "llama-3.2-11b-vision-preview", + "llama-3.2-90b-text-preview", + "llama-3.2-90b-vision-preview", + "llama-guard-3-8b", + "llama3-70b-8192", + "llama3-8b-8192", + "llama3-groq-70b-8192-tool-use-preview", + "llama3-groq-8b-8192-tool-use-preview", + "llava-v1.5-7b-4096-preview", + "mixtral-8x7b-32768", + "distil-whisper-large-v3-en", + "whisper-large-v3", + "whisper-large-v3-turbo", +} diff --git a/service/aiproxy/relay/adaptor/interface.go b/service/aiproxy/relay/adaptor/interface.go new file mode 100644 index 00000000000..2429fa075cb --- /dev/null +++ b/service/aiproxy/relay/adaptor/interface.go @@ -0,0 +1,24 @@ +package adaptor + +import ( + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +type Adaptor interface { + Init(meta *meta.Meta) + GetRequestURL(meta *meta.Meta) (string, error) + SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error + ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) + ConvertImageRequest(request *model.ImageRequest) (any, error) + ConvertSTTRequest(request *http.Request) (io.ReadCloser, error) + ConvertTTSRequest(request *model.TextToSpeechRequest) (any, error) + DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) + DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) + GetModelList() []string + GetChannelName() string +} diff --git a/service/aiproxy/relay/adaptor/lingyiwanwu/constants.go b/service/aiproxy/relay/adaptor/lingyiwanwu/constants.go new file mode 100644 index 00000000000..30000e9dc83 --- /dev/null +++ b/service/aiproxy/relay/adaptor/lingyiwanwu/constants.go @@ -0,0 +1,9 @@ +package lingyiwanwu + +// https://platform.lingyiwanwu.com/docs + +var ModelList = []string{ + "yi-34b-chat-0205", + "yi-34b-chat-200k", + "yi-vl-plus", +} diff --git a/service/aiproxy/relay/adaptor/minimax/constants.go b/service/aiproxy/relay/adaptor/minimax/constants.go new file mode 100644 index 00000000000..1b2fc10485d --- /dev/null +++ b/service/aiproxy/relay/adaptor/minimax/constants.go @@ -0,0 +1,11 @@ +package minimax + +// https://www.minimaxi.com/document/guides/chat-model/V2?id=65e0736ab2845de20908e2dd + +var ModelList = []string{ + "abab6.5-chat", + "abab6.5s-chat", + "abab6-chat", + "abab5.5-chat", + "abab5.5s-chat", +} diff --git a/service/aiproxy/relay/adaptor/minimax/main.go b/service/aiproxy/relay/adaptor/minimax/main.go new file mode 100644 index 00000000000..13e9bc27c24 --- /dev/null +++ b/service/aiproxy/relay/adaptor/minimax/main.go @@ -0,0 +1,15 @@ +package minimax + +import ( + "fmt" + + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" +) + +func GetRequestURL(meta *meta.Meta) (string, error) { + if meta.Mode == relaymode.ChatCompletions { + return meta.BaseURL + "/v1/text/chatcompletion_v2", nil + } + return "", fmt.Errorf("unsupported relay mode %d for minimax", meta.Mode) +} diff --git a/service/aiproxy/relay/adaptor/mistral/constants.go b/service/aiproxy/relay/adaptor/mistral/constants.go new file mode 100644 index 00000000000..cdb157f5721 --- /dev/null +++ b/service/aiproxy/relay/adaptor/mistral/constants.go @@ -0,0 +1,10 @@ +package mistral + +var ModelList = []string{ + "open-mistral-7b", + "open-mixtral-8x7b", + "mistral-small-latest", + "mistral-medium-latest", + "mistral-large-latest", + "mistral-embed", +} diff --git a/service/aiproxy/relay/adaptor/moonshot/constants.go b/service/aiproxy/relay/adaptor/moonshot/constants.go new file mode 100644 index 00000000000..1b86f0fa6e4 --- /dev/null +++ b/service/aiproxy/relay/adaptor/moonshot/constants.go @@ -0,0 +1,7 @@ +package moonshot + +var ModelList = []string{ + "moonshot-v1-8k", + "moonshot-v1-32k", + "moonshot-v1-128k", +} diff --git a/service/aiproxy/relay/adaptor/novita/constants.go b/service/aiproxy/relay/adaptor/novita/constants.go new file mode 100644 index 00000000000..c6618308e22 --- /dev/null +++ b/service/aiproxy/relay/adaptor/novita/constants.go @@ -0,0 +1,19 @@ +package novita + +// https://novita.ai/llm-api + +var ModelList = []string{ + "meta-llama/llama-3-8b-instruct", + "meta-llama/llama-3-70b-instruct", + "nousresearch/hermes-2-pro-llama-3-8b", + "nousresearch/nous-hermes-llama2-13b", + "mistralai/mistral-7b-instruct", + "cognitivecomputations/dolphin-mixtral-8x22b", + "sao10k/l3-70b-euryale-v2.1", + "sophosympatheia/midnight-rose-70b", + "gryphe/mythomax-l2-13b", + "Nous-Hermes-2-Mixtral-8x7B-DPO", + "lzlv_70b", + "teknium/openhermes-2.5-mistral-7b", + "microsoft/wizardlm-2-8x22b", +} diff --git a/service/aiproxy/relay/adaptor/novita/main.go b/service/aiproxy/relay/adaptor/novita/main.go new file mode 100644 index 00000000000..b33c100aed9 --- /dev/null +++ b/service/aiproxy/relay/adaptor/novita/main.go @@ -0,0 +1,15 @@ +package novita + +import ( + "fmt" + + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" +) + +func GetRequestURL(meta *meta.Meta) (string, error) { + if meta.Mode == relaymode.ChatCompletions { + return meta.BaseURL + "/chat/completions", nil + } + return "", fmt.Errorf("unsupported relay mode %d for novita", meta.Mode) +} diff --git a/service/aiproxy/relay/adaptor/ollama/adaptor.go b/service/aiproxy/relay/adaptor/ollama/adaptor.go new file mode 100644 index 00000000000..2c6f048f61e --- /dev/null +++ b/service/aiproxy/relay/adaptor/ollama/adaptor.go @@ -0,0 +1,88 @@ +package ollama + +import ( + "errors" + "io" + "net/http" + + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +type Adaptor struct{} + +func (a *Adaptor) Init(_ *meta.Meta) { +} + +func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { + // https://github.com/ollama/ollama/blob/main/docs/api.md + fullRequestURL := meta.BaseURL + "/api/chat" + if meta.Mode == relaymode.Embeddings { + fullRequestURL = meta.BaseURL + "/api/embed" + } + return fullRequestURL, nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { + adaptor.SetupCommonRequestHeader(c, req, meta) + req.Header.Set("Authorization", "Bearer "+meta.APIKey) + return nil +} + +func (a *Adaptor) ConvertRequest(_ *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + switch relayMode { + case relaymode.Embeddings: + ollamaEmbeddingRequest := ConvertEmbeddingRequest(request) + return ollamaEmbeddingRequest, nil + default: + return ConvertRequest(request), nil + } +} + +func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + return adaptor.DoRequestHelper(a, c, meta, requestBody) +} + +func (a *Adaptor) ConvertSTTRequest(*http.Request) (io.ReadCloser, error) { + return nil, nil +} + +func (a *Adaptor) ConvertTTSRequest(*model.TextToSpeechRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + err, usage = StreamHandler(c, resp) + } else { + switch meta.Mode { + case relaymode.Embeddings: + err, usage = EmbeddingHandler(c, resp) + default: + err, usage = Handler(c, resp) + } + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return "ollama" +} diff --git a/service/aiproxy/relay/adaptor/ollama/constants.go b/service/aiproxy/relay/adaptor/ollama/constants.go new file mode 100644 index 00000000000..d9dc72a8a51 --- /dev/null +++ b/service/aiproxy/relay/adaptor/ollama/constants.go @@ -0,0 +1,11 @@ +package ollama + +var ModelList = []string{ + "codellama:7b-instruct", + "llama2:7b", + "llama2:latest", + "llama3:latest", + "phi3:latest", + "qwen:0.5b-chat", + "qwen:7b", +} diff --git a/service/aiproxy/relay/adaptor/ollama/main.go b/service/aiproxy/relay/adaptor/ollama/main.go new file mode 100644 index 00000000000..d3967f6bb2d --- /dev/null +++ b/service/aiproxy/relay/adaptor/ollama/main.go @@ -0,0 +1,250 @@ +package ollama + +import ( + "bufio" + "net/http" + "strings" + + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common/conv" + "github.com/labring/sealos/service/aiproxy/common/render" + + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/random" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/image" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/constant" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +func ConvertRequest(request *model.GeneralOpenAIRequest) *ChatRequest { + ollamaRequest := ChatRequest{ + Model: request.Model, + Options: &Options{ + Seed: int(request.Seed), + Temperature: request.Temperature, + TopP: request.TopP, + FrequencyPenalty: request.FrequencyPenalty, + PresencePenalty: request.PresencePenalty, + NumPredict: request.MaxTokens, + NumCtx: request.NumCtx, + }, + Stream: request.Stream, + } + for _, message := range request.Messages { + openaiContent := message.ParseContent() + var imageUrls []string + var contentText string + for _, part := range openaiContent { + switch part.Type { + case model.ContentTypeText: + contentText = part.Text + case model.ContentTypeImageURL: + _, data, _ := image.GetImageFromURL(part.ImageURL.URL) + imageUrls = append(imageUrls, data) + } + } + ollamaRequest.Messages = append(ollamaRequest.Messages, Message{ + Role: message.Role, + Content: contentText, + Images: imageUrls, + }) + } + return &ollamaRequest +} + +func responseOllama2OpenAI(response *ChatResponse) *openai.TextResponse { + choice := openai.TextResponseChoice{ + Index: 0, + Message: model.Message{ + Role: response.Message.Role, + Content: response.Message.Content, + }, + } + if response.Done { + choice.FinishReason = "stop" + } + fullTextResponse := openai.TextResponse{ + ID: "chatcmpl-" + random.GetUUID(), + Model: response.Model, + Object: "chat.completion", + Created: helper.GetTimestamp(), + Choices: []openai.TextResponseChoice{choice}, + Usage: model.Usage{ + PromptTokens: response.PromptEvalCount, + CompletionTokens: response.EvalCount, + TotalTokens: response.PromptEvalCount + response.EvalCount, + }, + } + return &fullTextResponse +} + +func streamResponseOllama2OpenAI(ollamaResponse *ChatResponse) *openai.ChatCompletionsStreamResponse { + var choice openai.ChatCompletionsStreamResponseChoice + choice.Delta.Role = ollamaResponse.Message.Role + choice.Delta.Content = ollamaResponse.Message.Content + if ollamaResponse.Done { + choice.FinishReason = &constant.StopFinishReason + } + response := openai.ChatCompletionsStreamResponse{ + ID: "chatcmpl-" + random.GetUUID(), + Object: "chat.completion.chunk", + Created: helper.GetTimestamp(), + Model: ollamaResponse.Model, + Choices: []openai.ChatCompletionsStreamResponseChoice{choice}, + } + return &response +} + +func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var usage model.Usage + scanner := bufio.NewScanner(resp.Body) + scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + if i := strings.Index(conv.BytesToString(data), "}\n"); i >= 0 { + return i + 2, data[0 : i+1], nil + } + if atEOF { + return len(data), data, nil + } + return 0, nil, nil + }) + + common.SetEventStreamHeaders(c) + + for scanner.Scan() { + data := scanner.Text() + if strings.HasPrefix(data, "}") { + data = strings.TrimPrefix(data, "}") + "}" + } + + var ollamaResponse ChatResponse + err := json.Unmarshal(conv.StringToBytes(data), &ollamaResponse) + if err != nil { + logger.SysError("error unmarshalling stream response: " + err.Error()) + continue + } + + if ollamaResponse.EvalCount != 0 { + usage.PromptTokens = ollamaResponse.PromptEvalCount + usage.CompletionTokens = ollamaResponse.EvalCount + usage.TotalTokens = ollamaResponse.PromptEvalCount + ollamaResponse.EvalCount + } + + response := streamResponseOllama2OpenAI(&ollamaResponse) + err = render.ObjectData(c, response) + if err != nil { + logger.SysError(err.Error()) + } + } + + if err := scanner.Err(); err != nil { + logger.SysError("error reading stream: " + err.Error()) + } + + render.Done(c) + + return nil, &usage +} + +func ConvertEmbeddingRequest(request *model.GeneralOpenAIRequest) *EmbeddingRequest { + return &EmbeddingRequest{ + Model: request.Model, + Input: request.ParseInput(), + Options: &Options{ + Seed: int(request.Seed), + Temperature: request.Temperature, + TopP: request.TopP, + FrequencyPenalty: request.FrequencyPenalty, + PresencePenalty: request.PresencePenalty, + }, + } +} + +func EmbeddingHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var ollamaResponse EmbeddingResponse + err := json.NewDecoder(resp.Body).Decode(&ollamaResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + + if ollamaResponse.Error != "" { + return &model.ErrorWithStatusCode{ + Error: model.Error{ + Message: ollamaResponse.Error, + Type: "ollama_error", + Param: "", + Code: "ollama_error", + }, + StatusCode: resp.StatusCode, + }, nil + } + + fullTextResponse := embeddingResponseOllama2OpenAI(&ollamaResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} + +func embeddingResponseOllama2OpenAI(response *EmbeddingResponse) *openai.EmbeddingResponse { + openAIEmbeddingResponse := openai.EmbeddingResponse{ + Object: "list", + Data: make([]openai.EmbeddingResponseItem, 0, 1), + Model: response.Model, + Usage: model.Usage{TotalTokens: 0}, + } + + for i, embedding := range response.Embeddings { + openAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, openai.EmbeddingResponseItem{ + Object: `embedding`, + Index: i, + Embedding: embedding, + }) + } + return &openAIEmbeddingResponse +} + +func Handler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var ollamaResponse ChatResponse + err := json.NewDecoder(resp.Body).Decode(&ollamaResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if ollamaResponse.Error != "" { + return &model.ErrorWithStatusCode{ + Error: model.Error{ + Message: ollamaResponse.Error, + Type: "ollama_error", + Param: "", + Code: "ollama_error", + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := responseOllama2OpenAI(&ollamaResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} diff --git a/service/aiproxy/relay/adaptor/ollama/model.go b/service/aiproxy/relay/adaptor/ollama/model.go new file mode 100644 index 00000000000..7dc4c773c89 --- /dev/null +++ b/service/aiproxy/relay/adaptor/ollama/model.go @@ -0,0 +1,51 @@ +package ollama + +type Options struct { + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty"` + Seed int `json:"seed,omitempty"` + TopK int `json:"top_k,omitempty"` + NumPredict int `json:"num_predict,omitempty"` + NumCtx int `json:"num_ctx,omitempty"` +} + +type Message struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + Images []string `json:"images,omitempty"` +} + +type ChatRequest struct { + Options *Options `json:"options,omitempty"` + Model string `json:"model,omitempty"` + Messages []Message `json:"messages,omitempty"` + Stream bool `json:"stream"` +} + +type ChatResponse struct { + Model string `json:"model,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + Response string `json:"response,omitempty"` + Error string `json:"error,omitempty"` + Message Message `json:"message,omitempty"` + TotalDuration int `json:"total_duration,omitempty"` + LoadDuration int `json:"load_duration,omitempty"` + PromptEvalCount int `json:"prompt_eval_count,omitempty"` + EvalCount int `json:"eval_count,omitempty"` + EvalDuration int `json:"eval_duration,omitempty"` + Done bool `json:"done,omitempty"` +} + +type EmbeddingRequest struct { + Options *Options `json:"options,omitempty"` + Model string `json:"model"` + Input []string `json:"input"` +} + +type EmbeddingResponse struct { + Error string `json:"error,omitempty"` + Model string `json:"model"` + Embeddings [][]float64 `json:"embeddings"` +} diff --git a/service/aiproxy/relay/adaptor/openai/adaptor.go b/service/aiproxy/relay/adaptor/openai/adaptor.go new file mode 100644 index 00000000000..a5cab79b89b --- /dev/null +++ b/service/aiproxy/relay/adaptor/openai/adaptor.go @@ -0,0 +1,213 @@ +package openai + +import ( + "bytes" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/doubao" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/minimax" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/novita" + "github.com/labring/sealos/service/aiproxy/relay/channeltype" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" +) + +type Adaptor struct { + meta *meta.Meta + contentType string + responseFormat string +} + +func (a *Adaptor) Init(meta *meta.Meta) { + a.meta = meta +} + +func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { + switch meta.ChannelType { + case channeltype.Azure: + switch meta.Mode { + case relaymode.ImagesGenerations: + // https://learn.microsoft.com/en-us/azure/ai-services/openai/dall-e-quickstart?tabs=dalle3%2Ccommand-line&pivots=rest-api + // https://{resource_name}.openai.azure.com/openai/deployments/dall-e-3/images/generations?api-version=2024-03-01-preview + return fmt.Sprintf("%s/openai/deployments/%s/images/generations?api-version=%s", meta.BaseURL, meta.ActualModelName, meta.Config.APIVersion), nil + case relaymode.AudioTranscription: + // https://learn.microsoft.com/en-us/azure/ai-services/openai/whisper-quickstart?tabs=command-line#rest-api + return fmt.Sprintf("%s/openai/deployments/%s/audio/transcriptions?api-version=%s", meta.BaseURL, meta.ActualModelName, meta.Config.APIVersion), nil + case relaymode.AudioSpeech: + // https://learn.microsoft.com/en-us/azure/ai-services/openai/text-to-speech-quickstart?tabs=command-line#rest-api + return fmt.Sprintf("%s/openai/deployments/%s/audio/speech?api-version=%s", meta.BaseURL, meta.ActualModelName, meta.Config.APIVersion), nil + } + + // https://learn.microsoft.com/en-us/azure/cognitive-services/openai/chatgpt-quickstart?pivots=rest-api&tabs=command-line#rest-api + requestURL := strings.Split(meta.RequestURLPath, "?")[0] + requestURL = fmt.Sprintf("%s?api-version=%s", requestURL, meta.Config.APIVersion) + task := strings.TrimPrefix(requestURL, "/v1/") + model := strings.ReplaceAll(meta.ActualModelName, ".", "") + // https://github.com/labring/sealos/service/aiproxy/issues/1191 + // {your endpoint}/openai/deployments/{your azure_model}/chat/completions?api-version={api_version} + requestURL = fmt.Sprintf("/openai/deployments/%s/%s", model, task) + return GetFullRequestURL(meta.BaseURL, requestURL, meta.ChannelType), nil + case channeltype.Minimax: + return minimax.GetRequestURL(meta) + case channeltype.Doubao: + return doubao.GetRequestURL(meta) + case channeltype.Novita: + return novita.GetRequestURL(meta) + default: + return GetFullRequestURL(meta.BaseURL, meta.RequestURLPath, meta.ChannelType), nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { + adaptor.SetupCommonRequestHeader(c, req, meta) + if meta.ChannelType == channeltype.Azure { + req.Header.Set("Api-Key", meta.APIKey) + return nil + } + if a.contentType != "" { + req.Header.Set("Content-Type", a.contentType) + } + req.Header.Set("Authorization", "Bearer "+meta.APIKey) + if meta.ChannelType == channeltype.OpenRouter { + req.Header.Set("Http-Referer", "https://github.com/labring/sealos/service/aiproxy") + req.Header.Set("X-Title", "One API") + } + return nil +} + +func (a *Adaptor) ConvertRequest(_ *gin.Context, _ int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if request.Stream { + // always return usage in stream mode + if request.StreamOptions == nil { + request.StreamOptions = &model.StreamOptions{} + } + request.StreamOptions.IncludeUsage = true + } + return request, nil +} + +func (a *Adaptor) ConvertTTSRequest(request *model.TextToSpeechRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if len(request.Input) > 4096 { + return nil, errors.New("input is too long (over 4096 characters)") + } + return request, nil +} + +func (a *Adaptor) ConvertSTTRequest(request *http.Request) (io.ReadCloser, error) { + if request == nil { + return nil, errors.New("request is nil") + } + + err := request.ParseMultipartForm(1024 * 1024 * 4) + if err != nil { + return nil, err + } + + multipartBody := &bytes.Buffer{} + multipartWriter := multipart.NewWriter(multipartBody) + + for key, values := range request.MultipartForm.Value { + for _, value := range values { + if key == "model" { + err = multipartWriter.WriteField(key, a.meta.ActualModelName) + if err != nil { + return nil, err + } + continue + } + if key == "response_format" { + a.responseFormat = value + } + err = multipartWriter.WriteField(key, value) + if err != nil { + return nil, err + } + } + } + + for key, files := range request.MultipartForm.File { + for _, fileHeader := range files { + file, err := fileHeader.Open() + if err != nil { + return nil, err + } + w, err := multipartWriter.CreateFormFile(key, fileHeader.Filename) + if err != nil { + file.Close() + return nil, err + } + _, err = io.Copy(w, file) + file.Close() + if err != nil { + return nil, err + } + } + } + + multipartWriter.Close() + a.contentType = multipartWriter.FormDataContentType() + return io.NopCloser(multipartBody), nil +} + +func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + return adaptor.DoRequestHelper(a, c, meta, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + var responseText string + err, responseText, usage = StreamHandler(c, resp, meta.Mode) + if usage == nil || usage.TotalTokens == 0 { + usage = ResponseText2Usage(responseText, meta.ActualModelName, meta.PromptTokens) + } + if usage.TotalTokens != 0 && usage.PromptTokens == 0 { // some channels don't return prompt tokens & completion tokens + usage.PromptTokens = meta.PromptTokens + usage.CompletionTokens = usage.TotalTokens - meta.PromptTokens + } + return + } + switch meta.Mode { + case relaymode.ImagesGenerations: + err, _ = ImageHandler(c, resp) + case relaymode.AudioTranscription: + err, usage = STTHandler(c, resp, meta, a.responseFormat) + case relaymode.AudioSpeech: + err, usage = TTSHandler(c, resp, meta) + case relaymode.Rerank: + err, usage = RerankHandler(c, resp, meta.PromptTokens, meta) + default: + err, usage = Handler(c, resp, meta.PromptTokens, meta.ActualModelName) + } + return +} + +func (a *Adaptor) GetModelList() []string { + _, modelList := GetCompatibleChannelMeta(a.meta.ChannelType) + return modelList +} + +func (a *Adaptor) GetChannelName() string { + channelName, _ := GetCompatibleChannelMeta(a.meta.ChannelType) + return channelName +} diff --git a/service/aiproxy/relay/adaptor/openai/compatible.go b/service/aiproxy/relay/adaptor/openai/compatible.go new file mode 100644 index 00000000000..401488ddc96 --- /dev/null +++ b/service/aiproxy/relay/adaptor/openai/compatible.go @@ -0,0 +1,70 @@ +package openai + +import ( + "github.com/labring/sealos/service/aiproxy/relay/adaptor/ai360" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/baichuan" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/deepseek" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/doubao" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/groq" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/lingyiwanwu" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/minimax" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/mistral" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/moonshot" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/novita" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/siliconflow" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/stepfun" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/togetherai" + "github.com/labring/sealos/service/aiproxy/relay/channeltype" +) + +var CompatibleChannels = []int{ + channeltype.Azure, + channeltype.AI360, + channeltype.Moonshot, + channeltype.Baichuan, + channeltype.Minimax, + channeltype.Doubao, + channeltype.Mistral, + channeltype.Groq, + channeltype.LingYiWanWu, + channeltype.StepFun, + channeltype.DeepSeek, + channeltype.TogetherAI, + channeltype.Novita, + channeltype.SiliconFlow, +} + +func GetCompatibleChannelMeta(channelType int) (string, []string) { + switch channelType { + case channeltype.Azure: + return "azure", ModelList + case channeltype.AI360: + return "360", ai360.ModelList + case channeltype.Moonshot: + return "moonshot", moonshot.ModelList + case channeltype.Baichuan: + return "baichuan", baichuan.ModelList + case channeltype.Minimax: + return "minimax", minimax.ModelList + case channeltype.Mistral: + return "mistralai", mistral.ModelList + case channeltype.Groq: + return "groq", groq.ModelList + case channeltype.LingYiWanWu: + return "lingyiwanwu", lingyiwanwu.ModelList + case channeltype.StepFun: + return "stepfun", stepfun.ModelList + case channeltype.DeepSeek: + return "deepseek", deepseek.ModelList + case channeltype.TogetherAI: + return "together.ai", togetherai.ModelList + case channeltype.Doubao: + return "doubao", doubao.ModelList + case channeltype.Novita: + return "novita", novita.ModelList + case channeltype.SiliconFlow: + return "siliconflow", siliconflow.ModelList + default: + return "openai", ModelList + } +} diff --git a/service/aiproxy/relay/adaptor/openai/constants.go b/service/aiproxy/relay/adaptor/openai/constants.go new file mode 100644 index 00000000000..aacdba1ad3e --- /dev/null +++ b/service/aiproxy/relay/adaptor/openai/constants.go @@ -0,0 +1,23 @@ +package openai + +var ModelList = []string{ + "gpt-3.5-turbo", "gpt-3.5-turbo-0301", "gpt-3.5-turbo-0613", "gpt-3.5-turbo-1106", "gpt-3.5-turbo-0125", + "gpt-3.5-turbo-16k", "gpt-3.5-turbo-16k-0613", + "gpt-3.5-turbo-instruct", + "gpt-4", "gpt-4-0314", "gpt-4-0613", "gpt-4-1106-preview", "gpt-4-0125-preview", + "gpt-4-32k", "gpt-4-32k-0314", "gpt-4-32k-0613", + "gpt-4-turbo-preview", "gpt-4-turbo", "gpt-4-turbo-2024-04-09", + "gpt-4o", "gpt-4o-2024-05-13", + "gpt-4o-2024-08-06", + "chatgpt-4o-latest", + "gpt-4o-mini", "gpt-4o-mini-2024-07-18", + "gpt-4-vision-preview", + "text-embedding-ada-002", "text-embedding-3-small", "text-embedding-3-large", + "text-curie-001", "text-babbage-001", "text-ada-001", "text-davinci-002", "text-davinci-003", + "text-moderation-latest", "text-moderation-stable", + "text-davinci-edit-001", + "davinci-002", "babbage-002", + "dall-e-2", "dall-e-3", + "whisper-1", + "tts-1", "tts-1-1106", "tts-1-hd", "tts-1-hd-1106", +} diff --git a/service/aiproxy/relay/adaptor/openai/helper.go b/service/aiproxy/relay/adaptor/openai/helper.go new file mode 100644 index 00000000000..4ba22af5b09 --- /dev/null +++ b/service/aiproxy/relay/adaptor/openai/helper.go @@ -0,0 +1,31 @@ +package openai + +import ( + "fmt" + "strings" + + "github.com/labring/sealos/service/aiproxy/relay/channeltype" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +func ResponseText2Usage(responseText string, modeName string, promptTokens int) *model.Usage { + usage := &model.Usage{} + usage.PromptTokens = promptTokens + usage.CompletionTokens = CountTokenText(responseText, modeName) + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + return usage +} + +func GetFullRequestURL(baseURL string, requestURL string, channelType int) string { + fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL) + + if strings.HasPrefix(baseURL, "https://gateway.ai.cloudflare.com") { + switch channelType { + case channeltype.OpenAI: + fullRequestURL = fmt.Sprintf("%s%s", baseURL, strings.TrimPrefix(requestURL, "/v1")) + case channeltype.Azure: + fullRequestURL = fmt.Sprintf("%s%s", baseURL, strings.TrimPrefix(requestURL, "/openai/deployments")) + } + } + return fullRequestURL +} diff --git a/service/aiproxy/relay/adaptor/openai/image.go b/service/aiproxy/relay/adaptor/openai/image.go new file mode 100644 index 00000000000..d52435fdba2 --- /dev/null +++ b/service/aiproxy/relay/adaptor/openai/image.go @@ -0,0 +1,44 @@ +package openai + +import ( + "bytes" + "io" + "net/http" + + "github.com/gin-gonic/gin" + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +func ImageHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { + var imageResponse ImageResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return ErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + err = resp.Body.Close() + if err != nil { + return ErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + err = json.Unmarshal(responseBody, &imageResponse) + if err != nil { + return ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + + resp.Body = io.NopCloser(bytes.NewBuffer(responseBody)) + + for k, v := range resp.Header { + c.Writer.Header().Set(k, v[0]) + } + c.Writer.WriteHeader(resp.StatusCode) + + _, err = io.Copy(c.Writer, resp.Body) + if err != nil { + return ErrorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError), nil + } + err = resp.Body.Close() + if err != nil { + return ErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + return nil, nil +} diff --git a/service/aiproxy/relay/adaptor/openai/main.go b/service/aiproxy/relay/adaptor/openai/main.go new file mode 100644 index 00000000000..a90d8ff8e99 --- /dev/null +++ b/service/aiproxy/relay/adaptor/openai/main.go @@ -0,0 +1,278 @@ +package openai + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net/http" + "strings" + + json "github.com/json-iterator/go" + + "github.com/labring/sealos/service/aiproxy/common/render" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/conv" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" +) + +const ( + dataPrefix = "data: " + done = "[DONE]" + dataPrefixLength = len(dataPrefix) +) + +func StreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*model.ErrorWithStatusCode, string, *model.Usage) { + defer resp.Body.Close() + + responseText := "" + scanner := bufio.NewScanner(resp.Body) + scanner.Split(bufio.ScanLines) + var usage *model.Usage + + common.SetEventStreamHeaders(c) + + doneRendered := false + for scanner.Scan() { + data := scanner.Text() + if len(data) < dataPrefixLength { // ignore blank line or wrong format + continue + } + if data[:dataPrefixLength] != dataPrefix && data[:dataPrefixLength] != done { + continue + } + if strings.HasPrefix(data[dataPrefixLength:], done) { + render.StringData(c, data) + doneRendered = true + continue + } + switch relayMode { + case relaymode.ChatCompletions: + var streamResponse ChatCompletionsStreamResponse + err := json.Unmarshal(conv.StringToBytes(data[dataPrefixLength:]), &streamResponse) + if err != nil { + logger.SysError("error unmarshalling stream response: " + err.Error()) + render.StringData(c, data) // if error happened, pass the data to client + continue // just ignore the error + } + if len(streamResponse.Choices) == 0 && streamResponse.Usage == nil { + // but for empty choice and no usage, we should not pass it to client, this is for azure + continue // just ignore empty choice + } + render.StringData(c, data) + for _, choice := range streamResponse.Choices { + responseText += conv.AsString(choice.Delta.Content) + } + if streamResponse.Usage != nil { + usage = streamResponse.Usage + } + case relaymode.Completions: + render.StringData(c, data) + var streamResponse CompletionsStreamResponse + err := json.Unmarshal(conv.StringToBytes(data[dataPrefixLength:]), &streamResponse) + if err != nil { + logger.SysError("error unmarshalling stream response: " + err.Error()) + continue + } + for _, choice := range streamResponse.Choices { + responseText += choice.Text + } + } + } + + if err := scanner.Err(); err != nil { + logger.SysError("error reading stream: " + err.Error()) + } + + if !doneRendered { + render.Done(c) + } + + return nil, responseText, usage +} + +func Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return ErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + var textResponse SlimTextResponse + err = json.Unmarshal(responseBody, &textResponse) + if err != nil { + return ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if textResponse.Error.Type != "" { + return &model.ErrorWithStatusCode{ + Error: textResponse.Error, + StatusCode: resp.StatusCode, + }, nil + } + + if textResponse.Usage.TotalTokens == 0 || (textResponse.Usage.PromptTokens == 0 && textResponse.Usage.CompletionTokens == 0) { + completionTokens := 0 + for _, choice := range textResponse.Choices { + completionTokens += CountTokenText(choice.Message.StringContent(), modelName) + } + textResponse.Usage = model.Usage{ + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + TotalTokens: promptTokens + completionTokens, + } + } + + for k, v := range resp.Header { + c.Writer.Header().Set(k, v[0]) + } + c.Writer.WriteHeader(resp.StatusCode) + + _, _ = c.Writer.Write(responseBody) + return nil, &textResponse.Usage +} + +func RerankHandler(c *gin.Context, resp *http.Response, promptTokens int, _ *meta.Meta) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return ErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + var rerankResponse SlimRerankResponse + err = json.Unmarshal(responseBody, &rerankResponse) + if err != nil { + return ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + + c.Writer.WriteHeader(resp.StatusCode) + + _, _ = c.Writer.Write(responseBody) + + if rerankResponse.Meta.Tokens == nil { + return nil, &model.Usage{ + PromptTokens: promptTokens, + CompletionTokens: 0, + TotalTokens: promptTokens, + } + } + if rerankResponse.Meta.Tokens.InputTokens <= 0 { + rerankResponse.Meta.Tokens.InputTokens = promptTokens + } + return nil, &model.Usage{ + PromptTokens: rerankResponse.Meta.Tokens.InputTokens, + CompletionTokens: rerankResponse.Meta.Tokens.OutputTokens, + TotalTokens: rerankResponse.Meta.Tokens.InputTokens + rerankResponse.Meta.Tokens.OutputTokens, + } +} + +func TTSHandler(c *gin.Context, resp *http.Response, meta *meta.Meta) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + for k, v := range resp.Header { + c.Writer.Header().Set(k, v[0]) + } + + _, _ = io.Copy(c.Writer, resp.Body) + return nil, &model.Usage{ + PromptTokens: meta.PromptTokens, + CompletionTokens: 0, + TotalTokens: meta.PromptTokens, + } +} + +func STTHandler(c *gin.Context, resp *http.Response, meta *meta.Meta, responseFormat string) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return ErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + + var openAIErr SlimTextResponse + if err = json.Unmarshal(responseBody, &openAIErr); err == nil { + if openAIErr.Error.Message != "" { + return ErrorWrapper(fmt.Errorf("type %s, code %v, message %s", openAIErr.Error.Type, openAIErr.Error.Code, openAIErr.Error.Message), "request_error", http.StatusInternalServerError), nil + } + } + + var text string + switch responseFormat { + case "text": + text = getTextFromText(responseBody) + case "srt": + text, err = getTextFromSRT(responseBody) + case "verbose_json": + text, err = getTextFromVerboseJSON(responseBody) + case "vtt": + text, err = getTextFromVTT(responseBody) + case "json": + fallthrough + default: + text, err = getTextFromJSON(responseBody) + } + if err != nil { + return ErrorWrapper(err, "get_text_from_body_err", http.StatusInternalServerError), nil + } + completionTokens := CountTokenText(text, meta.ActualModelName) + + for k, v := range resp.Header { + c.Writer.Header().Set(k, v[0]) + } + _, _ = c.Writer.Write(responseBody) + + return nil, &model.Usage{ + PromptTokens: 0, + CompletionTokens: completionTokens, + TotalTokens: completionTokens, + } +} + +func getTextFromVTT(body []byte) (string, error) { + return getTextFromSRT(body) +} + +func getTextFromVerboseJSON(body []byte) (string, error) { + var whisperResponse WhisperVerboseJSONResponse + if err := json.Unmarshal(body, &whisperResponse); err != nil { + return "", fmt.Errorf("unmarshal_response_body_failed err :%w", err) + } + return whisperResponse.Text, nil +} + +func getTextFromSRT(body []byte) (string, error) { + scanner := bufio.NewScanner(bytes.NewReader(body)) + var builder strings.Builder + var textLine bool + for scanner.Scan() { + line := scanner.Text() + if textLine { + builder.WriteString(line) + textLine = false + continue + } else if strings.Contains(line, "-->") { + textLine = true + continue + } + } + if err := scanner.Err(); err != nil { + return "", err + } + return builder.String(), nil +} + +func getTextFromText(body []byte) string { + return strings.TrimSuffix(conv.BytesToString(body), "\n") +} + +func getTextFromJSON(body []byte) (string, error) { + var whisperResponse WhisperJSONResponse + if err := json.Unmarshal(body, &whisperResponse); err != nil { + return "", fmt.Errorf("unmarshal_response_body_failed err :%w", err) + } + return whisperResponse.Text, nil +} diff --git a/service/aiproxy/relay/adaptor/openai/model.go b/service/aiproxy/relay/adaptor/openai/model.go new file mode 100644 index 00000000000..fe8123b68d1 --- /dev/null +++ b/service/aiproxy/relay/adaptor/openai/model.go @@ -0,0 +1,139 @@ +package openai + +import "github.com/labring/sealos/service/aiproxy/relay/model" + +type TextContent struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` +} + +type ImageContent struct { + ImageURL *model.ImageURL `json:"image_url,omitempty"` + Type string `json:"type,omitempty"` +} + +type ChatRequest struct { + Model string `json:"model"` + Messages []model.Message `json:"messages"` + MaxTokens int `json:"max_tokens"` +} + +type TextRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Messages []model.Message `json:"messages"` + MaxTokens int `json:"max_tokens"` +} + +// ImageRequest docs: https://platform.openai.com/docs/api-reference/images/create +type ImageRequest struct { + Model string `json:"model"` + Prompt string `binding:"required" json:"prompt"` + Size string `json:"size,omitempty"` + Quality string `json:"quality,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` + Style string `json:"style,omitempty"` + User string `json:"user,omitempty"` + N int `json:"n,omitempty"` +} + +type WhisperJSONResponse struct { + Text string `json:"text,omitempty"` +} + +type WhisperVerboseJSONResponse struct { + Task string `json:"task,omitempty"` + Language string `json:"language,omitempty"` + Text string `json:"text,omitempty"` + Segments []Segment `json:"segments,omitempty"` + Duration float64 `json:"duration,omitempty"` +} + +type Segment struct { + Text string `json:"text"` + Tokens []int `json:"tokens"` + ID int `json:"id"` + Seek int `json:"seek"` + Start float64 `json:"start"` + End float64 `json:"end"` + Temperature float64 `json:"temperature"` + AvgLogprob float64 `json:"avg_logprob"` + CompressionRatio float64 `json:"compression_ratio"` + NoSpeechProb float64 `json:"no_speech_prob"` +} + +type UsageOrResponseText struct { + *model.Usage + ResponseText string +} + +type SlimTextResponse struct { + Error model.Error `json:"error"` + Choices []TextResponseChoice `json:"choices"` + model.Usage `json:"usage"` +} + +type SlimRerankResponse struct { + Meta model.RerankMeta `json:"meta"` +} + +type TextResponseChoice struct { + FinishReason string `json:"finish_reason"` + model.Message `json:"message"` + Index int `json:"index"` +} + +type TextResponse struct { + ID string `json:"id"` + Model string `json:"model,omitempty"` + Object string `json:"object"` + Choices []TextResponseChoice `json:"choices"` + model.Usage `json:"usage"` + Created int64 `json:"created"` +} + +type EmbeddingResponseItem struct { + Object string `json:"object"` + Embedding []float64 `json:"embedding"` + Index int `json:"index"` +} + +type EmbeddingResponse struct { + Object string `json:"object"` + Model string `json:"model"` + Data []EmbeddingResponseItem `json:"data"` + model.Usage `json:"usage"` +} + +type ImageData struct { + URL string `json:"url,omitempty"` + B64Json string `json:"b64_json,omitempty"` + RevisedPrompt string `json:"revised_prompt,omitempty"` +} + +type ImageResponse struct { + Data []ImageData `json:"data"` + Created int64 `json:"created"` +} + +type ChatCompletionsStreamResponseChoice struct { + FinishReason *string `json:"finish_reason,omitempty"` + Delta model.Message `json:"delta"` + Index int `json:"index"` +} + +type ChatCompletionsStreamResponse struct { + Usage *model.Usage `json:"usage,omitempty"` + ID string `json:"id"` + Object string `json:"object"` + Model string `json:"model"` + Choices []ChatCompletionsStreamResponseChoice `json:"choices"` + Created int64 `json:"created"` +} + +type CompletionsStreamResponse struct { + Choices []struct { + Text string `json:"text"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` +} diff --git a/service/aiproxy/relay/adaptor/openai/token.go b/service/aiproxy/relay/adaptor/openai/token.go new file mode 100644 index 00000000000..c9607b0e6d0 --- /dev/null +++ b/service/aiproxy/relay/adaptor/openai/token.go @@ -0,0 +1,224 @@ +package openai + +import ( + "errors" + "fmt" + "math" + "strings" + "sync" + "unicode/utf8" + + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/common/image" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/relay/model" + "github.com/pkoukk/tiktoken-go" +) + +// tokenEncoderMap won't grow after initialization +var ( + tokenEncoderMap = map[string]*tiktoken.Tiktoken{} + defaultTokenEncoder *tiktoken.Tiktoken + tokenEncoderLock sync.RWMutex +) + +func init() { + gpt35TokenEncoder, err := tiktoken.EncodingForModel("gpt-3.5-turbo") + if err != nil { + logger.FatalLog("failed to get gpt-3.5-turbo token encoder: " + err.Error()) + } + defaultTokenEncoder = gpt35TokenEncoder +} + +func getTokenEncoder(model string) *tiktoken.Tiktoken { + tokenEncoderLock.RLock() + tokenEncoder, ok := tokenEncoderMap[model] + tokenEncoderLock.RUnlock() + + if ok && tokenEncoder != nil { + return tokenEncoder + } + if ok { + tokenEncoder, err := tiktoken.EncodingForModel(model) + if err != nil { + logger.SysError(fmt.Sprintf("failed to get token encoder for model %s: %s, using encoder for gpt-3.5-turbo", model, err.Error())) + tokenEncoder = defaultTokenEncoder + } + tokenEncoderLock.Lock() + tokenEncoderMap[model] = tokenEncoder + tokenEncoderLock.Unlock() + return tokenEncoder + } + return defaultTokenEncoder +} + +func getTokenNum(tokenEncoder *tiktoken.Tiktoken, text string) int { + if config.GetApproximateTokenEnabled() { + return int(float64(len(text)) * 0.38) + } + return len(tokenEncoder.Encode(text, nil, nil)) +} + +func CountTokenMessages(messages []model.Message, model string) int { + tokenEncoder := getTokenEncoder(model) + // Reference: + // https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb + // https://github.com/pkoukk/tiktoken-go/issues/6 + // + // Every message follows <|start|>{role/name}\n{content}<|end|>\n + var tokensPerMessage int + var tokensPerName int + if model == "gpt-3.5-turbo-0301" { + tokensPerMessage = 4 + tokensPerName = -1 // If there's a name, the role is omitted + } else { + tokensPerMessage = 3 + tokensPerName = 1 + } + tokenNum := 0 + for _, message := range messages { + tokenNum += tokensPerMessage + switch v := message.Content.(type) { + case string: + tokenNum += getTokenNum(tokenEncoder, v) + case []any: + for _, it := range v { + m := it.(map[string]any) + switch m["type"] { + case "text": + if textValue, ok := m["text"]; ok { + if textString, ok := textValue.(string); ok { + tokenNum += getTokenNum(tokenEncoder, textString) + } + } + case "image_url": + imageURL, ok := m["image_url"].(map[string]any) + if ok { + url := imageURL["url"].(string) + detail := "" + if imageURL["detail"] != nil { + detail = imageURL["detail"].(string) + } + imageTokens, err := countImageTokens(url, detail, model) + if err != nil { + logger.SysError("error counting image tokens: " + err.Error()) + } else { + tokenNum += imageTokens + } + } + } + } + } + tokenNum += getTokenNum(tokenEncoder, message.Role) + if message.Name != nil { + tokenNum += tokensPerName + tokenNum += getTokenNum(tokenEncoder, *message.Name) + } + } + tokenNum += 3 // Every reply is primed with <|start|>assistant<|message|> + return tokenNum +} + +const ( + lowDetailCost = 85 + highDetailCostPerTile = 170 + additionalCost = 85 + // gpt-4o-mini cost higher than other model + gpt4oMiniLowDetailCost = 2833 + gpt4oMiniHighDetailCost = 5667 + gpt4oMiniAdditionalCost = 2833 +) + +// https://platform.openai.com/docs/guides/vision/calculating-costs +// https://github.com/openai/openai-cookbook/blob/05e3f9be4c7a2ae7ecf029a7c32065b024730ebe/examples/How_to_count_tokens_with_tiktoken.ipynb +func countImageTokens(url string, detail string, model string) (_ int, err error) { + fetchSize := true + var width, height int + // Reference: https://platform.openai.com/docs/guides/vision/low-or-high-fidelity-image-understanding + // detail == "auto" is undocumented on how it works, it just said the model will use the auto setting which will look at the image input size and decide if it should use the low or high setting. + // According to the official guide, "low" disable the high-res model, + // and only receive low-res 512px x 512px version of the image, indicating + // that image is treated as low-res when size is smaller than 512px x 512px, + // then we can assume that image size larger than 512px x 512px is treated + // as high-res. Then we have the following logic: + // if detail == "" || detail == "auto" { + // width, height, err = image.GetImageSize(url) + // if err != nil { + // return 0, err + // } + // fetchSize = false + // // not sure if this is correct + // if width > 512 || height > 512 { + // detail = "high" + // } else { + // detail = "low" + // } + // } + + // However, in my test, it seems to be always the same as "high". + // The following image, which is 125x50, is still treated as high-res, taken + // 255 tokens in the response of non-stream chat completion api. + // https://upload.wikimedia.org/wikipedia/commons/1/10/18_Infantry_Division_Messina.jpg + if detail == "" || detail == "auto" { + // assume by test, not sure if this is correct + detail = "high" + } + switch detail { + case "low": + if strings.HasPrefix(model, "gpt-4o-mini") { + return gpt4oMiniLowDetailCost, nil + } + return lowDetailCost, nil + case "high": + if fetchSize { + width, height, err = image.GetImageSize(url) + if err != nil { + return 0, err + } + } + if width > 2048 || height > 2048 { // max(width, height) > 2048 + ratio := float64(2048) / math.Max(float64(width), float64(height)) + width = int(float64(width) * ratio) + height = int(float64(height) * ratio) + } + if width > 768 && height > 768 { // min(width, height) > 768 + ratio := float64(768) / math.Min(float64(width), float64(height)) + width = int(float64(width) * ratio) + height = int(float64(height) * ratio) + } + numSquares := int(math.Ceil(float64(width)/512) * math.Ceil(float64(height)/512)) + if strings.HasPrefix(model, "gpt-4o-mini") { + return numSquares*gpt4oMiniHighDetailCost + gpt4oMiniAdditionalCost, nil + } + result := numSquares*highDetailCostPerTile + additionalCost + return result, nil + default: + return 0, errors.New("invalid detail option") + } +} + +func CountTokenInput(input any, model string) int { + switch v := input.(type) { + case string: + return CountTokenText(v, model) + case []string: + text := "" + for _, s := range v { + text += s + } + return CountTokenText(text, model) + } + return 0 +} + +func CountTokenText(text string, model string) int { + if strings.HasPrefix(model, "tts") { + return utf8.RuneCountInString(text) + } + tokenEncoder := getTokenEncoder(model) + return getTokenNum(tokenEncoder, text) +} + +func CountToken(text string) int { + return CountTokenInput(text, "gpt-3.5-turbo") +} diff --git a/service/aiproxy/relay/adaptor/openai/util.go b/service/aiproxy/relay/adaptor/openai/util.go new file mode 100644 index 00000000000..b37dd52571e --- /dev/null +++ b/service/aiproxy/relay/adaptor/openai/util.go @@ -0,0 +1,15 @@ +package openai + +import "github.com/labring/sealos/service/aiproxy/relay/model" + +func ErrorWrapper(err error, code string, statusCode int) *model.ErrorWithStatusCode { + Error := model.Error{ + Message: err.Error(), + Type: "aiproxy_error", + Code: code, + } + return &model.ErrorWithStatusCode{ + Error: Error, + StatusCode: statusCode, + } +} diff --git a/service/aiproxy/relay/adaptor/palm/adaptor.go b/service/aiproxy/relay/adaptor/palm/adaptor.go new file mode 100644 index 00000000000..e65dde874a1 --- /dev/null +++ b/service/aiproxy/relay/adaptor/palm/adaptor.go @@ -0,0 +1,73 @@ +package palm + +import ( + "errors" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +type Adaptor struct{} + +func (a *Adaptor) Init(_ *meta.Meta) { +} + +func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { + return meta.BaseURL + "/v1beta2/models/chat-bison-001:generateMessage", nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { + adaptor.SetupCommonRequestHeader(c, req, meta) + req.Header.Set("X-Goog-Api-Key", meta.APIKey) + return nil +} + +func (a *Adaptor) ConvertRequest(_ *gin.Context, _ int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return ConvertRequest(request), nil +} + +func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + return adaptor.DoRequestHelper(a, c, meta, requestBody) +} + +func (a *Adaptor) ConvertSTTRequest(*http.Request) (io.ReadCloser, error) { + return nil, nil +} + +func (a *Adaptor) ConvertTTSRequest(*model.TextToSpeechRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + var responseText string + err, responseText = StreamHandler(c, resp) + usage = openai.ResponseText2Usage(responseText, meta.ActualModelName, meta.PromptTokens) + } else { + err, usage = Handler(c, resp, meta.PromptTokens, meta.ActualModelName) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return "google palm" +} diff --git a/service/aiproxy/relay/adaptor/palm/constants.go b/service/aiproxy/relay/adaptor/palm/constants.go new file mode 100644 index 00000000000..a8349362c25 --- /dev/null +++ b/service/aiproxy/relay/adaptor/palm/constants.go @@ -0,0 +1,5 @@ +package palm + +var ModelList = []string{ + "PaLM-2", +} diff --git a/service/aiproxy/relay/adaptor/palm/model.go b/service/aiproxy/relay/adaptor/palm/model.go new file mode 100644 index 00000000000..5f46f82f485 --- /dev/null +++ b/service/aiproxy/relay/adaptor/palm/model.go @@ -0,0 +1,40 @@ +package palm + +import ( + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +type ChatMessage struct { + Author string `json:"author"` + Content string `json:"content"` +} + +type Filter struct { + Reason string `json:"reason"` + Message string `json:"message"` +} + +type Prompt struct { + Messages []ChatMessage `json:"messages"` +} + +type ChatRequest struct { + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"topP,omitempty"` + Prompt Prompt `json:"prompt"` + CandidateCount int `json:"candidateCount,omitempty"` + TopK int `json:"topK,omitempty"` +} + +type Error struct { + Message string `json:"message"` + Status string `json:"status"` + Code int `json:"code"` +} + +type ChatResponse struct { + Candidates []ChatMessage `json:"candidates"` + Messages []model.Message `json:"messages"` + Filters []Filter `json:"filters"` + Error Error `json:"error"` +} diff --git a/service/aiproxy/relay/adaptor/palm/palm.go b/service/aiproxy/relay/adaptor/palm/palm.go new file mode 100644 index 00000000000..41921a93894 --- /dev/null +++ b/service/aiproxy/relay/adaptor/palm/palm.go @@ -0,0 +1,147 @@ +package palm + +import ( + "net/http" + + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common/render" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/common/random" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/constant" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +// https://developers.generativeai.google/api/rest/generativelanguage/models/generateMessage#request-body +// https://developers.generativeai.google/api/rest/generativelanguage/models/generateMessage#response-body + +func ConvertRequest(textRequest *model.GeneralOpenAIRequest) *ChatRequest { + palmRequest := ChatRequest{ + Prompt: Prompt{ + Messages: make([]ChatMessage, 0, len(textRequest.Messages)), + }, + Temperature: textRequest.Temperature, + CandidateCount: textRequest.N, + TopP: textRequest.TopP, + TopK: textRequest.MaxTokens, + } + for _, message := range textRequest.Messages { + palmMessage := ChatMessage{ + Content: message.StringContent(), + } + if message.Role == "user" { + palmMessage.Author = "0" + } else { + palmMessage.Author = "1" + } + palmRequest.Prompt.Messages = append(palmRequest.Prompt.Messages, palmMessage) + } + return &palmRequest +} + +func responsePaLM2OpenAI(response *ChatResponse) *openai.TextResponse { + fullTextResponse := openai.TextResponse{ + Choices: make([]openai.TextResponseChoice, 0, len(response.Candidates)), + } + for i, candidate := range response.Candidates { + choice := openai.TextResponseChoice{ + Index: i, + Message: model.Message{ + Role: "assistant", + Content: candidate.Content, + }, + FinishReason: "stop", + } + fullTextResponse.Choices = append(fullTextResponse.Choices, choice) + } + return &fullTextResponse +} + +func streamResponsePaLM2OpenAI(palmResponse *ChatResponse) *openai.ChatCompletionsStreamResponse { + var choice openai.ChatCompletionsStreamResponseChoice + if len(palmResponse.Candidates) > 0 { + choice.Delta.Content = palmResponse.Candidates[0].Content + } + choice.FinishReason = &constant.StopFinishReason + var response openai.ChatCompletionsStreamResponse + response.Object = "chat.completion.chunk" + response.Model = "palm2" + response.Choices = []openai.ChatCompletionsStreamResponseChoice{choice} + return &response +} + +func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, string) { + defer resp.Body.Close() + + responseText := "" + responseID := "chatcmpl-" + random.GetUUID() + createdTime := helper.GetTimestamp() + + var palmResponse ChatResponse + err := json.NewDecoder(resp.Body).Decode(&palmResponse) + if err != nil { + logger.SysError("error unmarshalling stream response: " + err.Error()) + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), "" + } + + common.SetEventStreamHeaders(c) + + fullTextResponse := streamResponsePaLM2OpenAI(&palmResponse) + fullTextResponse.ID = responseID + fullTextResponse.Created = createdTime + if len(palmResponse.Candidates) > 0 { + responseText = palmResponse.Candidates[0].Content + } + + err = render.ObjectData(c, fullTextResponse) + if err != nil { + logger.SysError("error stream response: " + err.Error()) + return openai.ErrorWrapper(err, "stream_response_failed", http.StatusInternalServerError), "" + } + + render.Done(c) + + return nil, responseText +} + +func Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var palmResponse ChatResponse + err := json.NewDecoder(resp.Body).Decode(&palmResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if palmResponse.Error.Code != 0 || len(palmResponse.Candidates) == 0 { + return &model.ErrorWithStatusCode{ + Error: model.Error{ + Message: palmResponse.Error.Message, + Type: palmResponse.Error.Status, + Param: "", + Code: palmResponse.Error.Code, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := responsePaLM2OpenAI(&palmResponse) + fullTextResponse.Model = modelName + completionTokens := openai.CountTokenText(palmResponse.Candidates[0].Content, modelName) + usage := model.Usage{ + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + TotalTokens: promptTokens + completionTokens, + } + fullTextResponse.Usage = usage + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil, &usage +} diff --git a/service/aiproxy/relay/adaptor/siliconflow/constants.go b/service/aiproxy/relay/adaptor/siliconflow/constants.go new file mode 100644 index 00000000000..0bf547611a9 --- /dev/null +++ b/service/aiproxy/relay/adaptor/siliconflow/constants.go @@ -0,0 +1,36 @@ +package siliconflow + +// https://docs.siliconflow.cn/docs/getting-started + +var ModelList = []string{ + "deepseek-ai/deepseek-llm-67b-chat", + "Qwen/Qwen1.5-14B-Chat", + "Qwen/Qwen1.5-7B-Chat", + "Qwen/Qwen1.5-110B-Chat", + "Qwen/Qwen1.5-32B-Chat", + "01-ai/Yi-1.5-6B-Chat", + "01-ai/Yi-1.5-9B-Chat-16K", + "01-ai/Yi-1.5-34B-Chat-16K", + "THUDM/chatglm3-6b", + "deepseek-ai/DeepSeek-V2-Chat", + "THUDM/glm-4-9b-chat", + "Qwen/Qwen2-72B-Instruct", + "Qwen/Qwen2-7B-Instruct", + "Qwen/Qwen2-57B-A14B-Instruct", + "deepseek-ai/DeepSeek-Coder-V2-Instruct", + "Qwen/Qwen2-1.5B-Instruct", + "internlm/internlm2_5-7b-chat", + "BAAI/bge-large-en-v1.5", + "BAAI/bge-large-zh-v1.5", + "Pro/Qwen/Qwen2-7B-Instruct", + "Pro/Qwen/Qwen2-1.5B-Instruct", + "Pro/Qwen/Qwen1.5-7B-Chat", + "Pro/THUDM/glm-4-9b-chat", + "Pro/THUDM/chatglm3-6b", + "Pro/01-ai/Yi-1.5-9B-Chat-16K", + "Pro/01-ai/Yi-1.5-6B-Chat", + "Pro/google/gemma-2-9b-it", + "Pro/internlm/internlm2_5-7b-chat", + "Pro/meta-llama/Meta-Llama-3-8B-Instruct", + "Pro/mistralai/Mistral-7B-Instruct-v0.2", +} diff --git a/service/aiproxy/relay/adaptor/stepfun/constants.go b/service/aiproxy/relay/adaptor/stepfun/constants.go new file mode 100644 index 00000000000..6a2346cac5b --- /dev/null +++ b/service/aiproxy/relay/adaptor/stepfun/constants.go @@ -0,0 +1,13 @@ +package stepfun + +var ModelList = []string{ + "step-1-8k", + "step-1-32k", + "step-1-128k", + "step-1-256k", + "step-1-flash", + "step-2-16k", + "step-1v-8k", + "step-1v-32k", + "step-1x-medium", +} diff --git a/service/aiproxy/relay/adaptor/tencent/adaptor.go b/service/aiproxy/relay/adaptor/tencent/adaptor.go new file mode 100644 index 00000000000..f900c483589 --- /dev/null +++ b/service/aiproxy/relay/adaptor/tencent/adaptor.go @@ -0,0 +1,96 @@ +package tencent + +import ( + "errors" + "io" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +// https://cloud.tencent.com/document/api/1729/101837 + +type Adaptor struct { + meta *meta.Meta + Sign string + Action string + Version string + Timestamp int64 +} + +func (a *Adaptor) Init(meta *meta.Meta) { + a.Action = "ChatCompletions" + a.Version = "2023-09-01" + a.Timestamp = helper.GetTimestamp() + a.meta = meta +} + +func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { + return meta.BaseURL + "/", nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { + adaptor.SetupCommonRequestHeader(c, req, meta) + req.Header.Set("Authorization", a.Sign) + req.Header.Set("X-Tc-Action", a.Action) + req.Header.Set("X-Tc-Version", a.Version) + req.Header.Set("X-Tc-Timestamp", strconv.FormatInt(a.Timestamp, 10)) + return nil +} + +func (a *Adaptor) ConvertRequest(_ *gin.Context, _ int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + _, secretID, secretKey, err := ParseConfig(a.meta.APIKey) + if err != nil { + return nil, err + } + // we have to calculate the sign here + a.Sign = GetSign(request, a, secretID, secretKey) + return request, nil +} + +func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + return adaptor.DoRequestHelper(a, c, meta, requestBody) +} + +func (a *Adaptor) ConvertSTTRequest(*http.Request) (io.ReadCloser, error) { + return nil, nil +} + +func (a *Adaptor) ConvertTTSRequest(*model.TextToSpeechRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + var responseText string + err, responseText = StreamHandler(c, resp) + usage = openai.ResponseText2Usage(responseText, meta.ActualModelName, meta.PromptTokens) + } else { + err, usage = Handler(c, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return "tencent" +} diff --git a/service/aiproxy/relay/adaptor/tencent/constants.go b/service/aiproxy/relay/adaptor/tencent/constants.go new file mode 100644 index 00000000000..e8631e5f476 --- /dev/null +++ b/service/aiproxy/relay/adaptor/tencent/constants.go @@ -0,0 +1,9 @@ +package tencent + +var ModelList = []string{ + "hunyuan-lite", + "hunyuan-standard", + "hunyuan-standard-256K", + "hunyuan-pro", + "hunyuan-vision", +} diff --git a/service/aiproxy/relay/adaptor/tencent/main.go b/service/aiproxy/relay/adaptor/tencent/main.go new file mode 100644 index 00000000000..6d81f5a8f05 --- /dev/null +++ b/service/aiproxy/relay/adaptor/tencent/main.go @@ -0,0 +1,221 @@ +package tencent + +import ( + "bufio" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common/render" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/conv" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/common/random" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/constant" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +func responseTencent2OpenAI(response *ChatResponse) *openai.TextResponse { + fullTextResponse := openai.TextResponse{ + Object: "chat.completion", + Created: helper.GetTimestamp(), + Usage: model.Usage{ + PromptTokens: response.Usage.PromptTokens, + CompletionTokens: response.Usage.CompletionTokens, + TotalTokens: response.Usage.TotalTokens, + }, + } + if len(response.Choices) > 0 { + choice := openai.TextResponseChoice{ + Index: 0, + Message: model.Message{ + Role: "assistant", + Content: response.Choices[0].Messages.Content, + }, + FinishReason: response.Choices[0].FinishReason, + } + fullTextResponse.Choices = append(fullTextResponse.Choices, choice) + } + return &fullTextResponse +} + +func streamResponseTencent2OpenAI(tencentResponse *ChatResponse) *openai.ChatCompletionsStreamResponse { + response := openai.ChatCompletionsStreamResponse{ + ID: "chatcmpl-" + random.GetUUID(), + Object: "chat.completion.chunk", + Created: helper.GetTimestamp(), + Model: "tencent-hunyuan", + } + if len(tencentResponse.Choices) > 0 { + var choice openai.ChatCompletionsStreamResponseChoice + choice.Delta.Content = tencentResponse.Choices[0].Delta.Content + if tencentResponse.Choices[0].FinishReason == "stop" { + choice.FinishReason = &constant.StopFinishReason + } + response.Choices = append(response.Choices, choice) + } + return &response +} + +func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, string) { + defer resp.Body.Close() + + var responseText string + scanner := bufio.NewScanner(resp.Body) + scanner.Split(bufio.ScanLines) + + common.SetEventStreamHeaders(c) + + for scanner.Scan() { + data := scanner.Bytes() + if len(data) < 6 || conv.BytesToString(data[:6]) != "data: " { + continue + } + data = data[6:] + + if conv.BytesToString(data) == "[DONE]" { + break + } + + var tencentResponse ChatResponse + err := json.Unmarshal(data, &tencentResponse) + if err != nil { + logger.SysErrorf("error unmarshalling stream response: %s, data: %s", err.Error(), conv.BytesToString(data)) + continue + } + + response := streamResponseTencent2OpenAI(&tencentResponse) + if len(response.Choices) != 0 { + responseText += conv.AsString(response.Choices[0].Delta.Content) + } + + err = render.ObjectData(c, response) + if err != nil { + logger.SysError(err.Error()) + } + } + + if err := scanner.Err(); err != nil { + logger.SysError("error reading stream: " + err.Error()) + } + + render.Done(c) + + return nil, responseText +} + +func Handler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var responseP ChatResponseP + err := json.NewDecoder(resp.Body).Decode(&responseP) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + + if responseP.Response.Error.Code != 0 { + return &model.ErrorWithStatusCode{ + Error: model.Error{ + Message: responseP.Response.Error.Message, + Code: responseP.Response.Error.Code, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := responseTencent2OpenAI(&responseP.Response) + fullTextResponse.Model = "hunyuan" + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + if err != nil { + return openai.ErrorWrapper(err, "write_response_body_failed", http.StatusInternalServerError), nil + } + return nil, &fullTextResponse.Usage +} + +func ParseConfig(config string) (appID int64, secretID string, secretKey string, err error) { + parts := strings.Split(config, "|") + if len(parts) != 3 { + err = errors.New("invalid tencent config") + return + } + appID, err = strconv.ParseInt(parts[0], 10, 64) + secretID = parts[1] + secretKey = parts[2] + return +} + +func sha256hex(s string) string { + b := sha256.Sum256(conv.StringToBytes(s)) + return hex.EncodeToString(b[:]) +} + +func hmacSha256(s, key string) string { + hashed := hmac.New(sha256.New, conv.StringToBytes(key)) + hashed.Write(conv.StringToBytes(s)) + return conv.BytesToString(hashed.Sum(nil)) +} + +func GetSign(req *model.GeneralOpenAIRequest, adaptor *Adaptor, secID, secKey string) string { + // build canonical request string + host := "hunyuan.tencentcloudapi.com" + httpRequestMethod := "POST" + canonicalURI := "/" + canonicalQueryString := "" + canonicalHeaders := fmt.Sprintf("content-type:%s\nhost:%s\nx-tc-action:%s\n", + "application/json", host, strings.ToLower(adaptor.Action)) + signedHeaders := "content-type;host;x-tc-action" + payload, _ := json.Marshal(req) + hashedRequestPayload := sha256hex(conv.BytesToString(payload)) + canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", + httpRequestMethod, + canonicalURI, + canonicalQueryString, + canonicalHeaders, + signedHeaders, + hashedRequestPayload) + // build string to sign + algorithm := "TC3-HMAC-SHA256" + requestTimestamp := strconv.FormatInt(adaptor.Timestamp, 10) + timestamp, _ := strconv.ParseInt(requestTimestamp, 10, 64) + t := time.Unix(timestamp, 0).UTC() + // must be the format 2006-01-02, ref to package time for more info + date := t.Format("2006-01-02") + credentialScope := fmt.Sprintf("%s/%s/tc3_request", date, "hunyuan") + hashedCanonicalRequest := sha256hex(canonicalRequest) + string2sign := fmt.Sprintf("%s\n%s\n%s\n%s", + algorithm, + requestTimestamp, + credentialScope, + hashedCanonicalRequest) + + // sign string + secretDate := hmacSha256(date, "TC3"+secKey) + secretService := hmacSha256("hunyuan", secretDate) + secretKey := hmacSha256("tc3_request", secretService) + signature := hex.EncodeToString(conv.StringToBytes(hmacSha256(string2sign, secretKey))) + + // build authorization + authorization := fmt.Sprintf("%s Credential=%s/%s, SignedHeaders=%s, Signature=%s", + algorithm, + secID, + credentialScope, + signedHeaders, + signature) + return authorization +} diff --git a/service/aiproxy/relay/adaptor/tencent/model.go b/service/aiproxy/relay/adaptor/tencent/model.go new file mode 100644 index 00000000000..1e3f1ae61b1 --- /dev/null +++ b/service/aiproxy/relay/adaptor/tencent/model.go @@ -0,0 +1,34 @@ +package tencent + +import "github.com/labring/sealos/service/aiproxy/relay/model" + +type Error struct { + Message string `json:"Message"` + Code int `json:"Code"` +} + +type Usage struct { + PromptTokens int `json:"PromptTokens"` + CompletionTokens int `json:"CompletionTokens"` + TotalTokens int `json:"TotalTokens"` +} + +type ResponseChoices struct { + FinishReason string `json:"FinishReason,omitempty"` // 流式结束标志位,为 stop 则表示尾包 + Messages model.Message `json:"Message,omitempty"` // 内容,同步模式返回内容,流模式为 null 输出 content 内容总数最多支持 1024token。 + Delta model.Message `json:"Delta,omitempty"` // 内容,流模式返回内容,同步模式为 null 输出 content 内容总数最多支持 1024token。 +} + +type ChatResponse struct { + ID string `json:"Id,omitempty"` + Note string `json:"Note,omitempty"` + ReqID string `json:"Req_id,omitempty"` + Choices []ResponseChoices `json:"Choices,omitempty"` + Error Error `json:"Error,omitempty"` + Usage Usage `json:"Usage,omitempty"` + Created int64 `json:"Created,omitempty"` +} + +type ChatResponseP struct { + Response ChatResponse `json:"Response,omitempty"` +} diff --git a/service/aiproxy/relay/adaptor/togetherai/constants.go b/service/aiproxy/relay/adaptor/togetherai/constants.go new file mode 100644 index 00000000000..0a79fbdcc5a --- /dev/null +++ b/service/aiproxy/relay/adaptor/togetherai/constants.go @@ -0,0 +1,10 @@ +package togetherai + +// https://docs.together.ai/docs/inference-models + +var ModelList = []string{ + "meta-llama/Llama-3-70b-chat-hf", + "deepseek-ai/deepseek-coder-33b-instruct", + "mistralai/Mixtral-8x22B-Instruct-v0.1", + "Qwen/Qwen1.5-72B-Chat", +} diff --git a/service/aiproxy/relay/adaptor/vertexai/adaptor.go b/service/aiproxy/relay/adaptor/vertexai/adaptor.go new file mode 100644 index 00000000000..829cfc6465d --- /dev/null +++ b/service/aiproxy/relay/adaptor/vertexai/adaptor.go @@ -0,0 +1,123 @@ +package vertexai + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + channelhelper "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/meta" + relaymodel "github.com/labring/sealos/service/aiproxy/relay/model" +) + +var _ channelhelper.Adaptor = new(Adaptor) + +const channelName = "vertexai" + +type Adaptor struct{} + +func (a *Adaptor) ConvertSTTRequest(*http.Request) (io.ReadCloser, error) { + return nil, nil +} + +func (a *Adaptor) ConvertTTSRequest(*relaymodel.TextToSpeechRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) Init(_ *meta.Meta) { +} + +func (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *relaymodel.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + + adaptor := GetAdaptor(request.Model) + if adaptor == nil { + return nil, errors.New("adaptor not found") + } + + return adaptor.ConvertRequest(c, relayMode, request) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *relaymodel.Usage, err *relaymodel.ErrorWithStatusCode) { + adaptor := GetAdaptor(meta.ActualModelName) + if adaptor == nil { + return nil, &relaymodel.ErrorWithStatusCode{ + StatusCode: http.StatusInternalServerError, + Error: relaymodel.Error{ + Message: "adaptor not found", + }, + } + } + return adaptor.DoResponse(c, resp, meta) +} + +func (a *Adaptor) GetModelList() (models []string) { + models = modelList + return +} + +func (a *Adaptor) GetChannelName() string { + return channelName +} + +func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { + var suffix string + if strings.HasPrefix(meta.ActualModelName, "gemini") { + if meta.IsStream { + suffix = "streamGenerateContent?alt=sse" + } else { + suffix = "generateContent" + } + } else { + if meta.IsStream { + suffix = "streamRawPredict?alt=sse" + } else { + suffix = "rawPredict" + } + } + + if meta.BaseURL != "" { + return fmt.Sprintf( + "%s/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", + meta.BaseURL, + meta.Config.VertexAIProjectID, + meta.Config.Region, + meta.ActualModelName, + suffix, + ), nil + } + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", + meta.Config.Region, + meta.Config.VertexAIProjectID, + meta.Config.Region, + meta.ActualModelName, + suffix, + ), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { + channelhelper.SetupCommonRequestHeader(c, req, meta) + token, err := getToken(c, meta.ChannelID, meta.Config.VertexAIADC) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + return nil +} + +func (a *Adaptor) ConvertImageRequest(request *relaymodel.ImageRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + return channelhelper.DoRequestHelper(a, c, meta, requestBody) +} diff --git a/service/aiproxy/relay/adaptor/vertexai/claude/adapter.go b/service/aiproxy/relay/adaptor/vertexai/claude/adapter.go new file mode 100644 index 00000000000..bb55f4dbf24 --- /dev/null +++ b/service/aiproxy/relay/adaptor/vertexai/claude/adapter.go @@ -0,0 +1,59 @@ +package vertexai + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/anthropic" + "github.com/pkg/errors" + + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +var ModelList = []string{ + "claude-3-haiku@20240307", + "claude-3-sonnet@20240229", + "claude-3-opus@20240229", + "claude-3-5-sonnet@20240620", + "claude-3-5-sonnet-v2@20241022", + "claude-3-5-haiku@20241022", +} + +const anthropicVersion = "vertex-2023-10-16" + +type Adaptor struct{} + +func (a *Adaptor) ConvertRequest(c *gin.Context, _ int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + + claudeReq := anthropic.ConvertRequest(request) + req := Request{ + AnthropicVersion: anthropicVersion, + // Model: claudeReq.Model, + Messages: claudeReq.Messages, + System: claudeReq.System, + MaxTokens: claudeReq.MaxTokens, + Temperature: claudeReq.Temperature, + TopP: claudeReq.TopP, + TopK: claudeReq.TopK, + Stream: claudeReq.Stream, + Tools: claudeReq.Tools, + } + + c.Set(ctxkey.RequestModel, request.Model) + c.Set(ctxkey.ConvertedRequest, req) + return req, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + err, usage = anthropic.StreamHandler(c, resp) + } else { + err, usage = anthropic.Handler(c, resp, meta.PromptTokens, meta.ActualModelName) + } + return +} diff --git a/service/aiproxy/relay/adaptor/vertexai/claude/model.go b/service/aiproxy/relay/adaptor/vertexai/claude/model.go new file mode 100644 index 00000000000..eda799ec8f0 --- /dev/null +++ b/service/aiproxy/relay/adaptor/vertexai/claude/model.go @@ -0,0 +1,17 @@ +package vertexai + +import "github.com/labring/sealos/service/aiproxy/relay/adaptor/anthropic" + +type Request struct { + ToolChoice any `json:"tool_choice,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + AnthropicVersion string `json:"anthropic_version"` + System string `json:"system,omitempty"` + Messages []anthropic.Message `json:"messages"` + StopSequences []string `json:"stop_sequences,omitempty"` + Tools []anthropic.Tool `json:"tools,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + TopK int `json:"top_k,omitempty"` + Stream bool `json:"stream,omitempty"` +} diff --git a/service/aiproxy/relay/adaptor/vertexai/gemini/adapter.go b/service/aiproxy/relay/adaptor/vertexai/gemini/adapter.go new file mode 100644 index 00000000000..861accadf5f --- /dev/null +++ b/service/aiproxy/relay/adaptor/vertexai/gemini/adapter.go @@ -0,0 +1,48 @@ +package vertexai + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/gemini" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" + "github.com/pkg/errors" + + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +var ModelList = []string{ + "gemini-1.5-pro-001", "gemini-1.5-flash-001", "gemini-pro", "gemini-pro-vision", +} + +type Adaptor struct{} + +func (a *Adaptor) ConvertRequest(c *gin.Context, _ int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + + geminiRequest := gemini.ConvertRequest(request) + c.Set(ctxkey.RequestModel, request.Model) + c.Set(ctxkey.ConvertedRequest, geminiRequest) + return geminiRequest, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + var responseText string + err, responseText = gemini.StreamHandler(c, resp) + usage = openai.ResponseText2Usage(responseText, meta.ActualModelName, meta.PromptTokens) + } else { + switch meta.Mode { + case relaymode.Embeddings: + err, usage = gemini.EmbeddingHandler(c, resp) + default: + err, usage = gemini.Handler(c, resp, meta.PromptTokens, meta.ActualModelName) + } + } + return +} diff --git a/service/aiproxy/relay/adaptor/vertexai/registry.go b/service/aiproxy/relay/adaptor/vertexai/registry.go new file mode 100644 index 00000000000..ee95a19a91d --- /dev/null +++ b/service/aiproxy/relay/adaptor/vertexai/registry.go @@ -0,0 +1,52 @@ +package vertexai + +import ( + "net/http" + + "github.com/gin-gonic/gin" + claude "github.com/labring/sealos/service/aiproxy/relay/adaptor/vertexai/claude" + gemini "github.com/labring/sealos/service/aiproxy/relay/adaptor/vertexai/gemini" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +type ModelType int + +const ( + VerterAIClaude ModelType = iota + 1 + VerterAIGemini +) + +var ( + modelMapping = map[string]ModelType{} + modelList = []string{} +) + +func init() { + modelList = append(modelList, claude.ModelList...) + for _, model := range claude.ModelList { + modelMapping[model] = VerterAIClaude + } + + modelList = append(modelList, gemini.ModelList...) + for _, model := range gemini.ModelList { + modelMapping[model] = VerterAIGemini + } +} + +type innerAIAdapter interface { + ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) + DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) +} + +func GetAdaptor(model string) innerAIAdapter { + adaptorType := modelMapping[model] + switch adaptorType { + case VerterAIClaude: + return &claude.Adaptor{} + case VerterAIGemini: + return &gemini.Adaptor{} + default: + return nil + } +} diff --git a/service/aiproxy/relay/adaptor/vertexai/token.go b/service/aiproxy/relay/adaptor/vertexai/token.go new file mode 100644 index 00000000000..77b64ba9db3 --- /dev/null +++ b/service/aiproxy/relay/adaptor/vertexai/token.go @@ -0,0 +1,64 @@ +package vertexai + +import ( + "context" + "fmt" + "time" + + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common/conv" + + credentials "cloud.google.com/go/iam/credentials/apiv1" + "cloud.google.com/go/iam/credentials/apiv1/credentialspb" + "github.com/patrickmn/go-cache" + "google.golang.org/api/option" +) + +type ApplicationDefaultCredentials struct { + Type string `json:"type"` + ProjectID string `json:"project_id"` + PrivateKeyID string `json:"private_key_id"` + PrivateKey string `json:"private_key"` + ClientEmail string `json:"client_email"` + ClientID string `json:"client_id"` + AuthURI string `json:"auth_uri"` + TokenURI string `json:"token_uri"` + AuthProviderX509CertURL string `json:"auth_provider_x509_cert_url"` + ClientX509CertURL string `json:"client_x509_cert_url"` + UniverseDomain string `json:"universe_domain"` +} + +var Cache = cache.New(50*time.Minute, 55*time.Minute) + +const defaultScope = "https://www.googleapis.com/auth/cloud-platform" + +func getToken(ctx context.Context, channelID int, adcJSON string) (string, error) { + cacheKey := fmt.Sprintf("vertexai-token-%d", channelID) + if token, found := Cache.Get(cacheKey); found { + return token.(string), nil + } + adc := &ApplicationDefaultCredentials{} + if err := json.Unmarshal(conv.StringToBytes(adcJSON), adc); err != nil { + return "", fmt.Errorf("failed to decode credentials file: %w", err) + } + + c, err := credentials.NewIamCredentialsClient(ctx, option.WithCredentialsJSON(conv.StringToBytes(adcJSON))) + if err != nil { + return "", fmt.Errorf("failed to create client: %w", err) + } + defer c.Close() + + req := &credentialspb.GenerateAccessTokenRequest{ + // See https://pkg.go.dev/cloud.google.com/go/iam/credentials/apiv1/credentialspb#GenerateAccessTokenRequest. + Name: "projects/-/serviceAccounts/" + adc.ClientEmail, + Scope: []string{defaultScope}, + } + resp, err := c.GenerateAccessToken(ctx, req) + if err != nil { + return "", fmt.Errorf("failed to generate access token: %w", err) + } + _ = resp + + Cache.Set(cacheKey, resp.AccessToken, cache.DefaultExpiration) + return resp.AccessToken, nil +} diff --git a/service/aiproxy/relay/adaptor/xunfei/adaptor.go b/service/aiproxy/relay/adaptor/xunfei/adaptor.go new file mode 100644 index 00000000000..0e6b917e8b6 --- /dev/null +++ b/service/aiproxy/relay/adaptor/xunfei/adaptor.go @@ -0,0 +1,76 @@ +package xunfei + +import ( + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +type Adaptor struct { + meta *meta.Meta +} + +func (a *Adaptor) Init(meta *meta.Meta) { + a.meta = meta +} + +func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { + return meta.BaseURL + "/v1/chat/completions", nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { + adaptor.SetupCommonRequestHeader(c, req, meta) + req.Header.Set("Authorization", "Bearer "+meta.APIKey) + return nil +} + +func (a *Adaptor) ConvertRequest(_ *gin.Context, _ int, request *model.GeneralOpenAIRequest) (any, error) { + domain, err := getXunfeiDomain(request.Model) + if err != nil { + return nil, err + } + request.Model = domain + return request, nil +} + +func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { + domain, err := getXunfeiDomain(request.Model) + if err != nil { + return nil, err + } + request.Model = domain + return request, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + return adaptor.DoRequestHelper(a, c, meta, requestBody) +} + +func (a *Adaptor) ConvertSTTRequest(*http.Request) (io.ReadCloser, error) { + return nil, nil +} + +func (a *Adaptor) ConvertTTSRequest(*model.TextToSpeechRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + err, usage = StreamHandler(c, resp, meta.PromptTokens, meta.ActualModelName) + } else { + err, usage = Handler(c, resp, meta.PromptTokens, meta.ActualModelName) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return "xunfei" +} diff --git a/service/aiproxy/relay/adaptor/xunfei/constants.go b/service/aiproxy/relay/adaptor/xunfei/constants.go new file mode 100644 index 00000000000..f39f5515260 --- /dev/null +++ b/service/aiproxy/relay/adaptor/xunfei/constants.go @@ -0,0 +1,10 @@ +package xunfei + +var ModelList = []string{ + "SparkDesk-Lite", + "SparkDesk-Pro", + "SparkDesk-Pro-128K", + "SparkDesk-Max", + "SparkDesk-Max-32k", + "SparkDesk-4.0-Ultra", +} diff --git a/service/aiproxy/relay/adaptor/xunfei/main.go b/service/aiproxy/relay/adaptor/xunfei/main.go new file mode 100644 index 00000000000..e14f4342197 --- /dev/null +++ b/service/aiproxy/relay/adaptor/xunfei/main.go @@ -0,0 +1,131 @@ +package xunfei + +import ( + "bufio" + "errors" + "net/http" + "strings" + + json "github.com/json-iterator/go" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/conv" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/common/render" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +// https://console.xfyun.cn/services/cbm +// https://www.xfyun.cn/doc/spark/HTTP%E8%B0%83%E7%94%A8%E6%96%87%E6%A1%A3.html + +func StreamHandler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + scanner := bufio.NewScanner(resp.Body) + scanner.Split(bufio.ScanLines) + + common.SetEventStreamHeaders(c) + id := helper.GetResponseID(c) + responseModel := c.GetString(ctxkey.OriginalModel) + var responseText string + + var usage *model.Usage + + for scanner.Scan() { + data := scanner.Bytes() + if len(data) < 6 || conv.BytesToString(data[:6]) != "data: " { + continue + } + data = data[6:] + + if conv.BytesToString(data) == "[DONE]" { + break + } + + var response openai.ChatCompletionsStreamResponse + err := json.Unmarshal(data, &response) + if err != nil { + logger.SysErrorf("error unmarshalling stream response: %s, data: %s", err.Error(), conv.BytesToString(data)) + continue + } + + if response.Usage != nil { + usage = response.Usage + } + + for _, v := range response.Choices { + v.Delta.Role = "assistant" + responseText += v.Delta.StringContent() + } + response.ID = id + response.Model = modelName + err = render.ObjectData(c, response) + if err != nil { + logger.SysError(err.Error()) + } + } + + if err := scanner.Err(); err != nil { + logger.SysError("error reading stream: " + err.Error()) + } + + render.Done(c) + + if usage == nil { + usage = openai.ResponseText2Usage(responseText, responseModel, promptTokens) + } + return nil, usage +} + +func Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var response openai.TextResponse + err := json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + + response.Model = modelName + var responseText string + for _, v := range response.Choices { + responseText += v.Message.Content.(string) + } + usage := openai.ResponseText2Usage(responseText, modelName, promptTokens) + response.Usage = *usage + response.ID = helper.GetResponseID(c) + jsonResponse, err := json.Marshal(response) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil, usage +} + +func getXunfeiDomain(modelName string) (string, error) { + _, s, ok := strings.Cut(modelName, "-") + if !ok { + return "", errors.New("invalid model name") + } + switch strings.ToLower(s) { + case "lite": + return "lite", nil + case "pro": + return "generalv3", nil + case "pro-128k": + return "pro-128k", nil + case "max": + return "generalv3.5", nil + case "max-32k": + return "max-32k", nil + case "4.0-ultra": + return "4.0Ultra", nil + } + return "", errors.New("invalid model name") +} diff --git a/service/aiproxy/relay/adaptor/zhipu/adaptor.go b/service/aiproxy/relay/adaptor/zhipu/adaptor.go new file mode 100644 index 00000000000..968d40ff3b7 --- /dev/null +++ b/service/aiproxy/relay/adaptor/zhipu/adaptor.go @@ -0,0 +1,157 @@ +package zhipu + +import ( + "errors" + "fmt" + "io" + "math" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" +) + +type Adaptor struct { + APIVersion string +} + +func (a *Adaptor) Init(_ *meta.Meta) { +} + +func (a *Adaptor) SetVersionByModeName(modelName string) { + if strings.HasPrefix(modelName, "glm-") { + a.APIVersion = "v4" + } else { + a.APIVersion = "v3" + } +} + +func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { + switch meta.Mode { + case relaymode.ImagesGenerations: + return meta.BaseURL + "/api/paas/v4/images/generations", nil + case relaymode.Embeddings: + return meta.BaseURL + "/api/paas/v4/embeddings", nil + } + a.SetVersionByModeName(meta.ActualModelName) + if a.APIVersion == "v4" { + return meta.BaseURL + "/api/paas/v4/chat/completions", nil + } + method := "invoke" + if meta.IsStream { + method = "sse-invoke" + } + return fmt.Sprintf("%s/api/paas/v3/model-api/%s/%s", meta.BaseURL, meta.ActualModelName, method), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { + adaptor.SetupCommonRequestHeader(c, req, meta) + token := GetToken(meta.APIKey) + req.Header.Set("Authorization", token) + return nil +} + +func (a *Adaptor) ConvertRequest(_ *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + switch relayMode { + case relaymode.Embeddings: + baiduEmbeddingRequest, err := ConvertEmbeddingRequest(*request) + return baiduEmbeddingRequest, err + default: + // TopP (0.0, 1.0) + if request.TopP != nil { + *request.TopP = math.Min(0.99, *request.TopP) + *request.TopP = math.Max(0.01, *request.TopP) + } + + // Temperature (0.0, 1.0) + if request.Temperature != nil { + *request.Temperature = math.Min(0.99, *request.Temperature) + *request.Temperature = math.Max(0.01, *request.Temperature) + } + a.SetVersionByModeName(request.Model) + if a.APIVersion == "v4" { + return request, nil + } + return ConvertRequest(request), nil + } +} + +func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + newRequest := ImageRequest{ + Model: request.Model, + Prompt: request.Prompt, + UserID: request.User, + } + return newRequest, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + return adaptor.DoRequestHelper(a, c, meta, requestBody) +} + +func (a *Adaptor) ConvertSTTRequest(*http.Request) (io.ReadCloser, error) { + return nil, nil +} + +func (a *Adaptor) ConvertTTSRequest(*model.TextToSpeechRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) DoResponseV4(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + if meta.IsStream { + err, _, usage = openai.StreamHandler(c, resp, meta.Mode) + } else { + err, usage = openai.Handler(c, resp, meta.PromptTokens, meta.ActualModelName) + } + return +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + switch meta.Mode { + case relaymode.Embeddings: + err, usage = EmbeddingsHandler(c, resp) + return + case relaymode.ImagesGenerations: + err, usage = openai.ImageHandler(c, resp) + return + } + if a.APIVersion == "v4" { + return a.DoResponseV4(c, resp, meta) + } + if meta.IsStream { + err, usage = StreamHandler(c, resp) + } else { + if meta.Mode == relaymode.Embeddings { + err, usage = EmbeddingsHandler(c, resp) + } else { + err, usage = Handler(c, resp) + } + } + return +} + +func ConvertEmbeddingRequest(request model.GeneralOpenAIRequest) (*EmbeddingRequest, error) { + return &EmbeddingRequest{ + Model: request.Model, + Input: request.Input, + }, nil +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return "zhipu" +} diff --git a/service/aiproxy/relay/adaptor/zhipu/constants.go b/service/aiproxy/relay/adaptor/zhipu/constants.go new file mode 100644 index 00000000000..e11921230cd --- /dev/null +++ b/service/aiproxy/relay/adaptor/zhipu/constants.go @@ -0,0 +1,7 @@ +package zhipu + +var ModelList = []string{ + "chatglm_turbo", "chatglm_pro", "chatglm_std", "chatglm_lite", + "glm-4", "glm-4v", "glm-3-turbo", "embedding-2", + "cogview-3", +} diff --git a/service/aiproxy/relay/adaptor/zhipu/main.go b/service/aiproxy/relay/adaptor/zhipu/main.go new file mode 100644 index 00000000000..5924e3a9acd --- /dev/null +++ b/service/aiproxy/relay/adaptor/zhipu/main.go @@ -0,0 +1,276 @@ +package zhipu + +import ( + "bufio" + "net/http" + "slices" + "strings" + "sync" + "time" + + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common/conv" + "github.com/labring/sealos/service/aiproxy/common/render" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/helper" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/constant" + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +// https://open.bigmodel.cn/doc/api#chatglm_std +// chatglm_std, chatglm_lite +// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/invoke +// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/sse-invoke + +var ( + zhipuTokens sync.Map + expSeconds int64 = 24 * 3600 +) + +func GetToken(apikey string) string { + data, ok := zhipuTokens.Load(apikey) + if ok { + td := data.(tokenData) + if time.Now().Before(td.ExpiryTime) { + return td.Token + } + } + + split := strings.Split(apikey, ".") + if len(split) != 2 { + logger.SysError("invalid zhipu key: " + apikey) + return "" + } + + id := split[0] + secret := split[1] + + expMillis := time.Now().Add(time.Duration(expSeconds)*time.Second).UnixNano() / 1e6 + expiryTime := time.Now().Add(time.Duration(expSeconds) * time.Second) + + timestamp := time.Now().UnixNano() / 1e6 + + payload := jwt.MapClaims{ + "api_key": id, + "exp": expMillis, + "timestamp": timestamp, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, payload) + + token.Header["alg"] = "HS256" + token.Header["sign_type"] = "SIGN" + + tokenString, err := token.SignedString(conv.StringToBytes(secret)) + if err != nil { + return "" + } + + zhipuTokens.Store(apikey, tokenData{ + Token: tokenString, + ExpiryTime: expiryTime, + }) + + return tokenString +} + +func ConvertRequest(request *model.GeneralOpenAIRequest) *Request { + return &Request{ + Prompt: request.Messages, + Temperature: request.Temperature, + TopP: request.TopP, + Incremental: false, + } +} + +func responseZhipu2OpenAI(response *Response) *openai.TextResponse { + fullTextResponse := openai.TextResponse{ + ID: response.Data.TaskID, + Object: "chat.completion", + Created: helper.GetTimestamp(), + Choices: make([]openai.TextResponseChoice, 0, len(response.Data.Choices)), + Usage: response.Data.Usage, + } + for i, choice := range response.Data.Choices { + openaiChoice := openai.TextResponseChoice{ + Index: i, + Message: model.Message{ + Role: choice.Role, + Content: strings.Trim(choice.Content.(string), "\""), + }, + FinishReason: "", + } + if i == len(response.Data.Choices)-1 { + openaiChoice.FinishReason = "stop" + } + fullTextResponse.Choices = append(fullTextResponse.Choices, openaiChoice) + } + return &fullTextResponse +} + +func streamResponseZhipu2OpenAI(zhipuResponse string) *openai.ChatCompletionsStreamResponse { + var choice openai.ChatCompletionsStreamResponseChoice + choice.Delta.Content = zhipuResponse + response := openai.ChatCompletionsStreamResponse{ + Object: "chat.completion.chunk", + Created: helper.GetTimestamp(), + Model: "chatglm", + Choices: []openai.ChatCompletionsStreamResponseChoice{choice}, + } + return &response +} + +func streamMetaResponseZhipu2OpenAI(zhipuResponse *StreamMetaResponse) (*openai.ChatCompletionsStreamResponse, *model.Usage) { + var choice openai.ChatCompletionsStreamResponseChoice + choice.Delta.Content = "" + choice.FinishReason = &constant.StopFinishReason + response := openai.ChatCompletionsStreamResponse{ + ID: zhipuResponse.RequestID, + Object: "chat.completion.chunk", + Created: helper.GetTimestamp(), + Model: "chatglm", + Choices: []openai.ChatCompletionsStreamResponseChoice{choice}, + } + return &response, &zhipuResponse.Usage +} + +func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var usage *model.Usage + scanner := bufio.NewScanner(resp.Body) + scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + if i := strings.Index(conv.BytesToString(data), "\n\n"); i >= 0 && slices.Contains(data, ':') { + return i + 2, data[0:i], nil + } + if atEOF { + return len(data), data, nil + } + return 0, nil, nil + }) + + common.SetEventStreamHeaders(c) + + for scanner.Scan() { + data := scanner.Text() + lines := strings.Split(data, "\n") + for i, line := range lines { + if len(line) < 6 { + continue + } + if strings.HasPrefix(line, "data: ") { + dataSegment := line[6:] + if i != len(lines)-1 { + dataSegment += "\n" + } + response := streamResponseZhipu2OpenAI(dataSegment) + err := render.ObjectData(c, response) + if err != nil { + logger.SysError("error marshalling stream response: " + err.Error()) + } + } else if strings.HasPrefix(line, "meta: ") { + metaSegment := line[6:] + var zhipuResponse StreamMetaResponse + err := json.Unmarshal(conv.StringToBytes(metaSegment), &zhipuResponse) + if err != nil { + logger.SysError("error unmarshalling stream response: " + err.Error()) + continue + } + response, zhipuUsage := streamMetaResponseZhipu2OpenAI(&zhipuResponse) + err = render.ObjectData(c, response) + if err != nil { + logger.SysError("error marshalling stream response: " + err.Error()) + } + usage = zhipuUsage + } + } + } + + if err := scanner.Err(); err != nil { + logger.SysError("error reading stream: " + err.Error()) + } + + render.Done(c) + + return nil, usage +} + +func Handler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var zhipuResponse Response + err := json.NewDecoder(resp.Body).Decode(&zhipuResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if !zhipuResponse.Success { + return &model.ErrorWithStatusCode{ + Error: model.Error{ + Message: zhipuResponse.Msg, + Type: "zhipu_error", + Param: "", + Code: zhipuResponse.Code, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := responseZhipu2OpenAI(&zhipuResponse) + fullTextResponse.Model = "chatglm" + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} + +func EmbeddingsHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { + defer resp.Body.Close() + + var zhipuResponse EmbeddingResponse + err := json.NewDecoder(resp.Body).Decode(&zhipuResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + fullTextResponse := embeddingResponseZhipu2OpenAI(&zhipuResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} + +func embeddingResponseZhipu2OpenAI(response *EmbeddingResponse) *openai.EmbeddingResponse { + openAIEmbeddingResponse := openai.EmbeddingResponse{ + Object: "list", + Data: make([]openai.EmbeddingResponseItem, 0, len(response.Embeddings)), + Model: response.Model, + Usage: model.Usage{ + PromptTokens: response.PromptTokens, + CompletionTokens: response.CompletionTokens, + TotalTokens: response.Usage.TotalTokens, + }, + } + + for _, item := range response.Embeddings { + openAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, openai.EmbeddingResponseItem{ + Object: `embedding`, + Index: item.Index, + Embedding: item.Embedding, + }) + } + return &openAIEmbeddingResponse +} diff --git a/service/aiproxy/relay/adaptor/zhipu/model.go b/service/aiproxy/relay/adaptor/zhipu/model.go new file mode 100644 index 00000000000..e773812cc5b --- /dev/null +++ b/service/aiproxy/relay/adaptor/zhipu/model.go @@ -0,0 +1,66 @@ +package zhipu + +import ( + "time" + + "github.com/labring/sealos/service/aiproxy/relay/model" +) + +type Request struct { + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + RequestID string `json:"request_id,omitempty"` + Prompt []model.Message `json:"prompt"` + Incremental bool `json:"incremental,omitempty"` +} + +type ResponseData struct { + TaskID string `json:"task_id"` + RequestID string `json:"request_id"` + TaskStatus string `json:"task_status"` + Choices []model.Message `json:"choices"` + model.Usage `json:"usage"` +} + +type Response struct { + Msg string `json:"msg"` + Data ResponseData `json:"data"` + Code int `json:"code"` + Success bool `json:"success"` +} + +type StreamMetaResponse struct { + RequestID string `json:"request_id"` + TaskID string `json:"task_id"` + TaskStatus string `json:"task_status"` + model.Usage `json:"usage"` +} + +type tokenData struct { + ExpiryTime time.Time + Token string +} + +type EmbeddingRequest struct { + Input any `json:"input"` + Model string `json:"model"` +} + +type EmbeddingResponse struct { + Model string `json:"model"` + Object string `json:"object"` + Embeddings []EmbeddingData `json:"data"` + model.Usage `json:"usage"` +} + +type EmbeddingData struct { + Object string `json:"object"` + Embedding []float64 `json:"embedding"` + Index int `json:"index"` +} + +type ImageRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + UserID string `json:"user_id,omitempty"` +} diff --git a/service/aiproxy/relay/adaptor_test.go b/service/aiproxy/relay/adaptor_test.go new file mode 100644 index 00000000000..14c7eb92cdc --- /dev/null +++ b/service/aiproxy/relay/adaptor_test.go @@ -0,0 +1,17 @@ +package relay + +import ( + "testing" + + "github.com/labring/sealos/service/aiproxy/relay/apitype" + "github.com/smartystreets/goconvey/convey" +) + +func TestGetAdaptor(t *testing.T) { + convey.Convey("get adaptor", t, func() { + for i := 0; i < apitype.Dummy; i++ { + a := GetAdaptor(i) + convey.So(a, convey.ShouldNotBeNil) + } + }) +} diff --git a/service/aiproxy/relay/apitype/define.go b/service/aiproxy/relay/apitype/define.go new file mode 100644 index 00000000000..212a1b6b1c3 --- /dev/null +++ b/service/aiproxy/relay/apitype/define.go @@ -0,0 +1,23 @@ +package apitype + +const ( + OpenAI = iota + Anthropic + PaLM + Baidu + Zhipu + Ali + Xunfei + AIProxyLibrary + Tencent + Gemini + Ollama + AwsClaude + Coze + Cohere + Cloudflare + DeepL + VertexAI + + Dummy // this one is only for count, do not add any channel after this +) diff --git a/service/aiproxy/relay/channeltype/define.go b/service/aiproxy/relay/channeltype/define.go new file mode 100644 index 00000000000..cf82655c7dc --- /dev/null +++ b/service/aiproxy/relay/channeltype/define.go @@ -0,0 +1,50 @@ +package channeltype + +const ( + Unknown = iota + OpenAI + API2D + Azure + CloseAI + OpenAISB + OpenAIMax + OhMyGPT + Custom + Ails + AIProxy + PaLM + API2GPT + AIGC2D + Anthropic + Baidu + Zhipu + Ali + Xunfei + AI360 + OpenRouter + AIProxyLibrary + FastGPT + Tencent + Gemini + Moonshot + Baichuan + Minimax + Mistral + Groq + Ollama + LingYiWanWu + StepFun + AwsClaude + Coze + Cohere + DeepSeek + Cloudflare + DeepL + TogetherAI + Doubao + Novita + VertextAI + SiliconFlow + + Dummy +) diff --git a/service/aiproxy/relay/channeltype/helper.go b/service/aiproxy/relay/channeltype/helper.go new file mode 100644 index 00000000000..87ad194a4c9 --- /dev/null +++ b/service/aiproxy/relay/channeltype/helper.go @@ -0,0 +1,42 @@ +package channeltype + +import "github.com/labring/sealos/service/aiproxy/relay/apitype" + +func ToAPIType(channelType int) int { + switch channelType { + case Anthropic: + return apitype.Anthropic + case Baidu: + return apitype.Baidu + case PaLM: + return apitype.PaLM + case Zhipu: + return apitype.Zhipu + case Ali: + return apitype.Ali + case Xunfei: + return apitype.Xunfei + case AIProxyLibrary: + return apitype.AIProxyLibrary + case Tencent: + return apitype.Tencent + case Gemini: + return apitype.Gemini + case Ollama: + return apitype.Ollama + case AwsClaude: + return apitype.AwsClaude + case Coze: + return apitype.Coze + case Cohere: + return apitype.Cohere + case Cloudflare: + return apitype.Cloudflare + case DeepL: + return apitype.DeepL + case VertextAI: + return apitype.VertexAI + default: + return apitype.OpenAI + } +} diff --git a/service/aiproxy/relay/channeltype/url.go b/service/aiproxy/relay/channeltype/url.go new file mode 100644 index 00000000000..5a485df485b --- /dev/null +++ b/service/aiproxy/relay/channeltype/url.go @@ -0,0 +1,53 @@ +package channeltype + +var ChannelBaseURLs = map[int]string{ + OpenAI: "https://api.openai.com", + API2D: "https://oa.api2d.net", + Azure: "", + CloseAI: "https://api.closeai-proxy.xyz", + OpenAISB: "https://api.openai-sb.com", + OpenAIMax: "https://api.openaimax.com", + OhMyGPT: "https://api.ohmygpt.com", + Custom: "", + Ails: "https://api.caipacity.com", + AIProxy: "https://api.aiproxy.io", + PaLM: "https://generativelanguage.googleapis.com", + API2GPT: "https://api.api2gpt.com", + AIGC2D: "https://api.aigc2d.com", + Anthropic: "https://api.anthropic.com", + Baidu: "https://aip.baidubce.com", + Zhipu: "https://open.bigmodel.cn", + Ali: "https://dashscope.aliyuncs.com", + Xunfei: "https://spark-api-open.xf-yun.com", + AI360: "https://ai.360.cn", + OpenRouter: "https://openrouter.ai/api", + AIProxyLibrary: "https://api.aiproxy.io", + FastGPT: "https://fastgpt.run/api/openapi", + Tencent: "https://hunyuan.tencentcloudapi.com", + Gemini: "https://generativelanguage.googleapis.com", + Moonshot: "https://api.moonshot.cn", + Baichuan: "https://api.baichuan-ai.com", + Minimax: "https://api.minimax.chat", + Mistral: "https://api.mistral.ai", + Groq: "https://api.groq.com/openai", + Ollama: "http://localhost:11434", + LingYiWanWu: "https://api.lingyiwanwu.com", + StepFun: "https://api.stepfun.com", + AwsClaude: "", + Coze: "https://api.coze.com", + Cohere: "https://api.cohere.ai", + DeepSeek: "https://api.deepseek.com", + Cloudflare: "https://api.cloudflare.com", + DeepL: "https://api-free.deepl.com", + TogetherAI: "https://api.together.xyz", + Doubao: "https://ark.cn-beijing.volces.com", + Novita: "https://api.novita.ai/v3/openai", + VertextAI: "", + SiliconFlow: "https://api.siliconflow.cn", +} + +func init() { + if len(ChannelBaseURLs) != Dummy-1 { + panic("channel base urls length not match") + } +} diff --git a/service/aiproxy/relay/channeltype/url_test.go b/service/aiproxy/relay/channeltype/url_test.go new file mode 100644 index 00000000000..9406d8d912f --- /dev/null +++ b/service/aiproxy/relay/channeltype/url_test.go @@ -0,0 +1,13 @@ +package channeltype + +import ( + "testing" + + "github.com/smartystreets/goconvey/convey" +) + +func TestChannelBaseURLs(t *testing.T) { + convey.Convey("channel base urls", t, func() { + convey.So(len(ChannelBaseURLs), convey.ShouldEqual, Dummy) + }) +} diff --git a/service/aiproxy/relay/constant/common.go b/service/aiproxy/relay/constant/common.go new file mode 100644 index 00000000000..03544fd3c93 --- /dev/null +++ b/service/aiproxy/relay/constant/common.go @@ -0,0 +1,7 @@ +package constant + +var ( + StopFinishReason = "stop" + StreamObject = "chat.completion.chunk" + NonStreamObject = "chat.completion" +) diff --git a/service/aiproxy/relay/constant/finishreason/define.go b/service/aiproxy/relay/constant/finishreason/define.go new file mode 100644 index 00000000000..1ed9c425533 --- /dev/null +++ b/service/aiproxy/relay/constant/finishreason/define.go @@ -0,0 +1,5 @@ +package finishreason + +const ( + Stop = "stop" +) diff --git a/service/aiproxy/relay/constant/role/define.go b/service/aiproxy/relay/constant/role/define.go new file mode 100644 index 00000000000..972488c5c9d --- /dev/null +++ b/service/aiproxy/relay/constant/role/define.go @@ -0,0 +1,5 @@ +package role + +const ( + Assistant = "assistant" +) diff --git a/service/aiproxy/relay/controller/audio.go b/service/aiproxy/relay/controller/audio.go new file mode 100644 index 00000000000..fb1be0ad0ab --- /dev/null +++ b/service/aiproxy/relay/controller/audio.go @@ -0,0 +1,153 @@ +package controller + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + + json "github.com/json-iterator/go" + "github.com/shopspring/decimal" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/balance" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/relay" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/meta" + relaymodel "github.com/labring/sealos/service/aiproxy/relay/model" + billingprice "github.com/labring/sealos/service/aiproxy/relay/price" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" +) + +func RelayAudioHelper(c *gin.Context, relayMode int) *relaymodel.ErrorWithStatusCode { + meta := meta.GetByContext(c) + + channelType := c.GetInt(ctxkey.Channel) + group := c.GetString(ctxkey.Group) + + adaptor := relay.GetAdaptor(meta.APIType) + if adaptor == nil { + return openai.ErrorWrapper(fmt.Errorf("invalid api type: %d", meta.APIType), "invalid_api_type", http.StatusBadRequest) + } + adaptor.Init(meta) + + meta.ActualModelName, _ = getMappedModelName(meta.OriginModelName, c.GetStringMapString(ctxkey.ModelMapping)) + + price, ok := billingprice.GetModelPrice(meta.OriginModelName, meta.ActualModelName, channelType) + if !ok { + return openai.ErrorWrapper(fmt.Errorf("model price not found: %s", meta.OriginModelName), "model_price_not_found", http.StatusInternalServerError) + } + completionPrice, ok := billingprice.GetModelPrice(meta.OriginModelName, meta.ActualModelName, channelType) + if !ok { + return openai.ErrorWrapper(fmt.Errorf("model price not found: %s", meta.OriginModelName), "model_price_not_found", http.StatusInternalServerError) + } + + var body io.ReadCloser + switch relayMode { + case relaymode.AudioSpeech: + var ttsRequest relaymodel.TextToSpeechRequest + err := common.UnmarshalBodyReusable(c, &ttsRequest) + if err != nil { + return openai.ErrorWrapper(err, "invalid_json", http.StatusBadRequest) + } + ttsRequest.Model = meta.ActualModelName + data, err := adaptor.ConvertTTSRequest(&ttsRequest) + if err != nil { + return openai.ErrorWrapper(err, "convert_tts_request_failed", http.StatusBadRequest) + } + jsonBody, err := json.Marshal(data) + if err != nil { + return openai.ErrorWrapper(err, "marshal_request_body_failed", http.StatusInternalServerError) + } + body = io.NopCloser(bytes.NewReader(jsonBody)) + meta.PromptTokens = openai.CountTokenText(ttsRequest.Input, meta.ActualModelName) + case relaymode.AudioTranscription: + var err error + body, err = adaptor.ConvertSTTRequest(c.Request) + if err != nil { + return openai.ErrorWrapper(err, "convert_stt_request_failed", http.StatusBadRequest) + } + default: + return openai.ErrorWrapper(fmt.Errorf("invalid relay mode: %d", relayMode), "invalid_relay_mode", http.StatusBadRequest) + } + + groupRemainBalance, postGroupConsumer, err := balance.Default.GetGroupRemainBalance(c.Request.Context(), group) + if err != nil { + logger.Errorf(c, "get group (%s) balance failed: %s", group, err) + return openai.ErrorWrapper( + fmt.Errorf("get group (%s) balance failed", group), + "get_group_balance_failed", + http.StatusInternalServerError, + ) + } + + preConsumedAmount := decimal.NewFromInt(int64(meta.PromptTokens)). + Mul(decimal.NewFromFloat(price)). + Div(decimal.NewFromInt(billingprice.PriceUnit)). + InexactFloat64() + // Check if group balance is enough + if groupRemainBalance < preConsumedAmount { + return openai.ErrorWrapper(errors.New("group balance is not enough"), "insufficient_group_balance", http.StatusForbidden) + } + + resp, err := adaptor.DoRequest(c, meta, body) + if err != nil { + logger.Errorf(c, "do request failed: %s", err.Error()) + ConsumeWaitGroup.Add(1) + go postConsumeAmount(context.Background(), + &ConsumeWaitGroup, + postGroupConsumer, + http.StatusInternalServerError, + c.Request.URL.Path, + nil, meta, price, completionPrice, err.Error(), + ) + return openai.ErrorWrapper(err, "do_request_failed", http.StatusInternalServerError) + } + + if isErrorHappened(meta, resp) { + err := RelayErrorHandler(resp, meta.Mode) + ConsumeWaitGroup.Add(1) + go postConsumeAmount(context.Background(), + &ConsumeWaitGroup, + postGroupConsumer, + resp.StatusCode, + c.Request.URL.Path, + nil, + meta, + price, + completionPrice, + err.String(), + ) + return err + } + + usage, respErr := adaptor.DoResponse(c, resp, meta) + if respErr != nil { + logger.Errorf(c, "do response failed: %s", respErr) + ConsumeWaitGroup.Add(1) + go postConsumeAmount(context.Background(), + &ConsumeWaitGroup, + postGroupConsumer, + respErr.StatusCode, + c.Request.URL.Path, + nil, meta, price, completionPrice, respErr.String(), + ) + return respErr + } + + ConsumeWaitGroup.Add(1) + go postConsumeAmount(context.Background(), + &ConsumeWaitGroup, + postGroupConsumer, + resp.StatusCode, + c.Request.URL.Path, + usage, meta, price, completionPrice, "", + ) + + return nil +} diff --git a/service/aiproxy/relay/controller/error.go b/service/aiproxy/relay/controller/error.go new file mode 100644 index 00000000000..7f6433e2644 --- /dev/null +++ b/service/aiproxy/relay/controller/error.go @@ -0,0 +1,141 @@ +package controller + +import ( + "fmt" + "io" + "net/http" + "strconv" + "strings" + + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common/config" + "github.com/labring/sealos/service/aiproxy/common/conv" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/relay/model" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" +) + +type GeneralErrorResponse struct { + Error model.Error `json:"error"` + Message string `json:"message"` + Msg string `json:"msg"` + Err string `json:"err"` + ErrorMsg string `json:"error_msg"` + Header struct { + Message string `json:"message"` + } `json:"header"` + Response struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } `json:"response"` +} + +func (e GeneralErrorResponse) ToMessage() string { + if e.Error.Message != "" { + return e.Error.Message + } + if e.Message != "" { + return e.Message + } + if e.Msg != "" { + return e.Msg + } + if e.Err != "" { + return e.Err + } + if e.ErrorMsg != "" { + return e.ErrorMsg + } + if e.Header.Message != "" { + return e.Header.Message + } + if e.Response.Error.Message != "" { + return e.Response.Error.Message + } + return "" +} + +func RelayErrorHandler(resp *http.Response, relayMode int) *model.ErrorWithStatusCode { + if resp == nil { + return &model.ErrorWithStatusCode{ + StatusCode: 500, + Error: model.Error{ + Message: "resp is nil", + Type: "upstream_error", + Code: "bad_response", + }, + } + } + switch relayMode { + case relaymode.Rerank: + return RerankErrorHandler(resp) + default: + return RelayDefaultErrorHanlder(resp) + } +} + +func RerankErrorHandler(resp *http.Response) *model.ErrorWithStatusCode { + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return &model.ErrorWithStatusCode{ + StatusCode: resp.StatusCode, + Error: model.Error{ + Message: err.Error(), + Type: "upstream_error", + Code: "bad_response", + }, + } + } + trimmedRespBody := strings.Trim(conv.BytesToString(respBody), "\"") + return &model.ErrorWithStatusCode{ + StatusCode: resp.StatusCode, + Error: model.Error{ + Message: trimmedRespBody, + Type: "upstream_error", + Code: "bad_response", + }, + } +} + +func RelayDefaultErrorHanlder(resp *http.Response) *model.ErrorWithStatusCode { + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return &model.ErrorWithStatusCode{ + StatusCode: resp.StatusCode, + Error: model.Error{ + Message: err.Error(), + }, + } + } + + ErrorWithStatusCode := &model.ErrorWithStatusCode{ + StatusCode: resp.StatusCode, + Error: model.Error{ + Message: "", + Type: "upstream_error", + Code: "bad_response_status_code", + Param: strconv.Itoa(resp.StatusCode), + }, + } + var errResponse GeneralErrorResponse + err = json.Unmarshal(respBody, &errResponse) + if err != nil { + return ErrorWithStatusCode + } + if config.DebugEnabled { + logger.SysLogf("error happened, status code: %d, response: \n%+v", resp.StatusCode, errResponse) + } + if errResponse.Error.Message != "" { + // OpenAI format error, so we override the default one + ErrorWithStatusCode.Error = errResponse.Error + } else { + ErrorWithStatusCode.Error.Message = errResponse.ToMessage() + } + if ErrorWithStatusCode.Error.Message == "" { + ErrorWithStatusCode.Error.Message = fmt.Sprintf("bad response status code %d", resp.StatusCode) + } + return ErrorWithStatusCode +} diff --git a/service/aiproxy/relay/controller/helper.go b/service/aiproxy/relay/controller/helper.go new file mode 100644 index 00000000000..084a940997b --- /dev/null +++ b/service/aiproxy/relay/controller/helper.go @@ -0,0 +1,154 @@ +package controller + +import ( + "context" + "net/http" + "strings" + "sync" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/balance" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/model" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/channeltype" + "github.com/labring/sealos/service/aiproxy/relay/controller/validator" + "github.com/labring/sealos/service/aiproxy/relay/meta" + relaymodel "github.com/labring/sealos/service/aiproxy/relay/model" + billingprice "github.com/labring/sealos/service/aiproxy/relay/price" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" + "github.com/shopspring/decimal" +) + +var ConsumeWaitGroup sync.WaitGroup + +func getAndValidateTextRequest(c *gin.Context, relayMode int) (*relaymodel.GeneralOpenAIRequest, error) { + textRequest := &relaymodel.GeneralOpenAIRequest{} + err := common.UnmarshalBodyReusable(c, textRequest) + if err != nil { + return nil, err + } + if relayMode == relaymode.Moderations && textRequest.Model == "" { + textRequest.Model = "text-moderation-latest" + } + if relayMode == relaymode.Embeddings && textRequest.Model == "" { + textRequest.Model = c.Param("model") + } + err = validator.ValidateTextRequest(textRequest, relayMode) + if err != nil { + return nil, err + } + return textRequest, nil +} + +func getPromptTokens(textRequest *relaymodel.GeneralOpenAIRequest, relayMode int) int { + switch relayMode { + case relaymode.ChatCompletions: + return openai.CountTokenMessages(textRequest.Messages, textRequest.Model) + case relaymode.Completions: + return openai.CountTokenInput(textRequest.Prompt, textRequest.Model) + case relaymode.Moderations: + return openai.CountTokenInput(textRequest.Input, textRequest.Model) + } + return 0 +} + +type PreCheckGroupBalanceReq struct { + PromptTokens int + MaxTokens int + Price float64 +} + +func getPreConsumedAmount(req *PreCheckGroupBalanceReq) float64 { + if req.Price == 0 || (req.PromptTokens == 0 && req.MaxTokens == 0) { + return 0 + } + preConsumedTokens := int64(req.PromptTokens) + if req.MaxTokens != 0 { + preConsumedTokens += int64(req.MaxTokens) + } + return decimal. + NewFromInt(preConsumedTokens). + Mul(decimal.NewFromFloat(req.Price)). + Div(decimal.NewFromInt(billingprice.PriceUnit)). + InexactFloat64() +} + +func preCheckGroupBalance(ctx context.Context, req *PreCheckGroupBalanceReq, meta *meta.Meta) (bool, balance.PostGroupConsumer, error) { + preConsumedAmount := getPreConsumedAmount(req) + + groupRemainBalance, postGroupConsumer, err := balance.Default.GetGroupRemainBalance(ctx, meta.Group) + if err != nil { + return false, nil, err + } + if groupRemainBalance < preConsumedAmount { + return false, nil, nil + } + return true, postGroupConsumer, nil +} + +func postConsumeAmount(ctx context.Context, consumeWaitGroup *sync.WaitGroup, postGroupConsumer balance.PostGroupConsumer, code int, endpoint string, usage *relaymodel.Usage, meta *meta.Meta, price, completionPrice float64, content string) { + defer consumeWaitGroup.Done() + if usage == nil { + err := model.BatchRecordConsume(ctx, meta.Group, code, meta.ChannelID, 0, 0, meta.OriginModelName, meta.TokenID, meta.TokenName, 0, price, completionPrice, endpoint, content) + if err != nil { + logger.Error(ctx, "error batch record consume: "+err.Error()) + } + return + } + promptTokens := usage.PromptTokens + completionTokens := usage.CompletionTokens + var amount float64 + totalTokens := promptTokens + completionTokens + if totalTokens != 0 { + // amount = (float64(promptTokens)*price + float64(completionTokens)*completionPrice) / billingPrice.PriceUnit + promptAmount := decimal.NewFromInt(int64(promptTokens)).Mul(decimal.NewFromFloat(price)).Div(decimal.NewFromInt(billingprice.PriceUnit)) + completionAmount := decimal.NewFromInt(int64(completionTokens)).Mul(decimal.NewFromFloat(completionPrice)).Div(decimal.NewFromInt(billingprice.PriceUnit)) + amount = promptAmount.Add(completionAmount).InexactFloat64() + if amount > 0 { + _amount, err := postGroupConsumer.PostGroupConsume(ctx, meta.TokenName, amount) + if err != nil { + logger.Error(ctx, "error consuming token remain amount: "+err.Error()) + err = model.CreateConsumeError(meta.Group, meta.TokenName, meta.OriginModelName, err.Error(), amount, meta.TokenID) + if err != nil { + logger.Error(ctx, "failed to create consume error: "+err.Error()) + } + } else { + amount = _amount + } + } + } + err := model.BatchRecordConsume(ctx, meta.Group, code, meta.ChannelID, promptTokens, completionTokens, meta.OriginModelName, meta.TokenID, meta.TokenName, amount, price, completionPrice, endpoint, content) + if err != nil { + logger.Error(ctx, "error batch record consume: "+err.Error()) + } +} + +func getMappedModelName(modelName string, mapping map[string]string) (string, bool) { + if mapping == nil { + return modelName, false + } + mappedModelName := mapping[modelName] + if mappedModelName != "" { + return mappedModelName, true + } + return modelName, false +} + +func isErrorHappened(meta *meta.Meta, resp *http.Response) bool { + if resp == nil { + return meta.ChannelType != channeltype.AwsClaude + } + if resp.StatusCode != http.StatusOK { + return true + } + if meta.ChannelType == channeltype.DeepL { + // skip stream check for deepl + return false + } + if meta.IsStream && strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") { + return true + } + return false +} diff --git a/service/aiproxy/relay/controller/image.go b/service/aiproxy/relay/controller/image.go new file mode 100644 index 00000000000..86c947a99e6 --- /dev/null +++ b/service/aiproxy/relay/controller/image.go @@ -0,0 +1,223 @@ +package controller + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + + json "github.com/json-iterator/go" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/balance" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/relay" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/channeltype" + "github.com/labring/sealos/service/aiproxy/relay/meta" + relaymodel "github.com/labring/sealos/service/aiproxy/relay/model" + billingprice "github.com/labring/sealos/service/aiproxy/relay/price" + "github.com/shopspring/decimal" +) + +func getImageRequest(c *gin.Context, _ int) (*relaymodel.ImageRequest, error) { + imageRequest := &relaymodel.ImageRequest{} + err := common.UnmarshalBodyReusable(c, imageRequest) + if err != nil { + return nil, err + } + if imageRequest.N == 0 { + imageRequest.N = 1 + } + if imageRequest.Size == "" { + imageRequest.Size = "1024x1024" + } + if imageRequest.Model == "" { + imageRequest.Model = "dall-e-2" + } + return imageRequest, nil +} + +func validateImageRequest(imageRequest *relaymodel.ImageRequest, _ *meta.Meta) *relaymodel.ErrorWithStatusCode { + // check prompt length + if imageRequest.Prompt == "" { + return openai.ErrorWrapper(errors.New("prompt is required"), "prompt_missing", http.StatusBadRequest) + } + + // model validation + if !billingprice.IsValidImageSize(imageRequest.Model, imageRequest.Size) { + return openai.ErrorWrapper(errors.New("size not supported for this image model"), "size_not_supported", http.StatusBadRequest) + } + + if !billingprice.IsValidImagePromptLength(imageRequest.Model, len(imageRequest.Prompt)) { + return openai.ErrorWrapper(errors.New("prompt is too long"), "prompt_too_long", http.StatusBadRequest) + } + + // Number of generated images validation + if !billingprice.IsWithinRange(imageRequest.Model, imageRequest.N) { + return openai.ErrorWrapper(errors.New("invalid value of n"), "n_not_within_range", http.StatusBadRequest) + } + return nil +} + +func getImageCostPrice(imageRequest *relaymodel.ImageRequest) (float64, error) { + if imageRequest == nil { + return 0, errors.New("imageRequest is nil") + } + imageCostPrice := billingprice.GetImageSizePrice(imageRequest.Model, imageRequest.Size) + if imageRequest.Quality == "hd" && imageRequest.Model == "dall-e-3" { + if imageRequest.Size == "1024x1024" { + imageCostPrice *= 2 + } else { + imageCostPrice *= 1.5 + } + } + return imageCostPrice, nil +} + +func RelayImageHelper(c *gin.Context, _ int) *relaymodel.ErrorWithStatusCode { + ctx := c.Request.Context() + meta := meta.GetByContext(c) + imageRequest, err := getImageRequest(c, meta.Mode) + if err != nil { + logger.Errorf(ctx, "getImageRequest failed: %s", err.Error()) + return openai.ErrorWrapper(err, "invalid_image_request", http.StatusBadRequest) + } + + // map model name + var isModelMapped bool + meta.OriginModelName = imageRequest.Model + imageRequest.Model, isModelMapped = getMappedModelName(imageRequest.Model, meta.ModelMapping) + meta.ActualModelName = imageRequest.Model + + // model validation + bizErr := validateImageRequest(imageRequest, meta) + if bizErr != nil { + return bizErr + } + + imageCostPrice, err := getImageCostPrice(imageRequest) + if err != nil { + return openai.ErrorWrapper(err, "get_image_cost_price_failed", http.StatusInternalServerError) + } + + // Convert the original image model + imageRequest.Model, _ = getMappedModelName(imageRequest.Model, billingprice.GetImageOriginModelName()) + c.Set("response_format", imageRequest.ResponseFormat) + + adaptor := relay.GetAdaptor(meta.APIType) + if adaptor == nil { + return openai.ErrorWrapper(fmt.Errorf("invalid api type: %d", meta.APIType), "invalid_api_type", http.StatusBadRequest) + } + adaptor.Init(meta) + + var requestBody io.Reader + switch meta.ChannelType { + case channeltype.Ali, + channeltype.Baidu, + channeltype.Zhipu: + finalRequest, err := adaptor.ConvertImageRequest(imageRequest) + if err != nil { + return openai.ErrorWrapper(err, "convert_image_request_failed", http.StatusInternalServerError) + } + jsonStr, err := json.Marshal(finalRequest) + if err != nil { + return openai.ErrorWrapper(err, "marshal_image_request_failed", http.StatusInternalServerError) + } + requestBody = bytes.NewReader(jsonStr) + default: + if isModelMapped || meta.ChannelType == channeltype.Azure { // make Azure channel request body + jsonStr, err := json.Marshal(imageRequest) + if err != nil { + return openai.ErrorWrapper(err, "marshal_image_request_failed", http.StatusInternalServerError) + } + requestBody = bytes.NewReader(jsonStr) + } else { + requestBody = c.Request.Body + } + } + + groupRemainBalance, postGroupConsumer, err := balance.Default.GetGroupRemainBalance(ctx, meta.Group) + if err != nil { + logger.Errorf(ctx, "get group (%s) balance failed: %s", meta.Group, err) + return openai.ErrorWrapper( + fmt.Errorf("get group (%s) balance failed", meta.Group), + "get_group_remain_balance_failed", + http.StatusInternalServerError, + ) + } + + amount := decimal.NewFromFloat(imageCostPrice).Mul(decimal.NewFromInt(int64(imageRequest.N))).InexactFloat64() + + if groupRemainBalance-amount < 0 { + return openai.ErrorWrapper( + errors.New("group balance is not enough"), + "insufficient_group_balance", + http.StatusForbidden, + ) + } + + // do request + resp, err := adaptor.DoRequest(c, meta, requestBody) + if err != nil { + logger.Errorf(ctx, "do request failed: %s", err.Error()) + ConsumeWaitGroup.Add(1) + go postConsumeAmount(context.Background(), + &ConsumeWaitGroup, + postGroupConsumer, + http.StatusInternalServerError, + c.Request.URL.Path, nil, meta, imageCostPrice, 0, err.Error(), + ) + return openai.ErrorWrapper(err, "do_request_failed", http.StatusInternalServerError) + } + + if isErrorHappened(meta, resp) { + err := RelayErrorHandler(resp, meta.Mode) + ConsumeWaitGroup.Add(1) + go postConsumeAmount(context.Background(), + &ConsumeWaitGroup, + postGroupConsumer, + resp.StatusCode, + c.Request.URL.Path, + nil, + meta, + imageCostPrice, + 0, + err.String(), + ) + return err + } + + // do response + _, respErr := adaptor.DoResponse(c, resp, meta) + if respErr != nil { + logger.Errorf(ctx, "do response failed: %s", respErr) + ConsumeWaitGroup.Add(1) + go postConsumeAmount(context.Background(), + &ConsumeWaitGroup, + postGroupConsumer, + respErr.StatusCode, + c.Request.URL.Path, + nil, + meta, + imageCostPrice, + 0, + respErr.String(), + ) + return respErr + } + + ConsumeWaitGroup.Add(1) + go postConsumeAmount(context.Background(), + &ConsumeWaitGroup, + postGroupConsumer, + resp.StatusCode, + c.Request.URL.Path, + nil, meta, imageCostPrice, 0, imageRequest.Size, + ) + + return nil +} diff --git a/service/aiproxy/relay/controller/rerank.go b/service/aiproxy/relay/controller/rerank.go new file mode 100644 index 00000000000..3654b9ef900 --- /dev/null +++ b/service/aiproxy/relay/controller/rerank.go @@ -0,0 +1,162 @@ +package controller + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/relay" + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/meta" + relaymodel "github.com/labring/sealos/service/aiproxy/relay/model" + billingprice "github.com/labring/sealos/service/aiproxy/relay/price" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" +) + +func RerankHelper(c *gin.Context) *relaymodel.ErrorWithStatusCode { + ctx := c.Request.Context() + meta := meta.GetByContext(c) + rerankRequest, err := getRerankRequest(c) + if err != nil { + logger.Errorf(ctx, "get rerank request failed: %s", err.Error()) + return openai.ErrorWrapper(err, "invalid_rerank_request", http.StatusBadRequest) + } + + meta.OriginModelName = rerankRequest.Model + rerankRequest.Model, _ = getMappedModelName(rerankRequest.Model, meta.ModelMapping) + meta.ActualModelName = rerankRequest.Model + + price, ok := billingprice.GetModelPrice(meta.OriginModelName, meta.ActualModelName, meta.ChannelType) + if !ok { + return openai.ErrorWrapper(fmt.Errorf("model price not found: %s", meta.OriginModelName), "model_price_not_found", http.StatusInternalServerError) + } + completionPrice, ok := billingprice.GetCompletionPrice(meta.OriginModelName, meta.ActualModelName, meta.ChannelType) + if !ok { + return openai.ErrorWrapper(fmt.Errorf("completion price not found: %s", meta.OriginModelName), "completion_price_not_found", http.StatusInternalServerError) + } + + meta.PromptTokens = rerankPromptTokens(rerankRequest) + + ok, postGroupConsumer, err := preCheckGroupBalance(ctx, &PreCheckGroupBalanceReq{ + PromptTokens: meta.PromptTokens, + Price: price, + }, meta) + if err != nil { + logger.Errorf(ctx, "get group (%s) balance failed: %s", meta.Group, err) + return openai.ErrorWrapper( + fmt.Errorf("get group (%s) balance failed", meta.Group), + "get_group_quota_failed", + http.StatusInternalServerError, + ) + } + if !ok { + return openai.ErrorWrapper(errors.New("group balance is not enough"), "insufficient_group_balance", http.StatusForbidden) + } + + adaptor := relay.GetAdaptor(meta.APIType) + if adaptor == nil { + return openai.ErrorWrapper(fmt.Errorf("invalid api type: %d", meta.APIType), "invalid_api_type", http.StatusBadRequest) + } + + requestBody, err := getRerankRequestBody(c, meta, rerankRequest, adaptor) + if err != nil { + logger.Errorf(ctx, "get rerank request body failed: %s", err.Error()) + return openai.ErrorWrapper(err, "invalid_rerank_request", http.StatusBadRequest) + } + + resp, err := adaptor.DoRequest(c, meta, requestBody) + if err != nil { + logger.Errorf(ctx, "do rerank request failed: %s", err.Error()) + ConsumeWaitGroup.Add(1) + go postConsumeAmount(context.Background(), + &ConsumeWaitGroup, + postGroupConsumer, + http.StatusInternalServerError, + c.Request.URL.Path, + nil, meta, price, completionPrice, err.Error(), + ) + return openai.ErrorWrapper(err, "do_request_failed", http.StatusInternalServerError) + } + + if isErrorHappened(meta, resp) { + err := RelayErrorHandler(resp, relaymode.Rerank) + ConsumeWaitGroup.Add(1) + go postConsumeAmount(context.Background(), + &ConsumeWaitGroup, + postGroupConsumer, + resp.StatusCode, + c.Request.URL.Path, + nil, + meta, + price, + completionPrice, + err.String(), + ) + return err + } + + usage, respErr := adaptor.DoResponse(c, resp, meta) + if respErr != nil { + logger.Errorf(ctx, "do rerank response failed: %+v", respErr) + ConsumeWaitGroup.Add(1) + go postConsumeAmount(context.Background(), + &ConsumeWaitGroup, + postGroupConsumer, + http.StatusInternalServerError, + c.Request.URL.Path, + usage, meta, price, completionPrice, respErr.String(), + ) + return respErr + } + + ConsumeWaitGroup.Add(1) + go postConsumeAmount(context.Background(), + &ConsumeWaitGroup, + postGroupConsumer, + http.StatusOK, + c.Request.URL.Path, + usage, meta, price, completionPrice, "", + ) + + return nil +} + +func getRerankRequest(c *gin.Context) (*relaymodel.RerankRequest, error) { + rerankRequest := &relaymodel.RerankRequest{} + err := common.UnmarshalBodyReusable(c, rerankRequest) + if err != nil { + return nil, err + } + if rerankRequest.Model == "" { + return nil, errors.New("model parameter must be provided") + } + if rerankRequest.Query == "" { + return nil, errors.New("query must not be empty") + } + if len(rerankRequest.Documents) == 0 { + return nil, errors.New("document list must not be empty") + } + + return rerankRequest, nil +} + +func getRerankRequestBody(_ *gin.Context, _ *meta.Meta, textRequest *relaymodel.RerankRequest, _ adaptor.Adaptor) (io.Reader, error) { + jsonData, err := json.Marshal(textRequest) + if err != nil { + return nil, err + } + return bytes.NewReader(jsonData), nil +} + +func rerankPromptTokens(rerankRequest *relaymodel.RerankRequest) int { + return len(rerankRequest.Query) + len(strings.Join(rerankRequest.Documents, "")) +} diff --git a/service/aiproxy/relay/controller/text.go b/service/aiproxy/relay/controller/text.go new file mode 100644 index 00000000000..b510e05950b --- /dev/null +++ b/service/aiproxy/relay/controller/text.go @@ -0,0 +1,152 @@ +package controller + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + + "github.com/gin-gonic/gin" + json "github.com/json-iterator/go" + "github.com/labring/sealos/service/aiproxy/common/logger" + "github.com/labring/sealos/service/aiproxy/relay" + "github.com/labring/sealos/service/aiproxy/relay/adaptor" + "github.com/labring/sealos/service/aiproxy/relay/adaptor/openai" + "github.com/labring/sealos/service/aiproxy/relay/meta" + "github.com/labring/sealos/service/aiproxy/relay/model" + billingprice "github.com/labring/sealos/service/aiproxy/relay/price" +) + +func RelayTextHelper(c *gin.Context) *model.ErrorWithStatusCode { + ctx := c.Request.Context() + meta := meta.GetByContext(c) + textRequest, err := getAndValidateTextRequest(c, meta.Mode) + if err != nil { + logger.Errorf(ctx, "get and validate text request failed: %s", err.Error()) + return openai.ErrorWrapper(err, "invalid_text_request", http.StatusBadRequest) + } + meta.IsStream = textRequest.Stream + + // map model name + meta.OriginModelName = textRequest.Model + textRequest.Model, _ = getMappedModelName(textRequest.Model, meta.ModelMapping) + meta.ActualModelName = textRequest.Model + + // get model price + price, ok := billingprice.GetModelPrice(meta.OriginModelName, meta.ActualModelName, meta.ChannelType) + if !ok { + return openai.ErrorWrapper(fmt.Errorf("model price not found: %s", meta.OriginModelName), "model_price_not_found", http.StatusInternalServerError) + } + completionPrice, ok := billingprice.GetCompletionPrice(meta.OriginModelName, meta.ActualModelName, meta.ChannelType) + if !ok { + return openai.ErrorWrapper(fmt.Errorf("completion price not found: %s", meta.OriginModelName), "completion_price_not_found", http.StatusInternalServerError) + } + // pre-consume balance + promptTokens := getPromptTokens(textRequest, meta.Mode) + meta.PromptTokens = promptTokens + ok, postGroupConsumer, err := preCheckGroupBalance(ctx, &PreCheckGroupBalanceReq{ + PromptTokens: promptTokens, + MaxTokens: textRequest.MaxTokens, + Price: price, + }, meta) + if err != nil { + logger.Errorf(ctx, "get group (%s) balance failed: %s", meta.Group, err) + return openai.ErrorWrapper( + fmt.Errorf("get group (%s) balance failed", meta.Group), + "get_group_quota_failed", + http.StatusInternalServerError, + ) + } + if !ok { + return openai.ErrorWrapper(errors.New("group balance is not enough"), "insufficient_group_balance", http.StatusForbidden) + } + + adaptor := relay.GetAdaptor(meta.APIType) + if adaptor == nil { + return openai.ErrorWrapper(fmt.Errorf("invalid api type: %d", meta.APIType), "invalid_api_type", http.StatusBadRequest) + } + adaptor.Init(meta) + + // get request body + requestBody, err := getRequestBody(c, meta, textRequest, adaptor) + if err != nil { + logger.Errorf(ctx, "get request body failed: %s", err.Error()) + return openai.ErrorWrapper(err, "convert_request_failed", http.StatusInternalServerError) + } + logger.Debugf(ctx, "converted request: \n%s", requestBody) + + // do request + resp, err := adaptor.DoRequest(c, meta, requestBody) + if err != nil { + logger.Errorf(ctx, "do request failed: %s", err.Error()) + ConsumeWaitGroup.Add(1) + go postConsumeAmount(context.Background(), + &ConsumeWaitGroup, + postGroupConsumer, + http.StatusInternalServerError, + c.Request.URL.Path, + nil, meta, price, completionPrice, err.Error(), + ) + return openai.ErrorWrapper(err, "do_request_failed", http.StatusInternalServerError) + } + + if isErrorHappened(meta, resp) { + err := RelayErrorHandler(resp, meta.Mode) + ConsumeWaitGroup.Add(1) + go postConsumeAmount(context.Background(), + &ConsumeWaitGroup, + postGroupConsumer, + resp.StatusCode, + c.Request.URL.Path, + nil, + meta, + price, + completionPrice, + err.String(), + ) + return err + } + + // do response + usage, respErr := adaptor.DoResponse(c, resp, meta) + if respErr != nil { + logger.Errorf(ctx, "do response failed: %s", respErr) + ConsumeWaitGroup.Add(1) + go postConsumeAmount(context.Background(), + &ConsumeWaitGroup, + postGroupConsumer, + respErr.StatusCode, + c.Request.URL.Path, + usage, + meta, + price, + completionPrice, + respErr.String(), + ) + return respErr + } + // post-consume amount + ConsumeWaitGroup.Add(1) + go postConsumeAmount(context.Background(), + &ConsumeWaitGroup, + postGroupConsumer, + resp.StatusCode, + c.Request.URL.Path, + usage, meta, price, completionPrice, "", + ) + return nil +} + +func getRequestBody(c *gin.Context, meta *meta.Meta, textRequest *model.GeneralOpenAIRequest, adaptor adaptor.Adaptor) (io.Reader, error) { + convertedRequest, err := adaptor.ConvertRequest(c, meta.Mode, textRequest) + if err != nil { + return nil, err + } + jsonData, err := json.Marshal(convertedRequest) + if err != nil { + return nil, err + } + return bytes.NewReader(jsonData), nil +} diff --git a/service/aiproxy/relay/controller/validator/validation.go b/service/aiproxy/relay/controller/validator/validation.go new file mode 100644 index 00000000000..4f29c84a86d --- /dev/null +++ b/service/aiproxy/relay/controller/validator/validation.go @@ -0,0 +1,38 @@ +package validator + +import ( + "errors" + "math" + + "github.com/labring/sealos/service/aiproxy/relay/model" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" +) + +func ValidateTextRequest(textRequest *model.GeneralOpenAIRequest, relayMode int) error { + if textRequest.MaxTokens < 0 || textRequest.MaxTokens > math.MaxInt32/2 { + return errors.New("max_tokens is invalid") + } + if textRequest.Model == "" { + return errors.New("model is required") + } + switch relayMode { + case relaymode.Completions: + if textRequest.Prompt == "" { + return errors.New("field prompt is required") + } + case relaymode.ChatCompletions: + if len(textRequest.Messages) == 0 { + return errors.New("field messages is required") + } + case relaymode.Embeddings: + case relaymode.Moderations: + if textRequest.Input == "" { + return errors.New("field input is required") + } + case relaymode.Edits: + if textRequest.Instruction == "" { + return errors.New("field instruction is required") + } + } + return nil +} diff --git a/service/aiproxy/relay/meta/relay_meta.go b/service/aiproxy/relay/meta/relay_meta.go new file mode 100644 index 00000000000..f07c862636d --- /dev/null +++ b/service/aiproxy/relay/meta/relay_meta.go @@ -0,0 +1,53 @@ +package meta + +import ( + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/aiproxy/common/ctxkey" + "github.com/labring/sealos/service/aiproxy/model" + "github.com/labring/sealos/service/aiproxy/relay/channeltype" + "github.com/labring/sealos/service/aiproxy/relay/relaymode" +) + +type Meta struct { + ModelMapping map[string]string + Config model.ChannelConfig + APIKey string + OriginModelName string + TokenName string + Group string + RequestURLPath string + BaseURL string + ActualModelName string + ChannelID int + ChannelType int + APIType int + Mode int + TokenID int + PromptTokens int + IsStream bool +} + +func GetByContext(c *gin.Context) *Meta { + meta := Meta{ + Mode: relaymode.GetByPath(c.Request.URL.Path), + ChannelType: c.GetInt(ctxkey.Channel), + ChannelID: c.GetInt(ctxkey.ChannelID), + TokenID: c.GetInt(ctxkey.TokenID), + TokenName: c.GetString(ctxkey.TokenName), + Group: c.GetString(ctxkey.Group), + ModelMapping: c.GetStringMapString(ctxkey.ModelMapping), + OriginModelName: c.GetString(ctxkey.RequestModel), + BaseURL: c.GetString(ctxkey.BaseURL), + APIKey: c.GetString(ctxkey.APIKey), + RequestURLPath: c.Request.URL.String(), + } + cfg, ok := c.Get(ctxkey.Config) + if ok { + meta.Config = cfg.(model.ChannelConfig) + } + if meta.BaseURL == "" { + meta.BaseURL = channeltype.ChannelBaseURLs[meta.ChannelType] + } + meta.APIType = channeltype.ToAPIType(meta.ChannelType) + return &meta +} diff --git a/service/aiproxy/relay/model/constant.go b/service/aiproxy/relay/model/constant.go new file mode 100644 index 00000000000..c9d6d645c69 --- /dev/null +++ b/service/aiproxy/relay/model/constant.go @@ -0,0 +1,7 @@ +package model + +const ( + ContentTypeText = "text" + ContentTypeImageURL = "image_url" + ContentTypeInputAudio = "input_audio" +) diff --git a/service/aiproxy/relay/model/general.go b/service/aiproxy/relay/model/general.go new file mode 100644 index 00000000000..8038f5ab751 --- /dev/null +++ b/service/aiproxy/relay/model/general.go @@ -0,0 +1,91 @@ +package model + +type ResponseFormat struct { + JSONSchema *JSONSchema `json:"json_schema,omitempty"` + Type string `json:"type,omitempty"` +} + +type JSONSchema struct { + Schema map[string]interface{} `json:"schema,omitempty"` + Strict *bool `json:"strict,omitempty"` + Description string `json:"description,omitempty"` + Name string `json:"name"` +} + +type Audio struct { + Voice string `json:"voice,omitempty"` + Format string `json:"format,omitempty"` +} + +type StreamOptions struct { + IncludeUsage bool `json:"include_usage,omitempty"` +} + +type GeneralOpenAIRequest struct { + Prediction any `json:"prediction,omitempty"` + Prompt any `json:"prompt,omitempty"` + Input any `json:"input,omitempty"` + Metadata any `json:"metadata,omitempty"` + Functions any `json:"functions,omitempty"` + LogitBias any `json:"logit_bias,omitempty"` + FunctionCall any `json:"function_call,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Stop any `json:"stop,omitempty"` + MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"` + TopLogprobs *int `json:"top_logprobs,omitempty"` + Style *string `json:"style,omitempty"` + Quality *string `json:"quality,omitempty"` + Audio *Audio `json:"audio,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + Store *bool `json:"store,omitempty"` + ServiceTier *string `json:"service_tier,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` + Logprobs *bool `json:"logprobs,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"` + EncodingFormat string `json:"encoding_format,omitempty"` + Model string `json:"model,omitempty"` + Instruction string `json:"instruction,omitempty"` + User string `json:"user,omitempty"` + Size string `json:"size,omitempty"` + Modalities []string `json:"modalities,omitempty"` + Messages []Message `json:"messages,omitempty"` + Tools []Tool `json:"tools,omitempty"` + N int `json:"n,omitempty"` + Dimensions int `json:"dimensions,omitempty"` + Seed float64 `json:"seed,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + TopK int `json:"top_k,omitempty"` + NumCtx int `json:"num_ctx,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +func (r GeneralOpenAIRequest) ParseInput() []string { + if r.Input == nil { + return nil + } + var input []string + switch v := r.Input.(type) { + case string: + input = []string{v} + case []any: + input = make([]string, 0, len(v)) + for _, item := range v { + if str, ok := item.(string); ok { + input = append(input, str) + } + } + } + return input +} + +type TextToSpeechRequest struct { + Model string `binding:"required" json:"model"` + Input string `binding:"required" json:"input"` + Voice string `binding:"required" json:"voice"` + ResponseFormat string `json:"response_format"` + Speed float64 `json:"speed"` +} diff --git a/service/aiproxy/relay/model/image.go b/service/aiproxy/relay/model/image.go new file mode 100644 index 00000000000..1ba51218c36 --- /dev/null +++ b/service/aiproxy/relay/model/image.go @@ -0,0 +1,12 @@ +package model + +type ImageRequest struct { + Model string `json:"model"` + Prompt string `binding:"required" json:"prompt"` + Size string `json:"size,omitempty"` + Quality string `json:"quality,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` + Style string `json:"style,omitempty"` + User string `json:"user,omitempty"` + N int `json:"n,omitempty"` +} diff --git a/service/aiproxy/relay/model/message.go b/service/aiproxy/relay/model/message.go new file mode 100644 index 00000000000..4e5def601e7 --- /dev/null +++ b/service/aiproxy/relay/model/message.go @@ -0,0 +1,90 @@ +package model + +type Message struct { + Content any `json:"content,omitempty"` + Name *string `json:"name,omitempty"` + Role string `json:"role,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls []Tool `json:"tool_calls,omitempty"` +} + +func (m Message) IsStringContent() bool { + _, ok := m.Content.(string) + return ok +} + +func (m Message) StringContent() string { + content, ok := m.Content.(string) + if ok { + return content + } + contentList, ok := m.Content.([]any) + if ok { + var contentStr string + for _, contentItem := range contentList { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + if contentMap["type"] == ContentTypeText { + if subStr, ok := contentMap["text"].(string); ok { + contentStr += subStr + } + } + } + return contentStr + } + return "" +} + +func (m Message) ParseContent() []MessageContent { + var contentList []MessageContent + content, ok := m.Content.(string) + if ok { + contentList = append(contentList, MessageContent{ + Type: ContentTypeText, + Text: content, + }) + return contentList + } + anyList, ok := m.Content.([]any) + if ok { + for _, contentItem := range anyList { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + switch contentMap["type"] { + case ContentTypeText: + if subStr, ok := contentMap["text"].(string); ok { + contentList = append(contentList, MessageContent{ + Type: ContentTypeText, + Text: subStr, + }) + } + case ContentTypeImageURL: + if subObj, ok := contentMap["image_url"].(map[string]any); ok { + contentList = append(contentList, MessageContent{ + Type: ContentTypeImageURL, + ImageURL: &ImageURL{ + URL: subObj["url"].(string), + }, + }) + } + } + } + return contentList + } + return nil +} + +type ImageURL struct { + URL string `json:"url,omitempty"` + Detail string `json:"detail,omitempty"` +} + +type MessageContent struct { + ImageURL *ImageURL `json:"image_url,omitempty"` + Type string `json:"type,omitempty"` + Text string `json:"text"` +} diff --git a/service/aiproxy/relay/model/misc.go b/service/aiproxy/relay/model/misc.go new file mode 100644 index 00000000000..21252028680 --- /dev/null +++ b/service/aiproxy/relay/model/misc.go @@ -0,0 +1,33 @@ +package model + +import "fmt" + +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type Error struct { + Code any `json:"code"` + Message string `json:"message"` + Type string `json:"type"` + Param string `json:"param"` +} + +func (e *Error) String() string { + return fmt.Sprintf("code: %v, message: %s, type: %s, param: %s", e.Code, e.Message, e.Type, e.Param) +} + +func (e *Error) Error() string { + return e.String() +} + +type ErrorWithStatusCode struct { + Error + StatusCode int `json:"status_code"` +} + +func (e *ErrorWithStatusCode) String() string { + return fmt.Sprintf("%s, status_code: %d", e.Error.String(), e.StatusCode) +} diff --git a/service/aiproxy/relay/model/rerank.go b/service/aiproxy/relay/model/rerank.go new file mode 100644 index 00000000000..a58b3cab225 --- /dev/null +++ b/service/aiproxy/relay/model/rerank.go @@ -0,0 +1,37 @@ +package model + +type RerankRequest struct { + TopN *int `json:"top_n,omitempty"` + MaxChunksPerDoc *int `json:"max_chunks_per_doc,omitempty"` + ReturnDocuments *bool `json:"return_documents,omitempty"` + OverlapTokens *int `json:"overlap_tokens,omitempty"` + Model string `json:"model"` + Query string `json:"query"` + Documents []string `json:"documents"` +} + +type Document struct { + Text string `json:"text"` +} + +type RerankResult struct { + Document Document `json:"document"` + Index int `json:"index"` + RelevanceScore float64 `json:"relevance_score"` +} + +type RerankMetaTokens struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +type RerankMeta struct { + Tokens *RerankMetaTokens `json:"tokens"` + Model string `json:"model"` +} + +type RerankResponse struct { + Meta RerankMeta `json:"meta"` + ID string `json:"id"` + Result []RerankResult `json:"result"` +} diff --git a/service/aiproxy/relay/model/tool.go b/service/aiproxy/relay/model/tool.go new file mode 100644 index 00000000000..5a25e419dc9 --- /dev/null +++ b/service/aiproxy/relay/model/tool.go @@ -0,0 +1,14 @@ +package model + +type Tool struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` // when splicing claude tools stream messages, it is empty + Function Function `json:"function"` +} + +type Function struct { + Parameters any `json:"parameters,omitempty"` + Arguments string `json:"arguments,omitempty"` + Description string `json:"description,omitempty"` + Name string `json:"name,omitempty"` +} diff --git a/service/aiproxy/relay/price/image.go b/service/aiproxy/relay/price/image.go new file mode 100644 index 00000000000..cd40bc01db7 --- /dev/null +++ b/service/aiproxy/relay/price/image.go @@ -0,0 +1,108 @@ +package price + +// 单个图片的价格 +var imageSizePrices = map[string]map[string]float64{ + "dall-e-2": { + "256x256": 1, + "512x512": 1.125, + "1024x1024": 1.25, + }, + "dall-e-3": { + "1024x1024": 1, + "1024x1792": 2, + "1792x1024": 2, + }, + "ali-stable-diffusion-xl": { + "512x1024": 1, + "1024x768": 1, + "1024x1024": 1, + "576x1024": 1, + "1024x576": 1, + }, + "ali-stable-diffusion-v1.5": { + "512x1024": 1, + "1024x768": 1, + "1024x1024": 1, + "576x1024": 1, + "1024x576": 1, + }, + "wanx-v1": { + "1024x1024": 1, + "720x1280": 1, + "1280x720": 1, + }, + "step-1x-medium": { + "256x256": 1, + "512x512": 1, + "768x768": 1, + "1024x1024": 1, + "1280x800": 1, + "800x1280": 1, + }, +} + +var imageGenerationAmounts = map[string][2]int{ + "dall-e-2": {1, 10}, + "dall-e-3": {1, 1}, // OpenAI allows n=1 currently. + "ali-stable-diffusion-xl": {1, 4}, // Ali + "ali-stable-diffusion-v1.5": {1, 4}, // Ali + "wanx-v1": {1, 4}, // Ali + "cogview-3": {1, 1}, + "step-1x-medium": {1, 1}, +} + +var imagePromptLengthLimitations = map[string]int{ + "dall-e-2": 1000, + "dall-e-3": 4000, + "ali-stable-diffusion-xl": 4000, + "ali-stable-diffusion-v1.5": 4000, + "wanx-v1": 4000, + "cogview-3": 833, + "step-1x-medium": 4000, +} + +var imageOriginModelName = map[string]string{ + "ali-stable-diffusion-xl": "stable-diffusion-xl", + "ali-stable-diffusion-v1.5": "stable-diffusion-v1.5", +} + +func GetImageOriginModelName() map[string]string { + return imageOriginModelName +} + +func IsValidImageSize(model string, size string) bool { + if !GetBillingEnabled() { + return true + } + if model == "cogview-3" || imageSizePrices[model] == nil { + return true + } + _, ok := imageSizePrices[model][size] + return ok +} + +func IsValidImagePromptLength(model string, promptLength int) bool { + if !GetBillingEnabled() { + return true + } + maxPromptLength, ok := imagePromptLengthLimitations[model] + return !ok || promptLength <= maxPromptLength +} + +func IsWithinRange(element string, value int) bool { + if !GetBillingEnabled() { + return true + } + amounts, ok := imageGenerationAmounts[element] + return !ok || (value >= amounts[0] && value <= amounts[1]) +} + +func GetImageSizePrice(model string, size string) float64 { + if !GetBillingEnabled() { + return 0 + } + if price, ok := imageSizePrices[model][size]; ok { + return price + } + return 1 +} diff --git a/service/aiproxy/relay/price/model.go b/service/aiproxy/relay/price/model.go new file mode 100644 index 00000000000..ed6104a5c35 --- /dev/null +++ b/service/aiproxy/relay/price/model.go @@ -0,0 +1,206 @@ +package price + +import ( + "fmt" + "sync" + "sync/atomic" + + json "github.com/json-iterator/go" + + "github.com/labring/sealos/service/aiproxy/common/conv" + "github.com/labring/sealos/service/aiproxy/common/logger" +) + +const ( + // /1K tokens + PriceUnit = 1000 +) + +// ModelPrice +// https://platform.openai.com/docs/models/model-endpoint-compatibility +// https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Blfmc9dlf +// https://openai.com/pricing +// 价格单位:人民币/1K tokens +var ( + modelPrice = map[string]float64{} + completionPrice = map[string]float64{} + modelPriceMu sync.RWMutex + completionPriceMu sync.RWMutex +) + +var ( + DefaultModelPrice map[string]float64 + DefaultCompletionPrice map[string]float64 +) + +func init() { + DefaultModelPrice = make(map[string]float64) + modelPriceMu.RLock() + for k, v := range modelPrice { + DefaultModelPrice[k] = v + } + modelPriceMu.RUnlock() + + DefaultCompletionPrice = make(map[string]float64) + completionPriceMu.RLock() + for k, v := range completionPrice { + DefaultCompletionPrice[k] = v + } + completionPriceMu.RUnlock() +} + +func AddNewMissingPrice(oldPrice string) string { + newPrice := make(map[string]float64) + err := json.Unmarshal(conv.StringToBytes(oldPrice), &newPrice) + if err != nil { + logger.SysError("error unmarshalling old price: " + err.Error()) + return oldPrice + } + for k, v := range DefaultModelPrice { + if _, ok := newPrice[k]; !ok { + newPrice[k] = v + } + } + jsonBytes, err := json.Marshal(newPrice) + if err != nil { + logger.SysError("error marshalling new price: " + err.Error()) + return oldPrice + } + return conv.BytesToString(jsonBytes) +} + +func ModelPrice2JSONString() string { + modelPriceMu.RLock() + jsonBytes, err := json.Marshal(modelPrice) + modelPriceMu.RUnlock() + if err != nil { + logger.SysError("error marshalling model price: " + err.Error()) + } + return conv.BytesToString(jsonBytes) +} + +var billingEnabled atomic.Bool + +func init() { + billingEnabled.Store(true) +} + +func GetBillingEnabled() bool { + return billingEnabled.Load() +} + +func SetBillingEnabled(enabled bool) { + billingEnabled.Store(enabled) +} + +func UpdateModelPriceByJSONString(jsonStr string) error { + newModelPrice := make(map[string]float64) + err := json.Unmarshal(conv.StringToBytes(jsonStr), &newModelPrice) + if err != nil { + logger.SysError("error unmarshalling model price: " + err.Error()) + return err + } + modelPriceMu.Lock() + modelPrice = newModelPrice + modelPriceMu.Unlock() + return nil +} + +func GetModelPrice(mapedName string, reqModel string, channelType int) (float64, bool) { + if !GetBillingEnabled() { + return 0, true + } + price, ok := getModelPrice(mapedName, channelType) + if !ok && reqModel != "" { + price, ok = getModelPrice(reqModel, channelType) + } + return price, ok +} + +func getModelPrice(modelName string, channelType int) (float64, bool) { + model := fmt.Sprintf("%s(%d)", modelName, channelType) + modelPriceMu.RLock() + defer modelPriceMu.RUnlock() + price, ok := modelPrice[model] + if ok { + return price, true + } + if price, ok := DefaultModelPrice[model]; ok { + return price, true + } + price, ok = modelPrice[modelName] + if ok { + return price, true + } + if price, ok := DefaultModelPrice[modelName]; ok { + return price, true + } + return 0, false +} + +func CompletionPrice2JSONString() string { + completionPriceMu.RLock() + jsonBytes, err := json.Marshal(completionPrice) + completionPriceMu.RUnlock() + if err != nil { + logger.SysError("error marshalling completion price: " + err.Error()) + } + return conv.BytesToString(jsonBytes) +} + +func UpdateCompletionPriceByJSONString(jsonStr string) error { + newCompletionPrice := make(map[string]float64) + err := json.Unmarshal(conv.StringToBytes(jsonStr), &newCompletionPrice) + if err != nil { + logger.SysError("error unmarshalling completion price: " + err.Error()) + return err + } + completionPriceMu.Lock() + completionPrice = newCompletionPrice + completionPriceMu.Unlock() + return nil +} + +func GetCompletionPrice(name string, reqModel string, channelType int) (float64, bool) { + if !GetBillingEnabled() { + return 0, true + } + price, ok := getCompletionPrice(name, channelType) + if !ok && reqModel != "" { + price, ok = getCompletionPrice(reqModel, channelType) + } + return price, ok +} + +func getCompletionPrice(name string, channelType int) (float64, bool) { + model := fmt.Sprintf("%s(%d)", name, channelType) + completionPriceMu.RLock() + defer completionPriceMu.RUnlock() + price, ok := completionPrice[model] + if ok { + return price, true + } + if price, ok := DefaultCompletionPrice[model]; ok { + return price, true + } + price, ok = completionPrice[name] + if ok { + return price, true + } + if price, ok := DefaultCompletionPrice[name]; ok { + return price, true + } + return getModelPrice(name, channelType) +} + +func GetModelPriceMap() map[string]float64 { + modelPriceMu.RLock() + defer modelPriceMu.RUnlock() + return modelPrice +} + +func GetCompletionPriceMap() map[string]float64 { + completionPriceMu.RLock() + defer completionPriceMu.RUnlock() + return completionPrice +} diff --git a/service/aiproxy/relay/relaymode/define.go b/service/aiproxy/relay/relaymode/define.go new file mode 100644 index 00000000000..88b2086a4c1 --- /dev/null +++ b/service/aiproxy/relay/relaymode/define.go @@ -0,0 +1,15 @@ +package relaymode + +const ( + Unknown = iota + ChatCompletions + Completions + Embeddings + Moderations + ImagesGenerations + Edits + AudioSpeech + AudioTranscription + AudioTranslation + Rerank +) diff --git a/service/aiproxy/relay/relaymode/helper.go b/service/aiproxy/relay/relaymode/helper.go new file mode 100644 index 00000000000..7a83ec53f73 --- /dev/null +++ b/service/aiproxy/relay/relaymode/helper.go @@ -0,0 +1,30 @@ +package relaymode + +import "strings" + +func GetByPath(path string) int { + switch { + case strings.HasPrefix(path, "/v1/chat/completions"): + return ChatCompletions + case strings.HasPrefix(path, "/v1/completions"): + return Completions + case strings.HasSuffix(path, "embeddings"): + return Embeddings + case strings.HasPrefix(path, "/v1/moderations"): + return Moderations + case strings.HasPrefix(path, "/v1/images/generations"): + return ImagesGenerations + case strings.HasPrefix(path, "/v1/edits"): + return Edits + case strings.HasPrefix(path, "/v1/audio/speech"): + return AudioSpeech + case strings.HasPrefix(path, "/v1/audio/transcriptions"): + return AudioTranscription + case strings.HasPrefix(path, "/v1/audio/translations"): + return AudioTranslation + case strings.HasPrefix(path, "/v1/rerank"): + return Rerank + default: + return Unknown + } +} diff --git a/service/aiproxy/router/api.go b/service/aiproxy/router/api.go new file mode 100644 index 00000000000..164af29df45 --- /dev/null +++ b/service/aiproxy/router/api.go @@ -0,0 +1,110 @@ +package router + +import ( + "github.com/gin-contrib/gzip" + "github.com/labring/sealos/service/aiproxy/common/env" + "github.com/labring/sealos/service/aiproxy/controller" + "github.com/labring/sealos/service/aiproxy/middleware" + + "github.com/gin-gonic/gin" +) + +func SetAPIRouter(router *gin.Engine) { + api := router.Group("/api") + if env.Bool("GZIP_ENABLED", false) { + api.Use(gzip.Gzip(gzip.DefaultCompression)) + } + + healthRouter := api.Group("") + healthRouter.GET("/status", controller.GetStatus) + + apiRouter := api.Group("") + apiRouter.Use(middleware.AdminAuth) + { + apiRouter.GET("/models", controller.BuiltinModels) + apiRouter.GET("/models/price", controller.ModelPrice) + apiRouter.GET("/models/enabled", controller.EnabledModels) + apiRouter.GET("/models/enabled/price", controller.EnabledModelsAndPrice) + apiRouter.GET("/models/enabled/channel", controller.EnabledType2Models) + apiRouter.GET("/models/enabled/channel/price", controller.EnabledType2ModelsAndPrice) + apiRouter.GET("/models/enabled/default", controller.ChannelDefaultModels) + apiRouter.GET("/models/enabled/default/:type", controller.ChannelDefaultModelsByType) + apiRouter.GET("/models/enabled/mapping/default", controller.ChannelDefaultModelMapping) + apiRouter.GET("/models/enabled/mapping/default/:type", controller.ChannelDefaultModelMappingByType) + apiRouter.GET("/models/enabled/all/default", controller.ChannelDefaultModelsAndMapping) + apiRouter.GET("/models/enabled/all/default/:type", controller.ChannelDefaultModelsAndMappingByType) + + groupsRoute := apiRouter.Group("/groups") + { + groupsRoute.GET("/", controller.GetGroups) + groupsRoute.GET("/search", controller.SearchGroups) + } + groupRoute := apiRouter.Group("/group") + { + groupRoute.POST("/", controller.CreateGroup) + groupRoute.GET("/:id", controller.GetGroup) + groupRoute.DELETE("/:id", controller.DeleteGroup) + groupRoute.POST("/:id/status", controller.UpdateGroupStatus) + groupRoute.POST("/:id/qpm", controller.UpdateGroupQPM) + } + optionRoute := apiRouter.Group("/option") + { + optionRoute.GET("/", controller.GetOptions) + optionRoute.PUT("/", controller.UpdateOption) + optionRoute.PUT("/batch", controller.UpdateOptions) + } + channelsRoute := apiRouter.Group("/channels") + { + channelsRoute.GET("/", controller.GetChannels) + channelsRoute.GET("/all", controller.GetAllChannels) + channelsRoute.POST("/", controller.AddChannels) + channelsRoute.GET("/search", controller.SearchChannels) + channelsRoute.GET("/test", controller.TestChannels) + channelsRoute.GET("/update_balance", controller.UpdateAllChannelsBalance) + } + channelRoute := apiRouter.Group("/channel") + { + channelRoute.GET("/:id", controller.GetChannel) + channelRoute.POST("/", controller.AddChannel) + channelRoute.PUT("/", controller.UpdateChannel) + channelRoute.POST("/:id/status", controller.UpdateChannelStatus) + channelRoute.DELETE("/:id", controller.DeleteChannel) + channelRoute.GET("/test/:id", controller.TestChannel) + channelRoute.GET("/update_balance/:id", controller.UpdateChannelBalance) + } + tokensRoute := apiRouter.Group("/tokens") + { + tokensRoute.GET("/", controller.GetTokens) + tokensRoute.GET("/:id", controller.GetToken) + tokensRoute.PUT("/:id", controller.UpdateToken) + tokensRoute.POST("/:id/status", controller.UpdateTokenStatus) + tokensRoute.POST("/:id/name", controller.UpdateTokenName) + tokensRoute.DELETE("/:id", controller.DeleteToken) + tokensRoute.GET("/search", controller.SearchTokens) + } + tokenRoute := apiRouter.Group("/token") + { + tokenRoute.GET("/:group/search", controller.SearchGroupTokens) + tokenRoute.GET("/:group", controller.GetGroupTokens) + tokenRoute.GET("/:group/:id", controller.GetGroupToken) + tokenRoute.POST("/:group", controller.AddToken) + tokenRoute.PUT("/:group/:id", controller.UpdateGroupToken) + tokenRoute.POST("/:group/:id/status", controller.UpdateGroupTokenStatus) + tokenRoute.POST("/:group/:id/name", controller.UpdateGroupTokenName) + tokenRoute.DELETE("/:group/:id", controller.DeleteGroupToken) + } + logsRoute := apiRouter.Group("/logs") + { + logsRoute.GET("/", controller.GetLogs) + logsRoute.DELETE("/", controller.DeleteHistoryLogs) + logsRoute.GET("/stat", controller.GetLogsStat) + logsRoute.GET("/search", controller.SearchLogs) + logsRoute.GET("/consume_error", controller.SearchConsumeError) + } + logRoute := apiRouter.Group("/log") + { + logRoute.GET("/:group/search", controller.SearchGroupLogs) + logRoute.GET("/:group", controller.GetGroupLogs) + } + } +} diff --git a/service/aiproxy/router/main.go b/service/aiproxy/router/main.go new file mode 100644 index 00000000000..a704ab8ecf2 --- /dev/null +++ b/service/aiproxy/router/main.go @@ -0,0 +1,10 @@ +package router + +import ( + "github.com/gin-gonic/gin" +) + +func SetRouter(router *gin.Engine) { + SetAPIRouter(router) + SetRelayRouter(router) +} diff --git a/service/aiproxy/router/relay.go b/service/aiproxy/router/relay.go new file mode 100644 index 00000000000..20886eb9ddf --- /dev/null +++ b/service/aiproxy/router/relay.go @@ -0,0 +1,80 @@ +package router + +import ( + "github.com/labring/sealos/service/aiproxy/controller" + "github.com/labring/sealos/service/aiproxy/middleware" + + "github.com/gin-gonic/gin" +) + +func SetRelayRouter(router *gin.Engine) { + router.Use(middleware.CORS()) + router.Use(middleware.GlobalAPIRateLimit) + // https://platform.openai.com/docs/api-reference/introduction + modelsRouter := router.Group("/v1/models") + modelsRouter.Use(middleware.TokenAuth) + { + modelsRouter.GET("", controller.ListModels) + modelsRouter.GET("/:model", controller.RetrieveModel) + } + dashboardRouter := router.Group("/v1/dashboard") + dashboardRouter.Use(middleware.TokenAuth) + { + dashboardRouter.GET("/billing/subscription", controller.GetSubscription) + dashboardRouter.GET("/billing/usage", controller.GetUsage) + } + relayV1Router := router.Group("/v1") + relayV1Router.Use(middleware.RelayPanicRecover, middleware.TokenAuth, middleware.Distribute) + { + relayV1Router.POST("/completions", controller.Relay) + relayV1Router.POST("/chat/completions", controller.Relay) + relayV1Router.POST("/edits", controller.Relay) + relayV1Router.POST("/images/generations", controller.Relay) + relayV1Router.POST("/images/edits", controller.RelayNotImplemented) + relayV1Router.POST("/images/variations", controller.RelayNotImplemented) + relayV1Router.POST("/embeddings", controller.Relay) + relayV1Router.POST("/engines/:model/embeddings", controller.Relay) + relayV1Router.POST("/audio/transcriptions", controller.Relay) + relayV1Router.POST("/audio/translations", controller.Relay) + relayV1Router.POST("/audio/speech", controller.Relay) + relayV1Router.POST("/rerank", controller.Relay) + relayV1Router.GET("/files", controller.RelayNotImplemented) + relayV1Router.POST("/files", controller.RelayNotImplemented) + relayV1Router.DELETE("/files/:id", controller.RelayNotImplemented) + relayV1Router.GET("/files/:id", controller.RelayNotImplemented) + relayV1Router.GET("/files/:id/content", controller.RelayNotImplemented) + relayV1Router.POST("/fine_tuning/jobs", controller.RelayNotImplemented) + relayV1Router.GET("/fine_tuning/jobs", controller.RelayNotImplemented) + relayV1Router.GET("/fine_tuning/jobs/:id", controller.RelayNotImplemented) + relayV1Router.POST("/fine_tuning/jobs/:id/cancel", controller.RelayNotImplemented) + relayV1Router.GET("/fine_tuning/jobs/:id/events", controller.RelayNotImplemented) + relayV1Router.DELETE("/models/:model", controller.RelayNotImplemented) + relayV1Router.POST("/moderations", controller.Relay) + relayV1Router.POST("/assistants", controller.RelayNotImplemented) + relayV1Router.GET("/assistants/:id", controller.RelayNotImplemented) + relayV1Router.POST("/assistants/:id", controller.RelayNotImplemented) + relayV1Router.DELETE("/assistants/:id", controller.RelayNotImplemented) + relayV1Router.GET("/assistants", controller.RelayNotImplemented) + relayV1Router.POST("/assistants/:id/files", controller.RelayNotImplemented) + relayV1Router.GET("/assistants/:id/files/:fileId", controller.RelayNotImplemented) + relayV1Router.DELETE("/assistants/:id/files/:fileId", controller.RelayNotImplemented) + relayV1Router.GET("/assistants/:id/files", controller.RelayNotImplemented) + relayV1Router.POST("/threads", controller.RelayNotImplemented) + relayV1Router.GET("/threads/:id", controller.RelayNotImplemented) + relayV1Router.POST("/threads/:id", controller.RelayNotImplemented) + relayV1Router.DELETE("/threads/:id", controller.RelayNotImplemented) + relayV1Router.POST("/threads/:id/messages", controller.RelayNotImplemented) + relayV1Router.GET("/threads/:id/messages/:messageId", controller.RelayNotImplemented) + relayV1Router.POST("/threads/:id/messages/:messageId", controller.RelayNotImplemented) + relayV1Router.GET("/threads/:id/messages/:messageId/files/:filesId", controller.RelayNotImplemented) + relayV1Router.GET("/threads/:id/messages/:messageId/files", controller.RelayNotImplemented) + relayV1Router.POST("/threads/:id/runs", controller.RelayNotImplemented) + relayV1Router.GET("/threads/:id/runs/:runsId", controller.RelayNotImplemented) + relayV1Router.POST("/threads/:id/runs/:runsId", controller.RelayNotImplemented) + relayV1Router.GET("/threads/:id/runs", controller.RelayNotImplemented) + relayV1Router.POST("/threads/:id/runs/:runsId/submit_tool_outputs", controller.RelayNotImplemented) + relayV1Router.POST("/threads/:id/runs/:runsId/cancel", controller.RelayNotImplemented) + relayV1Router.GET("/threads/:id/runs/:runsId/steps/:stepId", controller.RelayNotImplemented) + relayV1Router.GET("/threads/:id/runs/:runsId/steps", controller.RelayNotImplemented) + } +} diff --git a/service/exceptionmonitor/helper/monitor/database_monitor.go b/service/exceptionmonitor/helper/monitor/database_monitor.go index 31b6f0aaa41..59eb8ad5949 100644 --- a/service/exceptionmonitor/helper/monitor/database_monitor.go +++ b/service/exceptionmonitor/helper/monitor/database_monitor.go @@ -46,10 +46,11 @@ func DatabaseExceptionMonitor() { } func checkDeletedDatabases() { - for databaseName := range api.LastDatabaseClusterStatus { - cluster, err := api.DynamicClient.Resource(databaseClusterGVR).Namespace(api.DatabaseNamespaceMap[databaseName]).Get(context.Background(), databaseName, metav1.GetOptions{}) + for databaseClusterUID, namespaceAndDatabaseClusterName := range api.DatabaseNamespaceMap { + namespace, databaseClusterName := getNamespaceAndDatabaseClusterName(namespaceAndDatabaseClusterName) + cluster, err := api.DynamicClient.Resource(databaseClusterGVR).Namespace(namespace).Get(context.Background(), databaseClusterName, metav1.GetOptions{}) if cluster == nil && errors.IsNotFound(err) { - handleClusterRecovery(databaseName, "", "Deleted") + handleClusterRecovery(databaseClusterUID, databaseClusterName, "", "Deleted") } } } @@ -87,34 +88,34 @@ func checkDatabasesInNamespace(namespace string) error { } func processCluster(cluster metav1unstructured.Unstructured) { - databaseClusterName, databaseType, namespace := cluster.GetName(), cluster.GetLabels()[api.DatabaseTypeLabel], cluster.GetNamespace() + databaseClusterName, databaseType, namespace, databaseClusterUID := cluster.GetName(), cluster.GetLabels()[api.DatabaseTypeLabel], cluster.GetNamespace(), string(cluster.GetUID()) status, _, err := metav1unstructured.NestedString(cluster.Object, "status", "phase") if err != nil { log.Printf("Unable to get %s status in ns %s: %v", databaseClusterName, namespace, err) } switch status { case api.StatusRunning, api.StatusStopped: - handleClusterRecovery(databaseClusterName, namespace, status) + handleClusterRecovery(databaseClusterUID, databaseClusterName, namespace, status) case api.StatusDeleting, api.StatusStopping: // No action needed break case api.StatusUnknown: - if _, ok := api.LastDatabaseClusterStatus[databaseClusterName]; !ok { - api.LastDatabaseClusterStatus[databaseClusterName] = status - api.DatabaseNamespaceMap[databaseClusterName] = namespace - api.ExceptionDatabaseMap[databaseClusterName] = true - alertMessage, feishuWebHook, notification := prepareAlertMessage(databaseClusterName, namespace, status, "", "status is empty", 0) - if err := sendAlert(alertMessage, feishuWebHook, notification); err != nil { + if _, ok := api.LastDatabaseClusterStatus[databaseClusterUID]; !ok { + api.LastDatabaseClusterStatus[databaseClusterUID] = status + api.DatabaseNamespaceMap[databaseClusterUID] = namespace + "-" + databaseClusterName + api.ExceptionDatabaseMap[databaseClusterUID] = true + alertMessage, feishuWebHook, notification := prepareAlertMessage(databaseClusterUID, databaseClusterName, namespace, status, "", "status is empty", 0) + if err := sendAlert(alertMessage, feishuWebHook, databaseClusterUID, notification); err != nil { log.Printf("Failed to send feishu %s in ns %s: %v", databaseClusterName, namespace, err) } } default: - handleClusterException(databaseClusterName, namespace, databaseType, status) + handleClusterException(databaseClusterUID, databaseClusterName, namespace, databaseType, status) } } -func handleClusterRecovery(databaseClusterName, namespace, status string) { - if api.ExceptionDatabaseMap[databaseClusterName] { +func handleClusterRecovery(databaseClusterUID, databaseClusterName, namespace, status string) { + if api.ExceptionDatabaseMap[databaseClusterUID] { notificationInfo := notification.Info{ DatabaseClusterName: databaseClusterName, Namespace: namespace, @@ -123,27 +124,27 @@ func handleClusterRecovery(databaseClusterName, namespace, status string) { NotificationType: "recovery", } recoveryMessage := notification.GetNotificationMessage(notificationInfo) - if err := notification.SendFeishuNotification(notificationInfo, recoveryMessage, api.FeishuWebHookMap[databaseClusterName]); err != nil { + if err := notification.SendFeishuNotification(notificationInfo, recoveryMessage, api.FeishuWebHookMap[databaseClusterUID]); err != nil { log.Printf("Error sending recovery notification: %v", err) } - cleanClusterStatus(databaseClusterName) + cleanClusterStatus(databaseClusterUID) } } -func cleanClusterStatus(databaseClusterName string) { - delete(api.LastDatabaseClusterStatus, databaseClusterName) - delete(api.DiskFullNamespaceMap, databaseClusterName) - delete(api.FeishuWebHookMap, databaseClusterName) - delete(api.ExceptionDatabaseMap, databaseClusterName) - delete(api.DatabaseNamespaceMap, databaseClusterName) +func cleanClusterStatus(databaseClusterUID string) { + delete(api.LastDatabaseClusterStatus, databaseClusterUID) + delete(api.DiskFullNamespaceMap, databaseClusterUID) + delete(api.FeishuWebHookMap, databaseClusterUID) + delete(api.ExceptionDatabaseMap, databaseClusterUID) + delete(api.DatabaseNamespaceMap, databaseClusterUID) } -func handleClusterException(databaseClusterName, namespace, databaseType, status string) { - if _, ok := api.LastDatabaseClusterStatus[databaseClusterName]; !ok && !api.DebtNamespaceMap[namespace] { - api.LastDatabaseClusterStatus[databaseClusterName] = status - api.DatabaseNamespaceMap[databaseClusterName] = namespace - api.ExceptionDatabaseMap[databaseClusterName] = true - if err := processClusterException(databaseClusterName, namespace, databaseType, status); err != nil { +func handleClusterException(databaseClusterUID, databaseClusterName, namespace, databaseType, status string) { + if _, ok := api.LastDatabaseClusterStatus[databaseClusterUID]; !ok && !api.DebtNamespaceMap[namespace] { + api.LastDatabaseClusterStatus[databaseClusterUID] = status + api.DatabaseNamespaceMap[databaseClusterUID] = namespace + "-" + databaseClusterName + api.ExceptionDatabaseMap[databaseClusterUID] = true + if err := processClusterException(databaseClusterUID, databaseClusterName, namespace, databaseType, status); err != nil { log.Printf("Failed to process cluster %s exception in ns %s: %v", databaseClusterName, namespace, err) } } @@ -152,7 +153,7 @@ func handleClusterException(databaseClusterName, namespace, databaseType, status //} } -func processClusterException(databaseClusterName, namespace, databaseType, status string) error { +func processClusterException(databaseClusterUID, databaseClusterName, namespace, databaseType, status string) error { debt, debtLevel, _ := checkDebt(namespace) if debt { databaseEvents, send := getDatabaseClusterEvents(databaseClusterName, namespace) @@ -161,8 +162,8 @@ func processClusterException(databaseClusterName, namespace, databaseType, statu if err != nil { return err } - alertMessage, feishuWebHook, notification := prepareAlertMessage(databaseClusterName, namespace, status, debtLevel, databaseEvents, maxUsage) - if err := sendAlert(alertMessage, feishuWebHook, notification); err != nil { + alertMessage, feishuWebHook, notification := prepareAlertMessage(databaseClusterUID, databaseClusterName, namespace, status, debtLevel, databaseEvents, maxUsage) + if err := sendAlert(alertMessage, feishuWebHook, databaseClusterUID, notification); err != nil { return err } } else { @@ -172,7 +173,7 @@ func processClusterException(databaseClusterName, namespace, databaseType, statu } } else { api.DebtNamespaceMap[namespace] = true - delete(api.LastDatabaseClusterStatus, databaseClusterName) + delete(api.LastDatabaseClusterStatus, databaseClusterUID) } return nil } @@ -197,7 +198,7 @@ func databaseQuotaExceptionFilter(databaseEvents string) bool { return !strings.Contains(databaseEvents, api.ExceededQuotaException) } -func prepareAlertMessage(databaseClusterName, namespace, status, debtLevel, databaseEvents string, maxUsage float64) (string, string, notification.Info) { +func prepareAlertMessage(databaseClusterUID, databaseClusterName, namespace, status, debtLevel, databaseEvents string, maxUsage float64) (string, string, notification.Info) { alertMessage, feishuWebHook := "", "" notificationInfo := notification.Info{ DatabaseClusterName: databaseClusterName, @@ -217,19 +218,19 @@ func prepareAlertMessage(databaseClusterName, namespace, status, debtLevel, data } alertMessage = notification.GetNotificationMessage(notificationInfo) } else { - if !api.DiskFullNamespaceMap[databaseClusterName] { + if !api.DiskFullNamespaceMap[databaseClusterUID] { feishuWebHook = api.FeishuWebhookURLMap["FeishuWebhookURLOther"] notificationInfo.Reason = "disk is full" alertMessage = notification.GetNotificationMessage(notificationInfo) notification.CreateNotification(namespace, databaseClusterName, status, "disk is full", "磁盘满了") } - api.DiskFullNamespaceMap[databaseClusterName] = true + api.DiskFullNamespaceMap[databaseClusterUID] = true } return alertMessage, feishuWebHook, notificationInfo } -func sendAlert(alertMessage, feishuWebHook string, notificationInfo notification.Info) error { - api.FeishuWebHookMap[notificationInfo.DatabaseClusterName] = feishuWebHook +func sendAlert(alertMessage, feishuWebHook, databaseClusterUID string, notificationInfo notification.Info) error { + api.FeishuWebHookMap[databaseClusterUID] = feishuWebHook return notification.SendFeishuNotification(notificationInfo, alertMessage, feishuWebHook) } @@ -247,3 +248,11 @@ func notifyQuotaExceeded(databaseClusterName, namespace, status, debtLevel strin notification.CreateNotification(namespace, databaseClusterName, status, api.ExceededQuotaException, "Quato满了") return notification.SendFeishuNotification(notificationInfo, alertMessage, api.FeishuWebhookURLMap["FeishuWebhookURLOther"]) } + +func getNamespaceAndDatabaseClusterName(namespaceAndDatabaseClusterName string) (string, string) { + firstIndex := strings.Index(namespaceAndDatabaseClusterName, "-") + secondIndex := strings.Index(namespaceAndDatabaseClusterName[firstIndex+1:], "-") + firstIndex + 1 + namespace := namespaceAndDatabaseClusterName[:secondIndex] + databaseClusterName := namespaceAndDatabaseClusterName[secondIndex+1:] + return namespace, databaseClusterName +} diff --git a/service/go.work b/service/go.work index e9b0e93f801..1978383b13f 100644 --- a/service/go.work +++ b/service/go.work @@ -1,15 +1,16 @@ -go 1.22 +go 1.22.7 use ( - ./database - ./pay + . ./account - ./launchpad + ./aiproxy + ./database ./exceptionmonitor - . + ./launchpad + ./pay ) replace ( github.com/labring/sealos/controllers/account => ../controllers/account github.com/labring/sealos/controllers/user => ../controllers/user -) \ No newline at end of file +) diff --git a/service/go.work.sum b/service/go.work.sum index 577fc5c95c1..dce39ff6b4c 100644 --- a/service/go.work.sum +++ b/service/go.work.sum @@ -1,6 +1,24 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cel.dev/expr v0.16.1/go.mod h1:AsGA5zb3WruAEQeQng1RZdGEXmBj0jvMWh6l5SnNuC8= cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= +cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= +cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= cloud.google.com/go v0.97.0 h1:3DXvAyifywvq64LfkKaMOmkWPS1CikIQdMe2lY9vxU8= cloud.google.com/go v0.110.0 h1:Zc8gqp3+a9/Eyph2KDmcGaPtbKRIoqq4YTlL4NMD0Ys= cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY= @@ -10,6 +28,8 @@ cloud.google.com/go v0.110.6/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5x cloud.google.com/go v0.110.8/go.mod h1:Iz8AkXJf1qmxC3Oxoep8R1T36w8B92yU29PcBhHO5fk= cloud.google.com/go v0.111.0 h1:YHLKNupSD1KqjDbQ3+LVdQ81h/UJbJyZG203cEfnQgM= cloud.google.com/go v0.111.0/go.mod h1:0mibmpKP1TyOOFYQY5izo0LnT+ecvOQ0Sg3OdmMiNRU= +cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= +cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= cloud.google.com/go/accessapproval v1.6.0 h1:x0cEHro/JFPd7eS4BlEWNTMecIj2HdXjOVB5BtvwER0= cloud.google.com/go/accessapproval v1.6.0/go.mod h1:R0EiYnwV5fsRFiKZkPHr6mwyk2wxUJ30nL4j2pcFY2E= cloud.google.com/go/accessapproval v1.7.1/go.mod h1:JYczztsHRMK7NTXb6Xw+dwbs/WnOJxbo/2mTI+Kgg68= @@ -92,6 +112,12 @@ cloud.google.com/go/beyondcorp v0.5.0/go.mod h1:uFqj9X+dSfrheVp7ssLTaRHd2EHqSL4Q cloud.google.com/go/beyondcorp v1.0.0/go.mod h1:YhxDWw946SCbmcWo3fAhw3V4XZMSpQ/VYfcKGAEU8/4= cloud.google.com/go/beyondcorp v1.0.3 h1:VXf9SnrnSmj2BF2cHkoTHvOUp8gjsz1KJFOMW7czdsY= cloud.google.com/go/beyondcorp v1.0.3/go.mod h1:HcBvnEd7eYr+HGDd5ZbuVmBYX019C6CEXBonXbCVwJo= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/bigquery v1.50.0 h1:RscMV6LbnAmhAzD893Lv9nXXy2WCaJmbxYPWDLbGqNQ= cloud.google.com/go/bigquery v1.50.0/go.mod h1:YrleYEh2pSEbgTBZYMJ5SuSr0ML3ypjRB1zgf7pvQLU= cloud.google.com/go/bigquery v1.53.0/go.mod h1:3b/iXjRQGU4nKa87cXeg6/gogLjO8C6PmuM8i5Bi/u4= @@ -139,6 +165,7 @@ cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IK cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI= cloud.google.com/go/compute v1.20.1 h1:6aKEtlUiwEpJzM001l0yFkpXmUVXaN8W+fbkb2AZNbg= cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= +cloud.google.com/go/compute v1.21.0 h1:JNBsyXVoOoNJtTQcnEY5uYpZIbeCTYIeDe0Xh1bySMk= cloud.google.com/go/compute v1.21.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= cloud.google.com/go/compute v1.23.1/go.mod h1:CqB3xpmPKKt3OJpW2ndFIXnA9A4xAy/F3Xp1ixncW78= @@ -148,6 +175,8 @@ cloud.google.com/go/compute/metadata v0.2.0 h1:nBbNSZyDpkNlo3DepaaLKVuO7ClyifSAm cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= cloud.google.com/go/contactcenterinsights v1.6.0 h1:jXIpfcH/VYSE1SYcPzO0n1VVb+sAamiLOgCw45JbOQk= cloud.google.com/go/contactcenterinsights v1.6.0/go.mod h1:IIDlT6CLcDoyv79kDv8iWxMSTZhLxSCofVV5W6YFM/w= cloud.google.com/go/contactcenterinsights v1.10.0/go.mod h1:bsg/R7zGLYMVxFFzfh9ooLTruLRCG9fnzhH9KznHhbM= @@ -203,6 +232,8 @@ cloud.google.com/go/dataqna v0.7.0/go.mod h1:Lx9OcIIeqCrw1a6KdO3/5KMP1wAmTc0slZW cloud.google.com/go/dataqna v0.8.1/go.mod h1:zxZM0Bl6liMePWsHA8RMGAfmTG34vJMapbHAxQ5+WA8= cloud.google.com/go/dataqna v0.8.4 h1:NJnu1kAPamZDs/if3bJ3+Wb6tjADHKL83NUWsaIp2zg= cloud.google.com/go/dataqna v0.8.4/go.mod h1:mySRKjKg5Lz784P6sCov3p1QD+RZQONRMRjzGNcFd0c= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/datastore v1.11.0 h1:iF6I/HaLs3Ado8uRKMvZRvF/ZLkWaWE9i8AiHzbC774= cloud.google.com/go/datastore v1.11.0/go.mod h1:TvGxBIHCS50u8jzG+AW/ppf87v1of8nwzFNgEZU1D3c= cloud.google.com/go/datastore v1.13.0/go.mod h1:KjdB88W897MRITkvWWJrg2OUtrR5XVj1EoLgSp6/N70= @@ -260,6 +291,7 @@ cloud.google.com/go/filestore v1.6.0/go.mod h1:di5unNuss/qfZTw2U9nhFqo8/ZDSc466d cloud.google.com/go/filestore v1.7.1/go.mod h1:y10jsorq40JJnjR/lQ8AfFbbcGlw3g+Dp8oN7i7FjV4= cloud.google.com/go/filestore v1.8.0 h1:/+wUEGwk3x3Kxomi2cP5dsR8+SIXxo7M0THDjreFSYo= cloud.google.com/go/filestore v1.8.0/go.mod h1:S5JCxIbFjeBhWMTfIYH2Jx24J6BqjwpkkPl+nBA5DlI= +cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA= cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE= cloud.google.com/go/firestore v1.11.0/go.mod h1:b38dKhgzlmNNGTNZZwe7ZRFEuRab1Hay3/DBsIGKKy4= @@ -349,6 +381,7 @@ cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHS cloud.google.com/go/longrunning v0.5.2/go.mod h1:nqo6DQbNV2pXhGDbDMoN2bWz68MjZUzqv2YttZiveCs= cloud.google.com/go/longrunning v0.5.4 h1:w8xEcbZodnA2BbW6sVirkkoC+1gP8wS57EUUgGS0GVg= cloud.google.com/go/longrunning v0.5.4/go.mod h1:zqNVncI0BOP8ST6XQD1+VcvuShMmq7+xFSzOL++V0dI= +cloud.google.com/go/longrunning v0.6.1/go.mod h1:nHISoOZpBcmlwbJmiVk5oDRz0qG/ZxPynEGs1iZ79s0= cloud.google.com/go/managedidentities v1.5.0 h1:ZRQ4k21/jAhrHBVKl/AY7SjgzeJwG1iZa+mJ82P+VNg= cloud.google.com/go/managedidentities v1.5.0/go.mod h1:+dWcZ0JlUmpuxpIDfyP5pP5y0bLdRwOS4Lp7gMni/LA= cloud.google.com/go/managedidentities v1.6.1/go.mod h1:h/irGhTN2SkZ64F43tfGPMbHnypMbu4RB3yl8YcuEak= @@ -439,6 +472,10 @@ cloud.google.com/go/privatecatalog v0.8.0/go.mod h1:nQ6pfaegeDAq/Q5lrfCQzQLhubPi cloud.google.com/go/privatecatalog v0.9.1/go.mod h1:0XlDXW2unJXdf9zFz968Hp35gl/bhF4twwpXZAW50JA= cloud.google.com/go/privatecatalog v0.9.4 h1:Vo10IpWKbNvc/z/QZPVXgCiwfjpWoZ/wbgful4Uh/4E= cloud.google.com/go/privatecatalog v0.9.4/go.mod h1:SOjm93f+5hp/U3PqMZAHTtBtluqLygrDrVO8X8tYtG0= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= cloud.google.com/go/pubsub v1.30.0 h1:vCge8m7aUKBJYOgrZp7EsNDf6QMd2CAlXZqWTn3yq6s= cloud.google.com/go/pubsub v1.30.0/go.mod h1:qWi1OPS0B+b5L+Sg6Gmc9zD1Y+HaM0MdUr7LsupY1P4= cloud.google.com/go/pubsub v1.33.0 h1:6SPCPvWav64tj0sVX/+npCBKhUi/UjJehy9op/V3p2g= @@ -535,6 +572,11 @@ cloud.google.com/go/speech v1.15.0/go.mod h1:y6oH7GhqCaZANH7+Oe0BhgIogsNInLlz542 cloud.google.com/go/speech v1.19.0/go.mod h1:8rVNzU43tQvxDaGvqOhpDqgkJTFowBpDvCJ14kGlJYo= cloud.google.com/go/speech v1.21.0 h1:qkxNao58oF8ghAHE1Eghen7XepawYEN5zuZXYWaUTA4= cloud.google.com/go/speech v1.21.0/go.mod h1:wwolycgONvfz2EDU8rKuHRW3+wc9ILPsAWoikBEWavY= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.29.0 h1:6weCgzRvMg7lzuUurI4697AqIRPU1SvzHhynwpW31jI= cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4= cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM= @@ -569,6 +611,7 @@ cloud.google.com/go/translate v1.7.0/go.mod h1:lMGRudH1pu7I3n3PETiOB2507gf3HnfLV cloud.google.com/go/translate v1.8.2/go.mod h1:d1ZH5aaOA0CNhWeXeC8ujd4tdCFw8XoNWRljklu5RHs= cloud.google.com/go/translate v1.10.0 h1:tncNaKmlZnayMMRX/mMM2d5AJftecznnxVBD4w070NI= cloud.google.com/go/translate v1.10.0/go.mod h1:Kbq9RggWsbqZ9W5YpM94Q1Xv4dshw/gr/SHfsl5yCZ0= +cloud.google.com/go/translate v1.10.3/go.mod h1:GW0vC1qvPtd3pgtypCv4k4U8B7EdgK9/QEF2aJEUovs= cloud.google.com/go/video v1.15.0 h1:upIbnGI0ZgACm58HPjAeBMleW3sl5cT84AbYQ8PWOgM= cloud.google.com/go/video v1.15.0/go.mod h1:SkgaXwT+lIIAKqWAJfktHT/RbgjSuY6DobxEp0C5yTQ= cloud.google.com/go/video v1.19.0/go.mod h1:9qmqPqw/Ib2tLqaeHgtakU+l5TcJxCJbhFXM7UJjVzU= @@ -616,6 +659,7 @@ cloud.google.com/go/workflows v1.10.0/go.mod h1:fZ8LmRmZQWacon9UCX1r/g/DfAXx5VcP cloud.google.com/go/workflows v1.11.1/go.mod h1:Z+t10G1wF7h8LgdY/EmRcQY8ptBD/nvofaL6FqlET6g= cloud.google.com/go/workflows v1.12.3 h1:qocsqETmLAl34mSa01hKZjcqAvt699gaoFbooGGMvaM= cloud.google.com/go/workflows v1.12.3/go.mod h1:fmOUeeqEwPzIU81foMjTRQIdwQHADi/vEr1cx9R1m5g= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= @@ -631,9 +675,9 @@ github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZ github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 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/Microsoft/hcsshim v0.12.0-rc.0 h1:wX/F5huJxH9APBkhKSEAqaiZsuBvbbDnyBROZAqsSaY= @@ -678,25 +722,38 @@ github.com/apache/thrift v0.16.0 h1:qEy6UW60iVOlUy+b9ZR0d5WzUWYGOo4HfopoyBaNmoY= github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= github.com/apecloud/kubeblocks v0.8.4 h1:8esK2e9iiziPXTlGXmX2uFTU/YGFXFvyvqnCBODqWM4= github.com/apecloud/kubeblocks v0.8.4/go.mod h1:xQpzfMy4V+WJI5IKBWB02qsKAlVR3nAE71CPkAs2uOs= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/astaxie/beego v1.12.3 h1:SAQkdD2ePye+v8Gn1r4X6IKZM1wd28EyUOVQ3PDSOOQ= github.com/astaxie/beego v1.12.3/go.mod h1:p3qIm0Ryx7zeBHLljmd7omloyca1s4yu1a8kM1FkpIA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.19/go.mod h1:zminj5ucw7w0r65bP6nhyOd3xL6veAUMc3ElGMoLVb4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0/go.mod h1:0jp+ltwkf+SwG2fm/PKo8t4y8pJSgOCO4D8Lz3k0aHQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.4/go.mod h1:4GQbF1vJzG60poZqWatZlhP31y8PGCCVTvIGPdaaYJ0= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.5/go.mod h1:wrMCEwjFPms+V86TCQQeOxQF/If4vT44FGIOFiMC2ck= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.4/go.mod h1:Tp/ly1cTjRLGBBmNccFumbZ8oqpZlpdhFf80SrRh4is= +github.com/aws/aws-sdk-go-v2/service/sts v1.32.4/go.mod h1:9XEUty5v5UAsMiFOBJrNibZgwCeOma73jgGwwhgffa8= +github.com/bazelbuild/rules_go v0.49.0/go.mod h1:Dhcz716Kqg1RHNWos+N6MlXNkjNP2EwZQ0LukRKJfMs= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g= github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= @@ -706,6 +763,9 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/checkpoint-restore/go-criu/v5 v5.3.0 h1:wpFFOoomK3389ue2lAb0Boag6XPht5QYpipxmSNL4d8= github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E= +github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= +github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= @@ -720,8 +780,7 @@ github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2u github.com/clbanning/mxj/v2 v2.5.7 h1:7q5lvUpaPF/WOkqgIDiwjBJaznaLCCBd78pi8ZyAnE0= github.com/clbanning/mxj/v2 v2.5.7/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403 h1:cqQfy1jclcSy/FwLjemeg3SR1yaINm74aQyupQ0Bl8M= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= @@ -736,6 +795,7 @@ github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4 h1:/inchEIKaYC1Akx+H+g github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20231109132714-523115ebc101 h1:7To3pQ+pZo0i3dsWEbinPNFs5gPSBOsJtx3wTT94VBY= github.com/cncf/xds/go v0.0.0-20231109132714-523115ebc101/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/containerd/cgroups/v3 v3.0.2 h1:f5WFqIVSgo5IZmtTT3qVBo6TzI1ON6sycSBKkymb9L0= github.com/containerd/cgroups/v3 v3.0.2/go.mod h1:JUgITrzdFqp42uI2ryGA+ge0ap/nxzYgkGmIcetmErE= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= @@ -751,12 +811,15 @@ github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmf github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.4.0 h1:y9YHcjnjynCd/DVbg5j9L/33jQM3MxJlbj/zWskzfGU= github.com/coreos/go-systemd/v22 v22.4.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= @@ -770,9 +833,7 @@ github.com/emicklei/go-restful/v3 v3.8.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry github.com/emicklei/go-restful/v3 v3.9.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emicklei/go-restful/v3 v3.10.2 h1:hIovbnmBTLjHXkqEBUz3HGpXZdM7ZrE9fJIZIqlJLqE= github.com/emicklei/go-restful/v3 v3.10.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0 h1:dulLQAYQFYtG5MTplgNGHWuV2D+OBD+Z8lmDBmbLg+s= @@ -782,21 +843,28 @@ github.com/envoyproxy/go-control-plane v0.11.1-0.20230524094728-9239064ad72f h1: github.com/envoyproxy/go-control-plane v0.11.1-0.20230524094728-9239064ad72f/go.mod h1:sfYdkwUW4BA3PbKjySwjJy+O4Pu0h62rlqCMHNk+K+Q= github.com/envoyproxy/go-control-plane v0.11.1 h1:wSUXTlLfiAQRWs2F+p+EKOY9rUyis1MyGqJ2DIk5HpM= github.com/envoyproxy/go-control-plane v0.11.1/go.mod h1:uhMcXKCQMEJHiAb0w+YGefQLaTEw+YhGluxZkrTmD0g= +github.com/envoyproxy/go-control-plane v0.13.0/go.mod h1:GRaKG3dwvFoTg4nj7aXdZnvMg4d7nvT/wl9WgVXn3Q8= github.com/envoyproxy/protoc-gen-validate v0.1.0 h1:EQciDnbrYxy13PgWoY8AqoxGiPrpgBZ1R8UNe3ddc+A= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.10.1 h1:c0g45+xCJhdgFGw7a5QAfdS4byAbud7miNWJ1WwEVf8= github.com/envoyproxy/protoc-gen-validate v0.10.1/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= github.com/envoyproxy/protoc-gen-validate v1.0.2 h1:QkIBuU5k+x7/QXPvPPnWXWlCdaBFApVqftFV6k087DA= github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= +github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/flowstack/go-jsonschema v0.1.1 h1:dCrjGJRXIlbDsLAgTJZTjhwUJnnxVWl1OgNyYh5nyDc= github.com/flowstack/go-jsonschema v0.1.1/go.mod h1:yL7fNggx1o8rm9RlgXv7hTBWxdBM0rVwpMwimd3F3N0= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 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/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df h1:Bao6dhmbTA1KFVxmJ6nBoMuOJit2yjEgLJpIMYpop0E= github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df/go.mod h1:GJr+FCSXshIwgHBtLglIg9M2l2kQSi6QjVAngtzI08Y= github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= @@ -805,43 +873,46 @@ github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNV github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -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-logr/zapr v1.2.4 h1:QHVo+6stLbfJmYGkQ7uGHUCu5hnAFAj6mDe6Ea0SeOo= github.com/go-logr/zapr v1.2.4/go.mod h1:FyHWQIzQORZ0QVE1BtVHv3cKtNLuXsbNLtpuhNapBOA= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/jsonreference v0.20.1/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro= github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 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/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.1.0 h1:/d3pCKDPWNnvIWe0vVUpNP32qc8U3PDVxySP/y360qE= github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= github.com/golang/glog v1.1.2 h1:DVjP2PbBOzHyzA+dn3WhHIq4NdVu3Q+pvivFICf/7fo= github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1 h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= github.com/google/cel-go v0.12.6 h1:kjeKudqV0OygrAqA9fX6J55S8gj+Jre2tckIm5RoG4M= @@ -853,33 +924,48 @@ github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6 github.com/google/gnostic v0.5.7-v3refs/go.mod h1:73MKFl6jIHelAJNaBGFzt3SPtZULs9dYrGFt8OiIsHQ= github.com/google/gnostic v0.6.9 h1:ZK/5VhkoX835RikCHpSUJV9a+S3e1zLh59YnyWeBW+0= github.com/google/gnostic v0.6.9/go.mod h1:Nm8234We1lq6iB9OmlgNv3nH91XLLVZHCDayfA3xq+E= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-intervals v0.0.2 h1:FGrVEiUnTRKR8yE04qzXYaJMtnIYqobR5QbblK3ixcM= github.com/google/go-intervals v0.0.2/go.mod h1:MkaR3LNRfeKLPmqgJYs4E66z5InYjmCjbbr4TQlcT6Y= github.com/google/go-pkcs11 v0.2.1-0.20230907215043-c6f79328ddf9 h1:OF1IPgv+F4NmqmJ98KTjdN97Vs1JxDPB3vbmYzV2dpk= github.com/google/go-pkcs11 v0.2.1-0.20230907215043-c6f79328ddf9/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= +github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw= github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.0/go.mod h1:OJpEgntRZo8ugHpF9hkoLJbS5dSI20XZeXJ9JVywLlM= github.com/google/s2a-go v0.1.4 h1:1kZ/sQM3srePvKs3tXAvQzo66XfcReoqFpIpIccE7Oc= github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k= github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.7.1 h1:gF4c0zjUP2H/s/hEGyLA3I0fA2ZWjzYiONAD6cvPr8A= github.com/googleapis/gax-go/v2 v2.7.1/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI= github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI= @@ -905,62 +991,99 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3 h1:lLT7ZLSzGLI08vc9cpd+tYmNWjd github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= +github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= +github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +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-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +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-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 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/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/iancoleman/strcase v0.2.0 h1:05I4QRnGpI0m37iZQRuskXh+w77mr6Z41lwQzuHLwW0= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20220517205856-0058ec4f073c h1:rwmN+hgiyp8QyBqzdEX43lTjKAxaqCrYHaU5op5P9J8= github.com/ianlancetaylor/demangle v0.0.0-20220517205856-0058ec4f073c/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= +github.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kisielk/errcheck v1.5.0 h1:e8esj/e4R+SAOwFwN+n3zr0nYeCyeweozKfO23MvHzY= github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= github.com/labring/operator-sdk v1.0.1 h1:JS+j9nF0lihkPJnMYJBZrH7Kfp/dKB2cnbBRMfkmE+g= github.com/labring/operator-sdk v1.0.1/go.mod h1:velfQ6SyrLXBeAShetQyR7q1zJNd8vGO6jjzbKcofj8= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/lufia/plan9stats v0.0.0-20230110061619-bbe2e5e100de h1:V53FWzU6KAZVi1tPp5UIsMoUWJ2/PNwYIDXnu7QuBCE= github.com/lufia/plan9stats v0.0.0-20230110061619-bbe2e5e100de/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE= github.com/lyft/protoc-gen-star/v2 v2.0.1 h1:keaAo8hRuAT0O3DfJ/wM3rufbAjGeJ1lAtWZHDjKGB0= github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= github.com/lyft/protoc-gen-star/v2 v2.0.3 h1:/3+/2sWyXeMLzKd1bX+ixWKgEMsULrIivpDsuaF441o= github.com/lyft/protoc-gen-star/v2 v2.0.3/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk= +github.com/lyft/protoc-gen-star/v2 v2.0.4-0.20230330145011-496ad1ac90a4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk= +github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/matoous/go-nanoid v1.5.0 h1:VRorl6uCngneC4oUQqOYtO3S0H5QKFtKuKycFG3euek= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= @@ -975,7 +1098,14 @@ github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dz github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/mistifyio/go-zfs/v3 v3.0.1 h1:YaoXgBePoMA12+S1u/ddkv+QqxcfiZK4prI6HPnkFiU= github.com/mistifyio/go-zfs/v3 v3.0.1/go.mod h1:CzVgeB0RvF2EGzQnytKVvVSDwmKJXxkOTUGbNrTja/k= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4 h1:BpfhmLKZf+SjVanKKhCgf3bg+511DmU9eDQTen7LLbY= @@ -993,6 +1123,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= +github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= @@ -1015,6 +1147,11 @@ github.com/opencontainers/runtime-spec v1.1.0 h1:HHUyrt9mwHUjtasSbXSMvs4cyFxh+Bl github.com/opencontainers/runtime-spec v1.1.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/selinux v1.11.0 h1:+5Zbo97w3Lbmb3PeqQtpmTkMwsW5nRI3YaLpt7tQ7oU= github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ= +github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= @@ -1022,17 +1159,22 @@ github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2 github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b h1:0LFwY6Q3gMACTjAbMZBjXAqTOzOwFaj2Ld6cjeQ7Rig= github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8= github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= @@ -1045,14 +1187,18 @@ github.com/prometheus/prom2json v1.3.3 h1:IYfSMiZ7sSOfliBoo89PcufjWO4eAR0gznGcET github.com/prometheus/prom2json v1.3.3/go.mod h1:Pv4yIPktEkK7btWsrUTWDDDrnpUrAELaOCj+oFwlgmc= github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/seccomp/libseccomp-golang v0.9.2-0.20220502022130-f33da4d89646 h1:RpforrEYXWkmGwJHIGnLZ3tTWStkjVVstwzNGqxX2Ds= github.com/seccomp/libseccomp-golang v0.9.2-0.20220502022130-f33da4d89646/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg= github.com/secure-io/sio-go v0.3.1 h1:dNvY9awjabXTYGsTF1PiCySl9Ltofk9GA3VdWlo7rRc= @@ -1064,8 +1210,12 @@ github.com/shirou/gopsutil/v3 v3.23.6 h1:5y46WPI9QBKBbK7EEccUPNXpJpNrvPuTD0O2zHE github.com/shirou/gopsutil/v3 v3.23.6/go.mod h1:j7QX50DrXYggrpN30W0Mo+I4/8U2UUIQrnrhqUeWrAU= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= +github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= +github.com/sirupsen/logrus v1.8.1/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/smartystreets/assertions v1.1.0 h1:MkTeG1DMwsrdH7QtLXy5W+fUxWq+vmb6cLmyJ7aRtF0= @@ -1078,22 +1228,30 @@ github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/afero v1.3.3 h1:p5gZEKLYoL7wh8VrJesMaYeNxdEd1v3cb4irOk9zB54= github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= +github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= +github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= +github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk= github.com/spf13/cobra v1.6.0 h1:42a0n6jwCot1pUmomAp4T7DeMD+20LFv4Q54pxLf2LI= github.com/spf13/cobra v1.6.0/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc= github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg= github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI= @@ -1135,6 +1293,8 @@ github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8 github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= @@ -1146,14 +1306,17 @@ go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA= go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= +go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/api/v3 v3.5.7 h1:sbcmosSVesNrWOJ58ZQFitHMdncusIifYcrBfwrlJSY= go.etcd.io/etcd/api/v3 v3.5.7/go.mod h1:9qew1gCdDDLu+VwmeG+iFpL+QlpHTo7iubavdVDgCAA= go.etcd.io/etcd/api/v3 v3.5.10 h1:szRajuUUbLyppkhs9K6BRtjY37l66XQQmw7oZRANE4k= go.etcd.io/etcd/api/v3 v3.5.10/go.mod h1:TidfmT4Uycad3NM/o25fG3J07odo4GBB9hoxaodFCtI= +go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/pkg/v3 v3.5.7 h1:y3kf5Gbp4e4q7egZdn5T7W9TSHUvkClN6u+Rq9mEOmg= go.etcd.io/etcd/client/pkg/v3 v3.5.7/go.mod h1:o0Abi1MK86iad3YrWhgUsbGx1pmTS+hrORWc2CamuhY= go.etcd.io/etcd/client/pkg/v3 v3.5.10 h1:kfYIdQftBnbAq8pUWFXfpuuxFSKzlmM5cSn76JByiT0= go.etcd.io/etcd/client/pkg/v3 v3.5.10/go.mod h1:DYivfIviIuQ8+/lCq4vcxuseg2P2XbHygkKwFo9fc8U= +go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= go.etcd.io/etcd/client/v2 v2.305.7 h1:AELPkjNR3/igjbO7CjyF1fPuVPjrblliiKj+Y6xSGOU= go.etcd.io/etcd/client/v2 v2.305.7/go.mod h1:GQGT5Z3TBuAQGvgPfhR7VPySu/SudxmEkRq9BgzFU6s= go.etcd.io/etcd/client/v2 v2.305.10 h1:MrmRktzv/XF8CvtQt+P6wLUlURaNpSDJHFZhe//2QE4= @@ -1174,8 +1337,13 @@ go.etcd.io/etcd/server/v3 v3.5.7 h1:BTBD8IJUV7YFgsczZMHhMTS67XuA4KpRquL0MFOJGRk= go.etcd.io/etcd/server/v3 v3.5.7/go.mod h1:gxBgT84issUVBRpZ3XkW1T55NjOb4vZZRI4wVvNhf4A= go.etcd.io/etcd/server/v3 v3.5.10 h1:4NOGyOwD5sUZ22PiWYKmfxqoeh72z6EhYjNosKGLmZg= go.etcd.io/etcd/server/v3 v3.5.10/go.mod h1:gBplPHfs6YI0L+RpGkTQO7buDbHv5HJGG/Bst0/zIPo= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.35.0 h1:xFSRQBbXF6VvYRf2lqMJXxoB72XI1K/azav8TekHHSw= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.35.0/go.mod h1:h8TWwRAhQpOd0aM5nYsRD8+flnkj+526GEIVlarH7eY= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.42.0 h1:ZOLJc06r4CB42laIXg/7udr0pbZyuAihN10A/XuiQRY= @@ -1206,6 +1374,7 @@ go.opentelemetry.io/otel/sdk v1.10.0 h1:jZ6K7sVn04kk/3DNUdJ4mqRlGDiXAVuIG+MMENpT go.opentelemetry.io/otel/sdk v1.10.0/go.mod h1:vO06iKzD5baltJz1zarxMCNHFpUlUiOy4s65ECtn6kE= go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= +go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok= go.opentelemetry.io/otel/trace v1.10.0 h1:npQMbR8o7mum8uF95yFbOEJffhs1sbCOfDh8zAJiH5E= go.opentelemetry.io/otel/trace v1.10.0/go.mod h1:Sij3YYczqAdz+EhmGhE6TpTxUO5/F/AzrK+kxfGqySM= go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg= @@ -1225,24 +1394,55 @@ go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/exp v0.0.0-20190121172915-509febef88a4 h1:c2HOrn5iMezYjSlGPncknSEr/8x5LELb/ilJbXi9DEA= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6 h1:QE6XYQK6naiK1EPAe1g/ILLxN5RBoH5xkJk3CqlMI/Y= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3 h1:XQyxROzUlZH+WIQwySDgnISgOivlhjIEwaQaJEJrrN0= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -1250,13 +1450,30 @@ golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -1268,8 +1485,21 @@ golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I= golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= @@ -1280,53 +1510,169 @@ golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74Ow golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/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.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +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-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/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-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/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-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/telemetry v0.0.0-20240208230135-b75ee8823808 h1:+Kc94D8UVEVxJnLXp/+FMfqQARZtWHfVrcRtcG8aT3g= golang.org/x/telemetry v0.0.0-20240208230135-b75ee8823808/go.mod h1:KG1lNk5ZFNssSZLrpVb4sMXKMpGwGXOxSG3rnu2gZQQ= +golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= +golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= +golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gomodules.xyz/jsonpatch/v2 v2.3.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= +google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= +google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8= google.golang.org/api v0.114.0 h1:1xQPji6cO2E2vLiI+C/XiFAnsn1WV3mjaEwGLhi3grE= google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg= google.golang.org/api v0.118.0/go.mod h1:76TtD3vkgmZ66zZzp72bUUklpmQmKlhh6sYtIjYK+5E= @@ -1334,15 +1680,51 @@ google.golang.org/api v0.126.0 h1:q4GJq+cAdMAC7XP7njvQ4tvohGLiSlytuL4BQxbIZ+o= google.golang.org/api v0.126.0/go.mod h1:mBwVAtz+87bEN6CbA1GtZPDOqY2R5ONPqJeIlvyo4Aw= google.golang.org/api v0.149.0 h1:b2CqT6kG+zqJIVKRQ3ELJVLN1PwHZ6DJ3dW8yl82rgY= google.golang.org/api v0.149.0/go.mod h1:Mwn1B7JTXrzXtnvmzQE2BD6bYZQ8DShKZDZbeN9I7qI= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= +google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20220107163113-42d7afdf6368/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20230323212658-478b75c54725/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= @@ -1360,6 +1742,8 @@ google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy google.golang.org/genproto v0.0.0-20231212172506-995d672761c0/go.mod h1:l/k7rMz0vFTBPy+tFSGvXEd3z+BcoG1k7EHbqm+YBsY= google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac h1:ZL/Teoy/ZGnzyrqK/Optxxp2pmVh+fmJ97slxSRyzUg= google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:+Rvu7ElI+aLzyDQhpHMFMMltsD6m7nqpuWDd2CwJw3k= +google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 h1:Q3nlH8iSQSRUwOskjbcSMcF2jiYMNiQYZ0c2KEJLKKU= +google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38/go.mod h1:xBI+tzfqGGN2JBeSebfKXFSdBpWVQ7sLW40PTupVRm4= google.golang.org/genproto/googleapis/api v0.0.0-20230525234035-dd9d682886f9 h1:m8v1xLLLzMe1m5P+gCTF8nJB9epwZQUBERm20Oy1poQ= google.golang.org/genproto/googleapis/api v0.0.0-20230525234035-dd9d682886f9/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= google.golang.org/genproto/googleapis/api v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= @@ -1371,10 +1755,12 @@ google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b/go. google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4= google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 h1:rcS6EyEaoCO52hQDupoSfrxI3R6C2Tq741is7X8OvnM= google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917/go.mod h1:CmlNWB9lSezaYELKS5Ym1r44VrrbPUa7JTvw+6MbpJ0= +google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I= google.golang.org/genproto/googleapis/bytestream v0.0.0-20230530153820-e85fd2cbaebc h1:g3hIDl0jRNd9PPTs2uBzYuaD5mQuwOkZY0vSc0LR32o= google.golang.org/genproto/googleapis/bytestream v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:ylj+BE99M198VPbBh6A8d9n3w8fChvyLK3wwBOjXBFA= google.golang.org/genproto/googleapis/bytestream v0.0.0-20231030173426-d783a09b4405 h1:o4S3HvTUEXgRsNSUQsALDVog0O9F/U1JJlHmmUN8Uas= google.golang.org/genproto/googleapis/bytestream v0.0.0-20231030173426-d783a09b4405/go.mod h1:GRUCuLdzVqZte8+Dl/D4N25yLzcGqqWaYkeVOwulFqw= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20241021214115-324edc3d5d38/go.mod h1:T8O3fECQbif8cez15vxAcjbwXxvL2xbnvbQ7ZfiMAMs= google.golang.org/genproto/googleapis/rpc v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc h1:XSJ8Vk1SWuNr8S18z1NZSziL0CPIXLCCMDOEFtHBOFc= google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= @@ -1387,14 +1773,24 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20231212172506-995d672761c0/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917/go.mod h1:xtjpI3tXFPP051KaWnhvxkiubL/6dJ18vLVf7q2pTOU= google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe h1:bQnxqljG/wqi4NTXu2+DJ3n7APcEA882QZ1JvhQAq9o= google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= @@ -1405,22 +1801,17 @@ google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9Y google.golang.org/grpc v1.60.1/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM= google.golang.org/grpc v1.61.0 h1:TOvOcuXn30kRao+gfcvsebNEa5iZIiLkisYEkf7R7o0= google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQfGwJD30Nv2jfUgzb5UcE= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -1428,15 +1819,21 @@ gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= +gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= 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.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc h1:/hemPrYIhOhy8zYrNj+069zDB68us2sMGsfkFJO0iZs= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= k8s.io/api v0.27.2/go.mod h1:ENmbocXfBT2ADujUXcBhHV55RIT31IIEvkntP6vZKS4= k8s.io/api v0.27.4/go.mod h1:O3smaaX15NfxjzILfiln1D8Z3+gEYpjEpiNA/1EVK1Y= k8s.io/api v0.28.2/go.mod h1:RVnJBsjU8tcMq7C3iaRSGMeaKt2TWEUXcpIt/90fjEg= @@ -1479,7 +1876,14 @@ k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/ k8s.io/utils v0.0.0-20230209194617-a36077c30491/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20231127182322-b307cd553661 h1:FepOBzJ0GXm8t0su67ln2wAZjbQ6RxQGZDnzuLcrUTI= k8s.io/utils v0.0.0-20231127182322-b307cd553661/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= +modernc.org/ccgo/v3 v3.17.0/go.mod h1:Sg3fwVpmLvCUTaqEUjiBDAvshIaKDB0RXaf+zgqFu8I= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.1.2 h1:trsWhjU5jZrx6UvFu4WzQDrN7Pga4a7Qg+zcfcj64PA= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.1.2/go.mod h1:+qG7ISXqCDVVcyO8hLn12AKVYYUjM7ftlqsqmrhMZE0= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.28.0 h1:TgtAeesdhpm2SGwkQasmbeqDo8th5wOBA5h/AjTKA4I= diff --git a/service/pkg/auth/authenticate.go b/service/pkg/auth/authenticate.go index 972a6559b1b..721bd29c51b 100644 --- a/service/pkg/auth/authenticate.go +++ b/service/pkg/auth/authenticate.go @@ -96,7 +96,7 @@ func Authenticate(ns, kc string) error { return fmt.Errorf("ping apiserver is no ok: %v", string(res)) } - if err := CheckResourceAccess(client, ns, "update", "pods"); err != nil { + if err := CheckResourceAccess(client, ns, "get", "pods"); err != nil { // fmt.Println(err.Error()) return fmt.Errorf("check resource access error: %v", err) }