Files
Enterprise-Knowledge-Base/internal/application/service/model.go
T
wizardchen fd3e2992b1 feat(models): support custom HTTP headers across all remote model calls
Introduce per-model `custom_headers` config (similar to OpenAI Python SDK's
`extra_headers`) so users can inject gateway auth tokens, trace IDs, etc.
into every outbound API request for chat / embedding / rerank / VLLM / ASR
models. Reserved headers like Authorization / Content-Type are always
preserved to avoid breaking auth or signing flows.

Along the way, unify production and "test connection" paths onto a single
`ConfigFromModel(*types.Model, appID, appSecret)` constructor in each
model package. Both `service.modelService.GetXxxModel` and the four
`handler.initialization.Check*Model` / `TestEmbeddingModel` endpoints now
go through the same field mapping, so new parameters only need to be
added in one place.

Backend:
- Add `CustomHeaders map[string]string` to `types.ModelParameters` with
  JSON/YAML `omitempty` tags for forward compatibility.
- New `internal/utils/extraheaders.go` providing `ApplyCustomHeaders`
  (for hand-rolled HTTP paths) and `WrapHTTPClientWithHeaders` /
  `CustomHeadersRoundTripper` (for SDK paths like go-openai). Reserved
  headers (Authorization, api-key, Content-Type, Accept, Host,
  Content-Length, User-Agent) are filtered out.
- Inject headers in every remote model path: chat (SDK + raw HTTP),
  embedding (openai/aliyun/jina/volcengine/nvidia/azure_openai), rerank
  (remote_api/aliyun/jina/nvidia/zhipu — via shared `customHeaderSetter`
  interface), VLM and ASR (via http.Client wrapping).
- Add `ConfigFromModel` to each of chat/embedding/rerank/vlm/asr,
  consolidating field mapping (ExtraConfig, CustomHeaders, InterfaceType
  defaulting, WeKnoraCloud credentials, etc.) and cover it with
  dedicated unit tests per package.
- Refactor `service/model.go`: replace ~100 lines of hand-written Config
  literals with one-line `ConfigFromModel` calls; drop the now-unused
  `stringMapToAnyMap` helper.
- Refactor `handler/initialization.go` test-connection endpoints: merge
  four ad-hoc request structs into one `ModelTestRequest`, extract
  `buildTestModel` and `resolveTenantWeKnoraCloudCreds` helpers, and
  route all four endpoints through `ConfigFromModel` + `NewXxx` so the
  test path is now behaviorally identical to production.

Frontend:
- Add `custom_headers` to the `ModelConfig` API type.
- `ModelEditorDialog.vue`: new Key-Value header editor (add/remove rows),
  auto-converts map <-> array when loading/saving, and passes the
  current header set to the `Test Connection` button so the preview
  reflects exactly what production will send.
- `ModelSettings.vue`: serialize the header array back into a map for
  the backend (drops empty rows).
- i18n: add custom-header labels / descriptions / placeholders to
  zh-CN, en-US, ru-RU, ko-KR.

Tests:
- `internal/utils/extraheaders_test.go` covers reserved-header filtering
  and round-tripper wrapping (including nil-client fallback).
- New `config_from_model_test.go` in chat / embedding / rerank / vlm /
  asr verifies all fields (CustomHeaders, ExtraConfig, InterfaceType
  defaulting, AppID/AppSecret) are propagated end-to-end.
2026-04-23 23:36:40 +08:00

507 lines
15 KiB
Go

