194 lines
8.2 KiB
Go
194 lines
8.2 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/url"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/secret"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type ProviderRepository interface {
|
|
Get(context.Context, string) (model.ProviderConfig, error)
|
|
List(context.Context) ([]model.ProviderConfig, error)
|
|
Save(context.Context, model.ProviderConfig, int64, string, string, bool) (model.ProviderConfig, error)
|
|
Audit(context.Context) ([]model.AuditEvent, error)
|
|
}
|
|
type ProviderService struct {
|
|
repo ProviderRepository
|
|
vault *secret.Vault
|
|
}
|
|
|
|
func NewProviderService(repo ProviderRepository, vault *secret.Vault) *ProviderService {
|
|
return &ProviderService{repo: repo, vault: vault}
|
|
}
|
|
|
|
type ProviderInput struct {
|
|
QuoteBaseURL string `json:"quote_base_url"`
|
|
MarketAdapter string `json:"market_adapter"`
|
|
HistoryBaseURL string `json:"history_base_url"`
|
|
Name string `json:"name"`
|
|
BaseURL string `json:"base_url"`
|
|
ModelName string `json:"model_name"`
|
|
WireAPI string `json:"wire_api"`
|
|
ModelCatalogURL string `json:"model_catalog_url"`
|
|
DisableResponseStorage *bool `json:"disable_response_storage"`
|
|
RequiresOpenAIAuth *bool `json:"requires_openai_auth"`
|
|
Enabled bool `json:"enabled"`
|
|
TimeoutSeconds int `json:"timeout_seconds"`
|
|
APIKey *string `json:"api_key,omitempty"`
|
|
ClearAPIKey bool `json:"clear_api_key"`
|
|
ExpectedVersion int64 `json:"expected_version"`
|
|
}
|
|
|
|
func providerKey(key string) bool {
|
|
return key == "model_primary" || key == "model_backup" || key == "model_review" || key == "market_primary"
|
|
}
|
|
func (s *ProviderService) authorize(actor model.User) error {
|
|
if actor.Role != "ADMIN" || actor.Status != "ACTIVE" {
|
|
return apperror.ErrForbidden
|
|
}
|
|
if s == nil || s.repo == nil {
|
|
return apperror.ErrUnavailable
|
|
}
|
|
return nil
|
|
}
|
|
func (s *ProviderService) List(ctx context.Context, actor model.User) ([]model.ProviderConfig, error) {
|
|
if err := s.authorize(actor); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.List(ctx)
|
|
}
|
|
func (s *ProviderService) Audit(ctx context.Context, actor model.User) ([]model.AuditEvent, error) {
|
|
if err := s.authorize(actor); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.Audit(ctx)
|
|
}
|
|
func (s *ProviderService) Save(ctx context.Context, actor model.User, key, requestID string, input ProviderInput) (model.ProviderConfig, error) {
|
|
var c model.ProviderConfig
|
|
if err := s.authorize(actor); err != nil {
|
|
return c, err
|
|
}
|
|
if !providerKey(key) || input.ExpectedVersion < 0 {
|
|
return c, apperror.ErrInvalidInput
|
|
}
|
|
if _, err := uuid.Parse(requestID); err != nil {
|
|
return c, apperror.ErrInvalidInput
|
|
}
|
|
if input.MarketAdapter != "" && (key != "market_primary" || (input.MarketAdapter != "eastmoney_public" && input.MarketAdapter != "sina_public")) {
|
|
return c, apperror.ErrInvalidInput
|
|
}
|
|
if input.QuoteBaseURL != "" {
|
|
u, err := url.Parse(input.QuoteBaseURL)
|
|
if key != "market_primary" || err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || len(input.QuoteBaseURL) > 2048 {
|
|
return c, apperror.ErrInvalidInput
|
|
}
|
|
}
|
|
if input.Enabled && input.MarketAdapter == "sina_public" && (input.HistoryBaseURL == "" || input.QuoteBaseURL == "") {
|
|
return c, apperror.ErrInvalidInput
|
|
}
|
|
if input.HistoryBaseURL != "" {
|
|
u, err := url.Parse(input.HistoryBaseURL)
|
|
if key != "market_primary" || err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || len(input.HistoryBaseURL) > 2048 {
|
|
return c, apperror.ErrInvalidInput
|
|
}
|
|
}
|
|
if input.Enabled && input.MarketAdapter == "eastmoney_public" && input.HistoryBaseURL == "" {
|
|
return c, apperror.ErrInvalidInput
|
|
}
|
|
input.Name = strings.TrimSpace(input.Name)
|
|
input.BaseURL = strings.TrimSpace(input.BaseURL)
|
|
input.ModelName = strings.TrimSpace(input.ModelName)
|
|
input.ModelCatalogURL = strings.TrimSpace(input.ModelCatalogURL)
|
|
if input.WireAPI == "" {
|
|
input.WireAPI = "responses"
|
|
}
|
|
if input.WireAPI != "responses" && input.WireAPI != "chat_completions" {
|
|
return c, apperror.ErrInvalidInput
|
|
}
|
|
if utf8.RuneCountInString(input.Name) > 80 || utf8.RuneCountInString(input.ModelName) > 120 || len(input.BaseURL) > 2048 || len(input.ModelCatalogURL) > 2048 || input.TimeoutSeconds < 1 || input.TimeoutSeconds > 120 {
|
|
return c, apperror.ErrInvalidInput
|
|
}
|
|
if input.BaseURL != "" {
|
|
u, err := url.Parse(input.BaseURL)
|
|
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" {
|
|
return c, apperror.ErrInvalidInput
|
|
}
|
|
// Credentials belong exclusively in the encrypted key field.
|
|
if strings.ContainsAny(u.Path, "\r\n") {
|
|
return c, apperror.ErrInvalidInput
|
|
}
|
|
}
|
|
if input.ModelCatalogURL != "" {
|
|
catalog, err := url.Parse(input.ModelCatalogURL)
|
|
base, baseErr := url.Parse(input.BaseURL)
|
|
if err != nil || baseErr != nil || catalog.Scheme != "https" || catalog.Host == "" || catalog.User != nil || catalog.RawQuery != "" || catalog.Fragment != "" || catalog.Opaque != "" || !strings.EqualFold(catalog.Host, base.Host) || base.Scheme != "https" {
|
|
return c, apperror.ErrInvalidInput
|
|
}
|
|
}
|
|
if input.APIKey != nil && (*input.APIKey == "" || len(*input.APIKey) > 4096 || strings.ContainsAny(*input.APIKey, "\r\n") || input.ClearAPIKey) {
|
|
return c, apperror.ErrInvalidInput
|
|
}
|
|
existing, err := s.repo.Get(ctx, key)
|
|
if err != nil && !errors.Is(err, apperror.ErrNotFound) {
|
|
return c, err
|
|
}
|
|
if existing.Version != input.ExpectedVersion {
|
|
return c, apperror.ErrConflict
|
|
}
|
|
if existing.BaseURL != input.BaseURL && len(existing.EncryptedAPIKey) > 0 && input.APIKey == nil && !input.ClearAPIKey {
|
|
return c, apperror.ErrInvalidInput
|
|
}
|
|
c = model.ProviderConfig{QuoteBaseURL: input.QuoteBaseURL, MarketAdapter: input.MarketAdapter, HistoryBaseURL: input.HistoryBaseURL, ConfigKey: key, Name: input.Name, BaseURL: input.BaseURL, ModelName: input.ModelName, Enabled: input.Enabled, TimeoutSeconds: input.TimeoutSeconds, EncryptedAPIKey: existing.EncryptedAPIKey, LastTestStatus: "NOT_CONFIGURED", WireAPI: input.WireAPI, ModelCatalogURL: input.ModelCatalogURL, DisableResponseStorage: true, RequiresOpenAIAuth: true}
|
|
if existing.Version > 0 {
|
|
c.DisableResponseStorage = existing.DisableResponseStorage
|
|
c.RequiresOpenAIAuth = existing.RequiresOpenAIAuth
|
|
}
|
|
if input.DisableResponseStorage != nil {
|
|
c.DisableResponseStorage = *input.DisableResponseStorage
|
|
}
|
|
if input.RequiresOpenAIAuth != nil {
|
|
c.RequiresOpenAIAuth = *input.RequiresOpenAIAuth
|
|
}
|
|
if input.ClearAPIKey {
|
|
c.EncryptedAPIKey = nil
|
|
}
|
|
if input.APIKey != nil {
|
|
c.EncryptedAPIKey, err = s.vault.Encrypt(*input.APIKey, key)
|
|
if err != nil {
|
|
return c, err
|
|
}
|
|
}
|
|
if c.Enabled {
|
|
if c.Name == "" || c.BaseURL == "" || (key != "market_primary" && (c.ModelName == "" || c.RequiresOpenAIAuth && len(c.EncryptedAPIKey) == 0)) {
|
|
return c, apperror.ErrInvalidInput
|
|
}
|
|
c.LastTestStatus = "NOT_TESTED"
|
|
}
|
|
return s.repo.Save(ctx, c, input.ExpectedVersion, actor.ID, requestID, input.APIKey != nil || input.ClearAPIKey)
|
|
}
|
|
|
|
// CopyPrimaryToReview lets a single endpoint/key configure a separate review call.
|
|
// Credentials stay in the server vault and are rebound to the destination config.
|
|
func (s *ProviderService) CopyPrimaryToReview(ctx context.Context, actor model.User, requestID string, expected int64) (model.ProviderConfig, error) {
|
|
if err := s.authorize(actor); err != nil {
|
|
return model.ProviderConfig{}, err
|
|
}
|
|
primary, err := s.repo.Get(ctx, "model_primary")
|
|
if err != nil {
|
|
return model.ProviderConfig{}, err
|
|
}
|
|
key, err := s.vault.Decrypt(primary.EncryptedAPIKey, primary.ConfigKey)
|
|
if err != nil {
|
|
return model.ProviderConfig{}, err
|
|
}
|
|
disabled, auth := primary.DisableResponseStorage, primary.RequiresOpenAIAuth
|
|
return s.Save(ctx, actor, "model_review", requestID, ProviderInput{Name: primary.Name, BaseURL: primary.BaseURL, ModelName: primary.ModelName, WireAPI: primary.WireAPI, ModelCatalogURL: primary.ModelCatalogURL, DisableResponseStorage: &disabled, RequiresOpenAIAuth: &auth, Enabled: primary.Enabled, TimeoutSeconds: primary.TimeoutSeconds, APIKey: &key, ExpectedVersion: expected})
|
|
}
|