Files

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})
}