package service
import (
"context"
"errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/asr"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/models/embedding"
"github.com/Tencent/WeKnora/internal/models/provider"
"github.com/Tencent/WeKnora/internal/models/rerank"
"github.com/Tencent/WeKnora/internal/models/utils/ollama"
"github.com/Tencent/WeKnora/internal/models/vlm"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/Tencent/WeKnora/internal/utils"
)
// ErrModelNotFound is returned when a model cannot be found in the repository
var ErrModelNotFound = errors.New("model not found")
// modelService implements the model service interface
type modelService struct {
repo interfaces.ModelRepository
ollamaService *ollama.OllamaService
pooler embedding.EmbedderPooler
tenantService interfaces.TenantService
}
// NewModelService creates a new model service instance
func NewModelService(repo interfaces.ModelRepository,
ollamaService *ollama.OllamaService,
pooler embedding.EmbedderPooler,
tenantService interfaces.TenantService,
) interfaces.ModelService {
return &modelService{
repo: repo,
ollamaService: ollamaService,
pooler: pooler,
tenantService: tenantService,
}
}
// decryptAppSecret 解密 AppSecret(如果为空或 cryptoSvc 为空则原样返回)
func (s *modelService) decryptAppSecret(encrypted string) string {
if encrypted == "" {
return encrypted
}
if key := utils.GetAESKey(); key != nil {
if encrypted, err := utils.DecryptAESGCM(encrypted, key); err == nil {
return encrypted
}
}
return encrypted
}
// resolveWeKnoraCloudCredentials 为 WeKnoraCloud 厂商模型补全 AppID/AppSecret。
// 当模型自身参数中未存储凭证时,自动从租户配置中获取(SaveCredentials 保存的凭证)。
func (s *modelService) resolveWeKnoraCloudCredentials(ctx context.Context, params *types.ModelParameters) (appID, appSecret string) {
appID = params.AppID
appSecret = s.decryptAppSecret(params.AppSecret)
if provider.ProviderName(params.Provider) != provider.ProviderWeKnoraCloud {
return
}
if appID != "" && appSecret != "" {
return
}
if s.tenantService == nil {
return
}
creds := s.tenantService.GetWeKnoraCloudCredentials(ctx)
if creds == nil {
return
}
if appID == "" {
appID = creds.AppID
}
if appSecret == "" {
appSecret = creds.AppSecret
}
return
}
// CreateModel creates a new model in the repository
// For local models, it initiates an asynchronous download process
// Remote models are immediately set to active status
func (s *modelService) CreateModel(ctx context.Context, model *types.Model) error {
logger.Infof(ctx, "Creating model: %s, type: %s, source: %s", model.Name, model.Type, model.Source)
// Handle remote models (e.g., OpenAI, Azure)
if model.Source == types.ModelSourceRemote {
logger.Info(ctx, "Remote model detected, setting status to active")
model.Status = types.ModelStatusActive
logger.Info(ctx, "Saving remote model to repository")
err := s.repo.Create(ctx, model)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_name": model.Name,
"model_type": model.Type,
})
return err
}
logger.Infof(ctx, "Remote model created successfully: %s", model.ID)
return nil
}
// Handle local models (e.g., Ollama)
logger.Info(ctx, "Local model detected, setting status to downloading")
model.Status = types.ModelStatusDownloading
logger.Info(ctx, "Saving local model to repository")
err := s.repo.Create(ctx, model)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_name": model.Name,
"model_type": model.Type,
})
return err
}
// Start asynchronous model download
logger.Infof(ctx, "Starting background download for model: %s", model.Name)
newCtx := logger.CloneContext(ctx)
go func() {
logger.Info(newCtx, "Background download started")
err := s.ollamaService.PullModel(newCtx, model.Name)
if err != nil {
logger.ErrorWithFields(newCtx, err, map[string]interface{}{
"model_name": model.Name,
})
model.Status = types.ModelStatusDownloadFailed
} else {
logger.Infof(newCtx, "Model download completed successfully: %s", model.Name)
model.Status = types.ModelStatusActive
}
logger.Infof(newCtx, "Updating model status to: %s", model.Status)
s.repo.Update(newCtx, model)
}()
logger.Infof(ctx, "Model creation initiated successfully: %s", model.ID)
return nil
}
// GetModelByID retrieves a model by its ID
// Returns an error if the model is not found or is in a non-active state
func (s *modelService) GetModelByID(ctx context.Context, id string) (*types.Model, error) {
// Check if ID is empty
if id == "" {
logger.Error(ctx, "Model ID is empty")
return nil, errors.New("model ID cannot be empty")
}
tenantID := types.MustTenantIDFromContext(ctx)
// Fetch model from repository
model, err := s.repo.GetByID(ctx, tenantID, id)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": id,
"tenant_id": tenantID,
})
return nil, err
}
// Check if model exists
if model == nil {
logger.Error(ctx, "Model not found")
return nil, ErrModelNotFound
}
logger.Infof(ctx, "Model found, name: %s, status: %s", model.Name, model.Status)
// Check model status
if model.Status == types.ModelStatusActive {
return model, nil
}
if model.Status == types.ModelStatusDownloading {
logger.Warn(ctx, "Model is currently downloading")
return nil, errors.New("model is currently downloading")
}
if model.Status == types.ModelStatusDownloadFailed {
logger.Error(ctx, "Model download failed")
return nil, errors.New("model download failed")
}
logger.Error(ctx, "Model status is abnormal")
return nil, errors.New("abnormal model status")
}
// ListModels returns all models belonging to the tenant
func (s *modelService) ListModels(ctx context.Context) ([]*types.Model, error) {
logger.Info(ctx, "Start listing models")
tenantID := types.MustTenantIDFromContext(ctx)
logger.Infof(ctx, "Listing models for tenant ID: %d", tenantID)
// List models from repository with no additional filters
models, err := s.repo.List(ctx, tenantID, "", "")
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"tenant_id": tenantID,
})
return nil, err
}
logger.Infof(ctx, "Retrieved %d models successfully", len(models))
return models, nil
}
// UpdateModel updates an existing model in the repository
func (s *modelService) UpdateModel(ctx context.Context, model *types.Model) error {
logger.Info(ctx, "Start updating model")
logger.Infof(ctx, "Updating model ID: %s, name: %s", model.ID, model.Name)
// Check if the model is builtin - builtin models cannot be updated
tenantID := types.MustTenantIDFromContext(ctx)
existingModel, err := s.repo.GetByID(ctx, tenantID, model.ID)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": model.ID,
})
return err
}
if existingModel != nil && existingModel.IsBuiltin {
logger.Warnf(ctx, "Attempted to update builtin model: %s", model.ID)
return errors.New("builtin models cannot be updated")
}
// Update model in repository
err = s.repo.Update(ctx, model)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": model.ID,
"model_name": model.Name,
})
return err
}
logger.Infof(ctx, "Model updated successfully: %s", model.ID)
return nil
}
// DeleteModel removes a model from the repository
func (s *modelService) DeleteModel(ctx context.Context, id string) error {
logger.Info(ctx, "Start deleting model")
logger.Infof(ctx, "Deleting model ID: %s", id)
tenantID := types.MustTenantIDFromContext(ctx)
logger.Infof(ctx, "Tenant ID: %d", tenantID)
// Check if the model is builtin - builtin models cannot be deleted
existingModel, err := s.repo.GetByID(ctx, tenantID, id)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": id,
})
return err
}
if existingModel != nil && existingModel.IsBuiltin {
logger.Warnf(ctx, "Attempted to delete builtin model: %s", id)
return errors.New("builtin models cannot be deleted")
}
// Delete model from repository
err = s.repo.Delete(ctx, tenantID, id)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": id,
"tenant_id": tenantID,
})
return err
}
logger.Infof(ctx, "Model deleted successfully: %s", id)
return nil
}
// GetEmbeddingModel retrieves and initializes an embedding model instance
// Takes a model ID and returns an Embedder interface implementation
func (s *modelService) GetEmbeddingModel(ctx context.Context, modelId string) (embedding.Embedder, error) {
// Get the model details
model, err := s.GetModelByID(ctx, modelId)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": modelId,
})
return nil, err
}
logger.Infof(ctx, "Getting embedding model: %s, source: %s", model.Name, model.Source)
appID, appSecret := s.resolveWeKnoraCloudCredentials(ctx, &model.Parameters)
embedder, err := embedding.NewEmbedder(embedding.ConfigFromModel(model, appID, appSecret), s.pooler, s.ollamaService)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": model.ID,
"model_name": model.Name,
})
return nil, err
}
logger.Info(ctx, "Embedding model initialized successfully")
return embedder, nil
}
// GetEmbeddingModelForTenant retrieves and initializes an embedding model for a specific tenant
// This is used for cross-tenant knowledge base sharing where the embedding model from
// the source tenant must be used to ensure vector compatibility
func (s *modelService) GetEmbeddingModelForTenant(ctx context.Context, modelId string, tenantID uint64) (embedding.Embedder, error) {
// Check if model ID is empty
if modelId == "" {
logger.Error(ctx, "Model ID is empty")
return nil, errors.New("model ID cannot be empty")
}
// Fetch model from repository using the specified tenant ID
model, err := s.repo.GetByID(ctx, tenantID, modelId)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": modelId,
"tenant_id": tenantID,
})
return nil, err
}
if model == nil {
logger.Error(ctx, "Model not found for specified tenant")
return nil, ErrModelNotFound
}
if model.Status != types.ModelStatusActive {
logger.Errorf(ctx, "Model is not active, status: %s", model.Status)
return nil, errors.New("model is not active")
}
logger.Infof(ctx, "Getting cross-tenant embedding model: %s, source: %s, tenant: %d", model.Name, model.Source, tenantID)
appID, appSecret := s.resolveWeKnoraCloudCredentials(ctx, &model.Parameters)
embedder, err := embedding.NewEmbedder(embedding.ConfigFromModel(model, appID, appSecret), s.pooler, s.ollamaService)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": model.ID,
"model_name": model.Name,
"tenant_id": tenantID,
})
return nil, err
}
logger.Info(ctx, "Cross-tenant embedding model initialized successfully")
return embedder, nil
}
// GetRerankModel retrieves and initializes a reranking model instance
// Takes a model ID and returns a Reranker interface implementation
func (s *modelService) GetRerankModel(ctx context.Context, modelId string) (rerank.Reranker, error) {
// Get the model details
model, err := s.GetModelByID(ctx, modelId)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": modelId,
})
return nil, err
}
logger.Infof(ctx, "Getting rerank model: %s, source: %s", model.Name, model.Source)
appID, appSecret := s.resolveWeKnoraCloudCredentials(ctx, &model.Parameters)
reranker, err := rerank.NewReranker(rerank.ConfigFromModel(model, appID, appSecret))
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": model.ID,
"model_name": model.Name,
})
return nil, err
}
logger.Info(ctx, "Rerank model initialized successfully")
return reranker, nil
}
// GetChatModel retrieves and initializes a chat model instance
// Takes a model ID and returns a Chat interface implementation
func (s *modelService) GetChatModel(ctx context.Context, modelId string) (chat.Chat, error) {
// Check if model ID is empty
if modelId == "" {
logger.Error(ctx, "Model ID is empty")
return nil, errors.New("model ID cannot be empty")
}
tenantID := types.MustTenantIDFromContext(ctx)
// Get the model directly from repository to avoid status checks
model, err := s.repo.GetByID(ctx, tenantID, modelId)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": modelId,
"tenant_id": tenantID,
})
return nil, err
}
if model == nil {
logger.Error(ctx, "Chat model not found")
return nil, ErrModelNotFound
}
logger.Infof(ctx, "Getting chat model: %s, source: %s", model.Name, model.Source)
appID, appSecret := s.resolveWeKnoraCloudCredentials(ctx, &model.Parameters)
chatModel, err := chat.NewChat(chat.ConfigFromModel(model, appID, appSecret), s.ollamaService)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": model.ID,
"model_name": model.Name,
})
return nil, err
}
return chatModel, nil
}
// GetVLMModel retrieves and initializes a vision language model instance.
func (s *modelService) GetVLMModel(ctx context.Context, modelId string) (vlm.VLM, error) {
if modelId == "" {
return nil, errors.New("model ID cannot be empty")
}
tenantID := types.MustTenantIDFromContext(ctx)
model, err := s.repo.GetByID(ctx, tenantID, modelId)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": modelId,
"tenant_id": tenantID,
})
return nil, err
}
if model == nil {
return nil, ErrModelNotFound
}
logger.Infof(ctx, "Getting VLM model: %s, source: %s", model.Name, model.Source)
appID, appSecret := s.resolveWeKnoraCloudCredentials(ctx, &model.Parameters)
vlmModel, err := vlm.NewVLM(vlm.ConfigFromModel(model, appID, appSecret), s.ollamaService)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": model.ID,
"model_name": model.Name,
})
return nil, err
}
return vlmModel, nil
}
// Note: default model selection logic has been removed; models no longer
// maintain a per-type default flag at the service layer.
// GetASRModel retrieves and initializes an automatic speech recognition model instance.
func (s *modelService) GetASRModel(ctx context.Context, modelId string) (asr.ASR, error) {
if modelId == "" {
return nil, errors.New("model ID cannot be empty")
}
tenantID := types.MustTenantIDFromContext(ctx)
model, err := s.repo.GetByID(ctx, tenantID, modelId)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": modelId,
"tenant_id": tenantID,
})
return nil, err
}
if model == nil {
return nil, ErrModelNotFound
}
logger.Infof(ctx, "Getting ASR model: %s, source: %s", model.Name, model.Source)
sttModel, err := asr.NewASR(asr.ConfigFromModel(model))
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": model.ID,
"model_name": model.Name,
})
return nil, err
}
return sttModel, nil
}