mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-11-10 21:32:43 +08:00
refa: consolidate model access and tenant defaults (#21740)
This commit is contained in:
1 parent
bd9caf4e1f
commit
6aec415a33
104 files changed
+2330
-2485
No files matched your search
+1
-1
@@ -48,7 +48,7 @@ DEVICE=${DEVICE:-cpu}
|
||||
# This controls only the bundled metadata service started by Compose; DB_TYPE
|
||||
# independently selects the metadata backend used by RAGFlow. Keep `mysql` to
|
||||
# start the in-cluster MySQL service. For external GaussDB, set both
|
||||
# DB_TYPE=gaussdb and METADATA_DB_PROFILE=gaussdb so MySQL stays disabled.
|
||||
# DB_TYPE=gaussdb and METADATA_DB_PROFILE=gaussdb so MySQL stays disabled.
|
||||
# For vastbase serving as both doc engine and metadata database, set DB_TYPE=vastbase
|
||||
# and METADATA_DB_PROFILE=vastbase so MySQL stays disabled.
|
||||
METADATA_DB_PROFILE=${METADATA_DB_PROFILE:-mysql}
|
||||
|
||||
@@ -261,4 +261,4 @@ user_default_llm:
|
||||
# path: audit_log.log # empty/missing = binary default (server_main.log / admin_server.log / ingestion_server.log)
|
||||
# max_size: 100 # MB before rotation
|
||||
# max_backups: 10 # retained rotated files
|
||||
# max_age: 30 # days
|
||||
# max_age: 30 # days
|
||||
@@ -24,7 +24,7 @@ func setupCategorizeDecisionModel(t *testing.T, baseURL string) *gorm.DB {
|
||||
dao.GetModelProviderManager()
|
||||
db := setupComponentTestDB(t)
|
||||
for _, row := range []any{
|
||||
&entity.TenantModelProvider{ID: "decision-provider", TenantID: "tenant-1", ProviderName: "SystemOne"},
|
||||
&entity.TenantModelProvider{ID: "decision-provider", UserID: "tenant-1", TenantID: "tenant-1", ProviderName: "SystemOne"},
|
||||
&entity.TenantModelInstance{ID: "decision-instance", ProviderID: "decision-provider", InstanceName: "default", APIKey: "test-key", Status: "active", Extra: fmt.Sprintf(`{"base_url":%q}`, baseURL)},
|
||||
&entity.TenantModel{ID: categorizeDecisionModelID, ProviderID: "decision-provider", InstanceID: "decision-instance", ModelName: "decision-model-preview", ModelType: int(entity.ModelTypeDecision), Status: "active"},
|
||||
} {
|
||||
|
||||
@@ -379,6 +379,7 @@ func TestCategorize_ResolvesTenantModelInstanceCredentials(t *testing.T) {
|
||||
pushComponentDB(t, db)
|
||||
if err := db.Create(&entity.TenantModelProvider{
|
||||
ID: "provider-1",
|
||||
UserID: "tenant-1",
|
||||
TenantID: "tenant-1",
|
||||
ProviderName: "SILICONFLOW",
|
||||
}).Error; err != nil {
|
||||
@@ -428,6 +429,7 @@ func TestCategorize_ResolvesInstanceCredentialsModelNameWithAt(t *testing.T) {
|
||||
pushComponentDB(t, db)
|
||||
if err := db.Create(&entity.TenantModelProvider{
|
||||
ID: "provider-1",
|
||||
UserID: "tenant-1",
|
||||
TenantID: "tenant-1",
|
||||
ProviderName: "LM-Studio",
|
||||
}).Error; err != nil {
|
||||
@@ -478,6 +480,7 @@ func TestCategorize_ResolvesTenantModelID(t *testing.T) {
|
||||
pushComponentDB(t, db)
|
||||
if err := db.Create(&entity.TenantModelProvider{
|
||||
ID: "provider-1",
|
||||
UserID: "tenant-1",
|
||||
TenantID: "tenant-1",
|
||||
ProviderName: "SILICONFLOW",
|
||||
}).Error; err != nil {
|
||||
@@ -538,6 +541,7 @@ func TestCategorize_ResolvesSoleActiveInstanceWhenDefaultMissing(t *testing.T) {
|
||||
pushComponentDB(t, db)
|
||||
if err := db.Create(&entity.TenantModelProvider{
|
||||
ID: "provider-1",
|
||||
UserID: "tenant-1",
|
||||
TenantID: "tenant-1",
|
||||
ProviderName: "SILICONFLOW",
|
||||
}).Error; err != nil {
|
||||
|
||||
@@ -30,8 +30,8 @@ import (
|
||||
"ragflow/internal/agent/component/prompts"
|
||||
"ragflow/internal/agent/runtime"
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity/models"
|
||||
"ragflow/internal/modelcontext"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -815,7 +815,7 @@ func (c *LLMComponent) prepareChatRequest(
|
||||
tenantID = tid
|
||||
}
|
||||
}
|
||||
contentLength := dao.ResolveModelContentLength(ctx, db, tenantID, originalModelID, p.Driver, p.ModelID)
|
||||
contentLength := modelcontext.ResolveModelContentLength(ctx, db, tenantID, originalModelID, p.Driver, p.ModelID)
|
||||
if contentLength <= 0 {
|
||||
// A 0 makes chat.FitMessages fall back to the 8192 default budget, which
|
||||
// can silently discard most of a large-context prompt, so surface the
|
||||
|
||||
@@ -123,7 +123,13 @@ func resolveTenantModelInstanceCredentials(ctx context.Context, db *gorm.DB, tid
|
||||
zap.String("model", modelName),
|
||||
zap.String("instance", instanceName))
|
||||
|
||||
provider, err := dao.NewTenantModelProviderDAO().GetByTenantIDAndProviderName(ctx, db, tid, providerName)
|
||||
owner, ownerErr := dao.NewUserTenantDAO().GetOwnerByTenantID(ctx, db, tid)
|
||||
if ownerErr != nil {
|
||||
common.Debug("llm credentials: new-table fallback: tenant owner lookup failed", zap.String("tenant", tid), zap.Error(ownerErr))
|
||||
return "", baseURL, false
|
||||
}
|
||||
ownerUserID := owner.UserID
|
||||
provider, err := dao.NewTenantModelProviderDAO().GetByTenantIDAndUserIDAndProviderName(ctx, db, tid, ownerUserID, providerName)
|
||||
if err != nil || provider == nil {
|
||||
common.Debug("llm credentials: new-table fallback: provider not found", zap.String("provider", providerName), zap.Error(err))
|
||||
return "", baseURL, false
|
||||
|
||||
@@ -275,6 +275,7 @@ func TestLLM_Invoke_CompositeModel_CustomContextOverride(t *testing.T) {
|
||||
|
||||
if err := db.Create(&entity.TenantModelProvider{
|
||||
ID: "provider-comp-1",
|
||||
UserID: "tenant-1",
|
||||
TenantID: "tenant-1",
|
||||
ProviderName: "OpenAI",
|
||||
}).Error; err != nil {
|
||||
@@ -337,6 +338,7 @@ func TestLLM_Invoke_UUIDModel_CustomContextOverride(t *testing.T) {
|
||||
|
||||
if err := db.Create(&entity.TenantModelProvider{
|
||||
ID: "provider-uuid-2",
|
||||
UserID: "tenant-1",
|
||||
TenantID: "tenant-1",
|
||||
ProviderName: "OpenAI",
|
||||
}).Error; err != nil {
|
||||
@@ -400,6 +402,7 @@ func TestLLM_Invoke_UUIDModel_ResolvesContentLength(t *testing.T) {
|
||||
|
||||
if err := db.Create(&entity.TenantModelProvider{
|
||||
ID: "provider-uuid-1",
|
||||
UserID: "tenant-1",
|
||||
TenantID: "tenant-1",
|
||||
ProviderName: "OpenAI",
|
||||
}).Error; err != nil {
|
||||
@@ -459,6 +462,7 @@ func TestLLM_ResolvesTenantModelID(t *testing.T) {
|
||||
|
||||
if err := db.Create(&entity.TenantModelProvider{
|
||||
ID: "provider-1",
|
||||
UserID: "tenant-1",
|
||||
TenantID: "tenant-1",
|
||||
ProviderName: "DeepSeek",
|
||||
}).Error; err != nil {
|
||||
@@ -577,7 +581,7 @@ func TestLLM_Invoke_UnresolvableModelFallsBackTo8192(t *testing.T) {
|
||||
|
||||
// TestLLM_Invoke_UsesModelContentLengthBudget verifies that the message
|
||||
// fitting budget in Invoke is the chat model's context window
|
||||
// (content_length) resolved via dao.ResolveModelContentLength — NOT the
|
||||
// (content_length) resolved via modelcontext.ResolveModelContentLength - NOT the
|
||||
// canvas max_tokens / the 8192 fallback. A user prompt far larger than the
|
||||
// 8192 fallback (but well inside gpt-4o@openai's 128k window) must be passed
|
||||
// through to the invoker untrimmed.
|
||||
|
||||
@@ -200,7 +200,7 @@ func TestMigratedMinerUControlsReachRequest(t *testing.T) {
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
db := openIngestionRuntimeDB(t)
|
||||
if err := db.AutoMigrate(&entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
if err := db.AutoMigrate(&entity.UserTenant{}, &entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
extra, err := json.Marshal(map[string]string{"base_url": server.URL})
|
||||
@@ -208,7 +208,7 @@ func TestMigratedMinerUControlsReachRequest(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range []any{
|
||||
&entity.TenantModelProvider{ID: "provider-mineru", TenantID: "tenant", ProviderName: "MinerU"},
|
||||
&entity.TenantModelProvider{ID: "provider-mineru", UserID: "tenant", TenantID: "tenant", ProviderName: "MinerU"},
|
||||
&entity.TenantModelInstance{ID: "instance-mineru", ProviderID: "provider-mineru", InstanceName: "default", APIKey: "synthetic-mineru-key", Status: "active", Extra: string(extra)},
|
||||
&entity.TenantModel{ID: "model-mineru", ProviderID: "provider-mineru", InstanceID: "instance-mineru", ModelName: "MinerU-model", ModelType: int(entity.ModelTypeOCR), Status: "active"},
|
||||
} {
|
||||
@@ -715,7 +715,7 @@ func openIngestionRuntimeDB(t *testing.T) *gorm.DB {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err := db.AutoMigrate(&entity.Knowledgebase{}, &entity.SystemSettings{}); err != nil {
|
||||
if err := db.AutoMigrate(&entity.Knowledgebase{}, &entity.SystemSettings{}, &entity.UserTenant{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
|
||||
@@ -18,6 +18,7 @@ package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"ragflow/internal/entity"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -69,6 +70,24 @@ func (dao *TenantModelDAO) GetByIDs(ctx context.Context, db *gorm.DB, ids []stri
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// ListByTenantIDAndModelType returns a tenant's authorized model records.
|
||||
// Zero-type rows are retained when filtering so the caller can derive their
|
||||
// type from the provider catalog before producing the response.
|
||||
func (dao *TenantModelDAO) ListByTenantIDAndModelType(ctx context.Context, db *gorm.DB, tenantID string, modelType int, authorize func(*gorm.DB) *gorm.DB) ([]*entity.TenantModel, error) {
|
||||
if authorize == nil {
|
||||
return nil, fmt.Errorf("model list requires an authorization scope")
|
||||
}
|
||||
var models []*entity.TenantModel
|
||||
query := db.WithContext(ctx).Model(&entity.TenantModel{}).
|
||||
Joins("JOIN tenant_model_provider ON tenant_model_provider.id = tenant_model.provider_id").
|
||||
Where("tenant_model_provider.tenant_id = ?", tenantID)
|
||||
if modelType != 0 {
|
||||
query = query.Where("(tenant_model.model_type = 0 OR (tenant_model.model_type & ?) > 0)", modelType)
|
||||
}
|
||||
err := authorize(query).Find(&models).Error
|
||||
return models, err
|
||||
}
|
||||
|
||||
func (dao *TenantModelDAO) GetModelByProviderIDAndInstanceIDAndModelName(ctx context.Context, db *gorm.DB, providerID, instanceID, modelName string) (*entity.TenantModel, error) {
|
||||
var model entity.TenantModel
|
||||
err := db.WithContext(ctx).Where("provider_id = ? AND instance_id = ? AND model_name = ?", providerID, instanceID, modelName).First(&model).Error
|
||||
@@ -140,7 +159,7 @@ func (dao *TenantModelDAO) DeleteByInstanceIDs(ctx context.Context, db *gorm.DB,
|
||||
// used to fetch per-tenant enable/disable overrides in bulk during
|
||||
// /api/v1/models response assembly. The Go port never WRITES to
|
||||
// tenant_model, so callers must treat an empty result as "use factory
|
||||
// defaults" — see ModelProviderService.ListTenantAddedModels.
|
||||
// defaults" - see ModelProviderService.ListModelsForUserInTenant.
|
||||
func (dao *TenantModelDAO) GetModelsByProviderIDsAndInstanceIDs(ctx context.Context, db *gorm.DB, providerIDs, instanceIDs []string) ([]*entity.TenantModel, error) {
|
||||
models := make([]*entity.TenantModel, 0)
|
||||
if len(providerIDs) == 0 || len(instanceIDs) == 0 {
|
||||
|
||||
@@ -28,6 +28,14 @@ import (
|
||||
// TenantModelInstanceDAO tenant model instance data access object
|
||||
type TenantModelInstanceDAO struct{}
|
||||
|
||||
// TenantModelInstanceSummary contains the fields needed by model list views.
|
||||
type TenantModelInstanceSummary struct {
|
||||
ID string
|
||||
InstanceName string
|
||||
ProviderID string
|
||||
Status string
|
||||
}
|
||||
|
||||
// NewTenantModelInstanceDAO create tenant model instance DAO
|
||||
func NewTenantModelInstanceDAO() *TenantModelInstanceDAO {
|
||||
return &TenantModelInstanceDAO{}
|
||||
@@ -67,21 +75,30 @@ func (dao *TenantModelInstanceDAO) GetAllInstancesByProviderID(ctx context.Conte
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// GetByProviderIDs returns all TenantModelInstance rows whose provider_id
|
||||
// is in providerIDs. Mirrors Python's
|
||||
// TenantModelInstanceService.get_by_provider_ids used by
|
||||
// models_api_service.list_tenant_added_models. An empty input slice
|
||||
// returns an empty (non-nil) slice with no error.
|
||||
func (dao *TenantModelInstanceDAO) GetByProviderIDs(ctx context.Context, db *gorm.DB, providerIDs []string) ([]*entity.TenantModelInstance, error) {
|
||||
instances := make([]*entity.TenantModelInstance, 0)
|
||||
// ListSummariesByProviderIDs loads only non-secret instance fields used by model lists.
|
||||
func (dao *TenantModelInstanceDAO) ListSummariesByProviderIDs(ctx context.Context, db *gorm.DB, providerIDs []string) ([]*TenantModelInstanceSummary, error) {
|
||||
instances := make([]*TenantModelInstanceSummary, 0)
|
||||
if len(providerIDs) == 0 {
|
||||
return instances, nil
|
||||
}
|
||||
err := db.WithContext(ctx).Where("provider_id IN ?", providerIDs).Find(&instances).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
err := db.WithContext(ctx).Model(&entity.TenantModelInstance{}).
|
||||
Select("id", "instance_name", "provider_id", "status").
|
||||
Where("provider_id IN ?", providerIDs).
|
||||
Find(&instances).Error
|
||||
return instances, err
|
||||
}
|
||||
|
||||
// ListProviderIDsWithInstances returns provider IDs that have at least one instance.
|
||||
func (dao *TenantModelInstanceDAO) ListProviderIDsWithInstances(ctx context.Context, db *gorm.DB, providerIDs []string) ([]string, error) {
|
||||
instances := make([]string, 0)
|
||||
if len(providerIDs) == 0 {
|
||||
return instances, nil
|
||||
}
|
||||
return instances, nil
|
||||
err := db.WithContext(ctx).Model(&entity.TenantModelInstance{}).
|
||||
Distinct("provider_id").
|
||||
Where("provider_id IN ?", providerIDs).
|
||||
Pluck("provider_id", &instances).Error
|
||||
return instances, err
|
||||
}
|
||||
|
||||
func (dao *TenantModelInstanceDAO) GetInstanceByApiKey(ctx context.Context, db *gorm.DB, apiKey, providerID string) (*entity.TenantModelInstance, error) {
|
||||
|
||||
@@ -26,6 +26,16 @@ import (
|
||||
"ragflow/internal/entity"
|
||||
)
|
||||
|
||||
func migrationTestMaxTokens(extra string) int64 {
|
||||
var value struct {
|
||||
MaxTokens int64 `json:"max_tokens"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(extra), &value); err != nil {
|
||||
return 0
|
||||
}
|
||||
return value.MaxTokens
|
||||
}
|
||||
|
||||
func setupTenantModelMaxTokensMigrationDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{TranslateError: true})
|
||||
@@ -154,7 +164,7 @@ func TestMigrateTenantModelMaxTokensBackfillsOnlyMissingValues(t *testing.T) {
|
||||
t.Fatalf("failed to read tenant model %q: %v", item.id, err)
|
||||
}
|
||||
}
|
||||
if got := modelExtraMaxTokens(missing.Extra); got != 65536 {
|
||||
if got := migrationTestMaxTokens(missing.Extra); got != 65536 {
|
||||
t.Fatalf("backfilled max_tokens = %d, want 65536 (extra: %s)", got, missing.Extra)
|
||||
}
|
||||
var missingExtra struct {
|
||||
@@ -166,10 +176,10 @@ func TestMigrateTenantModelMaxTokensBackfillsOnlyMissingValues(t *testing.T) {
|
||||
if !missingExtra.IsTools {
|
||||
t.Fatalf("backfill lost existing extra fields: %s", missing.Extra)
|
||||
}
|
||||
if got := modelExtraMaxTokens(configured.Extra); got != 16384 {
|
||||
if got := migrationTestMaxTokens(configured.Extra); got != 16384 {
|
||||
t.Fatalf("existing max_tokens was overwritten: got %d, want 16384", got)
|
||||
}
|
||||
if got := modelExtraMaxTokens(zero.Extra); got != 0 {
|
||||
if got := migrationTestMaxTokens(zero.Extra); got != 0 {
|
||||
t.Fatalf("explicit zero max_tokens was overwritten: got %d, want 0", got)
|
||||
}
|
||||
if hasMaxTokens, err := tenantModelExtraHasMaxTokens(zero.Extra); err != nil || !hasMaxTokens {
|
||||
@@ -239,7 +249,7 @@ func TestBackfillTenantModelMaxTokensSkipsCatalogDefaults(t *testing.T) {
|
||||
if tc.wantUpdated == 0 && model.Extra != `{"is_tools":true}` {
|
||||
t.Fatalf("catalog default changed extra: %s", model.Extra)
|
||||
}
|
||||
if tc.wantUpdated == 1 && int64(modelExtraMaxTokens(model.Extra)) != tc.limit {
|
||||
if tc.wantUpdated == 1 && migrationTestMaxTokens(model.Extra) != tc.limit {
|
||||
t.Fatalf("custom limit was not preserved: %s", model.Extra)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -45,33 +45,29 @@ func (dao *TenantModelProviderDAO) GetByID(ctx context.Context, db *gorm.DB, id
|
||||
return &provider, nil
|
||||
}
|
||||
|
||||
// GetByTenantIDAndProviderName get the providers by tenant ID and provider name
|
||||
func (dao *TenantModelProviderDAO) GetByTenantIDAndProviderName(ctx context.Context, db *gorm.DB, tenantID, providerName string) (*entity.TenantModelProvider, error) {
|
||||
// GetByTenantIDAndUserIDAndProviderName gets a user's provider configuration in a tenant.
|
||||
func (dao *TenantModelProviderDAO) GetByTenantIDAndUserIDAndProviderName(ctx context.Context, db *gorm.DB, tenantID, userID, providerName string) (*entity.TenantModelProvider, error) {
|
||||
var provider entity.TenantModelProvider
|
||||
err := db.WithContext(ctx).Where("tenant_id = ? AND provider_name = ?", tenantID, providerName).First(&provider).Error
|
||||
err := db.WithContext(ctx).
|
||||
Where("tenant_id = ? AND user_id = ? AND LOWER(provider_name) = LOWER(?)", tenantID, userID, providerName).
|
||||
First(&provider).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &provider, nil
|
||||
}
|
||||
|
||||
// DeleteByTenantIDAndProviderName DeleteByTenantID deletes all providers by tenant ID (hard delete)
|
||||
func (dao *TenantModelProviderDAO) DeleteByTenantIDAndProviderName(ctx context.Context, db *gorm.DB, tenantID, providerName string) (int64, error) {
|
||||
result := db.WithContext(ctx).Unscoped().Where("tenant_id = ? AND provider_name = ?", tenantID, providerName).Delete(&entity.TenantModelProvider{})
|
||||
return result.RowsAffected, result.Error
|
||||
// ListByTenantIDAndUserID returns one user's provider configurations in a tenant.
|
||||
func (dao *TenantModelProviderDAO) ListByTenantIDAndUserID(ctx context.Context, db *gorm.DB, tenantID, userID string) ([]*entity.TenantModelProvider, error) {
|
||||
var providers []*entity.TenantModelProvider
|
||||
err := db.WithContext(ctx).
|
||||
Where("tenant_id = ? AND user_id = ?", tenantID, userID).
|
||||
Find(&providers).Error
|
||||
return providers, err
|
||||
}
|
||||
|
||||
// GetByTenantID returns all TenantModelProvider rows for a tenant.
|
||||
// Mirrors Python's TenantModelProviderService.get_by_tenant_id and is the
|
||||
// entry point for /api/v1/models ("list all added models"). The Go port
|
||||
// uses this to enumerate which providers a tenant has linked before
|
||||
// fanning out to TenantModelInstanceDAO / TenantModelDAO for the joined
|
||||
// result.
|
||||
func (dao *TenantModelProviderDAO) GetByTenantID(ctx context.Context, db *gorm.DB, tenantID string) ([]*entity.TenantModelProvider, error) {
|
||||
var providers []*entity.TenantModelProvider
|
||||
err := db.WithContext(ctx).Where("tenant_id = ?", tenantID).Find(&providers).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return providers, nil
|
||||
// DeleteByID hard-deletes one provider configuration.
|
||||
func (dao *TenantModelProviderDAO) DeleteByID(ctx context.Context, db *gorm.DB, id string) (int64, error) {
|
||||
result := db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&entity.TenantModelProvider{})
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
@@ -129,6 +129,9 @@ func (dao *UserTenantDAO) GetOwnerByTenantID(ctx context.Context, db *gorm.DB, t
|
||||
Where("tenant_id = ? AND user_id = ? AND status = ?", tenantID, tenantID, entity.TenantMembershipStatusJoined).
|
||||
Take(&membership).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &entity.UserTenant{UserID: tenantID, TenantID: tenantID}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &membership, nil
|
||||
|
||||
@@ -50,3 +50,21 @@ func TestGetOwnerByTenantIDFallsBackToTenantIDUserID(t *testing.T) {
|
||||
t.Fatalf("GetOwnerByTenantID() = %#v, want the tenant_id=user_id membership", membership)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOwnerByTenantIDUsesTenantIDWhenOwnerRowIsMissing(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&entity.UserTenant{}); err != nil {
|
||||
t.Fatalf("migrate user_tenant: %v", err)
|
||||
}
|
||||
|
||||
owner, err := NewUserTenantDAO().GetOwnerByTenantID(t.Context(), db, "personal-tenant")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOwnerByTenantID() error = %v", err)
|
||||
}
|
||||
if owner.UserID != "personal-tenant" || owner.TenantID != "personal-tenant" {
|
||||
t.Fatalf("GetOwnerByTenantID() = %#v, want tenant ID as the personal owner", owner)
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,7 @@ func (h *ChatHandler) Create(c *gin.Context) {
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/chat/mindmap [post]
|
||||
func (h *ChatHandler) MindMap(c *gin.Context) {
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
common.ErrorWithCode(c, errorCode, errorMessage)
|
||||
return
|
||||
@@ -207,13 +207,13 @@ func (h *ChatHandler) MindMap(c *gin.Context) {
|
||||
|
||||
ctx := c.Request.Context()
|
||||
searchConfig := map[string]interface{}{}
|
||||
modelTenantID := user.ID
|
||||
var modelTenantID string
|
||||
if req.SearchID != "" {
|
||||
if h.searchSvc == nil {
|
||||
jsonInternalError(c, fmt.Errorf("search service not configured"))
|
||||
return
|
||||
}
|
||||
detail, err := h.searchSvc.GetDetail(ctx, user.ID, req.SearchID)
|
||||
detail, err := h.searchSvc.GetDetail(ctx, req.SearchID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
@@ -238,7 +238,6 @@ func (h *ChatHandler) MindMap(c *gin.Context) {
|
||||
KbIDs: kbIDs,
|
||||
SearchID: req.SearchID,
|
||||
SearchConfig: searchConfig,
|
||||
AuthUserID: user.ID,
|
||||
ModelTenantID: modelTenantID,
|
||||
ChunkSvc: h.chunkSvc,
|
||||
LLM: h.modelFactory,
|
||||
|
||||
@@ -73,7 +73,7 @@ func (h *ChatHandler) ChatAudioSpeech(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ttsModel, err := h.modelFactory.NewDefaultTTSModel(ctx, service.ModelAccess{UserID: user.ID, TenantID: user.ID})
|
||||
ttsModel, err := h.modelFactory.NewDefaultTTSModel(ctx, service.Identity{UserID: user.ID, TenantID: user.ID})
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, common.CodeDataError, err.Error())
|
||||
return
|
||||
@@ -268,7 +268,7 @@ func (h *ChatHandler) ChatAudioTranscription(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
asrModel, err := h.modelFactory.NewDefaultASRModel(ctx, service.ModelAccess{UserID: user.ID, TenantID: user.ID})
|
||||
asrModel, err := h.modelFactory.NewDefaultASRModel(ctx, service.Identity{UserID: user.ID, TenantID: user.ID})
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, common.CodeDataError, err.Error())
|
||||
return
|
||||
|
||||
@@ -43,7 +43,7 @@ type ChatRecommendationRequest struct {
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/chat/recommendation [post]
|
||||
func (h *ChatHandler) Recommendation(c *gin.Context) {
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
common.ErrorWithCode(c, errorCode, errorMessage)
|
||||
return
|
||||
@@ -59,7 +59,7 @@ func (h *ChatHandler) Recommendation(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
questions, err := service.GenerateRelatedQuestions(ctx, user.ID, req.Question, req.SearchID, h.searchSvc, h.tenantSvc, h.modelFactory)
|
||||
questions, err := service.GenerateRelatedQuestions(ctx, req.Question, req.SearchID, h.searchSvc, h.modelFactory)
|
||||
if err != nil {
|
||||
common.Warn("chat recommendation failed", zap.String("error", err.Error()))
|
||||
common.ResponseWithCodeData(c, common.CodeOperatingError, nil, err.Error())
|
||||
|
||||
@@ -43,12 +43,12 @@ func NewCompilationTemplateHandler(svc *service.CompilationTemplateService) *Com
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /v1/compilation-templates/builtins [get]
|
||||
func (h *CompilationTemplateHandler) ListBuiltins(c *gin.Context) {
|
||||
user, code, msg := GetUser(c)
|
||||
_, code, msg := GetUser(c)
|
||||
if code != common.CodeSuccess {
|
||||
common.ErrorWithCode(c, code, msg)
|
||||
return
|
||||
}
|
||||
templates, err := h.compilationTemplateService.ListBuiltins(c.Request.Context(), user.ID)
|
||||
templates, err := h.compilationTemplateService.ListBuiltins(c.Request.Context())
|
||||
if err != nil {
|
||||
common.ResponseWithCodeData(c, common.CodeExceptionError, nil, err.Error())
|
||||
return
|
||||
|
||||
@@ -50,8 +50,8 @@ type KBServiceIface interface {
|
||||
|
||||
// ModelCreator supplies typed model instances to Dify retrieval.
|
||||
type ModelCreator interface {
|
||||
NewEmbeddingModel(ctx context.Context, access service.ModelAccess, modelRef string) (*modelModule.EmbeddingModel, error)
|
||||
NewDefaultChatModel(ctx context.Context, access service.ModelAccess) (*modelModule.ChatModel, error)
|
||||
NewEmbeddingModel(ctx context.Context, access service.Identity, modelRef string) (*modelModule.EmbeddingModel, error)
|
||||
NewDefaultChatModel(ctx context.Context, access service.Identity) (*modelModule.ChatModel, error)
|
||||
}
|
||||
|
||||
// MetadataServiceIface abstracts MetadataService for the Dify handler.
|
||||
@@ -240,7 +240,7 @@ func (h *DifyRetrievalHandler) Retrieval(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Get embedding model
|
||||
access := service.ModelAccess{UserID: user.ID, TenantID: kb.TenantID}
|
||||
access := service.Identity{UserID: user.ID, TenantID: kb.TenantID}
|
||||
embModel, err := h.modelFactory.NewEmbeddingModel(ctx, access, kb.EmbdID)
|
||||
if err != nil {
|
||||
common.ResponseWithHttpCodeData(c, http.StatusInternalServerError, common.CodeServerError, nil, fmt.Sprintf("failed to get embedding model: %v", err))
|
||||
|
||||
@@ -65,12 +65,12 @@ func (m *mockKBService) CheckAccess(ctx context.Context, subject permission.Subj
|
||||
|
||||
type mockModelFactory struct{}
|
||||
|
||||
func (mockModelFactory) NewEmbeddingModel(context.Context, service.ModelAccess, string) (*modelModule.EmbeddingModel, error) {
|
||||
func (mockModelFactory) NewEmbeddingModel(context.Context, service.Identity, string) (*modelModule.EmbeddingModel, error) {
|
||||
name := "test-model"
|
||||
return modelModule.NewEmbeddingModel(&modelModule.DummyModel{}, &name, &modelModule.APIConfig{}, 0), nil
|
||||
}
|
||||
|
||||
func (mockModelFactory) NewDefaultChatModel(context.Context, service.ModelAccess) (*modelModule.ChatModel, error) {
|
||||
func (mockModelFactory) NewDefaultChatModel(context.Context, service.Identity) (*modelModule.ChatModel, error) {
|
||||
name := "test-model"
|
||||
return modelModule.NewChatModel(&modelModule.DummyModel{}, &name, &modelModule.APIConfig{}), nil
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ type mindMapRunConfig struct {
|
||||
KbIDs common.StringSlice
|
||||
SearchID string
|
||||
SearchConfig map[string]interface{}
|
||||
AuthUserID string
|
||||
ModelTenantID string
|
||||
ChunkSvc service.Retriever
|
||||
LLM *service.ModelFactory
|
||||
@@ -48,12 +47,16 @@ func runMindMap(ctx context.Context, config mindMapRunConfig) (mindMapNode, erro
|
||||
if config.LLM == nil {
|
||||
return mindMapNode{}, fmt.Errorf("LLM not configured")
|
||||
}
|
||||
identity, err := service.ResolveIdentity(ctx)
|
||||
if err != nil {
|
||||
return mindMapNode{}, err
|
||||
}
|
||||
modelTenantID := config.ModelTenantID
|
||||
if modelTenantID == "" {
|
||||
modelTenantID = config.AuthUserID
|
||||
modelTenantID = identity.TenantID
|
||||
}
|
||||
retrievalReq := mindMapRetrievalRequest(config.Question, config.KbIDs, config.SearchID, config.SearchConfig)
|
||||
ranks, err := config.ChunkSvc.RetrievalTest(ctx, retrievalReq, config.AuthUserID)
|
||||
ranks, err := config.ChunkSvc.RetrievalTest(ctx, retrievalReq)
|
||||
if err != nil {
|
||||
return mindMapNode{}, err
|
||||
}
|
||||
@@ -69,7 +72,8 @@ func runMindMap(ctx context.Context, config mindMapRunConfig) (mindMapNode, erro
|
||||
|
||||
// A stale search_config chat_id should not prevent mind-map generation;
|
||||
// fall back to the tenant's configured default model.
|
||||
access := service.ModelAccess{UserID: config.AuthUserID, TenantID: modelTenantID}
|
||||
identity.TenantID = modelTenantID
|
||||
access := identity
|
||||
chatModel, modelErr := config.LLM.NewChatModel(streamCtx, access, modelID)
|
||||
if modelErr != nil {
|
||||
chatModel, modelErr = config.LLM.NewDefaultChatModel(streamCtx, access)
|
||||
|
||||
@@ -1058,10 +1058,8 @@ func (h *ProviderHandler) ChatToModel(c *gin.Context) {
|
||||
Verbosity: req.Verbosity,
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
email := c.GetString("email")
|
||||
modelUsage := common.ModelUsage{
|
||||
UserID: userID,
|
||||
UserEmail: email,
|
||||
Type: "chat",
|
||||
StartAt: time.Now(),
|
||||
@@ -1117,7 +1115,6 @@ func (h *ProviderHandler) ChatToModel(c *gin.Context) {
|
||||
errorCode, err := h.modelCallService.ChatToModelStreamWithSender(
|
||||
ctx,
|
||||
modelRef,
|
||||
userID,
|
||||
messages,
|
||||
&chatConfig,
|
||||
&modelUsage,
|
||||
@@ -1144,7 +1141,6 @@ func (h *ProviderHandler) ChatToModel(c *gin.Context) {
|
||||
response, errorCode, err = h.modelCallService.ChatToModelWithMessages(
|
||||
ctx,
|
||||
modelRef,
|
||||
userID,
|
||||
messages,
|
||||
&chatConfig,
|
||||
&modelUsage,
|
||||
@@ -1203,7 +1199,6 @@ func (h *ProviderHandler) EmbedText(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
modelRef := ""
|
||||
if req.ModelID != nil {
|
||||
modelRef = *req.ModelID
|
||||
@@ -1220,7 +1215,7 @@ func (h *ProviderHandler) EmbedText(c *gin.Context) {
|
||||
var errorCode common.ErrorCode
|
||||
var err error
|
||||
|
||||
response, errorCode, err = h.modelCallService.EmbedText(ctx, modelRef, userID, req.Texts, &embeddingConfig)
|
||||
response, errorCode, err = h.modelCallService.EmbedText(ctx, modelRef, req.Texts, &embeddingConfig)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, errorCode, err.Error())
|
||||
return
|
||||
@@ -1270,7 +1265,6 @@ func (h *ProviderHandler) RerankDocument(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
modelRef := ""
|
||||
if req.ModelID != nil {
|
||||
modelRef = *req.ModelID
|
||||
@@ -1287,7 +1281,7 @@ func (h *ProviderHandler) RerankDocument(c *gin.Context) {
|
||||
var errorCode common.ErrorCode
|
||||
var err error
|
||||
|
||||
response, errorCode, err = h.modelCallService.RerankDocument(ctx, modelRef, userID, req.Query, req.Documents, &rerankConfig)
|
||||
response, errorCode, err = h.modelCallService.RerankDocument(ctx, modelRef, req.Query, req.Documents, &rerankConfig)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, errorCode, err.Error())
|
||||
return
|
||||
@@ -1338,7 +1332,6 @@ func (h *ProviderHandler) TranscribeAudio(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
modelRef := ""
|
||||
if req.ModelID != nil {
|
||||
modelRef = *req.ModelID
|
||||
@@ -1385,7 +1378,7 @@ func (h *ProviderHandler) TranscribeAudio(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Stream response using sender function ( the best performance, no channel)
|
||||
errorCode, err := h.modelCallService.TranscribeAudioStream(ctx, modelRef, userID, req.File, &asrConfig, sender)
|
||||
errorCode, err := h.modelCallService.TranscribeAudioStream(ctx, modelRef, req.File, &asrConfig, sender)
|
||||
if errorCode != common.CodeSuccess {
|
||||
c.SSEvent("error", err.Error())
|
||||
}
|
||||
@@ -1397,7 +1390,7 @@ func (h *ProviderHandler) TranscribeAudio(c *gin.Context) {
|
||||
var errorCode common.ErrorCode
|
||||
var err error
|
||||
|
||||
response, errorCode, err = h.modelCallService.TranscribeAudio(ctx, modelRef, userID, req.File, &asrConfig)
|
||||
response, errorCode, err = h.modelCallService.TranscribeAudio(ctx, modelRef, req.File, &asrConfig)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, errorCode, err.Error())
|
||||
return
|
||||
@@ -1447,7 +1440,6 @@ func (h *ProviderHandler) AudioSpeech(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
modelRef := ""
|
||||
if req.ModelID != nil {
|
||||
modelRef = *req.ModelID
|
||||
@@ -1494,7 +1486,7 @@ func (h *ProviderHandler) AudioSpeech(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Stream response using sender function ( the best performance, no channel)
|
||||
errorCode, err := h.modelCallService.AudioSpeechStream(ctx, modelRef, userID, req.Text, &ttsConfig, sender)
|
||||
errorCode, err := h.modelCallService.AudioSpeechStream(ctx, modelRef, req.Text, &ttsConfig, sender)
|
||||
if errorCode != common.CodeSuccess {
|
||||
c.SSEvent("error", err.Error())
|
||||
}
|
||||
@@ -1506,7 +1498,7 @@ func (h *ProviderHandler) AudioSpeech(c *gin.Context) {
|
||||
var errorCode common.ErrorCode
|
||||
var err error
|
||||
|
||||
response, errorCode, err = h.modelCallService.AudioSpeech(ctx, modelRef, userID, req.Text, &ttsConfig)
|
||||
response, errorCode, err = h.modelCallService.AudioSpeech(ctx, modelRef, req.Text, &ttsConfig)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, errorCode, err.Error())
|
||||
return
|
||||
@@ -1555,8 +1547,6 @@ func (h *ProviderHandler) OCRFile(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
modelRef := ""
|
||||
if req.ModelID != nil {
|
||||
modelRef = *req.ModelID
|
||||
@@ -1571,7 +1561,7 @@ func (h *ProviderHandler) OCRFile(c *gin.Context) {
|
||||
var errorCode common.ErrorCode
|
||||
var err error
|
||||
|
||||
response, errorCode, err = h.modelCallService.OCRFile(ctx, modelRef, userID, req.Content, req.URL, &OCRConfig)
|
||||
response, errorCode, err = h.modelCallService.OCRFile(ctx, modelRef, req.Content, req.URL, &OCRConfig)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, errorCode, err.Error())
|
||||
return
|
||||
@@ -1623,8 +1613,6 @@ func (h *ProviderHandler) ParseFile(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
modelRef := ""
|
||||
if req.ModelID != nil {
|
||||
modelRef = *req.ModelID
|
||||
@@ -1645,7 +1633,7 @@ func (h *ProviderHandler) ParseFile(c *gin.Context) {
|
||||
var errorCode common.ErrorCode
|
||||
var err error
|
||||
|
||||
response, errorCode, err = h.modelCallService.ParseFile(ctx, modelRef, userID, req.Content, req.URL, &parseFileConfig)
|
||||
response, errorCode, err = h.modelCallService.ParseFile(ctx, modelRef, req.Content, req.URL, &parseFileConfig)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, errorCode, err.Error())
|
||||
return
|
||||
@@ -1654,22 +1642,9 @@ func (h *ProviderHandler) ParseFile(c *gin.Context) {
|
||||
common.SuccessWithData(c, response, "success")
|
||||
}
|
||||
|
||||
// ListTenantAddedModels is the response handler for GET /api/v1/models.
|
||||
// It is the Go port of Python's
|
||||
// api/apps/restful_apis/models_api.py:get_added_models and feeds
|
||||
// web/src/hooks/use-llm-request.tsx → useFetchAllAddedModels. The data
|
||||
// shape is the array form (one row per (provider × instance × llm) with
|
||||
// model_type: string[]), matching the IAddedModel interface in
|
||||
// web/src/interfaces/database/llm.ts:64-71.
|
||||
//
|
||||
// The previous contract routed this path to TenantHandler.GetModels →
|
||||
// TenantService.ListTenantDefaultModels, which only enumerates the 6-7
|
||||
// default tenant fields and returned `[]` for any tenant without
|
||||
// defaults, breaking the front-end's "View Models" list. The Go port
|
||||
// has no writers for tenant_model, so this endpoint must be driven by
|
||||
// the factory catalog cross-referenced with the tenant's instance list —
|
||||
// see service.ModelProviderService.ListTenantAddedModels.
|
||||
func (h *ProviderHandler) ListTenantAddedModels(c *gin.Context) {
|
||||
// ListModelsForUserInTenant handles GET /api/v1/models for the authenticated
|
||||
// user and selected tenant context.
|
||||
func (h *ProviderHandler) ListModelsForUserInTenant(c *gin.Context) {
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
common.ErrorWithCode(c, errorCode, errorMessage)
|
||||
@@ -1680,11 +1655,11 @@ func (h *ProviderHandler) ListTenantAddedModels(c *gin.Context) {
|
||||
tenantID := c.Query("tenant_id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
addedModels, code, err := h.modelProviderService.ListTenantAddedModels(ctx, user.ID, tenantID, modelType)
|
||||
models, code, err := h.modelProviderService.ListModelsForUserInTenant(ctx, user.ID, tenantID, modelType)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.SuccessWithData(c, addedModels, "success")
|
||||
common.SuccessWithData(c, models, "success")
|
||||
}
|
||||
+19
-18
@@ -85,12 +85,11 @@ func getOwnerIDs(c *gin.Context) []string {
|
||||
// @Success 200 {object} service.ListSearchAppsResponse
|
||||
// @Router /api/v1/searches [get]
|
||||
func (h *SearchHandler) ListSearches(c *gin.Context) {
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
common.ErrorWithCode(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
userID := user.ID
|
||||
|
||||
// Parse query parameters
|
||||
keywords := c.Query("keywords")
|
||||
@@ -132,7 +131,7 @@ func (h *SearchHandler) ListSearches(c *gin.Context) {
|
||||
|
||||
// List search apps with filtering
|
||||
ctx := c.Request.Context()
|
||||
result, err := h.searchService.ListSearches(ctx, userID, keywords, page, pageSize, terms, ownerIDs)
|
||||
result, err := h.searchService.ListSearches(ctx, keywords, page, pageSize, terms, ownerIDs)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, false) {
|
||||
return
|
||||
@@ -169,7 +168,12 @@ func (h *SearchHandler) CreateSearch(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userID := user.ID
|
||||
// Get current user from context (same as Python current_user)
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
common.ErrorWithCode(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body (same as Python get_request_json())
|
||||
var req CreateSearchRequest
|
||||
@@ -185,7 +189,7 @@ func (h *SearchHandler) CreateSearch(c *gin.Context) {
|
||||
|
||||
// Create search (same as Python SearchService.save within DB.atomic())
|
||||
ctx := c.Request.Context()
|
||||
result, err := h.searchService.CreateSearch(ctx, userID, req.Name, req.Description)
|
||||
result, err := h.searchService.CreateSearch(ctx, req.Name, req.Description)
|
||||
if err != nil {
|
||||
common.ResponseWithHttpCodeData(c, http.StatusInternalServerError, common.CodeBadRequest, nil, err.Error())
|
||||
return
|
||||
@@ -206,12 +210,11 @@ func (h *SearchHandler) CreateSearch(c *gin.Context) {
|
||||
// @Router /api/v1/searches/{search_id} [get]
|
||||
func (h *SearchHandler) GetSearch(c *gin.Context) {
|
||||
// Get current user from context (same as Python current_user)
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
common.ErrorWithCode(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
userID := user.ID
|
||||
|
||||
// Get search_id from path parameter (same as Python <search_id>)
|
||||
searchID := c.Param("search_id")
|
||||
@@ -222,7 +225,7 @@ func (h *SearchHandler) GetSearch(c *gin.Context) {
|
||||
|
||||
// Get search detail with permission check
|
||||
ctx := c.Request.Context()
|
||||
search, err := h.searchService.GetSearchDetail(ctx, userID, searchID)
|
||||
search, err := h.searchService.GetSearchDetail(ctx, searchID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
@@ -263,12 +266,11 @@ func (h *SearchHandler) GetSearch(c *gin.Context) {
|
||||
// @Router /api/v1/searches/{search_id} [delete]
|
||||
func (h *SearchHandler) DeleteSearch(c *gin.Context) {
|
||||
// Get current user from context (same as Python current_user)
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
common.ErrorWithCode(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
userID := user.ID
|
||||
|
||||
// Get search_id from path parameter (same as Python <search_id>)
|
||||
searchID := c.Param("search_id")
|
||||
@@ -279,7 +281,7 @@ func (h *SearchHandler) DeleteSearch(c *gin.Context) {
|
||||
|
||||
// Delete search with permission check
|
||||
ctx := c.Request.Context()
|
||||
err := h.searchService.DeleteSearch(ctx, userID, searchID)
|
||||
err := h.searchService.DeleteSearch(ctx, searchID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
@@ -305,12 +307,11 @@ func (h *SearchHandler) DeleteSearch(c *gin.Context) {
|
||||
// @Router /api/v1/searches/{search_id} [put]
|
||||
func (h *SearchHandler) UpdateSearch(c *gin.Context) {
|
||||
// Get current user from context (same as Python current_user)
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
common.ErrorWithCode(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
userID := user.ID
|
||||
|
||||
// Get search_id from path parameter (same as Python <search_id>)
|
||||
searchID := c.Param("search_id")
|
||||
@@ -334,7 +335,7 @@ func (h *SearchHandler) UpdateSearch(c *gin.Context) {
|
||||
|
||||
// Update search
|
||||
ctx := c.Request.Context()
|
||||
updatedSearch, err := h.searchService.UpdateSearch(ctx, userID, searchID, &req)
|
||||
updatedSearch, err := h.searchService.UpdateSearch(ctx, searchID, &req)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
@@ -376,7 +377,7 @@ func (h *SearchHandler) UpdateSearch(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *SearchHandler) Completion(c *gin.Context) {
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
common.ErrorWithCode(c, errorCode, errorMessage)
|
||||
return
|
||||
@@ -394,7 +395,7 @@ func (h *SearchHandler) Completion(c *gin.Context) {
|
||||
searchSvc = service.NewSearchService()
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
plan, code, err := searchSvc.PrepareCompletion(ctx, user.ID, c.Param("search_id"), &req)
|
||||
plan, code, err := searchSvc.PrepareCompletion(ctx, c.Param("search_id"), &req)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
@@ -434,14 +435,14 @@ func (h *SearchHandler) Completion(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
adapter := &service.TenantStreamAdapter{Factory: h.modelFactory, TenantID: plan.UserID, ModelID: plan.ModelID}
|
||||
adapter := &service.TenantStreamAdapter{Factory: h.modelFactory, Identity: plan.Identity, ModelID: plan.ModelID}
|
||||
|
||||
ctx, cancel := context.WithCancel(c.Request.Context())
|
||||
defer cancel()
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
|
||||
hadError := false
|
||||
for delta := range h.askService.StreamWithOptions(ctx, adapter, plan.UserID, plan.Question, plan.DatasetIDs, plan.Options) {
|
||||
for delta := range h.askService.StreamWithOptions(ctx, adapter, plan.Question, plan.DatasetIDs, plan.Options) {
|
||||
var ok bool
|
||||
switch delta.Kind {
|
||||
case service.AskDeltaAnswer:
|
||||
|
||||
@@ -131,7 +131,7 @@ func (h *SearchBotHandler) SetAskService(svc *service.AskService) { h.askSvc = s
|
||||
// @Router /api/v1/searchbots/related-questions [post]
|
||||
func (h *SearchBotHandler) Handle(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
common.ErrorWithCode(c, errorCode, errorMessage)
|
||||
return
|
||||
@@ -148,7 +148,7 @@ func (h *SearchBotHandler) Handle(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
questions, err := service.GenerateRelatedQuestions(ctx, user.ID, req.Question, req.SearchID, h.searchSvc, h.tenantSvc, h.modelFactory)
|
||||
questions, err := service.GenerateRelatedQuestions(ctx, req.Question, req.SearchID, h.searchSvc, h.modelFactory)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
@@ -169,7 +169,7 @@ func (h *SearchBotHandler) Handle(c *gin.Context) {
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/searchbots/retrieval [post]
|
||||
func (h *SearchBotHandler) RetrievalTest(c *gin.Context) {
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
common.ResponseWithHttpCodeData(c, http.StatusUnauthorized, errorCode, nil, errorMessage)
|
||||
return
|
||||
@@ -211,7 +211,7 @@ func (h *SearchBotHandler) RetrievalTest(c *gin.Context) {
|
||||
|
||||
svcReq := toRetrievalServiceRequest(&req)
|
||||
ctx := c.Request.Context()
|
||||
result, err := h.chunkSvc.RetrievalTest(ctx, svcReq, user.ID)
|
||||
result, err := h.chunkSvc.RetrievalTest(ctx, svcReq)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
@@ -234,7 +234,7 @@ func (h *SearchBotHandler) RetrievalTest(c *gin.Context) {
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/searchbots/ask [post]
|
||||
func (h *SearchBotHandler) Ask(c *gin.Context) {
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
common.ErrorWithCode(c, errorCode, errorMessage)
|
||||
return
|
||||
@@ -269,13 +269,21 @@ func (h *SearchBotHandler) Ask(c *gin.Context) {
|
||||
// Resolve chat model ID.
|
||||
modelID := ""
|
||||
options := service.AskStreamOptions{}
|
||||
askAccess, accessErr := service.ResolveIdentity(c.Request.Context())
|
||||
if accessErr != nil {
|
||||
if respondPermissionErrorIf(c, accessErr, true) {
|
||||
return
|
||||
}
|
||||
jsonInternalError(c, accessErr)
|
||||
return
|
||||
}
|
||||
if req.SearchID != "" {
|
||||
if h.searchSvc == nil {
|
||||
jsonInternalError(c, fmt.Errorf("search service not configured"))
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
detail, err := h.searchSvc.GetDetail(ctx, user.ID, req.SearchID)
|
||||
detail, err := h.searchSvc.GetDetail(ctx, req.SearchID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
@@ -289,11 +297,10 @@ func (h *SearchBotHandler) Ask(c *gin.Context) {
|
||||
modelID = chatID
|
||||
}
|
||||
}
|
||||
if modelID == "" && h.tenantSvc != nil {
|
||||
if modelID == "" && h.modelFactory != nil {
|
||||
ctx := c.Request.Context()
|
||||
defaultModel, err := h.tenantSvc.GetDefaultModelNameForUser(ctx, user.ID, entity.ModelTypeChat)
|
||||
if err == nil && defaultModel != "" {
|
||||
modelID = defaultModel
|
||||
if info, infoErr := h.modelFactory.ResolveDefaultInfo(ctx, askAccess, entity.ModelTypeChat); infoErr == nil {
|
||||
modelID = service.ModelReference(info)
|
||||
}
|
||||
}
|
||||
if modelID == "" {
|
||||
@@ -318,8 +325,8 @@ func (h *SearchBotHandler) Ask(c *gin.Context) {
|
||||
defer cancel()
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
hadError := false
|
||||
adapter := &service.TenantStreamAdapter{Factory: h.modelFactory, TenantID: user.ID, ModelID: modelID}
|
||||
for delta := range h.askSvc.StreamWithOptions(ctx, adapter, user.ID, req.Question, filtered, options) {
|
||||
adapter := &service.TenantStreamAdapter{Factory: h.modelFactory, Identity: askAccess, ModelID: modelID}
|
||||
for delta := range h.askSvc.StreamWithOptions(ctx, adapter, req.Question, filtered, options) {
|
||||
var ok bool
|
||||
switch delta.Kind {
|
||||
case service.AskDeltaAnswer:
|
||||
@@ -353,7 +360,7 @@ func (h *SearchBotHandler) Ask(c *gin.Context) {
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/searchbots/mindmap [post]
|
||||
func (h *SearchBotHandler) MindMap(c *gin.Context) {
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
common.ErrorWithCode(c, errorCode, errorMessage)
|
||||
return
|
||||
@@ -391,7 +398,7 @@ func (h *SearchBotHandler) MindMap(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
detail, err := h.searchSvc.GetDetail(ctx, user.ID, req.SearchID)
|
||||
detail, err := h.searchSvc.GetDetail(ctx, req.SearchID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
@@ -404,15 +411,13 @@ func (h *SearchBotHandler) MindMap(c *gin.Context) {
|
||||
|
||||
ctx := c.Request.Context()
|
||||
mindMap, err := runMindMap(ctx, mindMapRunConfig{
|
||||
Question: req.Question,
|
||||
KbIDs: filtered,
|
||||
SearchID: req.SearchID,
|
||||
SearchConfig: searchConfig,
|
||||
AuthUserID: user.ID,
|
||||
ModelTenantID: user.ID,
|
||||
ChunkSvc: h.chunkSvc,
|
||||
LLM: h.modelFactory,
|
||||
TenantSvc: h.tenantSvc,
|
||||
Question: req.Question,
|
||||
KbIDs: filtered,
|
||||
SearchID: req.SearchID,
|
||||
SearchConfig: searchConfig,
|
||||
ChunkSvc: h.chunkSvc,
|
||||
LLM: h.modelFactory,
|
||||
TenantSvc: h.tenantSvc,
|
||||
})
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
@@ -449,7 +454,7 @@ func (h *SearchBotHandler) SearchBotDetail(c *gin.Context) {
|
||||
jsonInternalError(c, fmt.Errorf("search service not configured"))
|
||||
return
|
||||
}
|
||||
detail, err := h.searchSvc.GetSearchShareDetail(ctx, identity.User.ID, searchID)
|
||||
detail, err := h.searchSvc.GetSearchShareDetail(ctx, searchID)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
"ragflow/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -36,16 +37,14 @@ import (
|
||||
// mockChunkService implements service.Retriever for testing.
|
||||
// It captures the last request received so tests can verify field mapping.
|
||||
type mockChunkService struct {
|
||||
retrievalTestFn func(req *service.RetrievalTestRequest, userID string) (*service.RetrievalTestResponse, error)
|
||||
retrievalTestFn func(req *service.RetrievalTestRequest) (*service.RetrievalTestResponse, error)
|
||||
LastReq *service.RetrievalTestRequest
|
||||
LastUserID string
|
||||
}
|
||||
|
||||
func (m *mockChunkService) RetrievalTest(ctx context.Context, req *service.RetrievalTestRequest, userID string) (*service.RetrievalTestResponse, error) {
|
||||
func (m *mockChunkService) RetrievalTest(ctx context.Context, req *service.RetrievalTestRequest) (*service.RetrievalTestResponse, error) {
|
||||
m.LastReq = req
|
||||
m.LastUserID = userID
|
||||
if m.retrievalTestFn != nil {
|
||||
return m.retrievalTestFn(req, userID)
|
||||
return m.retrievalTestFn(req)
|
||||
}
|
||||
return &service.RetrievalTestResponse{
|
||||
Chunks: []map[string]interface{}{{"docnm_kwd": "test", "content_with_weight": "content"}},
|
||||
@@ -98,9 +97,6 @@ func TestSearchBotsRetrieval_Basic(t *testing.T) {
|
||||
if mockSvc.LastReq.Question != "test question" {
|
||||
t.Errorf("Question = %q, want \"test question\"", mockSvc.LastReq.Question)
|
||||
}
|
||||
if mockSvc.LastUserID != "user1" {
|
||||
t.Errorf("userID = %q, want \"user1\"", mockSvc.LastUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchBotsRetrieval_MissingKbID(t *testing.T) {
|
||||
@@ -167,7 +163,7 @@ func TestSearchBotsRetrieval_NoAuth(t *testing.T) {
|
||||
func TestSearchBotsRetrieval_ServiceError(t *testing.T) {
|
||||
h, _, r := setupSearchbotsTest("user1")
|
||||
h.chunkSvc = &mockChunkService{
|
||||
retrievalTestFn: func(req *service.RetrievalTestRequest, userID string) (*service.RetrievalTestResponse, error) {
|
||||
retrievalTestFn: func(req *service.RetrievalTestRequest) (*service.RetrievalTestResponse, error) {
|
||||
return nil, errors.New("db error")
|
||||
},
|
||||
}
|
||||
@@ -467,7 +463,7 @@ type fakeChunkRetriever struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeChunkRetriever) RetrievalTest(ctx context.Context, req *service.RetrievalTestRequest, userID string) (*service.RetrievalTestResponse, error) {
|
||||
func (f *fakeChunkRetriever) RetrievalTest(ctx context.Context, req *service.RetrievalTestRequest) (*service.RetrievalTestResponse, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
@@ -538,6 +534,7 @@ func TestAskHandler_NoChatModel(t *testing.T) {
|
||||
strings.NewReader(`{"question": "test", "kb_ids": ["kb1"]}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set("user", &entity.User{ID: "user-1"})
|
||||
c.Request = c.Request.WithContext(permission.WithSubject(c.Request.Context(), permission.Subject{UserID: "user-1"}))
|
||||
h.Ask(c)
|
||||
body := buf.String()
|
||||
if !strings.Contains(body, "chat model not configured") {
|
||||
|
||||
+20
-12
@@ -39,17 +39,17 @@ import (
|
||||
|
||||
// TenantHandler tenant handler
|
||||
type TenantHandler struct {
|
||||
tenantService *service.TenantService
|
||||
userService *service.UserService
|
||||
datasetService *dataset.DatasetService
|
||||
tenantService *service.TenantService
|
||||
userTenantService *service.UserTenantService
|
||||
datasetService *dataset.DatasetService
|
||||
}
|
||||
|
||||
// NewTenantHandler create tenant handler
|
||||
func NewTenantHandler(tenantService *service.TenantService, userService *service.UserService, datasetService *dataset.DatasetService) *TenantHandler {
|
||||
func NewTenantHandler(tenantService *service.TenantService, userTenantService *service.UserTenantService, datasetService *dataset.DatasetService) *TenantHandler {
|
||||
return &TenantHandler{
|
||||
tenantService: tenantService,
|
||||
userService: userService,
|
||||
datasetService: datasetService,
|
||||
tenantService: tenantService,
|
||||
userTenantService: userTenantService,
|
||||
datasetService: datasetService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,9 +84,17 @@ func (h *TenantHandler) setDefaultModels(c *gin.Context, wrapModels bool) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
err := h.tenantService.SetTenantDefaultModels(ctx, user.ID, req.ModelProvider, req.ModelInstance, req.ModelName, req.ModelType, req.ModelID)
|
||||
code, err := h.userTenantService.SetModelSettings(ctx, user.ID, &service.SetModelSettingsRequest{
|
||||
DefaultModel: &service.DefaultModelSelection{
|
||||
ModelProvider: req.ModelProvider,
|
||||
ModelInstance: req.ModelInstance,
|
||||
ModelName: req.ModelName,
|
||||
ModelType: req.ModelType,
|
||||
ModelID: req.ModelID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
common.ResponseWithCodeData(c, common.CodeExceptionError, false, err.Error())
|
||||
common.ResponseWithCodeData(c, code, false, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -98,7 +106,7 @@ func (h *TenantHandler) setDefaultModels(c *gin.Context, wrapModels bool) {
|
||||
common.SuccessNoData(c, "success")
|
||||
}
|
||||
|
||||
// GetDefaultModels returns the tenant's default model selections. The
|
||||
// GetDefaultModels returns the user's model selections for the current tenant. The
|
||||
// response wraps the model list under `data.models` to mirror the
|
||||
// Python `list_tenant_default_models` contract (api/apps/restful_apis/
|
||||
// models_api.py:84). The frontend hook `useFetchDefaultModels`
|
||||
@@ -111,7 +119,7 @@ func (h *TenantHandler) GetDefaultModels(c *gin.Context) {
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
defaultModels, err := h.tenantService.ListTenantDefaultModels(ctx, user.ID)
|
||||
defaultModels, err := h.userTenantService.ListDefaultModels(ctx, user.ID)
|
||||
if err != nil {
|
||||
common.ResponseWithCodeData(c, common.CodeExceptionError, false, err.Error())
|
||||
return
|
||||
@@ -142,7 +150,7 @@ func (h *TenantHandler) TenantInfo(c *gin.Context) {
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
tenantInfo, err := h.tenantService.GetTenantInfo(ctx, user.ID)
|
||||
tenantInfo, err := h.userTenantService.GetCurrentTenantInfo(ctx, user.ID)
|
||||
if err != nil {
|
||||
common.ResponseWithCodeData(c, common.CodeExceptionError, false, err.Error())
|
||||
return
|
||||
|
||||
@@ -35,13 +35,15 @@ import (
|
||||
|
||||
// UserHandler user handler
|
||||
type UserHandler struct {
|
||||
userService *service.UserService
|
||||
userService *service.UserService
|
||||
userTenantService *service.UserTenantService
|
||||
}
|
||||
|
||||
// NewUserHandler create user handler
|
||||
func NewUserHandler(userService *service.UserService) *UserHandler {
|
||||
func NewUserHandler(userService *service.UserService, userTenantService *service.UserTenantService) *UserHandler {
|
||||
return &UserHandler{
|
||||
userService: userService,
|
||||
userService: userService,
|
||||
userTenantService: userTenantService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,7 +475,7 @@ func (h *UserHandler) ChangePassword(c *gin.Context) {
|
||||
// @Description Update tenant model configuration
|
||||
// @Tags users
|
||||
// @Security ApiKeyAuth
|
||||
// @Param request body service.SetTenantInfoRequest true "tenant info"
|
||||
// @Param request body service.SetModelSettingsRequest true "tenant model settings"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /v1/user/set_tenant_info [post]
|
||||
func (h *UserHandler) SetTenantInfo(c *gin.Context) {
|
||||
@@ -524,7 +526,7 @@ func (h *UserHandler) SetTenantInfo(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
req := service.SetTenantInfoRequest{Raw: payload}
|
||||
req := service.SetModelSettingsRequest{Raw: payload}
|
||||
if value, ok := payload["tenant_id"].(string); ok {
|
||||
req.TenantID = &value
|
||||
}
|
||||
@@ -547,7 +549,7 @@ func (h *UserHandler) SetTenantInfo(c *gin.Context) {
|
||||
req.TTSID = &value
|
||||
}
|
||||
|
||||
code, err := h.userService.SetTenantInfo(ctx, user.ID, &req)
|
||||
code, err := h.userTenantService.SetModelSettings(ctx, user.ID, &req)
|
||||
if err != nil {
|
||||
common.ResponseWithCodeData(c, code, nil, err.Error())
|
||||
operationLog.Code = int(code)
|
||||
|
||||
@@ -31,13 +31,14 @@ import (
|
||||
"ragflow/internal/entity"
|
||||
modelModule "ragflow/internal/entity/models"
|
||||
"ragflow/internal/ingestion/component/schema"
|
||||
"ragflow/internal/modelcontext"
|
||||
"ragflow/internal/permission"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type tenantModelExtra struct {
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
MaxTokens json.RawMessage `json:"max_tokens"`
|
||||
}
|
||||
|
||||
type resolvedModelConfig struct {
|
||||
@@ -114,8 +115,7 @@ var resolveTenantOCRModelByProvider = defaultResolveTenantOCRModelByProvider
|
||||
// model is resolved by ID; its resolution error is returned rather than trying
|
||||
// another model.
|
||||
func defaultResolveTenantOCRModelByProvider(ctx context.Context, db *gorm.DB, tenantID string, providerName string) (modelModule.ModelDriver, string, *modelModule.APIConfig, int, error) {
|
||||
providerDAO := dao.NewTenantModelProviderDAO()
|
||||
provider, err := providerDAO.GetByTenantIDAndProviderName(ctx, db, tenantID, providerName)
|
||||
provider, err := getTenantModelProviderByName(ctx, db, tenantID, providerName)
|
||||
if err != nil {
|
||||
// Some OCR capabilities are registered under sibling provider names
|
||||
// (cloud vs. local). Tolerate the alternate spelling before giving up
|
||||
@@ -124,7 +124,7 @@ func defaultResolveTenantOCRModelByProvider(ctx context.Context, db *gorm.DB, te
|
||||
if alias == providerName {
|
||||
continue
|
||||
}
|
||||
if p, aerr := providerDAO.GetByTenantIDAndProviderName(ctx, db, tenantID, alias); aerr == nil {
|
||||
if p, aerr := getTenantModelProviderByName(ctx, db, tenantID, alias); aerr == nil {
|
||||
provider, err = p, nil
|
||||
break
|
||||
}
|
||||
@@ -240,8 +240,12 @@ func resolveModelConfigByID(ctx context.Context, db *gorm.DB, tenantID string, m
|
||||
|
||||
// Scope the lookup itself so missing and foreign models are indistinguishable
|
||||
// before exposing status, type, instance configuration, or driver details.
|
||||
owner, err := dao.NewUserTenantDAO().GetOwnerByTenantID(ctx, db, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve tenant owner for model lookup: %w", err)
|
||||
}
|
||||
providers := db.Session(&gorm.Session{}).Model(&entity.TenantModelProvider{}).
|
||||
Select("id").Where("tenant_id = ?", tenantID)
|
||||
Select("id").Where("tenant_id = ? AND user_id = ?", tenantID, owner.UserID)
|
||||
scopedDB := db.Session(&gorm.Session{}).Where("provider_id IN (?)", providers)
|
||||
modelObj, err := modelDAO.GetByID(ctx, scopedDB, modelID)
|
||||
if err != nil {
|
||||
@@ -305,8 +309,8 @@ func resolveModelConfigByID(ctx context.Context, db *gorm.DB, tenantID string, m
|
||||
if err := json.Unmarshal([]byte(modelObj.Extra), &tenantExtra); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenantExtra.MaxTokens != nil && *tenantExtra.MaxTokens > 0 {
|
||||
contextLength = *tenantExtra.MaxTokens
|
||||
if maxTokens := modelcontext.MaxTokensFromJSON(tenantExtra.MaxTokens); maxTokens > 0 {
|
||||
contextLength = maxTokens
|
||||
}
|
||||
}
|
||||
apiConfig := &modelModule.APIConfig{ApiKey: &apiKey, Region: ®ion, BaseURL: &baseURL}
|
||||
@@ -323,11 +327,10 @@ func resolveModelConfigFromProviderInstance(ctx context.Context, db *gorm.DB, te
|
||||
}
|
||||
catalogName := modelModule.CatalogProviderName(providerName)
|
||||
|
||||
providerDAO := dao.NewTenantModelProviderDAO()
|
||||
instanceDAO := dao.NewTenantModelInstanceDAO()
|
||||
modelDAO := dao.NewTenantModelDAO()
|
||||
|
||||
provider, err := providerDAO.GetByTenantIDAndProviderName(ctx, db, tenantID, providerName)
|
||||
provider, err := getTenantModelProviderByName(ctx, db, tenantID, providerName)
|
||||
if err != nil {
|
||||
return nil, "", nil, 0, fmt.Errorf("provider %q lookup failed: %w", providerName, err)
|
||||
}
|
||||
@@ -378,8 +381,8 @@ func resolveModelConfigFromProviderInstance(ctx context.Context, db *gorm.DB, te
|
||||
if err := json.Unmarshal([]byte(modelObj.Extra), &tenantExtra); err != nil {
|
||||
return nil, "", nil, 0, err
|
||||
}
|
||||
if tenantExtra.MaxTokens != nil && *tenantExtra.MaxTokens > 0 {
|
||||
contextLength = *tenantExtra.MaxTokens
|
||||
if maxTokens := modelcontext.MaxTokensFromJSON(tenantExtra.MaxTokens); maxTokens > 0 {
|
||||
contextLength = maxTokens
|
||||
}
|
||||
}
|
||||
apiConfig := &modelModule.APIConfig{ApiKey: &apiKey, Region: ®ion, BaseURL: &baseURL}
|
||||
@@ -437,3 +440,11 @@ func newModelDriverForBaseURLLocal(driver modelModule.ModelDriver, providerName,
|
||||
func errorsIsRecordNotFound(err error) bool {
|
||||
return err != nil && (errors.Is(err, gorm.ErrRecordNotFound) || strings.Contains(err.Error(), gorm.ErrRecordNotFound.Error()))
|
||||
}
|
||||
|
||||
func getTenantModelProviderByName(ctx context.Context, db *gorm.DB, tenantID, providerName string) (*entity.TenantModelProvider, error) {
|
||||
owner, err := dao.NewUserTenantDAO().GetOwnerByTenantID(ctx, db, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dao.NewTenantModelProviderDAO().GetByTenantIDAndUserIDAndProviderName(ctx, db, tenantID, owner.UserID, providerName)
|
||||
}
|
||||
@@ -66,12 +66,12 @@ func TestResolveModelConfigForStoredMistralOCRProvider(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
if err := db.AutoMigrate(&entity.UserTenant{}, &entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
const storedKey = `{"MISTRAL_OCR_API_KEY": "sk-env"}`
|
||||
for _, row := range []interface{}{
|
||||
&entity.TenantModelProvider{ID: "provider-mistral-ocr", TenantID: "tenant-1", ProviderName: "Mistral OCR"},
|
||||
&entity.TenantModelProvider{ID: "provider-mistral-ocr", UserID: "tenant-1", TenantID: "tenant-1", ProviderName: "Mistral OCR"},
|
||||
&entity.TenantModelInstance{ID: "instance-mistral-ocr", ProviderID: "provider-mistral-ocr", InstanceName: "default", APIKey: storedKey, Status: "active", Extra: "{}"},
|
||||
&entity.TenantModel{ID: "model-mistral-ocr", ProviderID: "provider-mistral-ocr", InstanceID: "instance-mistral-ocr", ModelName: "mistral-ocr-latest", ModelType: int(entity.ModelTypeOCR), Status: "active"},
|
||||
} {
|
||||
@@ -262,12 +262,12 @@ func TestResolveModelConfigByIDScopedQuerySQL(t *testing.T) {
|
||||
t.Fatalf("model queries=%d, want one scoped lookup: %+v", len(queries), queries)
|
||||
}
|
||||
query := queries[0]
|
||||
const wantSQL = "SELECT * FROM `tenant_model` WHERE provider_id IN (SELECT `id` FROM `tenant_model_provider` WHERE tenant_id = ?) AND id = ? LIMIT 1"
|
||||
const wantSQL = "SELECT * FROM `tenant_model` WHERE provider_id IN (SELECT `id` FROM `tenant_model_provider` WHERE tenant_id = ? AND user_id = ?) AND id = ? LIMIT 1"
|
||||
if query.sql != wantSQL {
|
||||
t.Errorf("scoped SQL=%q, want %q", query.sql, wantSQL)
|
||||
}
|
||||
if len(query.vars) != 2 || query.vars[0] != tenantID || query.vars[1] != modelID {
|
||||
t.Errorf("bound vars=%#v, want tenant ID then model ID", query.vars)
|
||||
if len(query.vars) != 3 || query.vars[0] != tenantID || query.vars[2] != modelID {
|
||||
t.Errorf("bound vars=%#v, want tenant ID, owner user ID, then model ID", query.vars)
|
||||
}
|
||||
if strings.Contains(query.sql, tenantID) || strings.Contains(query.sql, modelID) {
|
||||
t.Errorf("an ID was interpolated into SQL: %q", query.sql)
|
||||
@@ -353,7 +353,7 @@ func openDispatchModelTestDB(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
if err := db.AutoMigrate(&entity.UserTenant{}, &entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
@@ -367,7 +367,7 @@ func openDispatchModelTestDB(t *testing.T) *gorm.DB {
|
||||
func TestResolveModelConfig_InactiveInstanceIsAnExplicitFailure(t *testing.T) {
|
||||
db := openDispatchModelTestDB(t)
|
||||
for _, row := range []interface{}{
|
||||
&entity.TenantModelProvider{ID: "provider-openai", TenantID: "tenant-1", ProviderName: "OpenAI"},
|
||||
&entity.TenantModelProvider{ID: "provider-openai", UserID: "tenant-1", TenantID: "tenant-1", ProviderName: "OpenAI"},
|
||||
&entity.TenantModelInstance{ID: "instance-default", ProviderID: "provider-openai", InstanceName: "default", APIKey: `{"OPENAI_API_KEY": "sk-inactive"}`, Status: "inactive"},
|
||||
&entity.TenantModel{ID: "model-1", ProviderID: "provider-openai", InstanceID: "instance-default", ModelName: "gpt-4o", ModelType: int(entity.ModelTypeChat), Status: "active"},
|
||||
} {
|
||||
@@ -403,7 +403,7 @@ func TestResolveModelConfig_InactiveInstanceIsAnExplicitFailure(t *testing.T) {
|
||||
func TestResolveModelConfig_MaxTokensOverrideSetsContextWindowOnly(t *testing.T) {
|
||||
db := openDispatchModelTestDB(t)
|
||||
for _, row := range []interface{}{
|
||||
&entity.TenantModelProvider{ID: "provider-openai", TenantID: "tenant-1", ProviderName: "OpenAI"},
|
||||
&entity.TenantModelProvider{ID: "provider-openai", UserID: "tenant-1", TenantID: "tenant-1", ProviderName: "OpenAI"},
|
||||
&entity.TenantModelInstance{ID: "instance-default", ProviderID: "provider-openai", InstanceName: "default", Status: "active"},
|
||||
&entity.TenantModel{ID: "model-1", ProviderID: "provider-openai", InstanceID: "instance-default", ModelName: "gpt-4o", ModelType: int(entity.ModelTypeChat), Status: "active"},
|
||||
} {
|
||||
|
||||
@@ -55,12 +55,12 @@ import (
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/entity/models"
|
||||
"ragflow/internal/ingestion/chunkcache"
|
||||
"ragflow/internal/ingestion/component/progress"
|
||||
"ragflow/internal/ingestion/component/schema"
|
||||
"ragflow/internal/modelcontext"
|
||||
"ragflow/internal/tokenizer"
|
||||
"ragflow/internal/utility"
|
||||
)
|
||||
@@ -1669,7 +1669,7 @@ func extractorContextLength(ctx context.Context, db *gorm.DB, llmID string) int
|
||||
if llmID == "" {
|
||||
return 0
|
||||
}
|
||||
return dao.ResolveModelContentLength(ctx, db, tid, llmID, "", "")
|
||||
return modelcontext.ResolveModelContentLength(ctx, db, tid, llmID, "", "")
|
||||
}
|
||||
|
||||
// defaultChatModelRef returns the tenant's default chat model reference —
|
||||
|
||||
@@ -1390,6 +1390,7 @@ func seedExtractorContextModel(t *testing.T, db *gorm.DB, tenantLLMID string) {
|
||||
}
|
||||
if err := db.Create(&entity.TenantModelProvider{
|
||||
ID: "provider-openai",
|
||||
UserID: "tenant-1",
|
||||
ProviderName: "OpenAI",
|
||||
TenantID: "tenant-1",
|
||||
}).Error; err != nil {
|
||||
|
||||
@@ -146,10 +146,10 @@ func imageOCRFixture(t *testing.T, provider string, fail bool) (*gorm.DB, *image
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err := db.AutoMigrate(&entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
if err := db.AutoMigrate(&entity.UserTenant{}, &entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := []any{&entity.TenantModelProvider{ID: "provider", TenantID: "tenant-1", ProviderName: provider}}
|
||||
rows := []any{&entity.TenantModelProvider{ID: "provider", UserID: "tenant-1", TenantID: "tenant-1", ProviderName: provider}}
|
||||
for _, instance := range []struct{ id, url string }{{"a", decoy.URL}, {"b", server.URL}} {
|
||||
rows = append(rows,
|
||||
&entity.TenantModelInstance{ID: "instance-" + instance.id, ProviderID: "provider", InstanceName: instance.id,
|
||||
|
||||
@@ -1592,12 +1592,12 @@ func setupMinerUVisionDispatchDB(t *testing.T, baseURL string) (*gorm.DB, string
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
if err := db.AutoMigrate(&entity.UserTenant{}, &entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
t.Fatalf("migrate model tables: %v", err)
|
||||
}
|
||||
modelID := "061436bbd8474d54be5bba3efbeac109"
|
||||
rows := []any{
|
||||
&entity.TenantModelProvider{ID: "provider-mineru-1", TenantID: "test-tenant", ProviderName: "MinerU"},
|
||||
&entity.TenantModelProvider{ID: "provider-mineru-1", UserID: "test-tenant", TenantID: "test-tenant", ProviderName: "MinerU"},
|
||||
&entity.TenantModelInstance{ID: "instance-mineru-1", ProviderID: "provider-mineru-1", InstanceName: "default", APIKey: "mineru-secret", Status: "active", Extra: `{"base_url":"` + baseURL + `"}`},
|
||||
&entity.TenantModel{ID: modelID, ProviderID: "provider-mineru-1", InstanceID: "instance-mineru-1", ModelName: "MinerU-model", ModelType: int(entity.ModelTypeOCR), Status: "active"},
|
||||
}
|
||||
@@ -1766,11 +1766,11 @@ func TestResolveTenantOCRModelByProvider_MinerUProviderAlias(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
if err := db.AutoMigrate(&entity.UserTenant{}, &entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
t.Fatalf("migrate model tables: %v", err)
|
||||
}
|
||||
rows := []any{
|
||||
&entity.TenantModelProvider{ID: "provider-minerunet-1", TenantID: "test-tenant", ProviderName: "MinerU.Net"},
|
||||
&entity.TenantModelProvider{ID: "provider-minerunet-1", UserID: "test-tenant", TenantID: "test-tenant", ProviderName: "MinerU.Net"},
|
||||
&entity.TenantModelInstance{ID: "instance-minerunet-1", ProviderID: "provider-minerunet-1", InstanceName: "default", APIKey: "mineru-secret", Status: "active", Extra: "{}"},
|
||||
&entity.TenantModel{ID: "model-minerunet-1", ProviderID: "provider-minerunet-1", InstanceID: "instance-minerunet-1", ModelName: "MinerU-model", ModelType: int(entity.ModelTypeOCR), Status: "active"},
|
||||
}
|
||||
|
||||
@@ -86,7 +86,12 @@ func maybeDispatchTenantPDFParser(ctx context.Context, db *gorm.DB, filename str
|
||||
var instance *entity.TenantModelInstance
|
||||
var err error
|
||||
if composite {
|
||||
provider, err = providerDAO.GetByTenantIDAndProviderName(ctx, db, tenantID, providerName)
|
||||
owner, ownerErr := dao.NewUserTenantDAO().GetOwnerByTenantID(ctx, db, tenantID)
|
||||
if ownerErr != nil {
|
||||
return parser.ParseResult{}, true, fmt.Errorf("parser: tenant owner lookup: %w", ownerErr)
|
||||
}
|
||||
ownerUserID := owner.UserID
|
||||
provider, err = providerDAO.GetByTenantIDAndUserIDAndProviderName(ctx, db, tenantID, ownerUserID, providerName)
|
||||
if err == nil {
|
||||
instance, err = instanceDAO.GetByProviderIDAndInstanceName(ctx, db, provider.ID, instanceName)
|
||||
}
|
||||
|
||||
@@ -98,10 +98,10 @@ func tenantPDFParserFixture(t *testing.T, provider string) (*gorm.DB, string, ch
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err := db.AutoMigrate(&entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
if err := db.AutoMigrate(&entity.UserTenant{}, &entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&entity.TenantModelProvider{ID: "provider", ProviderName: provider, TenantID: "tenant-1"}).Error; err != nil {
|
||||
if err := db.Create(&entity.TenantModelProvider{ID: "provider", ProviderName: provider, UserID: "tenant-1", TenantID: "tenant-1"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, inst := range []struct{ id, url string }{{"a", decoy.URL}, {"b", server.URL}} {
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestImageOCRSavedConfigToSearchableChunk(t *testing.T) {
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
defer sqlDB.Close()
|
||||
if err := db.AutoMigrate(&entity.Knowledgebase{}, &entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
if err := db.AutoMigrate(&entity.Knowledgebase{}, &entity.UserTenant{}, &entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := DefaultRegistry()
|
||||
@@ -120,7 +120,7 @@ func TestImageOCRSavedConfigToSearchableChunk(t *testing.T) {
|
||||
}})
|
||||
for _, row := range []any{
|
||||
&entity.Knowledgebase{ID: "kb-1", TenantID: "tenant-1", Name: "Image OCR fixture", ParserConfig: config},
|
||||
&entity.TenantModelProvider{ID: "ocr-provider", TenantID: "tenant-1", ProviderName: "PaddleOCR.local"},
|
||||
&entity.TenantModelProvider{ID: "ocr-provider", UserID: "tenant-1", TenantID: "tenant-1", ProviderName: "PaddleOCR.local"},
|
||||
&entity.TenantModelInstance{ID: "ocr-instance", ProviderID: "ocr-provider", InstanceName: "selected",
|
||||
Status: "active", APIKey: "fixture-key", Extra: fmt.Sprintf(`{"base_url":%q}`, server.URL)},
|
||||
&entity.TenantModel{ID: "ocr-model", ProviderID: "ocr-provider", InstanceID: "ocr-instance",
|
||||
|
||||
@@ -707,7 +707,7 @@ func init() {
|
||||
return kb.EmbdID, nil
|
||||
},
|
||||
func(ctx context.Context, tenantID, embdID string) (*models.EmbeddingModel, error) {
|
||||
embeddingModel, err := modelFactory.NewEmbeddingModel(ctx, service.ModelAccess{TenantID: tenantID}, embdID)
|
||||
embeddingModel, err := modelFactory.NewEmbeddingModel(ctx, service.Identity{TenantID: tenantID}, embdID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -582,7 +582,7 @@ func newKnowledgeCompilerDepsResolver() kc.DepsResolver {
|
||||
// actually run instead of failing / falling back to a no-op. Use the
|
||||
// tenant model ID so later model resolution can use the same persisted
|
||||
// tenant model directly.
|
||||
defaultInfo, derr := modelFactory.ResolveDefaultInfo(modelCtx, service.ModelAccess{TenantID: tenantID}, entity.ModelTypeChat)
|
||||
defaultInfo, derr := modelFactory.ResolveDefaultInfo(modelCtx, service.Identity{TenantID: tenantID}, entity.ModelTypeChat)
|
||||
if derr != nil || defaultInfo == nil || strings.TrimSpace(defaultInfo.ID) == "" {
|
||||
return kc.Deps{}, fmt.Errorf("knowledge_compiler: llm_id is empty and no tenant default chat model available: %w", derr)
|
||||
}
|
||||
@@ -596,7 +596,7 @@ func newKnowledgeCompilerDepsResolver() kc.DepsResolver {
|
||||
// generation cap and must not be used as the input budget.
|
||||
llmMax := kc.DefaultLLMContextLength
|
||||
// Bound the model-config lookup and inherit cancellation from the task.
|
||||
modelInfo, contextLengthErr := modelFactory.ResolveInfo(modelCtx, service.ModelAccess{TenantID: tenantID}, entity.ModelTypeChat, llmID)
|
||||
modelInfo, contextLengthErr := modelFactory.ResolveInfo(modelCtx, service.Identity{TenantID: tenantID}, entity.ModelTypeChat, llmID)
|
||||
if contextLengthErr == nil && modelInfo != nil && modelInfo.ContextLength > 0 {
|
||||
llmMax = modelInfo.ContextLength
|
||||
}
|
||||
@@ -642,7 +642,7 @@ func (c *kcChatInvoker) Chat(ctx context.Context, req kc.ChatRequest) (*kc.ChatR
|
||||
if req.LLMID != "" {
|
||||
llmID = req.LLMID
|
||||
}
|
||||
chatModel, err := c.factory.NewChatModel(ctx, service.ModelAccess{TenantID: c.tenantID}, llmID)
|
||||
chatModel, err := c.factory.NewChatModel(ctx, service.Identity{TenantID: c.tenantID}, llmID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -822,7 +822,7 @@ func (e *kcEmbedder) Encode(ctx context.Context, texts []string) ([][]float32, e
|
||||
// loudly instead of silently producing empty vectors.
|
||||
func (e *kcEmbedder) resolveModel(ctx context.Context) (*models.EmbeddingModel, error) {
|
||||
if embdID := strings.TrimSpace(e.embdID); embdID != "" {
|
||||
embeddingModel, err := e.factory.NewEmbeddingModel(ctx, service.ModelAccess{TenantID: e.tenantID}, embdID)
|
||||
embeddingModel, err := e.factory.NewEmbeddingModel(ctx, service.Identity{TenantID: e.tenantID}, embdID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("knowledge_compiler: resolve embedding model: %w", err)
|
||||
}
|
||||
@@ -831,7 +831,7 @@ func (e *kcEmbedder) resolveModel(ctx context.Context) (*models.EmbeddingModel,
|
||||
}
|
||||
return embeddingModel, nil
|
||||
}
|
||||
embeddingModel, err := e.factory.NewDefaultEmbeddingModel(ctx, service.ModelAccess{TenantID: e.tenantID})
|
||||
embeddingModel, err := e.factory.NewDefaultEmbeddingModel(ctx, service.Identity{TenantID: e.tenantID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("knowledge_compiler: embedding_model is required and no tenant default embedding model is set: %w", err)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
package dao
|
||||
package modelcontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -24,13 +24,14 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/entity/models"
|
||||
)
|
||||
|
||||
// ResolveModelContentLength returns the chat model's effective context window
|
||||
// (context_length) in tokens for modelRef — a tenant_model UUID or a
|
||||
// composite "model@provider" / "model@instance@provider" reference — or 0
|
||||
// (context_length) in tokens for modelRef - a tenant_model UUID or a
|
||||
// composite "model@provider" / "model@instance@provider" reference - or 0
|
||||
// when it cannot be resolved. tenantID scopes the composite-reference lookup
|
||||
// to the tenant's own provider/instance/model rows. driver and modelName are
|
||||
// an optional provider catalog fallback used when modelRef is not a
|
||||
@@ -45,12 +46,11 @@ import (
|
||||
// context_length (via the tenant row, the composite reference parts, or
|
||||
// the resolved driver + model name).
|
||||
//
|
||||
// This is the shared implementation behind the agent LLM component, the
|
||||
// ingestion Extractor, and service.ModelProviderService — those packages
|
||||
// cannot import each other (import cycles), so the lookup lives here.
|
||||
// This is the shared implementation behind the Agent LLM component and the
|
||||
// Ingestion Extractor, which cannot depend on the Service package.
|
||||
func ResolveModelContentLength(ctx context.Context, db *gorm.DB, tenantID, modelRef, driver, modelName string) int {
|
||||
if db == nil {
|
||||
db = DB
|
||||
db = dao.DB
|
||||
}
|
||||
pureName, instanceName, providerName, composite := splitCompositeModelRef(modelRef)
|
||||
|
||||
@@ -64,31 +64,31 @@ func ResolveModelContentLength(ctx context.Context, db *gorm.DB, tenantID, model
|
||||
}
|
||||
|
||||
// 2. Catalog fallbacks (only when there is no custom override).
|
||||
// 2a. Active tenant row → its provider row → catalog context_length.
|
||||
// 2a. Active tenant row, its provider row, then catalog context_length.
|
||||
if obj != nil && obj.Status == "active" {
|
||||
if provider, err := NewTenantModelProviderDAO().GetByID(ctx, db, obj.ProviderID); err == nil && provider != nil {
|
||||
if mdl, err := GetModelProviderManager().GetModelByName(models.CatalogProviderName(provider.ProviderName), obj.ModelName); err == nil && mdl.ContextLength != nil {
|
||||
if provider, err := dao.NewTenantModelProviderDAO().GetByID(ctx, db, obj.ProviderID); err == nil && provider != nil {
|
||||
if mdl, err := dao.GetModelProviderManager().GetModelByName(models.CatalogProviderName(provider.ProviderName), obj.ModelName); err == nil && mdl.ContextLength != nil {
|
||||
return *mdl.ContextLength
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2b. Composite reference with no tenant row → catalog by reference parts.
|
||||
// 2b. Composite reference with no tenant row; query catalog by reference parts.
|
||||
if composite {
|
||||
if mdl, err := GetModelProviderManager().GetModelByName(models.CatalogProviderName(providerName), pureName); err == nil && mdl.ContextLength != nil {
|
||||
if mdl, err := dao.GetModelProviderManager().GetModelByName(models.CatalogProviderName(providerName), pureName); err == nil && mdl.ContextLength != nil {
|
||||
return *mdl.ContextLength
|
||||
}
|
||||
}
|
||||
// 2c. Resolved driver + bare model name: fallback when modelRef is a
|
||||
// tenant id that could not be resolved without a database.
|
||||
if driver != "" && modelName != "" {
|
||||
if mdl, err := GetModelProviderManager().GetModelByName(models.CatalogProviderName(driver), modelName); err == nil && mdl.ContextLength != nil {
|
||||
if mdl, err := dao.GetModelProviderManager().GetModelByName(models.CatalogProviderName(driver), modelName); err == nil && mdl.ContextLength != nil {
|
||||
return *mdl.ContextLength
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// lookupTenantModel resolves the tenant_model row for modelRef — by UUID, or
|
||||
// lookupTenantModel resolves the tenant_model row for modelRef by UUID, or
|
||||
// for a composite reference through the tenant's provider/instance rows (chat
|
||||
// model type). Returns nil when the row cannot be resolved.
|
||||
func lookupTenantModel(ctx context.Context, db *gorm.DB, tenantID, modelRef, pureName, instanceName, providerName string, composite bool) *entity.TenantModel {
|
||||
@@ -99,15 +99,20 @@ func lookupTenantModel(ctx context.Context, db *gorm.DB, tenantID, modelRef, pur
|
||||
if tenantID == "" {
|
||||
return nil
|
||||
}
|
||||
provider, err := NewTenantModelProviderDAO().GetByTenantIDAndProviderName(ctx, db, tenantID, providerName)
|
||||
owner, err := dao.NewUserTenantDAO().GetOwnerByTenantID(ctx, db, tenantID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ownerUserID := owner.UserID
|
||||
provider, err := dao.NewTenantModelProviderDAO().GetByTenantIDAndUserIDAndProviderName(ctx, db, tenantID, ownerUserID, providerName)
|
||||
if err != nil || provider == nil {
|
||||
return nil
|
||||
}
|
||||
instance, err := NewTenantModelInstanceDAO().GetByProviderIDAndInstanceName(ctx, db, provider.ID, instanceName)
|
||||
instance, err := dao.NewTenantModelInstanceDAO().GetByProviderIDAndInstanceName(ctx, db, provider.ID, instanceName)
|
||||
if err != nil || instance == nil || instance.Status != "active" {
|
||||
return nil
|
||||
}
|
||||
obj, err := NewTenantModelDAO().GetByProviderIDAndInstanceIDAndModelTypeAndModelName(ctx, db, provider.ID, instance.ID, int(entity.ModelTypeChat), pureName)
|
||||
obj, err := dao.NewTenantModelDAO().GetByProviderIDAndInstanceIDAndModelTypeAndModelName(ctx, db, provider.ID, instance.ID, int(entity.ModelTypeChat), pureName)
|
||||
if err != nil || obj == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -116,7 +121,7 @@ func lookupTenantModel(ctx context.Context, db *gorm.DB, tenantID, modelRef, pur
|
||||
if modelRef == "" {
|
||||
return nil
|
||||
}
|
||||
obj, err := NewTenantModelDAO().GetByID(ctx, db, modelRef)
|
||||
obj, err := dao.NewTenantModelDAO().GetByID(ctx, db, modelRef)
|
||||
if err != nil || obj == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -130,7 +135,7 @@ func lookupTenantModel(ctx context.Context, db *gorm.DB, tenantID, modelRef, pur
|
||||
|
||||
// modelExtraMaxTokens returns the per-model "max_tokens" override from the
|
||||
// tenant_model.extra JSON, or 0 when absent/invalid/non-positive. Mirrors
|
||||
// Python's model_extra.get("max_tokens") — the custom context-window length a
|
||||
// Python's model_extra.get("max_tokens"): the custom context-window length a
|
||||
// user configures for a model. Both JSON numbers and numeric strings are
|
||||
// accepted (some callers persist extra.max_tokens as a string).
|
||||
func modelExtraMaxTokens(extra string) int {
|
||||
@@ -143,17 +148,25 @@ func modelExtraMaxTokens(extra string) int {
|
||||
if err := json.Unmarshal([]byte(extra), &m); err != nil || len(m.MaxTokens) == 0 {
|
||||
return 0
|
||||
}
|
||||
return MaxTokensFromJSON(m.MaxTokens)
|
||||
}
|
||||
|
||||
// MaxTokensFromJSON parses a positive max_tokens JSON number or numeric string.
|
||||
func MaxTokensFromJSON(raw json.RawMessage) int {
|
||||
if len(raw) == 0 {
|
||||
return 0
|
||||
}
|
||||
// Accept JSON numbers (int or float, e.g. 32000 / 32000.0) and numeric
|
||||
// strings ("32000"); json.Number keeps the raw lexical form.
|
||||
var num json.Number
|
||||
if err := json.Unmarshal(m.MaxTokens, &num); err == nil {
|
||||
if err := json.Unmarshal(raw, &num); err == nil {
|
||||
if f, err := strconv.ParseFloat(num.String(), 64); err == nil && f > 0 {
|
||||
return int(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
var str string
|
||||
if err := json.Unmarshal(m.MaxTokens, &str); err == nil {
|
||||
if err := json.Unmarshal(raw, &str); err == nil {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(str)); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
+100
-64
@@ -14,22 +14,52 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
package dao
|
||||
package modelcontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
)
|
||||
|
||||
func TestMaxTokensFromJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json json.RawMessage
|
||||
want int
|
||||
}{
|
||||
{name: "integer", json: json.RawMessage(`32000`), want: 32000},
|
||||
{name: "fractional number", json: json.RawMessage(`32000.9`), want: 32000},
|
||||
{name: "numeric string", json: json.RawMessage(`" 32000 "`), want: 32000},
|
||||
{name: "zero", json: json.RawMessage(`0`)},
|
||||
{name: "negative", json: json.RawMessage(`-1`)},
|
||||
{name: "invalid string", json: json.RawMessage(`"large"`)},
|
||||
{name: "null", json: json.RawMessage(`null`)},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := MaxTokensFromJSON(test.json); got != test.want {
|
||||
t.Fatalf("MaxTokensFromJSON(%s) = %d, want %d", test.json, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTestContextLength(ctx context.Context, db *gorm.DB, tenantID, modelRef, driver, modelName string) int {
|
||||
return ResolveModelContentLength(ctx, db, tenantID, modelRef, driver, modelName)
|
||||
}
|
||||
|
||||
// TestResolveModelContentLength_CompositeReference resolves context_length
|
||||
// for a composite "model@provider" reference from the provider catalog.
|
||||
func TestResolveModelContentLength_CompositeReference(t *testing.T) {
|
||||
if got := ResolveModelContentLength(t.Context(), nil, "", "gpt-4o@openai", "", ""); got != 128000 {
|
||||
t.Fatalf("ResolveModelContentLength(gpt-4o@openai) = %d, want 128000", got)
|
||||
if got := resolveTestContextLength(t.Context(), nil, "", "gpt-4o@openai", "", ""); got != 128000 {
|
||||
t.Fatalf("resolveTestContextLength(gpt-4o@openai) = %d, want 128000", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +68,8 @@ func TestResolveModelContentLength_CompositeReference(t *testing.T) {
|
||||
// instance-bearing form used by tenant model instances — from the provider
|
||||
// catalog.
|
||||
func TestResolveModelContentLength_CompositeReferenceThreePart(t *testing.T) {
|
||||
if got := ResolveModelContentLength(t.Context(), nil, "", "gpt-4o@default@openai", "", ""); got != 128000 {
|
||||
t.Fatalf("ResolveModelContentLength(gpt-4o@default@openai) = %d, want 128000", got)
|
||||
if got := resolveTestContextLength(t.Context(), nil, "", "gpt-4o@default@openai", "", ""); got != 128000 {
|
||||
t.Fatalf("resolveTestContextLength(gpt-4o@default@openai) = %d, want 128000", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,32 +80,32 @@ func TestResolveModelContentLength_CompositeReferenceThreePart(t *testing.T) {
|
||||
// and provider anchored on the right. When the catalog lookup fails it falls
|
||||
// through to the driver+modelName fallback, and to 0 when no fallback exists.
|
||||
func TestResolveModelContentLength_MultiSegmentComposite(t *testing.T) {
|
||||
if got := ResolveModelContentLength(t.Context(), nil, "", "a@b@c@d", "openai", "gpt-4o"); got != 128000 {
|
||||
t.Fatalf("ResolveModelContentLength(a@b@c@d, openai, gpt-4o) = %d, want 128000 (driver fallback)", got)
|
||||
if got := resolveTestContextLength(t.Context(), nil, "", "a@b@c@d", "openai", "gpt-4o"); got != 128000 {
|
||||
t.Fatalf("resolveTestContextLength(a@b@c@d, openai, gpt-4o) = %d, want 128000 (driver fallback)", got)
|
||||
}
|
||||
if got := ResolveModelContentLength(t.Context(), nil, "", "gpt-4o@default@openai@extra", "", ""); got != 0 {
|
||||
t.Fatalf("ResolveModelContentLength(gpt-4o@default@openai@extra) = %d, want 0", got)
|
||||
if got := resolveTestContextLength(t.Context(), nil, "", "gpt-4o@default@openai@extra", "", ""); got != 0 {
|
||||
t.Fatalf("resolveTestContextLength(gpt-4o@default@openai@extra) = %d, want 0", got)
|
||||
}
|
||||
modelName, instanceName, providerName, ok := splitCompositeModelRef("a@b@c@d")
|
||||
if !ok || modelName != "a@b" || instanceName != "c" || providerName != "d" {
|
||||
t.Fatalf("splitCompositeModelRef(a@b@c@d) = %q/%q/%q/%v, want a@b/c/d/true", modelName, instanceName, providerName, ok)
|
||||
modelName, instanceName, providerName, err := common.ParseCompositeModelName("a@b@c@d")
|
||||
if err != nil || modelName != "a@b" || instanceName != "c" || providerName != "d" {
|
||||
t.Fatalf("ParseCompositeModelName(a@b@c@d) = %q/%q/%q/%v, want a@b/c/d", modelName, instanceName, providerName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveModelContentLength_DriverModelFallback resolves context_length
|
||||
// from the resolved driver + bare model name, which needs no database.
|
||||
func TestResolveModelContentLength_DriverModelFallback(t *testing.T) {
|
||||
if got := ResolveModelContentLength(t.Context(), nil, "", "", "openai", "gpt-4o"); got != 128000 {
|
||||
t.Fatalf("ResolveModelContentLength(openai/gpt-4o) = %d, want 128000", got)
|
||||
if got := resolveTestContextLength(t.Context(), nil, "", "", "openai", "gpt-4o"); got != 128000 {
|
||||
t.Fatalf("resolveTestContextLength(openai/gpt-4o) = %d, want 128000", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveModelContentLength_Unknown returns 0 for unknown references.
|
||||
func TestResolveModelContentLength_Unknown(t *testing.T) {
|
||||
if got := ResolveModelContentLength(t.Context(), nil, "", "no-such-model@no-such-provider", "", ""); got != 0 {
|
||||
if got := resolveTestContextLength(t.Context(), nil, "", "no-such-model@no-such-provider", "", ""); got != 0 {
|
||||
t.Fatalf("unknown model = %d, want 0", got)
|
||||
}
|
||||
if got := ResolveModelContentLength(t.Context(), nil, "", "", "", ""); got != 0 {
|
||||
if got := resolveTestContextLength(t.Context(), nil, "", "", "", ""); got != 0 {
|
||||
t.Fatalf("empty reference = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -84,13 +114,12 @@ func TestResolveModelContentLength_Unknown(t *testing.T) {
|
||||
// tenant_model UUID row through the provider catalog.
|
||||
func TestResolveModelContentLength_TenantModelUUID(t *testing.T) {
|
||||
db := openModelContextTestDB(t)
|
||||
pushDB(t, db)
|
||||
ctx := t.Context()
|
||||
|
||||
seedOpenAIChatModel(t, db, "")
|
||||
|
||||
if got := ResolveModelContentLength(ctx, db, "tenant-1", "0123456789abcdef0123456789abcdef", "", ""); got != 128000 {
|
||||
t.Fatalf("ResolveModelContentLength(uuid) = %d, want 128000", got)
|
||||
if got := resolveTestContextLength(ctx, db, "tenant-1", "0123456789abcdef0123456789abcdef", "", ""); got != 128000 {
|
||||
t.Fatalf("resolveTestContextLength(uuid) = %d, want 128000", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,13 +128,12 @@ func TestResolveModelContentLength_TenantModelUUID(t *testing.T) {
|
||||
// provider catalog's context_length for a UUID reference.
|
||||
func TestResolveModelContentLength_ExtraOverrideUUID(t *testing.T) {
|
||||
db := openModelContextTestDB(t)
|
||||
pushDB(t, db)
|
||||
ctx := t.Context()
|
||||
|
||||
seedOpenAIChatModel(t, db, `{"max_tokens": 32000}`)
|
||||
|
||||
if got := ResolveModelContentLength(ctx, db, "tenant-1", "0123456789abcdef0123456789abcdef", "", ""); got != 32000 {
|
||||
t.Fatalf("ResolveModelContentLength(uuid+extra) = %d, want 32000 (custom override wins over catalog 128000)", got)
|
||||
if got := resolveTestContextLength(ctx, db, "tenant-1", "0123456789abcdef0123456789abcdef", "", ""); got != 32000 {
|
||||
t.Fatalf("resolveTestContextLength(uuid+extra) = %d, want 32000 (custom override wins over catalog 128000)", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,13 +142,12 @@ func TestResolveModelContentLength_ExtraOverrideUUID(t *testing.T) {
|
||||
// tenant's provider/instance/model rows.
|
||||
func TestResolveModelContentLength_ExtraOverrideComposite(t *testing.T) {
|
||||
db := openModelContextTestDB(t)
|
||||
pushDB(t, db)
|
||||
ctx := t.Context()
|
||||
|
||||
seedOpenAIChatModel(t, db, `{"max_tokens": 32000}`)
|
||||
|
||||
if got := ResolveModelContentLength(ctx, db, "tenant-1", "gpt-4o@OpenAI", "", ""); got != 32000 {
|
||||
t.Fatalf("ResolveModelContentLength(composite+extra) = %d, want 32000", got)
|
||||
if got := resolveTestContextLength(ctx, db, "tenant-1", "gpt-4o@OpenAI", "", ""); got != 32000 {
|
||||
t.Fatalf("resolveTestContextLength(composite+extra) = %d, want 32000", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,13 +157,12 @@ func TestResolveModelContentLength_ExtraOverrideComposite(t *testing.T) {
|
||||
// override (the catalog cannot provide a value).
|
||||
func TestResolveModelContentLength_CustomModelExtraComposite(t *testing.T) {
|
||||
db := openModelContextTestDB(t)
|
||||
pushDB(t, db)
|
||||
ctx := t.Context()
|
||||
|
||||
seedCustomChatModel(t, db, "my-local-model", `{"max_tokens": 32000}`)
|
||||
|
||||
if got := ResolveModelContentLength(ctx, db, "tenant-1", "my-local-model@OpenAI", "", ""); got != 32000 {
|
||||
t.Fatalf("ResolveModelContentLength(custom+extra) = %d, want 32000", got)
|
||||
if got := resolveTestContextLength(ctx, db, "tenant-1", "my-local-model@OpenAI", "", ""); got != 32000 {
|
||||
t.Fatalf("resolveTestContextLength(custom+extra) = %d, want 32000", got)
|
||||
}
|
||||
// Without the override the custom model is unknown to the catalog → 0.
|
||||
if err := db.Model(&entity.TenantModel{}).
|
||||
@@ -144,7 +170,7 @@ func TestResolveModelContentLength_CustomModelExtraComposite(t *testing.T) {
|
||||
Update("extra", "").Error; err != nil {
|
||||
t.Fatalf("clear custom model extra: %v", err)
|
||||
}
|
||||
if got := ResolveModelContentLength(ctx, db, "tenant-1", "my-local-model@OpenAI", "", ""); got != 0 {
|
||||
if got := resolveTestContextLength(ctx, db, "tenant-1", "my-local-model@OpenAI", "", ""); got != 0 {
|
||||
t.Fatalf("custom model without extra = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -155,7 +181,6 @@ func TestResolveModelContentLength_CustomModelExtraComposite(t *testing.T) {
|
||||
// catalog by reference parts (gpt-4o → 128000).
|
||||
func TestResolveModelContentLength_InactiveInstanceCompositeFallsBackToCatalog(t *testing.T) {
|
||||
db := openModelContextTestDB(t)
|
||||
pushDB(t, db)
|
||||
ctx := t.Context()
|
||||
|
||||
seedOpenAIChatModel(t, db, `{"max_tokens": 32000}`)
|
||||
@@ -165,7 +190,7 @@ func TestResolveModelContentLength_InactiveInstanceCompositeFallsBackToCatalog(t
|
||||
t.Fatalf("set instance inactive: %v", err)
|
||||
}
|
||||
|
||||
if got := ResolveModelContentLength(ctx, db, "tenant-1", "gpt-4o@OpenAI", "", ""); got != 128000 {
|
||||
if got := resolveTestContextLength(ctx, db, "tenant-1", "gpt-4o@OpenAI", "", ""); got != 128000 {
|
||||
t.Fatalf("inactive-instance composite = %d, want catalog 128000", got)
|
||||
}
|
||||
}
|
||||
@@ -176,7 +201,6 @@ func TestResolveModelContentLength_InactiveInstanceCompositeFallsBackToCatalog(t
|
||||
// catalog by reference parts (gpt-4o → 128000).
|
||||
func TestResolveModelContentLength_InactiveModelCompositeFallsBackToCatalog(t *testing.T) {
|
||||
db := openModelContextTestDB(t)
|
||||
pushDB(t, db)
|
||||
ctx := t.Context()
|
||||
|
||||
seedOpenAIChatModel(t, db, `{"max_tokens": 32000}`)
|
||||
@@ -186,7 +210,7 @@ func TestResolveModelContentLength_InactiveModelCompositeFallsBackToCatalog(t *t
|
||||
t.Fatalf("set model inactive: %v", err)
|
||||
}
|
||||
|
||||
if got := ResolveModelContentLength(ctx, db, "tenant-1", "gpt-4o@OpenAI", "", ""); got != 128000 {
|
||||
if got := resolveTestContextLength(ctx, db, "tenant-1", "gpt-4o@OpenAI", "", ""); got != 128000 {
|
||||
t.Fatalf("inactive-model composite = %d, want catalog 128000", got)
|
||||
}
|
||||
}
|
||||
@@ -196,13 +220,12 @@ func TestResolveModelContentLength_InactiveModelCompositeFallsBackToCatalog(t *t
|
||||
// callers persist extra.max_tokens as a string).
|
||||
func TestResolveModelContentLength_ExtraOverrideStringForm(t *testing.T) {
|
||||
db := openModelContextTestDB(t)
|
||||
pushDB(t, db)
|
||||
ctx := t.Context()
|
||||
|
||||
seedOpenAIChatModel(t, db, `{"max_tokens": "32000"}`)
|
||||
|
||||
if got := ResolveModelContentLength(ctx, db, "tenant-1", "0123456789abcdef0123456789abcdef", "", ""); got != 32000 {
|
||||
t.Fatalf("ResolveModelContentLength(string-form extra) = %d, want 32000", got)
|
||||
if got := resolveTestContextLength(ctx, db, "tenant-1", "0123456789abcdef0123456789abcdef", "", ""); got != 32000 {
|
||||
t.Fatalf("resolveTestContextLength(string-form extra) = %d, want 32000", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,13 +233,12 @@ func TestResolveModelContentLength_ExtraOverrideStringForm(t *testing.T) {
|
||||
// verifies that a non-numeric max_tokens string is treated as "no override".
|
||||
func TestResolveModelContentLength_NonNumericStringExtraFallsBackToCatalog(t *testing.T) {
|
||||
db := openModelContextTestDB(t)
|
||||
pushDB(t, db)
|
||||
ctx := t.Context()
|
||||
|
||||
seedOpenAIChatModel(t, db, `{"max_tokens": "huge"}`)
|
||||
|
||||
if got := ResolveModelContentLength(ctx, db, "tenant-1", "0123456789abcdef0123456789abcdef", "", ""); got != 128000 {
|
||||
t.Fatalf("ResolveModelContentLength(non-numeric string extra) = %d, want catalog 128000", got)
|
||||
if got := resolveTestContextLength(ctx, db, "tenant-1", "0123456789abcdef0123456789abcdef", "", ""); got != 128000 {
|
||||
t.Fatalf("resolveTestContextLength(non-numeric string extra) = %d, want catalog 128000", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,13 +247,12 @@ func TestResolveModelContentLength_NonNumericStringExtraFallsBackToCatalog(t *te
|
||||
// context_length is used.
|
||||
func TestResolveModelContentLength_InvalidExtraFallsBackToCatalog(t *testing.T) {
|
||||
db := openModelContextTestDB(t)
|
||||
pushDB(t, db)
|
||||
ctx := t.Context()
|
||||
|
||||
seedOpenAIChatModel(t, db, `{bad json`)
|
||||
|
||||
if got := ResolveModelContentLength(ctx, db, "tenant-1", "0123456789abcdef0123456789abcdef", "", ""); got != 128000 {
|
||||
t.Fatalf("ResolveModelContentLength(invalid extra) = %d, want catalog 128000", got)
|
||||
if got := resolveTestContextLength(ctx, db, "tenant-1", "0123456789abcdef0123456789abcdef", "", ""); got != 128000 {
|
||||
t.Fatalf("resolveTestContextLength(invalid extra) = %d, want catalog 128000", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,13 +260,12 @@ func TestResolveModelContentLength_InvalidExtraFallsBackToCatalog(t *testing.T)
|
||||
// non-positive max_tokens override is ignored in favor of the catalog.
|
||||
func TestResolveModelContentLength_ZeroExtraFallsBackToCatalog(t *testing.T) {
|
||||
db := openModelContextTestDB(t)
|
||||
pushDB(t, db)
|
||||
ctx := t.Context()
|
||||
|
||||
seedOpenAIChatModel(t, db, `{"max_tokens": 0}`)
|
||||
|
||||
if got := ResolveModelContentLength(ctx, db, "tenant-1", "0123456789abcdef0123456789abcdef", "", ""); got != 128000 {
|
||||
t.Fatalf("ResolveModelContentLength(zero extra) = %d, want catalog 128000", got)
|
||||
if got := resolveTestContextLength(ctx, db, "tenant-1", "0123456789abcdef0123456789abcdef", "", ""); got != 128000 {
|
||||
t.Fatalf("resolveTestContextLength(zero extra) = %d, want catalog 128000", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +274,6 @@ func TestResolveModelContentLength_ZeroExtraFallsBackToCatalog(t *testing.T) {
|
||||
// it carries a max_tokens override.
|
||||
func TestResolveModelContentLength_InactiveTenantModel(t *testing.T) {
|
||||
db := openModelContextTestDB(t)
|
||||
pushDB(t, db)
|
||||
ctx := t.Context()
|
||||
|
||||
seedOpenAIChatModel(t, db, `{"max_tokens": 32000}`)
|
||||
@@ -266,7 +285,7 @@ func TestResolveModelContentLength_InactiveTenantModel(t *testing.T) {
|
||||
}
|
||||
|
||||
// The UUID is not a composite ref, so an inactive row yields 0.
|
||||
if got := ResolveModelContentLength(ctx, db, "tenant-1", "0123456789abcdef0123456789abcdef", "", ""); got != 0 {
|
||||
if got := resolveTestContextLength(ctx, db, "tenant-1", "0123456789abcdef0123456789abcdef", "", ""); got != 0 {
|
||||
t.Fatalf("inactive tenant model = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -288,8 +307,10 @@ func seedCustomChatModel(t *testing.T, db *gorm.DB, modelName, extra string) {
|
||||
|
||||
func seedChatModel(t *testing.T, db *gorm.DB, providerID, providerName, modelName, extra string) {
|
||||
t.Helper()
|
||||
seedTenantOwner(t, db)
|
||||
if err := db.Create(&entity.TenantModelProvider{
|
||||
ID: providerID,
|
||||
UserID: "tenant-1",
|
||||
ProviderName: providerName,
|
||||
TenantID: "tenant-1",
|
||||
}).Error; err != nil {
|
||||
@@ -316,13 +337,27 @@ func seedChatModel(t *testing.T, db *gorm.DB, providerID, providerName, modelNam
|
||||
}
|
||||
}
|
||||
|
||||
func seedTenantOwner(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
status := entity.TenantMembershipStatusJoined
|
||||
if err := db.Create(&entity.UserTenant{
|
||||
ID: "membership-tenant-1",
|
||||
UserID: "tenant-1",
|
||||
TenantID: "tenant-1",
|
||||
Role: "owner",
|
||||
Status: &status,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create tenant owner: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func openModelContextTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{TranslateError: true})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
if err := db.AutoMigrate(&entity.UserTenant{}, &entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
@@ -335,17 +370,16 @@ func openModelContextTestDB(t *testing.T) *gorm.DB {
|
||||
func TestResolveModelContentLength_StoredMistralOCRProvider(t *testing.T) {
|
||||
const want = 262144 // mistral-large-latest context_length in conf/models/mistral.json
|
||||
|
||||
if got := ResolveModelContentLength(t.Context(), nil, "", "mistral-large-latest@default@Mistral OCR", "", ""); got != want {
|
||||
if got := resolveTestContextLength(t.Context(), nil, "", "mistral-large-latest@default@Mistral OCR", "", ""); got != want {
|
||||
t.Fatalf("composite without tenant row = %d, want %d", got, want)
|
||||
}
|
||||
if got := ResolveModelContentLength(t.Context(), nil, "", "", "Mistral OCR", "mistral-large-latest"); got != want {
|
||||
if got := resolveTestContextLength(t.Context(), nil, "", "", "Mistral OCR", "mistral-large-latest"); got != want {
|
||||
t.Fatalf("driver fallback = %d, want %d", got, want)
|
||||
}
|
||||
|
||||
db := openModelContextTestDB(t)
|
||||
pushDB(t, db)
|
||||
seedChatModel(t, db, "provider-mistral-ocr", "Mistral OCR", "mistral-large-latest", "")
|
||||
if got := ResolveModelContentLength(t.Context(), db, "tenant-1", "0123456789abcdef0123456789abcdef", "", ""); got != want {
|
||||
if got := resolveTestContextLength(t.Context(), db, "tenant-1", "0123456789abcdef0123456789abcdef", "", ""); got != want {
|
||||
t.Fatalf("tenant model id = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
@@ -359,16 +393,17 @@ func TestResolveModelContentLength_StoredMistralOCRProvider(t *testing.T) {
|
||||
// "model@instance@provider" reference keeps resolving that instance.
|
||||
func TestResolveModelContentLength_LegacyCompositeNeverUsesNonDefaultInstance(t *testing.T) {
|
||||
db := openModelContextTestDB(t)
|
||||
pushDB(t, db)
|
||||
ctx := t.Context()
|
||||
|
||||
if err := db.Create(&entity.TenantModelProvider{
|
||||
ID: "provider-openai",
|
||||
UserID: "tenant-1",
|
||||
ProviderName: "OpenAI",
|
||||
TenantID: "tenant-1",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create provider: %v", err)
|
||||
}
|
||||
seedTenantOwner(t, db)
|
||||
if err := db.Create(&entity.TenantModelInstance{
|
||||
ID: "instance-prod",
|
||||
ProviderID: "provider-openai",
|
||||
@@ -391,12 +426,12 @@ func TestResolveModelContentLength_LegacyCompositeNeverUsesNonDefaultInstance(t
|
||||
|
||||
// The implicit "default" instance does not exist, so the "prod" instance's
|
||||
// tenant override must not answer a two-part reference.
|
||||
if got := ResolveModelContentLength(ctx, db, "tenant-1", "gpt-4o@OpenAI", "", ""); got != 128000 {
|
||||
t.Fatalf("ResolveModelContentLength(gpt-4o@OpenAI) = %d, want 128000 (the default instance is unavailable; another instance must not be substituted)", got)
|
||||
if got := resolveTestContextLength(ctx, db, "tenant-1", "gpt-4o@OpenAI", "", ""); got != 128000 {
|
||||
t.Fatalf("resolveTestContextLength(gpt-4o@OpenAI) = %d, want 128000 (the default instance is unavailable; another instance must not be substituted)", got)
|
||||
}
|
||||
// An explicit instance reference still resolves that instance.
|
||||
if got := ResolveModelContentLength(ctx, db, "tenant-1", "gpt-4o@prod@OpenAI", "", ""); got != 32000 {
|
||||
t.Fatalf("ResolveModelContentLength(gpt-4o@prod@OpenAI) = %d, want 32000 (explicit instance reference)", got)
|
||||
if got := resolveTestContextLength(ctx, db, "tenant-1", "gpt-4o@prod@OpenAI", "", ""); got != 32000 {
|
||||
t.Fatalf("resolveTestContextLength(gpt-4o@prod@OpenAI) = %d, want 32000 (explicit instance reference)", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,16 +441,17 @@ func TestResolveModelContentLength_LegacyCompositeNeverUsesNonDefaultInstance(t
|
||||
// instance keeps its own override for explicit references.
|
||||
func TestResolveModelContentLength_LegacyCompositeDoesNotGuessBetweenActiveInstances(t *testing.T) {
|
||||
db := openModelContextTestDB(t)
|
||||
pushDB(t, db)
|
||||
ctx := t.Context()
|
||||
|
||||
if err := db.Create(&entity.TenantModelProvider{
|
||||
ID: "provider-openai",
|
||||
UserID: "tenant-1",
|
||||
ProviderName: "OpenAI",
|
||||
TenantID: "tenant-1",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create provider: %v", err)
|
||||
}
|
||||
seedTenantOwner(t, db)
|
||||
for _, instance := range []*entity.TenantModelInstance{
|
||||
{ID: "instance-prod", ProviderID: "provider-openai", InstanceName: "prod", Status: "active"},
|
||||
{ID: "instance-staging", ProviderID: "provider-openai", InstanceName: "staging", Status: "active"},
|
||||
@@ -441,13 +477,13 @@ func TestResolveModelContentLength_LegacyCompositeDoesNotGuessBetweenActiveInsta
|
||||
}
|
||||
}
|
||||
|
||||
if got := ResolveModelContentLength(ctx, db, "tenant-1", "gpt-4o@OpenAI", "", ""); got != 128000 {
|
||||
t.Fatalf("ResolveModelContentLength(gpt-4o@OpenAI) = %d, want 128000 (no default instance; no instance may be guessed)", got)
|
||||
if got := resolveTestContextLength(ctx, db, "tenant-1", "gpt-4o@OpenAI", "", ""); got != 128000 {
|
||||
t.Fatalf("resolveTestContextLength(gpt-4o@OpenAI) = %d, want 128000 (no default instance; no instance may be guessed)", got)
|
||||
}
|
||||
if got := ResolveModelContentLength(ctx, db, "tenant-1", "gpt-4o@prod@OpenAI", "", ""); got != 32000 {
|
||||
t.Fatalf("ResolveModelContentLength(gpt-4o@prod@OpenAI) = %d, want 32000", got)
|
||||
if got := resolveTestContextLength(ctx, db, "tenant-1", "gpt-4o@prod@OpenAI", "", ""); got != 32000 {
|
||||
t.Fatalf("resolveTestContextLength(gpt-4o@prod@OpenAI) = %d, want 32000", got)
|
||||
}
|
||||
if got := ResolveModelContentLength(ctx, db, "tenant-1", "gpt-4o@staging@OpenAI", "", ""); got != 16000 {
|
||||
t.Fatalf("ResolveModelContentLength(gpt-4o@staging@OpenAI) = %d, want 16000", got)
|
||||
if got := resolveTestContextLength(ctx, db, "tenant-1", "gpt-4o@staging@OpenAI", "", ""); got != 16000 {
|
||||
t.Fatalf("resolveTestContextLength(gpt-4o@staging@OpenAI) = %d, want 16000", got)
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,9 @@ func (p *communityPolicy) Scope(ctx context.Context, subject Subject, query Scop
|
||||
}
|
||||
|
||||
func (p *communityPolicy) QueryScope(ctx context.Context, subject Subject, query ScopeQuery) (QueryScope, bool, error) {
|
||||
if query.Kind == ResourceKindModelProvider || query.Kind == ResourceKindModelInstance || query.Kind == ResourceKindModel {
|
||||
return p.modelQueryScope(ctx, subject, query)
|
||||
}
|
||||
if query.Kind == ResourceKindMCPServer && query.Operation == OperationRead && query.Parent.ID == "" && query.Parent.Kind == "" && query.Entry == nil {
|
||||
return p.mcpServerReadQueryScope(ctx, subject)
|
||||
}
|
||||
@@ -218,6 +221,93 @@ func (p *communityPolicy) QueryScope(ctx context.Context, subject Subject, query
|
||||
}}, true, nil
|
||||
}
|
||||
|
||||
func (p *communityPolicy) modelQueryScope(ctx context.Context, subject Subject, query ScopeQuery) (QueryScope, bool, error) {
|
||||
parent := query.Parent
|
||||
if query.Entry != nil || (parent.ID == "") != (parent.Kind == "") {
|
||||
return QueryScope{}, false, nil
|
||||
}
|
||||
if err := validateSubject(subject); err != nil {
|
||||
return QueryScope{}, true, err
|
||||
}
|
||||
source, ok := p.source.(*databaseSource)
|
||||
if !ok || source.db == nil || tenantRoleMembershipEnabled(source.db) {
|
||||
return QueryScope{}, false, nil
|
||||
}
|
||||
if query.Kind == ResourceKindModelInstance && parent.ID != "" && parent.Kind != ResourceKindModelProvider {
|
||||
return QueryScope{}, false, nil
|
||||
}
|
||||
if query.Kind == ResourceKindModel && parent.ID != "" && parent.Kind != ResourceKindModelProvider && parent.Kind != ResourceKindModelInstance {
|
||||
return QueryScope{}, false, nil
|
||||
}
|
||||
|
||||
providerAccess := func(providerIDColumn, parentID string, ownerOnly bool) *gorm.DB {
|
||||
provider := source.db.WithContext(ctx).Model(&entity.TenantModelProvider{}).
|
||||
Select("1").
|
||||
Where("tenant_model_provider.id = " + providerIDColumn)
|
||||
if parentID != "" {
|
||||
provider = provider.Where("tenant_model_provider.id = ?", parentID)
|
||||
}
|
||||
owner := source.db.WithContext(ctx).Model(&entity.UserTenant{}).
|
||||
Select("user_tenant.user_id").
|
||||
Where("user_tenant.tenant_id = tenant_model_provider.tenant_id AND user_tenant.role = ? AND user_tenant.status = ?", string(RoleOwner), string(entity.StatusValid)).
|
||||
Limit(1)
|
||||
provider = provider.Where("tenant_model_provider.user_id = COALESCE((?), tenant_model_provider.tenant_id)", owner)
|
||||
if ownerOnly {
|
||||
return provider.Where("tenant_model_provider.user_id = ?", subject.UserID)
|
||||
}
|
||||
membership := source.db.WithContext(ctx).Model(&entity.UserTenant{}).
|
||||
Select("1").
|
||||
Where("user_tenant.tenant_id = tenant_model_provider.tenant_id AND user_tenant.user_id = ? AND user_tenant.status = ? AND user_tenant.role IN ?",
|
||||
subject.UserID, string(entity.StatusValid), []string{string(RoleOwner), string(RoleAdmin), string(RoleNormal)})
|
||||
return provider.Where("(tenant_model_provider.user_id = ? OR EXISTS (?))", subject.UserID, membership)
|
||||
}
|
||||
|
||||
switch query.Kind {
|
||||
case ResourceKindModelProvider:
|
||||
if parent.ID != "" || query.Operation != OperationRead {
|
||||
return QueryScope{}, false, nil
|
||||
}
|
||||
return QueryScope{apply: func(query *gorm.DB) *gorm.DB {
|
||||
db := source.db.WithContext(ctx)
|
||||
owner := db.Model(&entity.UserTenant{}).
|
||||
Select("user_tenant.user_id").
|
||||
Where("user_tenant.tenant_id = tenant_model_provider.tenant_id AND user_tenant.role = ? AND user_tenant.status = ?", string(RoleOwner), string(entity.StatusValid)).
|
||||
Limit(1)
|
||||
membership := db.Model(&entity.UserTenant{}).
|
||||
Select("1").
|
||||
Where("user_tenant.tenant_id = tenant_model_provider.tenant_id AND user_tenant.user_id = ? AND user_tenant.status = ? AND user_tenant.role IN ?",
|
||||
subject.UserID, string(entity.StatusValid), []string{string(RoleOwner), string(RoleAdmin), string(RoleNormal)})
|
||||
return query.
|
||||
Where("tenant_model_provider.user_id = COALESCE((?), tenant_model_provider.tenant_id)", owner).
|
||||
Where("(tenant_model_provider.user_id = ? OR EXISTS (?))", subject.UserID, membership)
|
||||
}}, true, nil
|
||||
case ResourceKindModelInstance:
|
||||
if query.Operation != OperationRead && query.Operation != OperationUse {
|
||||
return QueryScope{}, false, nil
|
||||
}
|
||||
ownerOnly := query.Operation == OperationRead
|
||||
return QueryScope{apply: func(query *gorm.DB) *gorm.DB {
|
||||
return query.Where("EXISTS (?)", providerAccess("tenant_model_instance.provider_id", parent.ID, ownerOnly))
|
||||
}}, true, nil
|
||||
case ResourceKindModel:
|
||||
if query.Operation != OperationRead && query.Operation != OperationUse {
|
||||
return QueryScope{}, false, nil
|
||||
}
|
||||
return QueryScope{apply: func(query *gorm.DB) *gorm.DB {
|
||||
modelQuery := query.Where("EXISTS (?)", providerAccess("tenant_model.provider_id", "", false))
|
||||
switch parent.Kind {
|
||||
case ResourceKindModelProvider:
|
||||
modelQuery = modelQuery.Where("tenant_model.provider_id = ?", parent.ID)
|
||||
case ResourceKindModelInstance:
|
||||
modelQuery = modelQuery.Where("tenant_model.instance_id = ?", parent.ID)
|
||||
}
|
||||
return modelQuery
|
||||
}}, true, nil
|
||||
default:
|
||||
return QueryScope{}, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *communityPolicy) mcpServerReadQueryScope(ctx context.Context, subject Subject) (QueryScope, bool, error) {
|
||||
if err := validateSubject(subject); err != nil {
|
||||
return QueryScope{}, true, err
|
||||
File renamed without changes.
@@ -554,9 +554,9 @@ func (r *Router) Setup(engine *gin.Engine) {
|
||||
|
||||
model := v1.Group("/models", ToggleModel())
|
||||
{
|
||||
// GET /models returns the tenant's added models across
|
||||
// all instances. Front-end useFetchAllAddedModels consumes this.
|
||||
model.GET("", r.providerHandler.ListTenantAddedModels)
|
||||
// GET /models returns models available to the authenticated user
|
||||
// in the selected tenant context.
|
||||
model.GET("", r.providerHandler.ListModelsForUserInTenant)
|
||||
model.PATCH("", r.tenantHandler.SetModels)
|
||||
// Tenant default-model selection (used by the agent page's useFetchDefaultModels hook)
|
||||
model.GET("/default", r.tenantHandler.GetDefaultModels)
|
||||
|
||||
@@ -106,6 +106,7 @@ func startServer(ctx context.Context, serverName string, arguments *bootstrap.Se
|
||||
|
||||
// Initialize service layer
|
||||
userService := service.NewUserService()
|
||||
userTenantService := service.NewUserTenantService()
|
||||
documentService := document.NewDocumentService()
|
||||
datasetsService := dataset.NewDatasetService()
|
||||
metadataService := service.NewMetadataService()
|
||||
@@ -120,13 +121,12 @@ func startServer(ctx context.Context, serverName string, arguments *bootstrap.Se
|
||||
statsService := service.NewStatsService()
|
||||
connectorService := service.NewConnectorService()
|
||||
searchService := service.NewSearchService()
|
||||
searchService.SetTenantService(tenantService)
|
||||
fileService := file.NewFileService(documentService)
|
||||
memoryService := service.NewMemoryService(dao.DB, engine.Get(), service.WithMemoryKvrocks(kvrocks.Get()), service.WithMemoryMQEngine(engine.GetMessageQueueEngine()))
|
||||
mcpService := service.NewMCPService()
|
||||
modelProviderService := service.NewModelProviderService()
|
||||
modelFactory := service.NewModelFactory()
|
||||
modelCallService := service.NewModelCallServiceWithProviderService(modelProviderService, modelFactory)
|
||||
modelCallService := service.NewModelCallService()
|
||||
|
||||
// Wire the real MemorySaver so the Message component can persist
|
||||
// conversation turns to memory stores declared in the canvas DSL.
|
||||
@@ -154,21 +154,21 @@ func startServer(ctx context.Context, serverName string, arguments *bootstrap.Se
|
||||
documentDAO,
|
||||
agenttool.RetrievalModelConstructors{
|
||||
Embedding: func(ctx context.Context, tenantID, modelRef string) (*modelModule.EmbeddingModel, error) {
|
||||
access := service.ModelAccess{TenantID: tenantID}
|
||||
access := service.Identity{TenantID: tenantID}
|
||||
if strings.TrimSpace(modelRef) == "" {
|
||||
return modelFactory.NewDefaultEmbeddingModel(ctx, access)
|
||||
}
|
||||
return modelFactory.NewEmbeddingModel(ctx, access, modelRef)
|
||||
},
|
||||
Chat: func(ctx context.Context, tenantID, modelRef string) (*modelModule.ChatModel, error) {
|
||||
access := service.ModelAccess{TenantID: tenantID}
|
||||
access := service.Identity{TenantID: tenantID}
|
||||
if strings.TrimSpace(modelRef) == "" {
|
||||
return modelFactory.NewDefaultChatModel(ctx, access)
|
||||
}
|
||||
return modelFactory.NewChatModel(ctx, access, modelRef)
|
||||
},
|
||||
Rerank: func(ctx context.Context, tenantID, modelRef string) (*modelModule.RerankModel, error) {
|
||||
return modelFactory.NewRerankModel(ctx, service.ModelAccess{TenantID: tenantID}, modelRef)
|
||||
return modelFactory.NewRerankModel(ctx, service.Identity{TenantID: tenantID}, modelRef)
|
||||
},
|
||||
},
|
||||
retrievalEnhancer,
|
||||
@@ -202,8 +202,8 @@ func startServer(ctx context.Context, serverName string, arguments *bootstrap.Se
|
||||
|
||||
// Initialize handler layer
|
||||
authHandler := handler.NewAuthHandler()
|
||||
userHandler := handler.NewUserHandler(userService)
|
||||
tenantHandler := handler.NewTenantHandler(tenantService, userService, datasetsService)
|
||||
userHandler := handler.NewUserHandler(userService, userTenantService)
|
||||
tenantHandler := handler.NewTenantHandler(tenantService, userTenantService, datasetsService)
|
||||
documentHandler := handler.NewDocumentHandler(documentService, datasetsService, fileService)
|
||||
datasetsHandler := handler.NewDatasetsHandler(datasetsService, metadataService)
|
||||
systemHandler := handler.NewSystemHandler(systemService)
|
||||
|
||||
@@ -2046,7 +2046,7 @@ func validateAgentChatModelReferences(ctx context.Context, userID, tenantID stri
|
||||
if !ok {
|
||||
modelRef, _ = node.Obj.Params["llm_id"].(string)
|
||||
}
|
||||
if _, err := modelFactory.ResolveInfo(ctx, ModelAccess{UserID: userID, TenantID: tenantID}, entity.ModelTypeChat, modelRef); err != nil {
|
||||
if _, err := modelFactory.ResolveInfo(ctx, Identity{UserID: userID, TenantID: tenantID}, entity.ModelTypeChat, modelRef); err != nil {
|
||||
if draft && (errors.Is(err, errModelConfigUnavailable) || errors.Is(err, gorm.ErrRecordNotFound)) && !errors.Is(err, permission.ErrPermissionDenied) && !errors.Is(err, permission.ErrResourceNotFound) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -415,7 +415,7 @@ func agenticUsageFields(ctx context.Context, usage *tokenizer.RunUsage) []zap.Fi
|
||||
// model choice to every chat model owned by the tenant: a failover chain is
|
||||
// exactly the list the dialog's author chose, and nothing else.
|
||||
func (s *ChatPipelineService) agenticModelChain(ctx context.Context, userID string, chat *entity.Chat) ([]*modelModule.ChatModel, error) {
|
||||
access := ModelAccess{UserID: userID, TenantID: chat.TenantID}
|
||||
access := Identity{UserID: userID, TenantID: chat.TenantID}
|
||||
primary, err := s.agenticPrimaryModel(ctx, access, chat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -445,7 +445,7 @@ func (s *ChatPipelineService) agenticModelChain(ctx context.Context, userID stri
|
||||
|
||||
// agenticPrimaryModel resolves the dialog's own model, or the tenant default
|
||||
// when it selected none.
|
||||
func (s *ChatPipelineService) agenticPrimaryModel(ctx context.Context, access ModelAccess, chat *entity.Chat) (*modelModule.ChatModel, error) {
|
||||
func (s *ChatPipelineService) agenticPrimaryModel(ctx context.Context, access Identity, chat *entity.Chat) (*modelModule.ChatModel, error) {
|
||||
var (
|
||||
model *modelModule.ChatModel
|
||||
err error
|
||||
|
||||
@@ -81,7 +81,7 @@ type AskStreamOptions struct {
|
||||
|
||||
// Retriever abstracts chunk retrieval for AskService.
|
||||
type Retriever interface {
|
||||
RetrievalTest(ctx context.Context, req *RetrievalTestRequest, userID string) (*RetrievalTestResponse, error)
|
||||
RetrievalTest(ctx context.Context, req *RetrievalTestRequest) (*RetrievalTestResponse, error)
|
||||
}
|
||||
|
||||
// StreamingLLM abstracts streaming chat for AskService. The first channel
|
||||
@@ -133,22 +133,22 @@ func NewAskService(retriever Retriever, embedder Embedder, tokenBudget, minStrea
|
||||
|
||||
// Stream runs the full ask pipeline. llm must not be nil. The returned
|
||||
// channel is closed when the pipeline completes or ctx is cancelled.
|
||||
func (s *AskService) Stream(ctx context.Context, llm StreamingLLM, userID, question string, kbIDs []string) <-chan AskDelta {
|
||||
return s.StreamWithOptions(ctx, llm, userID, question, kbIDs, AskStreamOptions{})
|
||||
func (s *AskService) Stream(ctx context.Context, llm StreamingLLM, question string, kbIDs []string) <-chan AskDelta {
|
||||
return s.StreamWithOptions(ctx, llm, question, kbIDs, AskStreamOptions{})
|
||||
}
|
||||
|
||||
// StreamWithOptions runs Stream while allowing callers such as saved Search
|
||||
// apps to pass search_config retrieval options through to RetrievalTest.
|
||||
func (s *AskService) StreamWithOptions(ctx context.Context, llm StreamingLLM, userID, question string, datasetIDs []string, opts AskStreamOptions) <-chan AskDelta {
|
||||
func (s *AskService) StreamWithOptions(ctx context.Context, llm StreamingLLM, question string, datasetIDs []string, opts AskStreamOptions) <-chan AskDelta {
|
||||
out := make(chan AskDelta, 32)
|
||||
go func() {
|
||||
defer close(out)
|
||||
s.run(ctx, llm, userID, question, datasetIDs, opts, out)
|
||||
s.run(ctx, llm, question, datasetIDs, opts, out)
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *AskService) run(ctx context.Context, llm StreamingLLM, userID, question string, datasetIDs []string, opts AskStreamOptions, out chan<- AskDelta) {
|
||||
func (s *AskService) run(ctx context.Context, llm StreamingLLM, question string, datasetIDs []string, opts AskStreamOptions, out chan<- AskDelta) {
|
||||
// Phase 1: Retrieval.
|
||||
topK := DefaultAskTopK
|
||||
if opts.TopK != nil {
|
||||
@@ -190,7 +190,7 @@ func (s *AskService) run(ctx context.Context, llm StreamingLLM, userID, question
|
||||
req.Page = &page
|
||||
req.Size = &ps
|
||||
|
||||
result, err := s.retriever.RetrievalTest(ctx, req, userID)
|
||||
result, err := s.retriever.RetrievalTest(ctx, req)
|
||||
if err != nil {
|
||||
common.Warn("AskService retrieval failed", zap.Error(err))
|
||||
s.sendOrCancel(out, AskDelta{Kind: AskDeltaError, Value: "retrieval failed"}, ctx)
|
||||
|
||||
@@ -32,7 +32,7 @@ type fakeRetriever struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *fakeRetriever) RetrievalTest(ctx context.Context, req *RetrievalTestRequest, userID string) (*RetrievalTestResponse, error) {
|
||||
func (r *fakeRetriever) RetrievalTest(ctx context.Context, req *RetrievalTestRequest) (*RetrievalTestResponse, error) {
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func TestAskService_RetrievalError(t *testing.T) {
|
||||
ret := &fakeRetriever{err: fmt.Errorf("engine down")}
|
||||
llm := &fakeStreamLLM{chunks: []string{"answer"}}
|
||||
svc := NewAskService(ret, nil, 0, 0)
|
||||
deltas := collect(svc.Stream(t.Context(), llm, "user1", "test", []string{"kb1"}))
|
||||
deltas := collect(svc.Stream(t.Context(), llm, "test", []string{"kb1"}))
|
||||
if len(deltas) < 1 || deltas[0].Kind != AskDeltaError {
|
||||
t.Fatalf("expected error delta, got %+v", deltas)
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func TestAskService_EmptyResult(t *testing.T) {
|
||||
ret := &fakeRetriever{result: &RetrievalTestResponse{Chunks: []map[string]interface{}{}}}
|
||||
llm := &fakeStreamLLM{chunks: []string{"answer"}}
|
||||
svc := NewAskService(ret, nil, 0, 0)
|
||||
deltas := collect(svc.Stream(t.Context(), llm, "user1", "test", []string{"kb1"}))
|
||||
deltas := collect(svc.Stream(t.Context(), llm, "test", []string{"kb1"}))
|
||||
if len(deltas) < 1 || !strings.Contains(deltas[0].Value, "no relevant information") {
|
||||
t.Fatalf("expected 'no relevant information', got %+v", deltas)
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func TestAskService_StreamingFlow(t *testing.T) {
|
||||
}}
|
||||
llm := &fakeStreamLLM{chunks: []string{"Hello", " world"}}
|
||||
svc := NewAskService(ret, nil, 0, 0)
|
||||
deltas := collect(svc.Stream(t.Context(), llm, "user1", "test", []string{"kb1"}))
|
||||
deltas := collect(svc.Stream(t.Context(), llm, "test", []string{"kb1"}))
|
||||
|
||||
var hasAnswer, hasFinal bool
|
||||
for _, d := range deltas {
|
||||
@@ -150,7 +150,7 @@ func TestAskService_EmptyLLMStreamReturnsError(t *testing.T) {
|
||||
}}
|
||||
llm := &fakeStreamLLM{}
|
||||
svc := NewAskService(ret, nil, 0, 0)
|
||||
deltas := collect(svc.Stream(t.Context(), llm, "user1", "test", []string{"kb1"}))
|
||||
deltas := collect(svc.Stream(t.Context(), llm, "test", []string{"kb1"}))
|
||||
|
||||
if len(deltas) != 1 || deltas[0].Kind != AskDeltaError || !strings.Contains(deltas[0].Value, "LLM call failed") {
|
||||
t.Fatalf("expected LLM error delta, got %+v", deltas)
|
||||
@@ -166,7 +166,7 @@ func TestAskService_ThinkTags(t *testing.T) {
|
||||
}}
|
||||
llm := &fakeStreamLLM{chunks: []string{"<think>", "reasoning...", "</think>", "visible answer"}}
|
||||
svc := NewAskService(ret, nil, 0, 0)
|
||||
deltas := collect(svc.Stream(t.Context(), llm, "user1", "test", []string{"kb1"}))
|
||||
deltas := collect(svc.Stream(t.Context(), llm, "test", []string{"kb1"}))
|
||||
|
||||
var hasMarker bool
|
||||
for _, d := range deltas {
|
||||
@@ -187,7 +187,7 @@ func TestAskService_LLMError(t *testing.T) {
|
||||
}}
|
||||
llm := &fakeStreamLLM{err: fmt.Errorf("model offline")}
|
||||
svc := NewAskService(ret, nil, 0, 0)
|
||||
deltas := collect(svc.Stream(t.Context(), llm, "user1", "test", []string{"kb1"}))
|
||||
deltas := collect(svc.Stream(t.Context(), llm, "test", []string{"kb1"}))
|
||||
if len(deltas) < 1 || deltas[0].Kind != AskDeltaError {
|
||||
t.Fatalf("expected error delta, got %+v", deltas)
|
||||
}
|
||||
@@ -209,7 +209,7 @@ func TestAskService_TemperatureFromLLMSetting(t *testing.T) {
|
||||
"temperature_enabled": true,
|
||||
},
|
||||
})
|
||||
deltas := collect(svc.StreamWithOptions(t.Context(), llm, "user1", "test", []string{"kb1"}, opts))
|
||||
deltas := collect(svc.StreamWithOptions(t.Context(), llm, "test", []string{"kb1"}, opts))
|
||||
|
||||
var hasFinal bool
|
||||
for _, d := range deltas {
|
||||
@@ -230,7 +230,7 @@ func TestAskService_DisablesThinkingForSummary(t *testing.T) {
|
||||
Chunks: []map[string]interface{}{{"id": "c1", "content_with_weight": "chunk", "docnm_kwd": "Doc", "kb_id": "kb1", "doc_id": "d1"}},
|
||||
}}
|
||||
llm := &capturingStreamLLM{fakeStreamLLM: fakeStreamLLM{chunks: []string{"answer"}}}
|
||||
collect(NewAskService(ret, nil, 0, 0).Stream(t.Context(), llm, "user1", "test", []string{"kb1"}))
|
||||
collect(NewAskService(ret, nil, 0, 0).Stream(t.Context(), llm, "test", []string{"kb1"}))
|
||||
if llm.gotConfig == nil || llm.gotConfig.Thinking == nil || *llm.gotConfig.Thinking {
|
||||
t.Fatalf("summary must explicitly disable thinking, got %+v", llm.gotConfig)
|
||||
}
|
||||
@@ -252,7 +252,7 @@ func TestAskService_TemperatureDisabledUsesDefault(t *testing.T) {
|
||||
"temperature_enabled": false,
|
||||
},
|
||||
})
|
||||
deltas := collect(svc.StreamWithOptions(t.Context(), llm, "user1", "test", []string{"kb1"}, opts))
|
||||
deltas := collect(svc.StreamWithOptions(t.Context(), llm, "test", []string{"kb1"}, opts))
|
||||
|
||||
var hasFinal bool
|
||||
for _, d := range deltas {
|
||||
@@ -285,7 +285,7 @@ func TestAskService_TemperatureMissingUsesLLMSettingDefaults(t *testing.T) {
|
||||
"temperature_enabled": true,
|
||||
},
|
||||
})
|
||||
collect(svc.StreamWithOptions(t.Context(), llm, "user1", "test", []string{"kb1"}, opts))
|
||||
collect(svc.StreamWithOptions(t.Context(), llm, "test", []string{"kb1"}, opts))
|
||||
|
||||
if llm.gotConfig == nil || llm.gotConfig.Temperature == nil || *llm.gotConfig.Temperature != DefaultAskTemperature {
|
||||
t.Errorf("expected LLM_SETTING_DEFAULTS temperature %v, got %+v", DefaultAskTemperature, llm.gotConfig)
|
||||
@@ -373,7 +373,7 @@ func TestAskService_ContextCancel(t *testing.T) {
|
||||
svc := NewAskService(ret, nil, 0, 0)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
deltas := collect(svc.Stream(ctx, llm, "user1", "test", []string{"kb1"}))
|
||||
deltas := collect(svc.Stream(ctx, llm, "test", []string{"kb1"}))
|
||||
// Should get no deltas (or very few) since context is cancelled.
|
||||
_ = deltas
|
||||
}
|
||||
@@ -387,7 +387,7 @@ func TestAskService_AsyncLLMErrorSurfaces(t *testing.T) {
|
||||
}}
|
||||
llm := &asyncErrStreamLLM{fakeStreamLLM: fakeStreamLLM{chunks: []string{"partial"}}}
|
||||
svc := NewAskService(ret, nil, 0, 0)
|
||||
deltas := collect(svc.Stream(t.Context(), llm, "user1", "test", []string{"kb1"}))
|
||||
deltas := collect(svc.Stream(t.Context(), llm, "test", []string{"kb1"}))
|
||||
|
||||
var errDelta *AskDelta
|
||||
for i := range deltas {
|
||||
@@ -412,7 +412,7 @@ func TestAskService_LLMErrorValueIsRealError(t *testing.T) {
|
||||
}}
|
||||
llm := &fakeStreamLLM{err: fmt.Errorf("provider name missing in model name: gpt-4o")}
|
||||
svc := NewAskService(ret, nil, 0, 0)
|
||||
deltas := collect(svc.Stream(t.Context(), llm, "user1", "test", []string{"kb1"}))
|
||||
deltas := collect(svc.Stream(t.Context(), llm, "test", []string{"kb1"}))
|
||||
if len(deltas) < 1 || deltas[0].Kind != AskDeltaError {
|
||||
t.Fatalf("expected error delta, got %+v", deltas)
|
||||
}
|
||||
|
||||
@@ -461,7 +461,7 @@ func resolveCreateLLMID(ctx context.Context, llmID, tenantID string, llmSetting
|
||||
}
|
||||
}
|
||||
modelFactory := NewModelFactory()
|
||||
target, err := modelFactory.ResolveInfo(ctx, ModelAccess{TenantID: tenantID}, modelType, llmID)
|
||||
target, err := modelFactory.ResolveInfo(ctx, Identity{TenantID: tenantID}, modelType, llmID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("`llm_id` %s doesn't exist", llmID)
|
||||
}
|
||||
@@ -477,7 +477,7 @@ func resolveCreateRerankID(ctx context.Context, rerankID, tenantID string) (stri
|
||||
return "", nil
|
||||
}
|
||||
modelFactory := NewModelFactory()
|
||||
target, err := modelFactory.ResolveInfo(ctx, ModelAccess{TenantID: tenantID}, entity.ModelTypeRerank, rerankID)
|
||||
target, err := modelFactory.ResolveInfo(ctx, Identity{TenantID: tenantID}, entity.ModelTypeRerank, rerankID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("`rerank_id` %s doesn't exist", rerankID)
|
||||
}
|
||||
@@ -936,7 +936,7 @@ func (s *ChatService) updateChatREST(ctx context.Context, userID, chatID string,
|
||||
req["tenant_decision_id"] = nil
|
||||
} else {
|
||||
modelFactory := NewModelFactory()
|
||||
target, err := modelFactory.ResolveInfo(ctx, ModelAccess{TenantID: userID}, entity.ModelTypeDecision, decisionID)
|
||||
target, err := modelFactory.ResolveInfo(ctx, Identity{TenantID: userID}, entity.ModelTypeDecision, decisionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("`decision_id` %s doesn't exist", decisionID)
|
||||
}
|
||||
@@ -1190,7 +1190,7 @@ func (s *ChatService) resolveRESTLLMID(ctx context.Context, llmID, tenantID stri
|
||||
}
|
||||
}
|
||||
modelFactory := NewModelFactory()
|
||||
target, err := modelFactory.ResolveInfo(ctx, ModelAccess{TenantID: tenantID}, modelType, llmID)
|
||||
target, err := modelFactory.ResolveInfo(ctx, Identity{TenantID: tenantID}, modelType, llmID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("`llm_id` %s doesn't exist", llmID)
|
||||
}
|
||||
@@ -1206,7 +1206,7 @@ func (s *ChatService) resolveRESTRerankID(ctx context.Context, rerankID, tenantI
|
||||
return "", nil
|
||||
}
|
||||
modelFactory := NewModelFactory()
|
||||
target, err := modelFactory.ResolveInfo(ctx, ModelAccess{TenantID: tenantID}, entity.ModelTypeRerank, rerankID)
|
||||
target, err := modelFactory.ResolveInfo(ctx, Identity{TenantID: tenantID}, entity.ModelTypeRerank, rerankID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("`rerank_id` %s doesn't exist", rerankID)
|
||||
}
|
||||
|
||||
@@ -1572,7 +1572,7 @@ func (s *ChatPipelineService) AsyncChatSolo(
|
||||
}
|
||||
|
||||
// 4. Construct the selected chat model through the factory.
|
||||
access := ModelAccess{UserID: userID, TenantID: chat.TenantID}
|
||||
access := Identity{UserID: userID, TenantID: chat.TenantID}
|
||||
var chatModel *modelModule.ChatModel
|
||||
if strings.TrimSpace(chat.LLMID) == "" {
|
||||
chatModel, err = s.ModelFactory.NewDefaultChatModel(ctx, access)
|
||||
@@ -2071,7 +2071,7 @@ func tokenizeText(text string) string {
|
||||
// models reject them at the provider (e.g. Zhipu GLM error 1210:
|
||||
// messages.content.type only allows 'text').
|
||||
func (s *ChatPipelineService) getLLMModelConfig(ctx context.Context, chat *entity.Chat) (map[string]interface{}, string, string, string, error) {
|
||||
access := ModelAccess{TenantID: chat.TenantID}
|
||||
access := Identity{TenantID: chat.TenantID}
|
||||
var info *modelModule.ModelInfo
|
||||
var err error
|
||||
if strings.TrimSpace(chat.LLMID) == "" {
|
||||
@@ -2153,7 +2153,7 @@ func (s *ChatPipelineService) getModels(ctx context.Context, chat *entity.Chat)
|
||||
}
|
||||
if kbs[0].EmbdID != "" {
|
||||
embdTenantID := kbs[0].TenantID
|
||||
model, err := s.ModelFactory.NewEmbeddingModel(ctx, ModelAccess{TenantID: embdTenantID}, kbs[0].EmbdID)
|
||||
model, err := s.ModelFactory.NewEmbeddingModel(ctx, Identity{TenantID: embdTenantID}, kbs[0].EmbdID)
|
||||
if err != nil {
|
||||
common.Warn("Failed to get embedding model for chat retrieval",
|
||||
zap.String("embdID", kbs[0].EmbdID),
|
||||
@@ -2166,7 +2166,7 @@ func (s *ChatPipelineService) getModels(ctx context.Context, chat *entity.Chat)
|
||||
}
|
||||
|
||||
// Chat model.
|
||||
access := ModelAccess{TenantID: chat.TenantID}
|
||||
access := Identity{TenantID: chat.TenantID}
|
||||
var chatModel *modelModule.ChatModel
|
||||
if strings.TrimSpace(chat.LLMID) == "" {
|
||||
chatModel, _ = s.ModelFactory.NewDefaultChatModel(ctx, access)
|
||||
@@ -2790,7 +2790,7 @@ func (s *ChatPipelineService) buildChatDriver(ctx context.Context, chat *entity.
|
||||
if chatModel != nil {
|
||||
return chatModel
|
||||
}
|
||||
access := ModelAccess{TenantID: chat.TenantID}
|
||||
access := Identity{TenantID: chat.TenantID}
|
||||
if strings.TrimSpace(chat.LLMID) == "" {
|
||||
model, err := s.ModelFactory.NewDefaultChatModel(ctx, access)
|
||||
if err != nil {
|
||||
|
||||
@@ -1862,7 +1862,7 @@ func setupChatPipelineToolSupportTestDB(t *testing.T) *gorm.DB {
|
||||
activeStatus := "1"
|
||||
rows := []interface{}{
|
||||
&entity.UserTenant{ID: "user-tenant-1", UserID: "user-1", TenantID: "tenant-1", Role: "owner", InvitedBy: "user-1", Status: &activeStatus},
|
||||
&entity.TenantModelProvider{ID: "provider-zhipu", TenantID: "tenant-1", ProviderName: "ZHIPU-AI"},
|
||||
&entity.TenantModelProvider{ID: "provider-zhipu", UserID: "user-1", TenantID: "tenant-1", ProviderName: "ZHIPU-AI"},
|
||||
&entity.TenantModelInstance{ID: "instance-zhipu", ProviderID: "provider-zhipu", InstanceName: "default", APIKey: "sk-test", Status: "active", Extra: "{}"},
|
||||
&entity.TenantModel{ID: "model-tools-on", ProviderID: "provider-zhipu", InstanceID: "instance-zhipu", ModelName: "glm-4-plus", ModelType: int(entity.ModelTypeChat), Status: "active", Extra: `{"is_tools":true}`},
|
||||
&entity.TenantModel{ID: "model-tools-off", ProviderID: "provider-zhipu", InstanceID: "instance-zhipu", ModelName: "glm-4-flash", ModelType: int(entity.ModelTypeChat), Status: "active", Extra: `{"is_tools":false}`},
|
||||
@@ -1941,7 +1941,7 @@ func TestResolveModelConfigRejectsImage2TextOnlyModel(t *testing.T) {
|
||||
svc := NewChatPipelineService()
|
||||
|
||||
_, err := svc.ModelFactory.resolveConfig(
|
||||
t.Context(), ModelAccess{TenantID: "tenant-1"}, entity.ModelTypeChat, "model-image2text-tools",
|
||||
t.Context(), Identity{TenantID: "tenant-1"}, entity.ModelTypeChat, "model-image2text-tools",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("ResolveModelConfig accepted an image2text-only model as chat")
|
||||
|
||||
@@ -43,7 +43,7 @@ func setupChatPipelineVisionTestDB(t *testing.T) *gorm.DB {
|
||||
activeStatus := "1"
|
||||
rows := []interface{}{
|
||||
&entity.UserTenant{ID: "user-tenant-1", UserID: "user-1", TenantID: "tenant-1", Role: "owner", InvitedBy: "user-1", Status: &activeStatus},
|
||||
&entity.TenantModelProvider{ID: "provider-zhipu", TenantID: "tenant-1", ProviderName: "ZHIPU-AI"},
|
||||
&entity.TenantModelProvider{ID: "provider-zhipu", UserID: "user-1", TenantID: "tenant-1", ProviderName: "ZHIPU-AI"},
|
||||
&entity.TenantModelInstance{ID: "instance-zhipu", ProviderID: "provider-zhipu", InstanceName: "default", APIKey: "sk-test", Status: "active", Extra: "{}"},
|
||||
&entity.TenantModel{ID: "model-flash-chat", ProviderID: "provider-zhipu", InstanceID: "instance-zhipu", ModelName: "glm-4-flash", ModelType: int(entity.ModelTypeChat), Status: "active"},
|
||||
&entity.TenantModel{ID: "model-4v", ProviderID: "provider-zhipu", InstanceID: "instance-zhipu", ModelName: "glm-4v", ModelType: int(entity.ModelTypeChat | entity.ModelTypeImage2Text), Status: "active"},
|
||||
|
||||
@@ -56,6 +56,7 @@ func setupChatRESTUpdateServiceTestDB(t *testing.T) *gorm.DB {
|
||||
}
|
||||
if err := db.Create(&entity.TenantModelProvider{
|
||||
ID: "provider-a",
|
||||
UserID: "user-1",
|
||||
TenantID: "user-1",
|
||||
ProviderName: "OpenAI",
|
||||
}).Error; err != nil {
|
||||
|
||||
@@ -63,9 +63,9 @@ type chatPipelineRunner interface {
|
||||
}
|
||||
|
||||
type chatModelInfoResolver interface {
|
||||
ResolveInfo(ctx context.Context, access ModelAccess, modelType entity.ModelType, modelRef string) (*modelModule.ModelInfo, error)
|
||||
ResolveDefaultInfo(ctx context.Context, access ModelAccess, modelType entity.ModelType) (*modelModule.ModelInfo, error)
|
||||
ResolveModelType(ctx context.Context, access ModelAccess, modelRef string) ([]entity.ModelType, error)
|
||||
ResolveInfo(ctx context.Context, access Identity, modelType entity.ModelType, modelRef string) (*modelModule.ModelInfo, error)
|
||||
ResolveDefaultInfo(ctx context.Context, access Identity, modelType entity.ModelType) (*modelModule.ModelInfo, error)
|
||||
ResolveModelType(ctx context.Context, access Identity, modelRef string) ([]entity.ModelType, error)
|
||||
}
|
||||
|
||||
// chunkFeedbackApplier is the dispatch seam for chunk-level feedback
|
||||
@@ -1225,13 +1225,19 @@ func (s *ChatSessionService) ChatCompletions(
|
||||
dialog.LLMID = llmID
|
||||
dialog.LLMSetting = genConfig
|
||||
} else if dialog.LLMID == "" {
|
||||
membership, err := NewTenantService().defaultModelMembership(ctx, userID, dialog.TenantID)
|
||||
if err != nil || membership.LLMID == "" {
|
||||
modelInfo, err := NewModelFactory().ResolveDefaultInfo(ctx, Identity{UserID: userID, TenantID: dialog.TenantID}, entity.ModelTypeChat)
|
||||
if err != nil {
|
||||
return fail(errors.New("no default chat model for tenant"))
|
||||
}
|
||||
dialog.LLMID = membership.LLMID
|
||||
if membership.TenantLLMID != nil && *membership.TenantLLMID != "" {
|
||||
dialog.TenantLLMID = membership.TenantLLMID
|
||||
if modelInfo.InstanceName == "" {
|
||||
dialog.LLMID = fmt.Sprintf("%s@%s", modelInfo.Name, modelInfo.ProviderName)
|
||||
} else {
|
||||
dialog.LLMID = fmt.Sprintf("%s@%s@%s", modelInfo.Name, modelInfo.InstanceName, modelInfo.ProviderName)
|
||||
}
|
||||
dialog.TenantLLMID = nil
|
||||
if modelInfo.ID != "" {
|
||||
modelID := modelInfo.ID
|
||||
dialog.TenantLLMID = &modelID
|
||||
}
|
||||
if dialog.LLMSetting == nil {
|
||||
dialog.LLMSetting = entity.JSONMap{}
|
||||
@@ -1818,7 +1824,7 @@ func (s *ChatSessionService) checkTenantLLMAPIKey(ctx context.Context, tenantID,
|
||||
modelFactory = NewModelFactory()
|
||||
}
|
||||
var err error
|
||||
access := ModelAccess{TenantID: tenantID}
|
||||
access := Identity{TenantID: tenantID}
|
||||
if modelName == "" {
|
||||
_, err = modelFactory.ResolveDefaultInfo(ctx, access, entity.ModelTypeChat)
|
||||
} else {
|
||||
|
||||
@@ -454,7 +454,7 @@ type fakeChatModelConfigResolver struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeChatModelConfigResolver) ResolveInfo(_ context.Context, access ModelAccess, _ entity.ModelType, modelRef string) (*modelModule.ModelInfo, error) {
|
||||
func (f *fakeChatModelConfigResolver) ResolveInfo(_ context.Context, access Identity, _ entity.ModelType, modelRef string) (*modelModule.ModelInfo, error) {
|
||||
f.tenantID = access.TenantID
|
||||
f.llmID = modelRef
|
||||
if f.err != nil {
|
||||
@@ -463,7 +463,7 @@ func (f *fakeChatModelConfigResolver) ResolveInfo(_ context.Context, access Mode
|
||||
return &modelModule.ModelInfo{ID: "resolved-model-id", Name: "resolved-model"}, nil
|
||||
}
|
||||
|
||||
func (f *fakeChatModelConfigResolver) ResolveDefaultInfo(_ context.Context, access ModelAccess, _ entity.ModelType) (*modelModule.ModelInfo, error) {
|
||||
func (f *fakeChatModelConfigResolver) ResolveDefaultInfo(_ context.Context, access Identity, _ entity.ModelType) (*modelModule.ModelInfo, error) {
|
||||
f.tenantID = access.TenantID
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
@@ -471,7 +471,7 @@ func (f *fakeChatModelConfigResolver) ResolveDefaultInfo(_ context.Context, acce
|
||||
return &modelModule.ModelInfo{ID: "resolved-model-id", Name: "resolved-model"}, nil
|
||||
}
|
||||
|
||||
func (f *fakeChatModelConfigResolver) ResolveModelType(ctx context.Context, access ModelAccess, modelRef string) ([]entity.ModelType, error) {
|
||||
func (f *fakeChatModelConfigResolver) ResolveModelType(ctx context.Context, access Identity, modelRef string) ([]entity.ModelType, error) {
|
||||
return []entity.ModelType{entity.ModelTypeChat}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -136,8 +136,9 @@ func NewChunkService() *ChunkService {
|
||||
// - Builds doc_aggs by aggregating chunks per document
|
||||
// 7. knowledge graph retrieval (not implemented)
|
||||
// 8. Apply retrieval by children to group child chunks under parent chunks
|
||||
func (s *ChunkService) RetrievalTest(ctx context.Context, req *service.RetrievalTestRequest, userID string) (*service.RetrievalTestResponse, error) {
|
||||
common.Info("RetrievalTest started", zap.String("userID", userID), zap.Any("kbID", req.Datasets), zap.String("question", req.Question))
|
||||
func (s *ChunkService) RetrievalTest(ctx context.Context, req *service.RetrievalTestRequest) (*service.RetrievalTestResponse, error) {
|
||||
subject, _ := permission.SubjectFromContext(ctx)
|
||||
common.Info("RetrievalTest started", zap.String("userID", subject.UserID), zap.Any("kbID", req.Datasets), zap.String("question", req.Question))
|
||||
|
||||
common.Debug(fmt.Sprintf("RetrievalTest request:\n"+
|
||||
" kbID=%v\n"+
|
||||
@@ -171,7 +172,10 @@ func (s *ChunkService) RetrievalTest(ctx context.Context, req *service.Retrieval
|
||||
var tenantIDs []string
|
||||
var kbRecords []*entity.Knowledgebase
|
||||
for _, datasetID := range req.Datasets {
|
||||
if err := s.checkDatasetAccess(ctx, datasetID, userID, permission.OperationUse); err != nil {
|
||||
if err := permission.NewDatabaseChecker(dao.DB).CheckResource(ctx, permission.Subject{}, permission.ResourceRef{
|
||||
Kind: permission.ResourceKindDataset,
|
||||
ID: datasetID,
|
||||
}, permission.OperationUse); err != nil {
|
||||
_, permissionErr := permissionresponse.NormalizeHidden(err)
|
||||
return nil, permissionErr
|
||||
}
|
||||
@@ -221,7 +225,7 @@ func (s *ChunkService) RetrievalTest(ctx context.Context, req *service.Retrieval
|
||||
if s.searchService == nil {
|
||||
return nil, fmt.Errorf("search service not configured")
|
||||
}
|
||||
searchDetail, err := s.searchService.GetDetail(ctx, userID, *req.SearchID)
|
||||
searchDetail, err := s.searchService.GetDetail(ctx, *req.SearchID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get search app %s: %w", *req.SearchID, err)
|
||||
}
|
||||
@@ -257,7 +261,7 @@ func (s *ChunkService) RetrievalTest(ctx context.Context, req *service.Retrieval
|
||||
if filter != nil {
|
||||
method, _ := filter["method"].(string)
|
||||
if method == "auto" || method == "semi_auto" {
|
||||
access := service.ModelAccess{TenantID: tenantIDs[0]}
|
||||
access := service.Identity{TenantID: tenantIDs[0]}
|
||||
if chatID != "" {
|
||||
chatModelForFilter, getErr = modelFactory.NewChatModel(ctx, access, chatID)
|
||||
if getErr != nil {
|
||||
@@ -305,20 +309,17 @@ func (s *ChunkService) RetrievalTest(ctx context.Context, req *service.Retrieval
|
||||
// Get chat model for cross_languages and keyword_extraction
|
||||
var llmModelName string
|
||||
if len(req.CrossLanguages) > 0 || (req.Keyword != nil && *req.Keyword) {
|
||||
tenantSvc := service.NewTenantService()
|
||||
var err error
|
||||
llmModelName, err = tenantSvc.GetDefaultModelNameForTenant(ctx, tenantIDs[0], entity.ModelTypeChat)
|
||||
if err != nil || llmModelName == "" {
|
||||
chatModel, err = modelFactory.NewDefaultChatModel(ctx, service.Identity{TenantID: tenantIDs[0]})
|
||||
if err != nil {
|
||||
common.Warn("Failed to get default chat model name for LLM transformations", zap.Error(err))
|
||||
} else {
|
||||
chatModel, err = modelFactory.NewChatModel(ctx, service.ModelAccess{TenantID: tenantIDs[0]}, llmModelName)
|
||||
if err != nil {
|
||||
common.Warn("Failed to get chat model for LLM transformations", zap.Error(err))
|
||||
} else {
|
||||
common.Info("Fetched chat model (tenant default) for cross_languages/keyword_extraction",
|
||||
zap.String("tenantID", tenantIDs[0]),
|
||||
zap.String("modelName", llmModelName))
|
||||
if info := chatModel.Info(); info != nil {
|
||||
llmModelName = service.ModelReference(info)
|
||||
}
|
||||
common.Info("Fetched chat model (tenant default) for cross_languages/keyword_extraction",
|
||||
zap.String("tenantID", tenantIDs[0]),
|
||||
zap.String("modelName", llmModelName))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +357,7 @@ func (s *ChunkService) RetrievalTest(ctx context.Context, req *service.Retrieval
|
||||
labels := metadataSvc.LabelQuestion(ctx, modifiedQuestion, kbRecords)
|
||||
|
||||
// Determine embedding model.
|
||||
access := service.ModelAccess{TenantID: tenantIDs[0]}
|
||||
access := service.Identity{TenantID: tenantIDs[0]}
|
||||
var embeddingModel *models.EmbeddingModel
|
||||
var embdID string
|
||||
if kbRecords[0].TenantEmbdID != nil && *kbRecords[0].TenantEmbdID != "" {
|
||||
@@ -445,7 +446,7 @@ func (s *ChunkService) RetrievalTest(ctx context.Context, req *service.Retrieval
|
||||
// Infinity/OceanBase chunks already carry real vectors and are left unchanged.
|
||||
hydrateChunkVectors(ctx, s.docEngine, filteredChunks, req.Datasets, tenantIDs)
|
||||
|
||||
common.Info("RetrievalTest completed", zap.String("userID", userID), zap.Any("kbID", req.Datasets), zap.String("question", req.Question), zap.Int64("chunkCount", int64(len(filteredChunks))))
|
||||
common.Info("RetrievalTest completed", zap.String("userID", subject.UserID), zap.Any("kbID", req.Datasets), zap.String("question", req.Question), zap.Int64("chunkCount", int64(len(filteredChunks))))
|
||||
|
||||
return &service.RetrievalTestResponse{
|
||||
Chunks: filteredChunks,
|
||||
@@ -1741,7 +1742,7 @@ func (s *ChunkService) getEmbeddingModel(ctx context.Context, tenantID, embdID s
|
||||
if s.getEmbeddingModelFunc != nil {
|
||||
return s.getEmbeddingModelFunc(tenantID, embdID)
|
||||
}
|
||||
embeddingModel, err := service.NewModelFactory().NewEmbeddingModel(ctx, service.ModelAccess{TenantID: tenantID}, embdID)
|
||||
embeddingModel, err := service.NewModelFactory().NewEmbeddingModel(ctx, service.Identity{TenantID: tenantID}, embdID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestRetrievalTestChecksDatasetUsePermission(t *testing.T) {
|
||||
_, err := (&ChunkService{kbDAO: dao.NewKnowledgebaseDAO()}).RetrievalTest(ctx, &service.RetrievalTestRequest{
|
||||
Question: "query",
|
||||
Datasets: []string{"kb-1"},
|
||||
}, "outsider")
|
||||
})
|
||||
if !permissionresponse.IsPermissionError(err) || !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
t.Fatalf("RetrievalTest() error = %v, want permission denial", err)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -269,7 +270,7 @@ func (s *CompilationTemplateGroupService) buildGroupItem(ctx context.Context, te
|
||||
Name: c.Name,
|
||||
Description: derefString(c.Description),
|
||||
Kind: c.Kind,
|
||||
Config: fillConfigDefaultLLM(ctx, c.Config, c.TenantID),
|
||||
Config: fillConfigDefaultLLM(ctx, c.Config, identityForTemplateTenant(ctx, tenantID)),
|
||||
CreateTime: timeStr(c.CreateTime),
|
||||
UpdateTime: timeStr(c.UpdateTime),
|
||||
})
|
||||
@@ -392,7 +393,7 @@ func (s *CompilationTemplateGroupService) insertChildren(ctx context.Context, db
|
||||
}
|
||||
}
|
||||
desc := child.Description
|
||||
config := fillConfigDefaultLLM(ctx, child.Config, &tenantID)
|
||||
config := fillConfigDefaultLLM(ctx, child.Config, identityForTemplateTenant(ctx, tenantID))
|
||||
if config == nil {
|
||||
config = entity.JSONMap{}
|
||||
}
|
||||
@@ -496,7 +497,7 @@ func (s *CompilationTemplateGroupService) reconcileChildren(ctx context.Context,
|
||||
|
||||
// Pass 3: apply updates and inserts.
|
||||
for _, item := range resolved {
|
||||
config := fillConfigDefaultLLM(ctx, item.child.Config, &tenantID)
|
||||
config := fillConfigDefaultLLM(ctx, item.child.Config, identityForTemplateTenant(ctx, tenantID))
|
||||
if config == nil {
|
||||
config = entity.JSONMap{}
|
||||
}
|
||||
@@ -549,6 +550,14 @@ func (s *CompilationTemplateGroupService) reconcileChildren(ctx context.Context,
|
||||
return s.assertUniqueChildNames(ctx, db, groupID, oldNames)
|
||||
}
|
||||
|
||||
func identityForTemplateTenant(ctx context.Context, tenantID string) Identity {
|
||||
access := Identity{UserID: tenantID, TenantID: tenantID}
|
||||
if subject, ok := permission.SubjectFromContext(ctx); ok && strings.TrimSpace(subject.UserID) != "" {
|
||||
access.UserID = subject.UserID
|
||||
}
|
||||
return access
|
||||
}
|
||||
|
||||
// assertUniqueChildNames rejects a group whose valid children collide on a
|
||||
// case-insensitive name, matching the DAO's duplicate guard which ignores
|
||||
// built-in templates. oldNames maps each child id to its name before the
|
||||
|
||||
@@ -38,15 +38,19 @@ import (
|
||||
// fill_config_default_llm: when the config has no explicit llm_id it lazily
|
||||
// fills the current member's default chat model id (read-side only; the DB row is left
|
||||
// untouched).
|
||||
func fillConfigDefaultLLM(ctx context.Context, config entity.JSONMap, tenantID *string) entity.JSONMap {
|
||||
if len(config) == 0 || tenantID == nil || *tenantID == "" {
|
||||
func fillConfigDefaultLLM(ctx context.Context, config entity.JSONMap, access Identity) entity.JSONMap {
|
||||
if len(config) == 0 || access.TenantID == "" {
|
||||
return config
|
||||
}
|
||||
if _, ok := config["llm_id"]; ok {
|
||||
return config
|
||||
}
|
||||
_, modelID, err := NewTenantService().GetDefaultModelRefsForTenant(ctx, *tenantID, entity.ModelTypeChat)
|
||||
if err != nil || modelID == "" {
|
||||
info, err := NewModelFactory().ResolveDefaultInfo(ctx, access, entity.ModelTypeChat)
|
||||
if err != nil {
|
||||
return config
|
||||
}
|
||||
modelID := ModelReference(info)
|
||||
if modelID == "" {
|
||||
return config
|
||||
}
|
||||
out := make(entity.JSONMap, len(config)+1)
|
||||
@@ -114,14 +118,17 @@ func NewCompilationTemplateService() *CompilationTemplateService {
|
||||
|
||||
// ListBuiltins returns the read-only template palette loaded from the YAML
|
||||
// definitions. Built-in templates are never persisted in the database.
|
||||
func (s *CompilationTemplateService) ListBuiltins(ctx context.Context, tenantID string) ([]*BuiltinTemplate, error) {
|
||||
func (s *CompilationTemplateService) ListBuiltins(ctx context.Context) ([]*BuiltinTemplate, error) {
|
||||
access, err := ResolveIdentity(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templates, err := loadBuiltinTemplates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantPtr := &tenantID
|
||||
for _, template := range templates {
|
||||
template.Config = fillConfigDefaultLLM(ctx, template.Config, tenantPtr)
|
||||
template.Config = fillConfigDefaultLLM(ctx, template.Config, access)
|
||||
}
|
||||
return s.sortBuiltins(templates), nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
@@ -30,13 +31,6 @@ func insertCreateDatasetTenant(t *testing.T, tenantID string) {
|
||||
}
|
||||
}
|
||||
ensureDatasetTestMembership(t, tenantID, tenantID, "owner")
|
||||
if err := dao.DB.Model(&entity.UserTenant{}).
|
||||
Where("user_id = ? AND tenant_id = ?", tenantID, tenantID).
|
||||
Updates(map[string]interface{}{
|
||||
"llm_id": "llm-default", "embd_id": "embd-default", "tenant_embd_id": "embd-1", "asr_id": "asr-default",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("configure owner model defaults: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testDatasetCreateService(t *testing.T) *DatasetService {
|
||||
@@ -144,6 +138,7 @@ func TestCreateDataset_ComponentParamsPopulated(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertCreateDatasetTenant(t, "tenant-1")
|
||||
seedCreateDatasetDefaultChatModel(t, db, "tenant-1")
|
||||
ctx := t.Context()
|
||||
|
||||
chunkMethod := "general"
|
||||
@@ -173,6 +168,24 @@ func TestCreateDataset_ComponentParamsPopulated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func seedCreateDatasetDefaultChatModel(t *testing.T, db *gorm.DB, tenantID string) {
|
||||
t.Helper()
|
||||
if err := db.Model(&entity.UserTenant{}).
|
||||
Where("user_id = ? AND tenant_id = ?", tenantID, tenantID).
|
||||
Update("llm_id", "llm-default").Error; err != nil {
|
||||
t.Fatalf("configure default chat model: %v", err)
|
||||
}
|
||||
for _, row := range []any{
|
||||
&entity.TenantModelProvider{ID: "provider-default-chat", UserID: tenantID, TenantID: tenantID, ProviderName: "OpenAI"},
|
||||
&entity.TenantModelInstance{ID: "instance-default-chat", ProviderID: "provider-default-chat", InstanceName: "default", Status: "active", Extra: "{}"},
|
||||
&entity.TenantModel{ID: "llm-default", ProviderID: "provider-default-chat", InstanceID: "instance-default-chat", ModelName: "gpt-4o", ModelType: int(entity.ModelTypeChat), Status: "active", Extra: "{}"},
|
||||
} {
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
t.Fatalf("seed default chat model %T: %v", row, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateDataset_ParentChildConfigReachesGeneralChunker verifies the public
|
||||
// parent-child setting controls the component that actually performs the
|
||||
// secondary split. Dropping this mapping silently leaves parent-child disabled.
|
||||
@@ -434,6 +447,47 @@ func TestCreateDataset_RejectsInvalidEmbeddingModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDataset_ExplicitEmbeddingDoesNotResolveInvalidDefault(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertCreateDatasetTenant(t, "tenant-1")
|
||||
if err := db.Model(&entity.UserTenant{}).
|
||||
Where("user_id = ? AND tenant_id = ?", "tenant-1", "tenant-1").
|
||||
Updates(map[string]interface{}{"embd_id": "invalid-default", "tenant_embd_id": "missing-default"}).Error; err != nil {
|
||||
t.Fatalf("configure invalid default embedding: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.TenantModelProvider{
|
||||
ID: "provider-explicit", UserID: "tenant-1", TenantID: "tenant-1", ProviderName: "OpenAI",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create model provider: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.TenantModelInstance{
|
||||
ID: "instance-explicit", ProviderID: "provider-explicit", InstanceName: "default", Status: "active", Extra: "{}",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create model instance: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.TenantModel{
|
||||
ID: "model-explicit", ProviderID: "provider-explicit", InstanceID: "instance-explicit",
|
||||
ModelName: "embedding-test", ModelType: int(entity.ModelTypeEmbedding), Status: "active", Extra: "{}",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create embedding model: %v", err)
|
||||
}
|
||||
|
||||
embeddingModel := "embedding-test@default@OpenAI"
|
||||
result, code, err := testDatasetCreateService(t).CreateDataset(t.Context(), &service.CreateDatasetRequest{
|
||||
Name: "explicit-embedding", ParserID: sptr("general"), ParseType: iptr(1), EmbeddingModel: &embeddingModel,
|
||||
}, testDatasetSubject("tenant-1"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDataset with explicit embedding = %v, want success", err)
|
||||
}
|
||||
if code != common.CodeSuccess {
|
||||
t.Fatalf("CreateDataset code = %d, want success", code)
|
||||
}
|
||||
if result["embedding_model"] != embeddingModel {
|
||||
t.Fatalf("embedding_model = %#v, want %q", result["embedding_model"], embeddingModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDataset_SetsExplicitLanguage(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
|
||||
@@ -229,12 +229,20 @@ func (d *DatasetService) CreateDataset(ctx context.Context, req *service.CreateD
|
||||
|
||||
var parserConfigMap map[string]interface{} = parserConfig
|
||||
|
||||
embdID, tenantEmbdID, err := service.NewTenantService().GetDefaultModelRefsForUser(ctx, userID, tenantID, entity.ModelTypeEmbedding)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
modelFactory := service.NewModelFactory()
|
||||
identity := service.Identity{UserID: userID, TenantID: tenantID}
|
||||
embdID, tenantEmbdID := "", ""
|
||||
if embeddingModel == "" {
|
||||
embeddingInfo, embeddingErr := modelFactory.ResolveDefaultInfo(ctx, identity, entity.ModelTypeEmbedding)
|
||||
if embeddingErr == nil {
|
||||
embdID = service.ModelReference(embeddingInfo)
|
||||
tenantEmbdID = embeddingInfo.ID
|
||||
} else if !errors.Is(embeddingErr, service.ErrNoDefaultModel) {
|
||||
return nil, common.CodeServerError, embeddingErr
|
||||
}
|
||||
}
|
||||
if embeddingModel != "" {
|
||||
ok, message := d.verifyEmbeddingAvailability(ctx, embeddingModel, service.ModelAccess{UserID: userID, TenantID: tenantID})
|
||||
ok, message := d.verifyEmbeddingAvailability(ctx, embeddingModel, identity)
|
||||
if !ok {
|
||||
return nil, common.CodeDataError, errors.New(message)
|
||||
}
|
||||
@@ -242,7 +250,7 @@ func (d *DatasetService) CreateDataset(ctx context.Context, req *service.CreateD
|
||||
tenantEmbdID = ""
|
||||
}
|
||||
if embdID != "" && tenantEmbdID == "" {
|
||||
target, err := service.NewModelFactory().ResolveInfo(ctx, service.ModelAccess{UserID: userID, TenantID: tenantID}, entity.ModelTypeEmbedding, embdID)
|
||||
target, err := modelFactory.ResolveInfo(ctx, identity, entity.ModelTypeEmbedding, embdID)
|
||||
if err == nil {
|
||||
tenantEmbdID = target.ID
|
||||
} else {
|
||||
@@ -259,9 +267,12 @@ func (d *DatasetService) CreateDataset(ctx context.Context, req *service.CreateD
|
||||
}
|
||||
name = dedupedName
|
||||
|
||||
llmID, _, err := service.NewTenantService().GetDefaultModelRefsForUser(ctx, userID, tenantID, entity.ModelTypeChat)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
llmID := ""
|
||||
chatInfo, chatErr := modelFactory.ResolveDefaultInfo(ctx, identity, entity.ModelTypeChat)
|
||||
if chatErr == nil {
|
||||
llmID = service.ModelReference(chatInfo)
|
||||
} else if !errors.Is(chatErr, service.ErrNoDefaultModel) {
|
||||
return nil, common.CodeServerError, chatErr
|
||||
}
|
||||
parserConfig = service.ApplyComponentScopedParserConfig(parserConfig, llmID)
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ func (d *DatasetService) CheckEmbedding(ctx context.Context, userID, datasetID s
|
||||
return nil, common.CodeServerError, errors.New("doc engine not initialized")
|
||||
}
|
||||
|
||||
embeddingModel, err := service.NewModelFactory().NewEmbeddingModel(ctx, service.ModelAccess{TenantID: kb.TenantID}, embeddingID)
|
||||
embeddingModel, err := service.NewModelFactory().NewEmbeddingModel(ctx, service.Identity{TenantID: kb.TenantID}, embeddingID)
|
||||
if err != nil {
|
||||
return nil, common.CodeDataError, err
|
||||
}
|
||||
@@ -281,7 +281,7 @@ func (d *DatasetService) sampleRandomChunksWithVectors(ctx context.Context, tena
|
||||
return samples, nil
|
||||
}
|
||||
|
||||
func (d *DatasetService) verifyEmbeddingAvailability(ctx context.Context, embdID string, access service.ModelAccess) (bool, string) {
|
||||
func (d *DatasetService) verifyEmbeddingAvailability(ctx context.Context, embdID string, access service.Identity) (bool, string) {
|
||||
_, err := service.NewModelFactory().ResolveInfo(ctx, access, entity.ModelTypeEmbedding, embdID)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
|
||||
@@ -51,7 +51,10 @@ func (d *DatasetService) UpdateDocumentMetadataConfig(ctx context.Context, userI
|
||||
if _, tenantErr := d.tenantDAO.GetByID(ctx, dao.DB, kb.TenantID); tenantErr != nil {
|
||||
return nil, common.CodeServerError, errors.New("database operation failed")
|
||||
}
|
||||
llmID, _, _ := service.NewTenantService().GetDefaultModelRefsForUser(ctx, userID, kb.TenantID, entity.ModelTypeChat)
|
||||
llmID := ""
|
||||
if modelInfo, modelErr := service.NewModelFactory().ResolveDefaultInfo(ctx, service.Identity{UserID: userID, TenantID: kb.TenantID}, entity.ModelTypeChat); modelErr == nil {
|
||||
llmID = service.ModelReference(modelInfo)
|
||||
}
|
||||
parserConfig = service.ApplyComponentScopedParserConfig(parserConfig, llmID)
|
||||
|
||||
if err = d.documentDAO.UpdateByID(ctx, dao.DB, doc.ID, map[string]interface{}{"parser_config": parserConfig}); err != nil {
|
||||
@@ -159,7 +162,10 @@ func (d *DatasetService) UpdateMetadataConfig(ctx context.Context, datasetID, us
|
||||
}
|
||||
delete(parserConfig, "enable_metadata")
|
||||
delete(parserConfig, "built_in_metadata")
|
||||
llmID, _, _ := service.NewTenantService().GetDefaultModelRefsForUser(ctx, userID, kb.TenantID, entity.ModelTypeChat)
|
||||
llmID := ""
|
||||
if modelInfo, modelErr := service.NewModelFactory().ResolveDefaultInfo(ctx, service.Identity{UserID: userID, TenantID: kb.TenantID}, entity.ModelTypeChat); modelErr == nil {
|
||||
llmID = service.ModelReference(modelInfo)
|
||||
}
|
||||
parserConfig = service.ApplyComponentScopedParserConfig(parserConfig, llmID)
|
||||
|
||||
if err = d.kbDAO.UpdateByID(ctx, dao.DB, kb.ID, map[string]interface{}{"parser_config": parserConfig}); err != nil {
|
||||
|
||||
@@ -147,7 +147,7 @@ func (d *DatasetService) SearchDatasets(ctx context.Context, req *service.Search
|
||||
common.Warn("Search service is not initialized for search_id", zap.String("searchID", searchID))
|
||||
return nil, fmt.Errorf("invalid search_id")
|
||||
}
|
||||
searchDetail, err := d.searchService.GetDetail(ctx, userID, searchID)
|
||||
searchDetail, err := d.searchService.GetDetail(ctx, searchID)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
@@ -215,7 +215,7 @@ func (d *DatasetService) SearchDatasets(ctx context.Context, req *service.Search
|
||||
if metadataFilter != nil {
|
||||
method, _ := metadataFilter["method"].(string)
|
||||
if method == "auto" || method == "semi_auto" {
|
||||
access := service.ModelAccess{TenantID: tenantIDs[0]}
|
||||
access := service.Identity{TenantID: tenantIDs[0]}
|
||||
if chatID != "" {
|
||||
chatModelForFilter, err = modelFactory.NewChatModel(ctx, access, chatID)
|
||||
if err != nil {
|
||||
@@ -260,7 +260,7 @@ func (d *DatasetService) SearchDatasets(ctx context.Context, req *service.Search
|
||||
}
|
||||
}
|
||||
if keyword {
|
||||
chatModel, err := modelFactory.NewDefaultChatModel(ctx, service.ModelAccess{TenantID: tenantIDs[0]})
|
||||
chatModel, err := modelFactory.NewDefaultChatModel(ctx, service.Identity{TenantID: tenantIDs[0]})
|
||||
if err != nil {
|
||||
common.Warn("Failed to get default chat model for LLM transformations", zap.Error(err))
|
||||
} else {
|
||||
@@ -280,7 +280,7 @@ func (d *DatasetService) SearchDatasets(ctx context.Context, req *service.Search
|
||||
// Determine embedding model
|
||||
var embeddingModel *modelModule.EmbeddingModel
|
||||
if kbRecords[0].EmbdID != "" {
|
||||
embeddingModel, err = modelFactory.NewEmbeddingModel(ctx, service.ModelAccess{TenantID: tenantIDs[0]}, kbRecords[0].EmbdID)
|
||||
embeddingModel, err = modelFactory.NewEmbeddingModel(ctx, service.Identity{TenantID: tenantIDs[0]}, kbRecords[0].EmbdID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get embedding model by embd_id: %w", err)
|
||||
}
|
||||
@@ -289,7 +289,7 @@ func (d *DatasetService) SearchDatasets(ctx context.Context, req *service.Search
|
||||
// Get rerank model if rerankID is specified
|
||||
var rerankModel *modelModule.RerankModel
|
||||
if rerankID != "" {
|
||||
rerankModel, err = modelFactory.NewRerankModel(ctx, service.ModelAccess{TenantID: tenantIDs[0]}, rerankID)
|
||||
rerankModel, err = modelFactory.NewRerankModel(ctx, service.Identity{TenantID: tenantIDs[0]}, rerankID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get rerank model by rerank_id: %w", err)
|
||||
}
|
||||
|
||||
@@ -54,11 +54,11 @@ func TestSearchDatasetsSearchIDOverridesIntegration(t *testing.T) {
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
searches := service.NewSearchService()
|
||||
created, err := searches.CreateSearch(f.ctx, f.owner, "retrieval_"+tt.name, nil)
|
||||
created, err := searches.CreateSearch(f.ctx, "retrieval_"+tt.name, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = searches.UpdateSearch(f.ctx, f.owner, created.SearchID, &service.UpdateSearchRequest{
|
||||
_, err = searches.UpdateSearch(f.ctx, created.SearchID, &service.UpdateSearchRequest{
|
||||
Name: "retrieval_" + tt.name,
|
||||
SearchConfig: map[string]interface{}{
|
||||
"keywords_similarity_weight": 1 - tt.weight,
|
||||
@@ -201,7 +201,7 @@ func newSearchIntegrationFixture(t *testing.T) *searchIntegrationFixture {
|
||||
}
|
||||
grantDatasetTestResource(t, owner, "kb", kb, owner, "owner")
|
||||
}
|
||||
model, err := service.NewModelFactory().NewEmbeddingModel(ctx, service.ModelAccess{TenantID: owner}, info.ModelID+"@Builtin")
|
||||
model, err := service.NewModelFactory().NewEmbeddingModel(ctx, service.Identity{TenantID: owner}, info.ModelID+"@Builtin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -271,14 +271,14 @@ func (d *DatasetService) UpdateDataset(ctx context.Context, datasetID, userID st
|
||||
} else {
|
||||
tenantEmbdID = ""
|
||||
}
|
||||
modelAccess := service.ModelAccess{UserID: userID, TenantID: lockedKB.TenantID}
|
||||
ok, message := d.verifyEmbeddingAvailability(ctx, effectiveEmbdID, modelAccess)
|
||||
identity := service.Identity{UserID: userID, TenantID: lockedKB.TenantID}
|
||||
ok, message := d.verifyEmbeddingAvailability(ctx, effectiveEmbdID, identity)
|
||||
if !ok {
|
||||
txCode = common.CodeDataError
|
||||
return errors.New(message)
|
||||
}
|
||||
if effectiveEmbdID != "" && tenantEmbdID == "" {
|
||||
target, err := service.NewModelFactory().ResolveInfo(ctx, modelAccess, entity.ModelTypeEmbedding, effectiveEmbdID)
|
||||
target, err := service.NewModelFactory().ResolveInfo(ctx, identity, entity.ModelTypeEmbedding, effectiveEmbdID)
|
||||
if err == nil {
|
||||
tenantEmbdID = target.ID
|
||||
}
|
||||
@@ -438,6 +438,9 @@ func (d *DatasetService) UpdateDataset(ctx context.Context, datasetID, userID st
|
||||
var membership entity.UserTenant
|
||||
if tenantErr := tx.Where("user_id = ? AND tenant_id = ? AND status = ?", userID, lockedKB.TenantID, entity.TenantMembershipStatusJoined).Take(&membership).Error; tenantErr == nil {
|
||||
llmID = membership.LLMID
|
||||
if membership.TenantLLMID != nil && strings.TrimSpace(*membership.TenantLLMID) != "" {
|
||||
llmID = strings.TrimSpace(*membership.TenantLLMID)
|
||||
}
|
||||
}
|
||||
effectiveParserConfig = service.ApplyComponentScopedParserConfig(
|
||||
effectiveParserConfig,
|
||||
|
||||
@@ -1314,6 +1314,44 @@ func TestUpdateDataset_AcceptsValidComponentParams_Builtin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDatasetUsesTenantModelIDForExtractorDefault(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertDatasetUpdateKB(t, "kb-1", "tenant-1", "Original")
|
||||
if err := db.Model(&entity.UserTenant{}).
|
||||
Where("user_id = ? AND tenant_id = ?", "tenant-1", "tenant-1").
|
||||
Updates(map[string]interface{}{
|
||||
"llm_id": "glm-4-flash@CI@ZHIPU-AI",
|
||||
"tenant_llm_id": "chat-model-id",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("set default chat model: %v", err)
|
||||
}
|
||||
|
||||
ctx := withDatasetTestContext(t.Context(), "tenant-1", "tenant-1")
|
||||
_, code, err := testDatasetUpdateService(t).UpdateDataset(ctx, "kb-1", "tenant-1", service.UpdateDatasetRequest{
|
||||
ParserConfig: map[string]interface{}{
|
||||
"Parser:HipSignsRhyme": map[string]interface{}{
|
||||
"pdf": map[string]interface{}{"parse_method": "deepdoc"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("UpdateDataset failed: code=%d err=%v", code, err)
|
||||
}
|
||||
|
||||
persisted, err := dao.NewKnowledgebaseDAO().GetByID(ctx, db, "kb-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get updated dataset: %v", err)
|
||||
}
|
||||
extractor, ok := persisted.ParserConfig["Extractor:AutoExtractDefault"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("extractor parser config = %#v", persisted.ParserConfig["Extractor:AutoExtractDefault"])
|
||||
}
|
||||
if extractor["llm_id"] != "chat-model-id" {
|
||||
t.Fatalf("extractor llm_id = %#v, want tenant model ID", extractor["llm_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// extractorNodeMetadataInTest returns the metadata object stored on the first
|
||||
// Extractor node of a parser_config (the component-scoped form).
|
||||
func extractorNodeMetadataInTest(t *testing.T, cfg map[string]interface{}) map[string]interface{} {
|
||||
|
||||
@@ -308,7 +308,7 @@ func TestIngest_ApplyKB_RealCreatedDatasetChain(t *testing.T) {
|
||||
// resolved embedding model id, keeping CreateDataset on its real path
|
||||
// (no embedding override).
|
||||
if err := dao.DB.Model(&entity.UserTenant{}).Where("user_id = ? AND tenant_id = ?", "user-1", "tenant-1").Update(
|
||||
"tenant_embd_id", "embd-1",
|
||||
"tenant_embd_id", "embd-default",
|
||||
).Error; err != nil {
|
||||
t.Fatalf("set membership embd id: %v", err)
|
||||
}
|
||||
|
||||
@@ -292,7 +292,10 @@ func (s *DocumentService) UpdateDatasetDocument(ctx context.Context, userID, dat
|
||||
}
|
||||
}
|
||||
pipelinepkg.ApplyParentChildChunkerConfig(cleaned, parentChildOverrides)
|
||||
llmID, _, _ := service.NewTenantService().GetDefaultModelRefsForUser(ctx, userID, kb.TenantID, entity.ModelTypeChat)
|
||||
llmID := ""
|
||||
if modelInfo, modelErr := service.NewModelFactory().ResolveDefaultInfo(ctx, service.Identity{UserID: userID, TenantID: kb.TenantID}, entity.ModelTypeChat); modelErr == nil {
|
||||
llmID = service.ModelReference(modelInfo)
|
||||
}
|
||||
cleaned = service.ApplyComponentScopedParserConfig(cleaned, llmID)
|
||||
parserUpdates["parser_config"] = cleaned
|
||||
}
|
||||
@@ -320,11 +323,12 @@ func (s *DocumentService) UpdateDatasetDocument(ctx context.Context, userID, dat
|
||||
common.Warn("document pipeline/version switch: failed to resolve component params defaults, keeping prior parser_config",
|
||||
zap.String("parserID", effParserID), zap.Error(cpErr))
|
||||
} else {
|
||||
llmID, _, llmErr := service.NewTenantService().GetDefaultModelRefsForUser(ctx, userID, kb.TenantID, entity.ModelTypeChat)
|
||||
if llmErr != nil {
|
||||
llmID := ""
|
||||
if modelInfo, llmErr := service.NewModelFactory().ResolveDefaultInfo(ctx, service.Identity{UserID: userID, TenantID: kb.TenantID}, entity.ModelTypeChat); llmErr == nil {
|
||||
llmID = service.ModelReference(modelInfo)
|
||||
} else if !errors.Is(llmErr, service.ErrNoDefaultModel) {
|
||||
common.Warn("document pipeline/version switch: failed to resolve default chat model, falling back to no llm binding",
|
||||
zap.String("tenant_id", kb.TenantID), zap.Error(llmErr))
|
||||
llmID = ""
|
||||
}
|
||||
cleaned := service.ApplyComponentScopedParserConfig(cpDefaults, llmID)
|
||||
parserUpdates["parser_config"] = cleaned
|
||||
|
||||
@@ -201,7 +201,10 @@ func rebuildParserConfig(ctx context.Context, kb *entity.Knowledgebase, target,
|
||||
cleaned["metadata"] = meta
|
||||
}
|
||||
pipelinepkg.ApplyParentChildChunkerConfig(cleaned, raw)
|
||||
llmID, _ := service.NewTenantService().GetDefaultModelNameForTenant(ctx, kb.TenantID, entity.ModelTypeChat)
|
||||
llmID := ""
|
||||
if modelInfo, modelErr := service.NewModelFactory().ResolveDefaultInfo(ctx, service.Identity{TenantID: kb.TenantID}, entity.ModelTypeChat); modelErr == nil {
|
||||
llmID = service.ModelReference(modelInfo)
|
||||
}
|
||||
cleaned = service.ApplyComponentScopedParserConfig(cleaned, llmID)
|
||||
return cleaned, true
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission/testsupport"
|
||||
"ragflow/internal/service"
|
||||
"ragflow/internal/storage"
|
||||
syncerconnector "ragflow/internal/syncer/connector"
|
||||
@@ -150,19 +149,7 @@ func tableParserConfig() entity.JSONMap {
|
||||
|
||||
func seedDocumentParserTenant(t *testing.T, tenantID, ownerID string) {
|
||||
t.Helper()
|
||||
if err := dao.DB.Create(&entity.Tenant{ID: tenantID}).Error; err != nil {
|
||||
t.Fatalf("insert tenant: %v", err)
|
||||
}
|
||||
status := string(entity.StatusValid)
|
||||
if err := dao.DB.Create(&entity.UserTenant{
|
||||
ID: "owner-" + tenantID, UserID: ownerID, TenantID: tenantID, Role: "owner", Status: &status,
|
||||
LLMID: "llm-default", EmbdID: "embd-default", ASRID: "asr-default",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert tenant owner membership: %v", err)
|
||||
}
|
||||
if err := testsupport.PrepareEnterprisePermissionFixture(dao.DB, tenantID, ownerID); err != nil {
|
||||
t.Fatalf("prepare parser tenant permission fixture: %v", err)
|
||||
}
|
||||
insertUserTenantForAccessCheck(t, ownerID, tenantID)
|
||||
}
|
||||
|
||||
// TestResolveDocumentParser_Priority pins the resolution order: the dataset's
|
||||
|
||||
@@ -549,6 +549,9 @@ func setupServiceTestDBWithDSN(t *testing.T, dsn string) *gorm.DB {
|
||||
&entity.User{},
|
||||
&entity.Tenant{},
|
||||
&entity.UserTenant{},
|
||||
&entity.TenantModelProvider{},
|
||||
&entity.TenantModelInstance{},
|
||||
&entity.TenantModel{},
|
||||
&entity.UserCanvas{},
|
||||
&entity.API4Conversation{},
|
||||
}
|
||||
@@ -1493,6 +1496,15 @@ func TestUploadEmptyDocument_CreatesVirtualDocumentAndFileLink(t *testing.T) {
|
||||
|
||||
func insertUserTenantForAccessCheck(t *testing.T, userID, tenantID string) {
|
||||
t.Helper()
|
||||
modelID := func(defaultID string) string {
|
||||
if userID == "user-1" && tenantID == "tenant-1" {
|
||||
return defaultID
|
||||
}
|
||||
return fmt.Sprintf("%s-%s-%s", defaultID, userID, tenantID)
|
||||
}
|
||||
chatModelID := modelID("llm-default")
|
||||
embeddingModelID := modelID("embd-default")
|
||||
asrModelID := modelID("asr-default")
|
||||
// Insert user if not exists (email is NOT NULL, password is nullable pointer)
|
||||
var existingUser entity.User
|
||||
if err := dao.DB.Where("id = ?", userID).First(&existingUser).Error; err != nil {
|
||||
@@ -1518,9 +1530,9 @@ func insertUserTenantForAccessCheck(t *testing.T, userID, tenantID string) {
|
||||
TenantID: tenantID,
|
||||
Role: "owner",
|
||||
Status: sptr(entity.TenantMembershipStatusJoined),
|
||||
LLMID: "llm-default",
|
||||
EmbdID: "embd-default",
|
||||
ASRID: "asr-default",
|
||||
LLMID: chatModelID,
|
||||
EmbdID: embeddingModelID,
|
||||
ASRID: asrModelID,
|
||||
}
|
||||
if err = dao.DB.Create(ut).Error; err != nil {
|
||||
t.Fatalf("insert test user_tenant: %v", err)
|
||||
@@ -1528,9 +1540,28 @@ func insertUserTenantForAccessCheck(t *testing.T, userID, tenantID string) {
|
||||
}
|
||||
if err := dao.DB.Model(&entity.UserTenant{}).
|
||||
Where("user_id = ? AND tenant_id = ?", userID, tenantID).
|
||||
Updates(map[string]interface{}{"llm_id": "llm-default", "embd_id": "embd-default", "asr_id": "asr-default"}).Error; err != nil {
|
||||
Updates(map[string]interface{}{"llm_id": chatModelID, "embd_id": embeddingModelID, "asr_id": asrModelID}).Error; err != nil {
|
||||
t.Fatalf("set test owner model defaults: %v", err)
|
||||
}
|
||||
providerID := fmt.Sprintf("test-default-provider-%s-%s", userID, tenantID)
|
||||
instanceID := fmt.Sprintf("test-default-instance-%s-%s", userID, tenantID)
|
||||
provider := &entity.TenantModelProvider{ID: providerID, UserID: userID, TenantID: tenantID, ProviderName: "OpenAI"}
|
||||
if err := dao.DB.Where("id = ?", providerID).FirstOrCreate(provider).Error; err != nil {
|
||||
t.Fatalf("seed test default provider: %v", err)
|
||||
}
|
||||
instance := &entity.TenantModelInstance{ID: instanceID, ProviderID: providerID, InstanceName: "default", Status: "active", Extra: "{}"}
|
||||
if err := dao.DB.Where("id = ?", instanceID).FirstOrCreate(instance).Error; err != nil {
|
||||
t.Fatalf("seed test default instance: %v", err)
|
||||
}
|
||||
for _, model := range []*entity.TenantModel{
|
||||
{ID: chatModelID, ProviderID: providerID, InstanceID: instanceID, ModelName: "gpt-4o", ModelType: int(entity.ModelTypeChat), Status: "active", Extra: "{}"},
|
||||
{ID: embeddingModelID, ProviderID: providerID, InstanceID: instanceID, ModelName: "text-embedding-3-small", ModelType: int(entity.ModelTypeEmbedding), Status: "active", Extra: "{}"},
|
||||
{ID: asrModelID, ProviderID: providerID, InstanceID: instanceID, ModelName: "whisper-1", ModelType: int(entity.ModelTypeSpeech2Text), Status: "active", Extra: "{}"},
|
||||
} {
|
||||
if err := dao.DB.Where("id = ?", model.ID).FirstOrCreate(model).Error; err != nil {
|
||||
t.Fatalf("seed test default model %s: %v", model.ID, err)
|
||||
}
|
||||
}
|
||||
if err := testsupport.PrepareEnterprisePermissionFixture(dao.DB, tenantID, userID); err != nil {
|
||||
t.Fatalf("prepare document permission fixture: %v", err)
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ func CrossLanguages(ctx context.Context, tenantID string, llmID string, query st
|
||||
var chatModel *modelModule.ChatModel
|
||||
var err error
|
||||
|
||||
access := ModelAccess{TenantID: tenantID}
|
||||
access := Identity{TenantID: tenantID}
|
||||
if llmID != "" {
|
||||
chatModel, err = modelFactory.NewChatModel(ctx, access, llmID)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// 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.
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"ragflow/internal/permission"
|
||||
)
|
||||
|
||||
// Identity identifies a user in a tenant. TenantID may be empty when an
|
||||
// edition derives the effective tenant from the resource or user.
|
||||
type Identity struct {
|
||||
UserID string
|
||||
TenantID string
|
||||
}
|
||||
|
||||
// ResolveIdentity returns the authenticated user and their active tenant.
|
||||
// Explicit Identity values remain available to background work without a
|
||||
// request context.
|
||||
func ResolveIdentity(ctx context.Context) (Identity, error) {
|
||||
subject, ok := permission.SubjectFromContext(ctx)
|
||||
if !ok || strings.TrimSpace(subject.UserID) == "" {
|
||||
return Identity{}, permission.ErrUnauthenticated
|
||||
}
|
||||
|
||||
tenantID, err := requireCurrentTenantID(ctx, subject.UserID)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
|
||||
return Identity{UserID: strings.TrimSpace(subject.UserID), TenantID: tenantID}, nil
|
||||
}
|
||||
@@ -503,7 +503,7 @@ func (s *MemoryService) CreateMemory(ctx context.Context, userID string, req *Cr
|
||||
// tenant_model row) — we leave the tenant_*_id fields nil and proceed.
|
||||
modelFactory := NewModelFactory()
|
||||
if req.LLMID != "" && req.TenantLLMID == nil {
|
||||
info, err := modelFactory.ResolveInfo(ctx, ModelAccess{UserID: userID, TenantID: tenantID}, entity.ModelTypeChat, req.LLMID)
|
||||
info, err := modelFactory.ResolveInfo(ctx, Identity{UserID: userID, TenantID: tenantID}, entity.ModelTypeChat, req.LLMID)
|
||||
if err != nil {
|
||||
common.Warn("CreateMemory: failed to resolve tenant LLM id",
|
||||
zap.String("tenant_id", tenantID), zap.String("llm_id", req.LLMID), zap.Error(err))
|
||||
@@ -513,7 +513,7 @@ func (s *MemoryService) CreateMemory(ctx context.Context, userID string, req *Cr
|
||||
}
|
||||
}
|
||||
if req.EmbdID != "" && req.TenantEmbdID == nil {
|
||||
info, err := modelFactory.ResolveInfo(ctx, ModelAccess{UserID: userID, TenantID: tenantID}, entity.ModelTypeEmbedding, req.EmbdID)
|
||||
info, err := modelFactory.ResolveInfo(ctx, Identity{UserID: userID, TenantID: tenantID}, entity.ModelTypeEmbedding, req.EmbdID)
|
||||
if err != nil {
|
||||
common.Warn("CreateMemory: failed to resolve tenant embedding id",
|
||||
zap.String("tenant_id", tenantID), zap.String("embd_id", req.EmbdID), zap.Error(err))
|
||||
@@ -673,7 +673,7 @@ func (s *MemoryService) UpdateMemory(ctx context.Context, userID string, memoryI
|
||||
if req.LLMID != nil {
|
||||
updateDict["llm_id"] = *req.LLMID
|
||||
if req.TenantLLMID == nil && *req.LLMID != "" {
|
||||
info, err := modelFactory.ResolveInfo(ctx, ModelAccess{TenantID: ownerTenantID}, entity.ModelTypeChat, *req.LLMID)
|
||||
info, err := modelFactory.ResolveInfo(ctx, Identity{TenantID: ownerTenantID}, entity.ModelTypeChat, *req.LLMID)
|
||||
if err != nil {
|
||||
common.Warn("UpdateMemory: failed to resolve tenant LLM id",
|
||||
zap.String("tenant_id", ownerTenantID), zap.String("llm_id", *req.LLMID), zap.Error(err))
|
||||
@@ -686,7 +686,7 @@ func (s *MemoryService) UpdateMemory(ctx context.Context, userID string, memoryI
|
||||
if req.EmbdID != nil {
|
||||
updateDict["embd_id"] = *req.EmbdID
|
||||
if req.TenantEmbdID == nil && *req.EmbdID != "" {
|
||||
info, err := modelFactory.ResolveInfo(ctx, ModelAccess{TenantID: ownerTenantID}, entity.ModelTypeEmbedding, *req.EmbdID)
|
||||
info, err := modelFactory.ResolveInfo(ctx, Identity{TenantID: ownerTenantID}, entity.ModelTypeEmbedding, *req.EmbdID)
|
||||
if err != nil {
|
||||
common.Warn("UpdateMemory: failed to resolve tenant embedding id",
|
||||
zap.String("tenant_id", ownerTenantID), zap.String("embd_id", *req.EmbdID), zap.Error(err))
|
||||
@@ -1539,7 +1539,7 @@ func memoryFusionWeights(keywordsSimilarityWeight float64) string {
|
||||
}
|
||||
|
||||
func (s *MemoryService) memoryMessageDenseExpr(ctx context.Context, question string, memory *entity.Memory, topN int, similarityThreshold float64) (*enginetypes.MatchDenseExpr, error) {
|
||||
embeddingModel, err := NewModelFactory().NewEmbeddingModel(ctx, ModelAccess{TenantID: memory.TenantID}, memory.EmbdID)
|
||||
embeddingModel, err := NewModelFactory().NewEmbeddingModel(ctx, Identity{TenantID: memory.TenantID}, memory.EmbdID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -496,7 +496,7 @@ func (s *MemoryMessageService) extractByLLM(ctx context.Context, mem *CreateMemo
|
||||
if mem.TenantLLMID != nil && *mem.TenantLLMID != "" {
|
||||
llmRef = *mem.TenantLLMID
|
||||
}
|
||||
chatModel, err := NewModelFactory().NewChatModel(ctx, ModelAccess{TenantID: mem.TenantID}, llmRef)
|
||||
chatModel, err := NewModelFactory().NewChatModel(ctx, Identity{TenantID: mem.TenantID}, llmRef)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve chat model: %w", classifyMemoryTaskDependencyError(err))
|
||||
}
|
||||
|
||||
@@ -355,7 +355,7 @@ func (s *MemoryMessageService) embedAndSaveMessages(ctx context.Context, mem *Cr
|
||||
for i, message := range messages {
|
||||
contents[i], _ = message["content"].(string)
|
||||
}
|
||||
embeddingModel, err := NewModelFactory().NewEmbeddingModel(ctx, ModelAccess{TenantID: mem.TenantID}, mem.EmbdID)
|
||||
embeddingModel, err := NewModelFactory().NewEmbeddingModel(ctx, Identity{TenantID: mem.TenantID}, mem.EmbdID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -467,14 +467,16 @@ func TestUpdateMemoryTeamMemberResolvesModelsAgainstOwnerTenant(t *testing.T) {
|
||||
|
||||
for _, row := range []struct {
|
||||
providerID string
|
||||
userID string
|
||||
tenantID string
|
||||
modelID string
|
||||
}{
|
||||
{providerID: "provider-owner", tenantID: "owner-1", modelID: "tenant-llm-owner"},
|
||||
{providerID: "provider-member", tenantID: "member-1", modelID: "tenant-llm-member"},
|
||||
{providerID: "provider-owner", userID: "owner-1", tenantID: "owner-1", modelID: "tenant-llm-owner"},
|
||||
{providerID: "provider-member", userID: "member-1", tenantID: "member-1", modelID: "tenant-llm-member"},
|
||||
} {
|
||||
if err := dao.DB.Create(&entity.TenantModelProvider{
|
||||
ID: row.providerID,
|
||||
UserID: row.userID,
|
||||
ProviderName: "OpenAI",
|
||||
TenantID: row.tenantID,
|
||||
}).Error; err != nil {
|
||||
|
||||
+51
-131
@@ -22,10 +22,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
modelModule "ragflow/internal/entity/models"
|
||||
)
|
||||
|
||||
@@ -34,38 +31,21 @@ import (
|
||||
// runtime configuration. Provider, instance, API configuration, and driver
|
||||
// resolution are handled internally.
|
||||
type ModelCallService struct {
|
||||
providerService *ModelProviderService
|
||||
modelFactory *ModelFactory
|
||||
modelFactory *ModelFactory
|
||||
}
|
||||
|
||||
// NewModelCallService creates a model-call service with the standard model
|
||||
// provider and model resolver.
|
||||
// NewModelCallService creates a model-call service with the standard resolver.
|
||||
func NewModelCallService() *ModelCallService {
|
||||
return NewModelCallServiceWithProviderService(NewModelProviderService(), NewModelFactory())
|
||||
}
|
||||
|
||||
// NewModelCallServiceWithProviderService creates a model-call service using
|
||||
// an existing provider service for provider metadata and balance operations.
|
||||
func NewModelCallServiceWithProviderService(providerService *ModelProviderService, modelFactory *ModelFactory) *ModelCallService {
|
||||
if providerService == nil {
|
||||
providerService = NewModelProviderService()
|
||||
}
|
||||
if modelFactory == nil {
|
||||
modelFactory = NewModelFactory()
|
||||
}
|
||||
return &ModelCallService{
|
||||
providerService: providerService,
|
||||
modelFactory: modelFactory,
|
||||
}
|
||||
return &ModelCallService{modelFactory: NewModelFactory()}
|
||||
}
|
||||
|
||||
// ChatToModelWithMessages sends messages to the model selected by modelRef.
|
||||
func (s *ModelCallService) ChatToModelWithMessages(ctx context.Context, modelRef, userID string, messages []modelModule.Message, config *modelModule.ChatConfig, usage *common.ModelUsage) (*modelModule.ChatResponse, common.ErrorCode, error) {
|
||||
access, tenantID, code, err := s.modelAccessForUser(ctx, userID)
|
||||
func (s *ModelCallService) ChatToModelWithMessages(ctx context.Context, modelRef string, messages []modelModule.Message, config *modelModule.ChatConfig, usage *common.ModelUsage) (*modelModule.ChatResponse, common.ErrorCode, error) {
|
||||
identity, code, err := s.identityForRequest(ctx)
|
||||
if err != nil {
|
||||
return nil, code, err
|
||||
}
|
||||
chatModel, err := s.modelFactory.NewChatModel(ctx, access, modelRef)
|
||||
chatModel, err := s.modelFactory.NewChatModel(ctx, identity, modelRef)
|
||||
if err != nil {
|
||||
return nil, common.CodeNotFound, err
|
||||
}
|
||||
@@ -73,7 +53,7 @@ func (s *ModelCallService) ChatToModelWithMessages(ctx context.Context, modelRef
|
||||
config = &modelModule.ChatConfig{}
|
||||
}
|
||||
info := chatModel.Info()
|
||||
populateModelUsage(usage, info, chatModel.APIConfig, tenantID, userID)
|
||||
populateModelUsage(usage, info, chatModel.APIConfig, identity.TenantID, identity.UserID)
|
||||
|
||||
response, err := chatModel.ChatWithMessages(ctx, messages, config, usage)
|
||||
if err != nil {
|
||||
@@ -84,31 +64,31 @@ func (s *ModelCallService) ChatToModelWithMessages(ctx context.Context, modelRef
|
||||
}
|
||||
return response, common.CodeSuccess, nil
|
||||
}
|
||||
func (s *ModelCallService) ChatToModelStreamWithSender(ctx context.Context, modelRef, userID string, messages []modelModule.Message, config *modelModule.ChatConfig, usage *common.ModelUsage, sender func(*string, *string) error) (common.ErrorCode, error) {
|
||||
access, tenantID, code, err := s.modelAccessForUser(ctx, userID)
|
||||
func (s *ModelCallService) ChatToModelStreamWithSender(ctx context.Context, modelRef string, messages []modelModule.Message, config *modelModule.ChatConfig, usage *common.ModelUsage, sender func(*string, *string) error) (common.ErrorCode, error) {
|
||||
identity, code, err := s.identityForRequest(ctx)
|
||||
if err != nil {
|
||||
return code, err
|
||||
}
|
||||
chatModel, err := s.modelFactory.NewChatModel(ctx, access, modelRef)
|
||||
chatModel, err := s.modelFactory.NewChatModel(ctx, identity, modelRef)
|
||||
if err != nil {
|
||||
return common.CodeNotFound, err
|
||||
}
|
||||
if config == nil {
|
||||
config = &modelModule.ChatConfig{}
|
||||
}
|
||||
populateModelUsage(usage, chatModel.Info(), chatModel.APIConfig, tenantID, userID)
|
||||
populateModelUsage(usage, chatModel.Info(), chatModel.APIConfig, identity.TenantID, identity.UserID)
|
||||
|
||||
if err := chatModel.ChatStreamlyWithSender(ctx, messages, config, usage, sender); err != nil {
|
||||
return common.CodeServerError, err
|
||||
}
|
||||
return common.CodeSuccess, nil
|
||||
}
|
||||
func (s *ModelCallService) EmbedText(ctx context.Context, modelRef, userID string, texts []string, config *modelModule.EmbeddingConfig) ([]modelModule.EmbeddingData, common.ErrorCode, error) {
|
||||
access, _, code, err := s.modelAccessForUser(ctx, userID)
|
||||
func (s *ModelCallService) EmbedText(ctx context.Context, modelRef string, texts []string, config *modelModule.EmbeddingConfig) ([]modelModule.EmbeddingData, common.ErrorCode, error) {
|
||||
identity, code, err := s.identityForRequest(ctx)
|
||||
if err != nil {
|
||||
return nil, code, err
|
||||
}
|
||||
embeddingModel, err := s.modelFactory.NewEmbeddingModel(ctx, access, modelRef)
|
||||
embeddingModel, err := s.modelFactory.NewEmbeddingModel(ctx, identity, modelRef)
|
||||
if err != nil {
|
||||
return nil, common.CodeNotFound, err
|
||||
}
|
||||
@@ -132,12 +112,12 @@ func (s *ModelCallService) EmbedText(ctx context.Context, modelRef, userID strin
|
||||
}
|
||||
return embeddings, common.CodeSuccess, nil
|
||||
}
|
||||
func (s *ModelCallService) RerankDocument(ctx context.Context, modelRef, userID, query string, documents []string, config *modelModule.RerankConfig) (*modelModule.RerankResponse, common.ErrorCode, error) {
|
||||
access, _, code, err := s.modelAccessForUser(ctx, userID)
|
||||
func (s *ModelCallService) RerankDocument(ctx context.Context, modelRef, query string, documents []string, config *modelModule.RerankConfig) (*modelModule.RerankResponse, common.ErrorCode, error) {
|
||||
identity, code, err := s.identityForRequest(ctx)
|
||||
if err != nil {
|
||||
return nil, code, err
|
||||
}
|
||||
rerankModel, err := s.modelFactory.NewRerankModel(ctx, access, modelRef)
|
||||
rerankModel, err := s.modelFactory.NewRerankModel(ctx, identity, modelRef)
|
||||
if err != nil {
|
||||
return nil, common.CodeNotFound, err
|
||||
}
|
||||
@@ -154,12 +134,12 @@ func (s *ModelCallService) RerankDocument(ctx context.Context, modelRef, userID,
|
||||
}
|
||||
return response, common.CodeSuccess, nil
|
||||
}
|
||||
func (s *ModelCallService) TranscribeAudio(ctx context.Context, modelRef, userID string, audioFile *string, config *modelModule.ASRConfig) (*modelModule.ASRResponse, common.ErrorCode, error) {
|
||||
access, _, code, err := s.modelAccessForUser(ctx, userID)
|
||||
func (s *ModelCallService) TranscribeAudio(ctx context.Context, modelRef string, audioFile *string, config *modelModule.ASRConfig) (*modelModule.ASRResponse, common.ErrorCode, error) {
|
||||
identity, code, err := s.identityForRequest(ctx)
|
||||
if err != nil {
|
||||
return nil, code, err
|
||||
}
|
||||
asrModel, err := s.modelFactory.NewASRModel(ctx, access, modelRef)
|
||||
asrModel, err := s.modelFactory.NewASRModel(ctx, identity, modelRef)
|
||||
if err != nil {
|
||||
return nil, common.CodeNotFound, err
|
||||
}
|
||||
@@ -176,12 +156,12 @@ func (s *ModelCallService) TranscribeAudio(ctx context.Context, modelRef, userID
|
||||
}
|
||||
return response, common.CodeSuccess, nil
|
||||
}
|
||||
func (s *ModelCallService) TranscribeAudioStream(ctx context.Context, modelRef, userID string, audioFile *string, config *modelModule.ASRConfig, sender func(*string, *string) error) (common.ErrorCode, error) {
|
||||
access, _, code, err := s.modelAccessForUser(ctx, userID)
|
||||
func (s *ModelCallService) TranscribeAudioStream(ctx context.Context, modelRef string, audioFile *string, config *modelModule.ASRConfig, sender func(*string, *string) error) (common.ErrorCode, error) {
|
||||
identity, code, err := s.identityForRequest(ctx)
|
||||
if err != nil {
|
||||
return code, err
|
||||
}
|
||||
asrModel, err := s.modelFactory.NewASRModel(ctx, access, modelRef)
|
||||
asrModel, err := s.modelFactory.NewASRModel(ctx, identity, modelRef)
|
||||
if err != nil {
|
||||
return common.CodeNotFound, err
|
||||
}
|
||||
@@ -193,28 +173,18 @@ func (s *ModelCallService) TranscribeAudioStream(ctx context.Context, modelRef,
|
||||
}
|
||||
return common.CodeSuccess, nil
|
||||
}
|
||||
func (s *ModelCallService) AudioSpeech(ctx context.Context, modelRef, userID string, audioContent *string, config *modelModule.TTSConfig) (*modelModule.TTSResponse, common.ErrorCode, error) {
|
||||
if s == nil || s.modelFactory == nil {
|
||||
return nil, common.CodeServerError, errors.New("model call service is not initialized")
|
||||
}
|
||||
tenantID, tenantErr := s.ownerTenantID(ctx, userID)
|
||||
access := ModelAccess{UserID: userID, TenantID: tenantID}
|
||||
if tenantErr != nil {
|
||||
if strings.TrimSpace(modelRef) != "" {
|
||||
return nil, common.CodeNotFound, tenantErr
|
||||
}
|
||||
// Some internal audio dispatchers pass a tenant ID in userID.
|
||||
access = ModelAccess{TenantID: userID}
|
||||
tenantID = userID
|
||||
func (s *ModelCallService) AudioSpeech(ctx context.Context, modelRef string, audioContent *string, config *modelModule.TTSConfig) (*modelModule.TTSResponse, common.ErrorCode, error) {
|
||||
identity, code, err := s.identityForRequest(ctx)
|
||||
if err != nil {
|
||||
return nil, code, err
|
||||
}
|
||||
var (
|
||||
ttsModel *modelModule.TTSModel
|
||||
err error
|
||||
)
|
||||
if strings.TrimSpace(modelRef) == "" {
|
||||
ttsModel, err = s.modelFactory.NewDefaultTTSModel(ctx, access)
|
||||
ttsModel, err = s.modelFactory.NewDefaultTTSModel(ctx, identity)
|
||||
} else {
|
||||
ttsModel, err = s.modelFactory.NewTTSModel(ctx, access, modelRef)
|
||||
ttsModel, err = s.modelFactory.NewTTSModel(ctx, identity, modelRef)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, common.CodeNotFound, err
|
||||
@@ -236,7 +206,7 @@ func (s *ModelCallService) AudioSpeechForTenant(ctx context.Context, modelRef, t
|
||||
if s == nil || s.modelFactory == nil {
|
||||
return nil, common.CodeServerError, errors.New("model call service is not initialized")
|
||||
}
|
||||
access := ModelAccess{TenantID: tenantID}
|
||||
access := Identity{TenantID: tenantID}
|
||||
var (
|
||||
ttsModel *modelModule.TTSModel
|
||||
err error
|
||||
@@ -262,12 +232,12 @@ func (s *ModelCallService) AudioSpeechForTenant(ctx context.Context, modelRef, t
|
||||
}
|
||||
return response, common.CodeSuccess, nil
|
||||
}
|
||||
func (s *ModelCallService) AudioSpeechStream(ctx context.Context, modelRef, userID string, audioContent *string, config *modelModule.TTSConfig, sender func(*string, *string) error) (common.ErrorCode, error) {
|
||||
access, _, code, err := s.modelAccessForUser(ctx, userID)
|
||||
func (s *ModelCallService) AudioSpeechStream(ctx context.Context, modelRef string, audioContent *string, config *modelModule.TTSConfig, sender func(*string, *string) error) (common.ErrorCode, error) {
|
||||
identity, code, err := s.identityForRequest(ctx)
|
||||
if err != nil {
|
||||
return code, err
|
||||
}
|
||||
ttsModel, err := s.modelFactory.NewTTSModel(ctx, access, modelRef)
|
||||
ttsModel, err := s.modelFactory.NewTTSModel(ctx, identity, modelRef)
|
||||
if err != nil {
|
||||
return common.CodeNotFound, err
|
||||
}
|
||||
@@ -279,12 +249,12 @@ func (s *ModelCallService) AudioSpeechStream(ctx context.Context, modelRef, user
|
||||
}
|
||||
return common.CodeSuccess, nil
|
||||
}
|
||||
func (s *ModelCallService) OCRFile(ctx context.Context, modelRef, userID string, content []byte, url *string, config *modelModule.OCRConfig) (*modelModule.OCRFileResponse, common.ErrorCode, error) {
|
||||
access, _, code, err := s.modelAccessForUser(ctx, userID)
|
||||
func (s *ModelCallService) OCRFile(ctx context.Context, modelRef string, content []byte, url *string, config *modelModule.OCRConfig) (*modelModule.OCRFileResponse, common.ErrorCode, error) {
|
||||
identity, code, err := s.identityForRequest(ctx)
|
||||
if err != nil {
|
||||
return nil, code, err
|
||||
}
|
||||
ocrModel, err := s.modelFactory.NewOCRModel(ctx, access, modelRef)
|
||||
ocrModel, err := s.modelFactory.NewOCRModel(ctx, identity, modelRef)
|
||||
if err != nil {
|
||||
return nil, common.CodeNotFound, err
|
||||
}
|
||||
@@ -303,46 +273,24 @@ func (s *ModelCallService) OCRFile(ctx context.Context, modelRef, userID string,
|
||||
}
|
||||
|
||||
// ParseFile parses content or url with the document parser selected by
|
||||
// modelRef. doc_parse is a provider catalog capability rather than a member
|
||||
// of entity.ModelType, so this method uses the existing model-info resolver.
|
||||
func (s *ModelCallService) ParseFile(ctx context.Context, modelRef, userID string, content []byte, url *string, config *modelModule.ParseFileConfig) (*modelModule.ParseFileResponse, common.ErrorCode, error) {
|
||||
info, err := s.resolveModelInfo(ctx, modelRef, userID)
|
||||
// modelRef. A zero model type resolves catalog-only capabilities such as doc_parse.
|
||||
func (s *ModelCallService) ParseFile(ctx context.Context, modelRef string, content []byte, url *string, config *modelModule.ParseFileConfig) (*modelModule.ParseFileResponse, common.ErrorCode, error) {
|
||||
identity, code, err := s.identityForRequest(ctx)
|
||||
if err != nil {
|
||||
return nil, code, err
|
||||
}
|
||||
target, err := s.modelFactory.resolveConfig(ctx, identity, 0, modelRef)
|
||||
if err != nil {
|
||||
return nil, common.CodeNotFound, err
|
||||
}
|
||||
if info.ModelInfo == nil || !info.ModelInfo.ModelTypeMap["doc_parse"] {
|
||||
if target.ModelInfo == nil || !target.ModelInfo.ModelTypeMap["doc_parse"] {
|
||||
return nil, common.CodeNotFound, fmt.Errorf("model %q is not a document parser", modelRef)
|
||||
}
|
||||
if config == nil {
|
||||
config = &modelModule.ParseFileConfig{}
|
||||
}
|
||||
|
||||
modelDriver := info.ProviderInfo.ModelDriver
|
||||
if info.ModelEntity != nil {
|
||||
if info.ModelEntity.Status != "active" {
|
||||
return nil, common.CodeNotFound, errors.New("model is inactive")
|
||||
}
|
||||
region := ""
|
||||
baseURL := ""
|
||||
if info.APIConfig != nil {
|
||||
if info.APIConfig.Region != nil {
|
||||
region = *info.APIConfig.Region
|
||||
}
|
||||
if info.APIConfig.BaseURL != nil {
|
||||
baseURL = *info.APIConfig.BaseURL
|
||||
}
|
||||
}
|
||||
modelDriver, err = newModelDriverForBaseURL(info.ProviderInfo.ModelDriver, info.ProviderEntity.ProviderName, region, baseURL)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
}
|
||||
|
||||
modelName := info.ModelInfo.Name
|
||||
if info.ModelEntity != nil && info.ModelEntity.ModelName != "" {
|
||||
modelName = info.ModelEntity.ModelName
|
||||
}
|
||||
response, err := modelDriver.ParseFile(ctx, &modelName, content, url, info.APIConfig, config, nil)
|
||||
response, err := target.Driver.ParseFile(ctx, &target.ModelName, content, url, target.APIConfig, config, nil)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
@@ -352,15 +300,15 @@ func (s *ModelCallService) ParseFile(ctx context.Context, modelRef, userID strin
|
||||
return response, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
func (s *ModelCallService) modelAccessForUser(ctx context.Context, userID string) (ModelAccess, string, common.ErrorCode, error) {
|
||||
if s == nil || s.providerService == nil || s.modelFactory == nil {
|
||||
return ModelAccess{}, "", common.CodeServerError, errors.New("model call service is not initialized")
|
||||
func (s *ModelCallService) identityForRequest(ctx context.Context) (Identity, common.ErrorCode, error) {
|
||||
if s == nil || s.modelFactory == nil {
|
||||
return Identity{}, common.CodeServerError, errors.New("model call service is not initialized")
|
||||
}
|
||||
tenantID, err := s.ownerTenantID(ctx, userID)
|
||||
identity, err := ResolveIdentity(ctx)
|
||||
if err != nil {
|
||||
return ModelAccess{}, "", common.CodeNotFound, err
|
||||
return Identity{}, common.CodeNotFound, err
|
||||
}
|
||||
return ModelAccess{UserID: userID, TenantID: tenantID}, tenantID, common.CodeSuccess, nil
|
||||
return identity, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
func populateModelUsage(usage *common.ModelUsage, info *modelModule.ModelInfo, apiConfig *modelModule.APIConfig, tenantID, userID string) {
|
||||
@@ -376,31 +324,3 @@ func populateModelUsage(usage *common.ModelUsage, info *modelModule.ModelInfo, a
|
||||
usage.APIKey = *apiConfig.ApiKey
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ModelCallService) ownerTenantID(ctx context.Context, userID string) (string, error) {
|
||||
return requireCurrentTenantID(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *ModelCallService) resolveModelInfo(ctx context.Context, modelRef, userID string) (*ModelInstanceAndProviderInfo, error) {
|
||||
if s == nil || s.providerService == nil {
|
||||
return nil, errors.New("model call service is not initialized")
|
||||
}
|
||||
modelRef = strings.TrimSpace(modelRef)
|
||||
if modelRef == "" {
|
||||
return nil, errors.New("model ref is required")
|
||||
}
|
||||
|
||||
model, err := s.providerService.modelDAO.GetByID(ctx, dao.DB, modelRef)
|
||||
if err == nil && model != nil {
|
||||
return s.providerService.getModelInstanceAndProviderByID(ctx, &modelRef, userID, nil)
|
||||
}
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
modelName, instanceName, providerName, err := common.ParseCompositeModelName(modelRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.providerService.getModelInstanceAndProviderByName(ctx, &providerName, &instanceName, &modelName, userID, nil)
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func chatStreamWithContext(ctx context.Context, chatModel *modelModule.ChatModel
|
||||
// channel-based streaming interface.
|
||||
type TenantStreamAdapter struct {
|
||||
Factory *ModelFactory
|
||||
TenantID string
|
||||
Identity Identity
|
||||
ModelID string
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func (a *TenantStreamAdapter) ChatStream(ctx context.Context, messages []modelMo
|
||||
if a.Factory == nil {
|
||||
return nil, nil, fmt.Errorf("model factory not configured")
|
||||
}
|
||||
chatModel, err := a.Factory.NewChatModel(ctx, ModelAccess{TenantID: a.TenantID}, a.ModelID)
|
||||
chatModel, err := a.Factory.NewChatModel(ctx, a.Identity, a.ModelID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
+308
-467
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,142 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/entity"
|
||||
)
|
||||
|
||||
func TestModelFactoryIDAndNameShareValidation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
table string
|
||||
updates map[string]interface{}
|
||||
}{
|
||||
{name: "active"},
|
||||
{name: "disabled model", table: "tenant_model", updates: map[string]interface{}{"status": "inactive"}},
|
||||
{name: "disabled instance", table: "tenant_model_instance", updates: map[string]interface{}{"status": "inactive"}},
|
||||
{name: "foreign tenant", table: "tenant_model_provider", updates: map[string]interface{}{"tenant_id": "foreign"}},
|
||||
{name: "foreign owner", table: "tenant_model_provider", updates: map[string]interface{}{"user_id": "foreign"}},
|
||||
{name: "wrong provider", table: "tenant_model_instance", updates: map[string]interface{}{"provider_id": "foreign"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
db := setupModelProviderServiceTestDB(t)
|
||||
useModelProviderServiceTestDB(t, db)
|
||||
seedModelProviderServiceScope(t, db)
|
||||
if test.table != "" {
|
||||
if err := db.Table(test.table).Where("1 = 1").Updates(test.updates).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ctx := serviceTestContext(t, "user-1", "user-1")
|
||||
factory := NewModelFactory()
|
||||
access := Identity{UserID: "user-1", TenantID: "user-1"}
|
||||
for _, ref := range []string{"model-1", "gpt-test@default@OpenAI"} {
|
||||
info, infoErr := factory.ResolveInfo(ctx, access, entity.ModelTypeChat, ref)
|
||||
types, typeErr := factory.ResolveModelType(ctx, access, ref)
|
||||
_, runErr := factory.resolveConfig(ctx, access, entity.ModelTypeChat, ref)
|
||||
for _, err := range []error{infoErr, typeErr, runErr} {
|
||||
if test.table != "" {
|
||||
if !errors.Is(err, errModelConfigUnavailable) {
|
||||
t.Fatalf("%s: error = %v, want unavailable", ref, err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("%s: %v", ref, err)
|
||||
}
|
||||
}
|
||||
if test.table == "" {
|
||||
if info.ID != "model-1" {
|
||||
t.Fatalf("%s: ID = %q", ref, info.ID)
|
||||
}
|
||||
if !reflect.DeepEqual(types, []entity.ModelType{entity.ModelTypeChat}) {
|
||||
t.Fatalf("%s: types = %v", ref, types)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelFactoryMetadataDoesNotBuildExecutionConfig(t *testing.T) {
|
||||
db := setupModelProviderServiceTestDB(t)
|
||||
useModelProviderServiceTestDB(t, db)
|
||||
seedModelProviderServiceScope(t, db)
|
||||
if err := db.Model(&entity.TenantModelInstance{}).Where("id = ?", "instance-1").Update("extra", "invalid JSON").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&entity.UserTenant{}).Where("id = ?", "user-tenant-1").Updates(map[string]interface{}{"llm_id": "gpt-test@default@OpenAI", "tenant_llm_id": "model-1"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory := NewModelFactory()
|
||||
access := Identity{UserID: "user-1", TenantID: "user-1"}
|
||||
ctx := serviceTestContext(t, "user-1", "user-1")
|
||||
for _, ref := range []string{"model-1", "gpt-test@default@OpenAI"} {
|
||||
if _, err := factory.ResolveInfo(ctx, access, entity.ModelTypeChat, ref); err != nil {
|
||||
t.Fatalf("metadata %s: %v", ref, err)
|
||||
}
|
||||
if _, err := factory.ResolveModelType(ctx, access, ref); err != nil {
|
||||
t.Fatalf("types %s: %v", ref, err)
|
||||
}
|
||||
if _, err := factory.resolveConfig(ctx, access, entity.ModelTypeChat, ref); err == nil {
|
||||
t.Fatalf("execution %s accepted malformed configuration", ref)
|
||||
}
|
||||
}
|
||||
if _, err := factory.ResolveDefaultInfo(ctx, access, entity.ModelTypeChat); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelFactoryCompositeSelectsRequestedType(t *testing.T) {
|
||||
db := setupModelProviderServiceTestDB(t)
|
||||
useModelProviderServiceTestDB(t, db)
|
||||
seedModelProviderServiceScope(t, db)
|
||||
if err := db.Create(&entity.TenantModel{
|
||||
ID: "model-embedding", ProviderID: "provider-1", InstanceID: "instance-1", ModelName: "gpt-test", ModelType: int(entity.ModelTypeEmbedding), Status: "active",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory := NewModelFactory()
|
||||
access := Identity{UserID: "user-1", TenantID: "user-1"}
|
||||
ctx := serviceTestContext(t, "user-1", "user-1")
|
||||
ref := "gpt-test@default@OpenAI"
|
||||
types, err := factory.ResolveModelType(ctx, access, ref)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(types, []entity.ModelType{entity.ModelTypeChat, entity.ModelTypeEmbedding}) {
|
||||
t.Fatalf("types = %v", types)
|
||||
}
|
||||
info, err := factory.ResolveInfo(ctx, access, entity.ModelTypeEmbedding, ref)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.ID != "model-embedding" {
|
||||
t.Fatalf("selected model = %q", info.ID)
|
||||
}
|
||||
if err := db.Model(&entity.TenantModel{}).Where("id = ?", "model-embedding").Update("status", "inactive").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, modelRef := range []string{"model-embedding", ref} {
|
||||
if _, err := factory.ResolveInfo(ctx, access, entity.ModelTypeEmbedding, modelRef); !errors.Is(err, errModelConfigUnavailable) {
|
||||
t.Fatalf("%s: disabled model error = %v", modelRef, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// 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.
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/permission"
|
||||
permissionresponse "ragflow/internal/permission/response"
|
||||
)
|
||||
|
||||
func checkModelPermission(ctx context.Context, userID string, kind permission.ResourceKind, id string, operation permission.Operation) error {
|
||||
return permission.NewDatabaseChecker(dao.DB).CheckResource(
|
||||
ctx,
|
||||
permission.Subject{UserID: userID},
|
||||
permission.ResourceRef{Kind: kind, ID: id},
|
||||
operation,
|
||||
)
|
||||
}
|
||||
|
||||
func accessibleModelResourceIDs(ctx context.Context, userID string, kind permission.ResourceKind, parent permission.ResourceRef, operation permission.Operation, entry *permission.ResourceRef, entryOperation permission.Operation) ([]string, error) {
|
||||
query := permission.ScopeQuery{Kind: kind, Parent: parent, Operation: operation}
|
||||
if entry != nil {
|
||||
query.Entry = entry
|
||||
query.EntryOp = entryOperation
|
||||
}
|
||||
scope, err := permission.NewDatabaseChecker(dao.DB).Scope(ctx, permission.Subject{UserID: userID}, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if scope.Mode != permission.ScopeIDs {
|
||||
return []string{}, nil
|
||||
}
|
||||
return scope.ResourceIDs, nil
|
||||
}
|
||||
func normalizeModelPermissionError(err error) (common.ErrorCode, error) {
|
||||
return permissionresponse.NormalizeHidden(err)
|
||||
}
|
||||
+328
-509
File diff suppressed because it is too large.
Load diff
@@ -574,8 +574,13 @@ func TestCreateProviderInstanceRollsBackInstanceWhenModelCreationFails(t *testin
|
||||
useModelProviderServiceTestDB(t, db)
|
||||
seedModelProviderServiceScope(t, db)
|
||||
|
||||
// The second entry duplicates the first one, so model creation fails after
|
||||
// the instance row has already been committed.
|
||||
// Reject compensating DELETEs: failure must roll back the transaction,
|
||||
// including the first model, without issuing any cleanup statements.
|
||||
if err := db.Exec(`CREATE TRIGGER reject_instance_cleanup BEFORE DELETE ON tenant_model_instance
|
||||
BEGIN SELECT RAISE(ABORT, 'instance cleanup is forbidden'); END`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The second entry duplicates the first, after its INSERT has succeeded.
|
||||
duplicated := CreateInstanceModelInfo{ModelName: "gpt-duplicate", ModelTypes: []string{"chat"}, MaxTokens: 8192}
|
||||
code, err := NewModelProviderService().CreateProviderInstance(
|
||||
serviceTestContext(t, "user-1", "user-1"),
|
||||
@@ -606,6 +611,24 @@ func TestCreateProviderInstanceRollsBackInstanceWhenModelCreationFails(t *testin
|
||||
if modelCount != 0 {
|
||||
t.Fatalf("models = %d, want 0 after rollback", modelCount)
|
||||
}
|
||||
code, err = NewModelProviderService().CreateProviderInstance(
|
||||
serviceTestContext(t, "user-1", "user-1"), "OpenAI", "broken-instance", "sk-rollback", "", "default", "user-1",
|
||||
[]CreateInstanceModelInfo{duplicated, {ModelName: "gpt-second", ModelTypes: []string{"chat"}}},
|
||||
)
|
||||
if code != common.CodeSuccess || err != nil {
|
||||
t.Fatalf("retry CreateProviderInstance() = (%v, %v)", code, err)
|
||||
}
|
||||
var instance entity.TenantModelInstance
|
||||
if err := db.Where("instance_name = ?", "broken-instance").Take(&instance).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&entity.TenantModel{}).Where("instance_id = ?", instance.ID).Count(&modelCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if modelCount != 2 {
|
||||
t.Fatalf("retry models = %d, want 2", modelCount)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestModelProviderServiceAlterModelStatusByID(t *testing.T) {
|
||||
@@ -855,6 +878,11 @@ func TestResolveSavedProviderModelConfigUsePermissionCannotOverrideSavedEndpoint
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed tenant member: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.UserTenant{
|
||||
ID: "user-tenant-owner", UserID: "user-1", TenantID: "tenant-1", Role: string(permission.RoleOwner), Status: &activeStatus,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed tenant owner: %v", err)
|
||||
}
|
||||
service := NewModelProviderService()
|
||||
ctx := serviceTestContext(t, "member-1", "tenant-1")
|
||||
config, err := service.ResolveSavedProviderModelConfig(ctx, "OpenAI", "default", "member-1", SavedProviderModelOptions{})
|
||||
@@ -1017,7 +1045,7 @@ func TestModelFactoryResolveModelConfigByID(t *testing.T) {
|
||||
seedModelProviderServiceScope(t, db)
|
||||
|
||||
ctx := serviceTestContext(t, "user-1", "user-1")
|
||||
target, err := NewModelFactory().resolveConfig(ctx, ModelAccess{TenantID: "user-1", UserID: "user-1"}, entity.ModelTypeChat, "model-1")
|
||||
target, err := NewModelFactory().resolveConfig(ctx, Identity{TenantID: "user-1", UserID: "user-1"}, entity.ModelTypeChat, "model-1")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveConfig() error = %v", err)
|
||||
}
|
||||
@@ -1050,7 +1078,7 @@ func TestModelProviderServiceMissingProviderAndInstancePreserveLookupError(t *te
|
||||
} {
|
||||
t.Run(modelRef, func(t *testing.T) {
|
||||
_, err := NewModelFactory().resolveConfig(
|
||||
serviceTestContext(t, "user-1", "tenant-1"), ModelAccess{TenantID: "tenant-1"}, entity.ModelTypeEmbedding, modelRef,
|
||||
serviceTestContext(t, "user-1", "tenant-1"), Identity{TenantID: "tenant-1"}, entity.ModelTypeEmbedding, modelRef,
|
||||
)
|
||||
if !errors.Is(err, errModelConfigUnavailable) || !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("resolveConfig() error = %v, want unavailable record-not-found error", err)
|
||||
@@ -1077,13 +1105,13 @@ func TestModelProviderServiceDecodesCompatibleInstanceExtra(t *testing.T) {
|
||||
{
|
||||
name: "model ID",
|
||||
resolve: func() (*modelTarget, error) {
|
||||
return solver.resolveConfig(serviceTestContext(t, "user-1", "user-1"), ModelAccess{TenantID: "user-1", UserID: "user-1"}, entity.ModelTypeChat, "model-1")
|
||||
return solver.resolveConfig(serviceTestContext(t, "user-1", "user-1"), Identity{TenantID: "user-1", UserID: "user-1"}, entity.ModelTypeChat, "model-1")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "provider instance",
|
||||
resolve: func() (*modelTarget, error) {
|
||||
return solver.resolveConfig(serviceTestContext(t, "user-1", "user-1"), ModelAccess{TenantID: "user-1"}, entity.ModelTypeChat, "gpt-test@default@OpenAI")
|
||||
return solver.resolveConfig(serviceTestContext(t, "user-1", "user-1"), Identity{TenantID: "user-1"}, entity.ModelTypeChat, "gpt-test@default@OpenAI")
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1358,7 +1386,7 @@ func TestModelFactoryResolveModelConfigReportsToolSupport(t *testing.T) {
|
||||
ctx := serviceTestContext(t, "user-1", "tenant-1")
|
||||
|
||||
// A reference that passes validation still reads the persisted flag.
|
||||
target, err := solver.resolveConfig(ctx, ModelAccess{TenantID: "tenant-1"}, entity.ModelTypeChat, "model-active")
|
||||
target, err := solver.resolveConfig(ctx, Identity{TenantID: "tenant-1"}, entity.ModelTypeChat, "model-active")
|
||||
if err != nil || target == nil || !target.SupportsTools {
|
||||
t.Fatalf("active chat model = (%v, %v), want tool support", target, err)
|
||||
}
|
||||
@@ -1371,7 +1399,7 @@ func TestModelFactoryResolveModelConfigReportsToolSupport(t *testing.T) {
|
||||
{"not enrolled as chat", "model-embedding"},
|
||||
{"provider owned by another tenant", "model-foreign"},
|
||||
} {
|
||||
target, err := solver.resolveConfig(ctx, ModelAccess{TenantID: "tenant-1"}, entity.ModelTypeChat, tc.modelID)
|
||||
target, err := solver.resolveConfig(ctx, Identity{TenantID: "tenant-1"}, entity.ModelTypeChat, tc.modelID)
|
||||
if err == nil {
|
||||
t.Errorf("%s: err = nil, want a validation error", tc.name)
|
||||
}
|
||||
@@ -1432,7 +1460,7 @@ func TestModelFactoryResolveModelConfigPrefersTenantToolFlag(t *testing.T) {
|
||||
solver := NewModelFactory()
|
||||
ctx := serviceTestContext(t, "user-1", "tenant-1")
|
||||
|
||||
target, err := solver.resolveConfig(ctx, ModelAccess{TenantID: "tenant-1"}, entity.ModelTypeChat, toolsOn+"@default@OpenAI")
|
||||
target, err := solver.resolveConfig(ctx, Identity{TenantID: "tenant-1"}, entity.ModelTypeChat, toolsOn+"@default@OpenAI")
|
||||
if err != nil {
|
||||
t.Fatalf("tool-capable model: err = %v", err)
|
||||
}
|
||||
@@ -1440,7 +1468,7 @@ func TestModelFactoryResolveModelConfigPrefersTenantToolFlag(t *testing.T) {
|
||||
t.Errorf("tool support = true, want false: the tenant_model flag must win over the catalog")
|
||||
}
|
||||
|
||||
target, err = solver.resolveConfig(ctx, ModelAccess{TenantID: "tenant-1"}, entity.ModelTypeChat, toolsOff+"@default@OpenAI")
|
||||
target, err = solver.resolveConfig(ctx, Identity{TenantID: "tenant-1"}, entity.ModelTypeChat, toolsOff+"@default@OpenAI")
|
||||
if err != nil {
|
||||
t.Fatalf("tool-incapable model: err = %v", err)
|
||||
}
|
||||
@@ -1448,7 +1476,7 @@ func TestModelFactoryResolveModelConfigPrefersTenantToolFlag(t *testing.T) {
|
||||
t.Errorf("tool support = false, want true: the tenant_model flag must win over the catalog")
|
||||
}
|
||||
|
||||
target, err = solver.resolveConfig(ctx, ModelAccess{TenantID: "tenant-1"}, entity.ModelTypeChat, toolsOn+"-disabled@default@OpenAI")
|
||||
target, err = solver.resolveConfig(ctx, Identity{TenantID: "tenant-1"}, entity.ModelTypeChat, toolsOn+"-disabled@default@OpenAI")
|
||||
if err == nil || (target != nil && target.SupportsTools) {
|
||||
t.Errorf("disabled model = (%v, %v), want (false, error)", target, err)
|
||||
}
|
||||
@@ -1474,7 +1502,7 @@ func TestModelFactoryResolveModelConfigPropagatesLookupFailure(t *testing.T) {
|
||||
|
||||
solver := NewModelFactory()
|
||||
ctx := serviceTestContext(t, "user-1", "tenant-1")
|
||||
if target, err := solver.resolveConfig(ctx, ModelAccess{TenantID: "tenant-1"}, entity.ModelTypeChat, "model-1"); err != nil || target == nil || !target.SupportsTools {
|
||||
if target, err := solver.resolveConfig(ctx, Identity{TenantID: "tenant-1"}, entity.ModelTypeChat, "model-1"); err != nil || target == nil || !target.SupportsTools {
|
||||
t.Fatalf("baseline = (%v, %v), want tool support", target, err)
|
||||
}
|
||||
|
||||
@@ -1485,11 +1513,11 @@ func TestModelFactoryResolveModelConfigPropagatesLookupFailure(t *testing.T) {
|
||||
t.Fatalf("failed to drop provider table: %v", err)
|
||||
}
|
||||
// Composite refs resolve the provider row directly.
|
||||
if target, err := solver.resolveConfig(ctx, ModelAccess{TenantID: "tenant-1"}, entity.ModelTypeChat, "gpt-test@default@OpenAI"); err == nil {
|
||||
if target, err := solver.resolveConfig(ctx, Identity{TenantID: "tenant-1"}, entity.ModelTypeChat, "gpt-test@default@OpenAI"); err == nil {
|
||||
t.Errorf("composite provider lookup failure = (%v, nil), want a propagated error", target)
|
||||
}
|
||||
// The UUID path too.
|
||||
if target, err := solver.resolveConfig(ctx, ModelAccess{TenantID: "tenant-1"}, entity.ModelTypeChat, "model-1"); err == nil {
|
||||
if target, err := solver.resolveConfig(ctx, Identity{TenantID: "tenant-1"}, entity.ModelTypeChat, "model-1"); err == nil {
|
||||
t.Errorf("uuid provider lookup failure = (%v, nil), want a propagated error", target)
|
||||
}
|
||||
|
||||
@@ -1497,7 +1525,7 @@ func TestModelFactoryResolveModelConfigPropagatesLookupFailure(t *testing.T) {
|
||||
if err := db.Migrator().DropTable(&entity.TenantModel{}); err != nil {
|
||||
t.Fatalf("failed to drop model table: %v", err)
|
||||
}
|
||||
if target, err := solver.resolveConfig(ctx, ModelAccess{TenantID: "tenant-1"}, entity.ModelTypeChat, "model-1"); err == nil {
|
||||
if target, err := solver.resolveConfig(ctx, Identity{TenantID: "tenant-1"}, entity.ModelTypeChat, "model-1"); err == nil {
|
||||
t.Errorf("model lookup failure = (%v, nil), want a propagated error", target)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,8 @@ func TestAudioSpeech_NoSelectionNoDefaultTTS(t *testing.T) {
|
||||
|
||||
svc := NewModelCallService()
|
||||
text := "hello"
|
||||
resp, code, err := svc.AudioSpeech(t.Context(), "", "tenant-1", &text, nil)
|
||||
ctx := serviceTestContext(t, "tenant-1", "tenant-1")
|
||||
resp, code, err := svc.AudioSpeech(ctx, "", &text, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing default TTS model, got resp=%v", resp)
|
||||
}
|
||||
@@ -81,7 +82,7 @@ func TestResolveDefaultModelConfig_NilTTSPointer(t *testing.T) {
|
||||
pushServiceDB(t, testDB)
|
||||
insertTTSTestTenant(t, "tenant-2", nil)
|
||||
|
||||
_, err := NewModelFactory().resolveDefaultConfig(t.Context(), ModelAccess{TenantID: "tenant-2"}, entity.ModelTypeTTS)
|
||||
_, err := NewModelFactory().resolveDefaultConfig(t.Context(), Identity{TenantID: "tenant-2"}, entity.ModelTypeTTS)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil tts_id, got nil")
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestModelFactoryResolvesStoredMistralOCRProvider(t *testing.T) {
|
||||
useModelProviderServiceTestDB(t, db)
|
||||
seedStoredMistralOCRProvider(t, db)
|
||||
|
||||
access := ModelAccess{TenantID: "user-1", UserID: "user-1"}
|
||||
access := Identity{TenantID: "user-1", UserID: "user-1"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
modelRef string
|
||||
@@ -96,7 +96,7 @@ func TestModelFactoryResolveModelTypeForStoredMistralOCRProvider(t *testing.T) {
|
||||
useModelProviderServiceTestDB(t, db)
|
||||
seedStoredMistralOCRProvider(t, db)
|
||||
|
||||
types, err := NewModelFactory().ResolveModelType(serviceTestContext(t, "user-1", "user-1"), ModelAccess{TenantID: "user-1", UserID: "user-1"}, "mistral-ocr-2512@default@Mistral OCR")
|
||||
types, err := NewModelFactory().ResolveModelType(serviceTestContext(t, "user-1", "user-1"), Identity{TenantID: "user-1", UserID: "user-1"}, "mistral-ocr-2512@default@Mistral OCR")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveModelType: %v", err)
|
||||
}
|
||||
@@ -115,28 +115,27 @@ func TestCatalogToolSupportForStoredMistralOCRProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The model call service resolves a "Mistral OCR" model by tenant model id and
|
||||
// by composite reference.
|
||||
func TestModelCallServiceResolvesStoredMistralOCRProvider(t *testing.T) {
|
||||
// ModelFactory preserves the stored OCR capability for the Mistral OCR provider.
|
||||
func TestModelFactoryResolvesStoredMistralOCRCapability(t *testing.T) {
|
||||
db := setupModelProviderServiceTestDB(t)
|
||||
useModelProviderServiceTestDB(t, db)
|
||||
seedStoredMistralOCRProvider(t, db)
|
||||
|
||||
svc := NewModelCallService()
|
||||
factory := NewModelFactory()
|
||||
for _, modelRef := range []string{"model-mistral-ocr", "mistral-ocr-latest@default@Mistral OCR"} {
|
||||
t.Run(modelRef, func(t *testing.T) {
|
||||
info, err := svc.resolveModelInfo(serviceTestContext(t, "user-1", "user-1"), modelRef, "user-1")
|
||||
target, err := factory.resolveConfig(serviceTestContext(t, "user-1", "user-1"), Identity{TenantID: "user-1", UserID: "user-1"}, 0, modelRef)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveModelInfo(%q): %v", modelRef, err)
|
||||
t.Fatalf("resolve model %q: %v", modelRef, err)
|
||||
}
|
||||
if info.ProviderInfo == nil || info.ProviderInfo.Name != "Mistral" {
|
||||
t.Fatalf("provider info = %+v, want the Mistral catalog entry", info.ProviderInfo)
|
||||
if target.ProviderName != "Mistral OCR" {
|
||||
t.Fatalf("provider name = %q, want the stored provider row", target.ProviderName)
|
||||
}
|
||||
if info.ModelInfo == nil || info.ModelInfo.Name != "mistral-ocr-latest" {
|
||||
t.Fatalf("model info = %+v, want mistral-ocr-latest", info.ModelInfo)
|
||||
if target.ModelInfo == nil || target.ModelInfo.Name != "mistral-ocr-latest" {
|
||||
t.Fatalf("model info = %+v, want mistral-ocr-latest", target.ModelInfo)
|
||||
}
|
||||
if info.ProviderEntity == nil || info.ProviderEntity.ProviderName != "Mistral OCR" {
|
||||
t.Fatalf("provider entity = %+v, want the stored row", info.ProviderEntity)
|
||||
if !target.ModelInfo.ModelTypeMap["ocr"] || target.ModelInfo.ModelTypeMap["doc_parse"] {
|
||||
t.Fatalf("model info = %+v, want OCR capability without unsupported document-parse capability", target.ModelInfo)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func (e *NavEmbedder) encode(ctx context.Context, tenantID string, texts []strin
|
||||
}
|
||||
name := e.embdModelName
|
||||
var model *modelModule.EmbeddingModel
|
||||
access := ModelAccess{TenantID: tenantID}
|
||||
access := Identity{TenantID: tenantID}
|
||||
var err error
|
||||
if name == "" {
|
||||
model, err = e.modelFactory.NewDefaultEmbeddingModel(ctx, access)
|
||||
|
||||
@@ -225,7 +225,7 @@ func navChatText(ctx context.Context, modelFactory *ModelFactory, tenantID, mode
|
||||
if modelFactory == nil {
|
||||
return "", fmt.Errorf("datasetnav: model factory not initialized")
|
||||
}
|
||||
access := ModelAccess{TenantID: tenantID}
|
||||
access := Identity{TenantID: tenantID}
|
||||
var (
|
||||
chatModel *modelModule.ChatModel
|
||||
err error
|
||||
|
||||
@@ -110,16 +110,14 @@ type OpenAIStreamEvent struct {
|
||||
// keeping handler-level concerns (message filtering, generation config merge,
|
||||
// reference metadata enrichment) on the service itself.
|
||||
type OpenAIChatService struct {
|
||||
chatSvc *ChatService
|
||||
tenantLLMSvc *TenantLLMService
|
||||
pipeline *ChatPipelineService
|
||||
chatSvc *ChatService
|
||||
pipeline *ChatPipelineService
|
||||
}
|
||||
|
||||
func NewOpenAIChatService() *OpenAIChatService {
|
||||
return &OpenAIChatService{
|
||||
chatSvc: NewChatService(),
|
||||
tenantLLMSvc: NewTenantLLMService(),
|
||||
pipeline: NewChatPipelineService(),
|
||||
chatSvc: NewChatService(),
|
||||
pipeline: NewChatPipelineService(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,15 +261,10 @@ func (s *OpenAIChatService) OpenAIChatCompletions(c *gin.Context, userID, chatID
|
||||
}
|
||||
}
|
||||
if req.Model != "model" {
|
||||
if _, mErr := s.pipeline.ModelFactory.ResolveInfo(ctx, ModelAccess{UserID: userID, TenantID: dialog.TenantID}, entity.ModelTypeChat, resolvedModel); mErr != nil {
|
||||
if _, mErr := s.pipeline.ModelFactory.ResolveInfo(ctx, Identity{UserID: userID, TenantID: dialog.TenantID}, entity.ModelTypeChat, resolvedModel); mErr != nil {
|
||||
s.writeArgError(c, fmt.Sprintf("`llm_id` %s doesn't exist", req.Model))
|
||||
return
|
||||
}
|
||||
apiKey, apiErr := s.tenantLLMSvc.GetAPIKeyFromInstance(ctx, dialog.TenantID, req.Model)
|
||||
if apiErr != nil || apiKey == "" {
|
||||
s.writeDataError(c, fmt.Sprintf("Cannot use specified model %s.", req.Model))
|
||||
return
|
||||
}
|
||||
dialog.LLMID = resolvedModel
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -29,18 +30,19 @@ import (
|
||||
)
|
||||
|
||||
// GenerateRelatedQuestions generates related search questions for chat/searchbot endpoints.
|
||||
func GenerateRelatedQuestions(ctx context.Context, userID, question, searchID string, searchSvc *SearchService, tenantSvc *TenantService, modelFactory *ModelFactory) ([]string, error) {
|
||||
func GenerateRelatedQuestions(ctx context.Context, question, searchID string, searchSvc *SearchService, modelFactory *ModelFactory) ([]string, error) {
|
||||
if modelFactory == nil {
|
||||
return nil, fmt.Errorf("model factory not configured")
|
||||
}
|
||||
searchConfig, err := relatedQuestionsSearchConfig(ctx, userID, searchID, searchSvc)
|
||||
searchConfig, err := relatedQuestionsSearchConfig(ctx, searchID, searchSvc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modelID := relatedQuestionsModelID(ctx, userID, searchConfig, tenantSvc)
|
||||
if modelID == "" {
|
||||
return nil, fmt.Errorf("no chat model configured")
|
||||
access, err := ResolveIdentity(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modelID, _ := searchConfig["chat_id"].(string)
|
||||
prompt, err := (prompts.EmbeddedPromptLoader{}).Load("related_question")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -49,7 +51,15 @@ func GenerateRelatedQuestions(ctx context.Context, userID, question, searchID st
|
||||
{Role: "system", Content: prompt},
|
||||
{Role: "user", Content: "\nKeywords: " + question + "\nRelated search terms:\n "},
|
||||
}
|
||||
chatModel, err := modelFactory.NewChatModel(ctx, ModelAccess{TenantID: userID}, modelID)
|
||||
var chatModel *modelModule.ChatModel
|
||||
if modelID == "" {
|
||||
chatModel, err = modelFactory.NewDefaultChatModel(ctx, access)
|
||||
if errors.Is(err, ErrNoDefaultModel) {
|
||||
return nil, fmt.Errorf("no chat model configured")
|
||||
}
|
||||
} else {
|
||||
chatModel, err = modelFactory.NewChatModel(ctx, access, modelID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -63,14 +73,14 @@ func GenerateRelatedQuestions(ctx context.Context, userID, question, searchID st
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
func relatedQuestionsSearchConfig(ctx context.Context, userID, searchID string, searchSvc *SearchService) (map[string]interface{}, error) {
|
||||
func relatedQuestionsSearchConfig(ctx context.Context, searchID string, searchSvc *SearchService) (map[string]interface{}, error) {
|
||||
if searchID == "" {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
if searchSvc == nil {
|
||||
return nil, fmt.Errorf("search service not configured")
|
||||
}
|
||||
detail, err := searchSvc.GetDetail(ctx, userID, searchID)
|
||||
detail, err := searchSvc.GetDetail(ctx, searchID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -87,18 +97,6 @@ func relatedQuestionsSearchConfigFromDetail(detail map[string]interface{}) map[s
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
func relatedQuestionsModelID(ctx context.Context, tenantID string, searchConfig map[string]interface{}, tenantSvc *TenantService) string {
|
||||
modelID, _ := searchConfig["chat_id"].(string)
|
||||
if modelID != "" || tenantSvc == nil {
|
||||
return modelID
|
||||
}
|
||||
defaultModel, err := tenantSvc.GetDefaultModelNameForTenant(ctx, tenantID, entity.ModelTypeChat)
|
||||
if err == nil {
|
||||
modelID = defaultModel
|
||||
}
|
||||
return modelID
|
||||
}
|
||||
|
||||
func relatedQuestionsConfig(searchConfig map[string]interface{}) *modelModule.ChatConfig {
|
||||
var genConf map[string]interface{}
|
||||
switch v := searchConfig["llm_setting"].(type) {
|
||||
|
||||
+59
-50
@@ -31,23 +31,15 @@ import (
|
||||
|
||||
// SearchService search service
|
||||
type SearchService struct {
|
||||
searchDAO *dao.SearchDAO
|
||||
datasetDAO *dao.KnowledgebaseDAO
|
||||
tenantService *TenantService
|
||||
searchDAO *dao.SearchDAO
|
||||
datasetDAO *dao.KnowledgebaseDAO
|
||||
}
|
||||
|
||||
// NewSearchService create search service
|
||||
func NewSearchService() *SearchService {
|
||||
return &SearchService{
|
||||
searchDAO: dao.NewSearchDAO(),
|
||||
datasetDAO: dao.NewKnowledgebaseDAO(),
|
||||
tenantService: NewTenantService(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SearchService) SetTenantService(tenantService *TenantService) {
|
||||
if tenantService != nil {
|
||||
s.tenantService = tenantService
|
||||
searchDAO: dao.NewSearchDAO(),
|
||||
datasetDAO: dao.NewKnowledgebaseDAO(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,11 +73,18 @@ type SearchShareDetail struct {
|
||||
}
|
||||
|
||||
// ListSearches list search apps with advanced filtering (equivalent to list_search_app)
|
||||
func (s *SearchService) ListSearches(ctx context.Context, userID string, keywords string, page, pageSize int, terms []dao.OrderTerm, ownerIDs []string) (*ListSearchAppsResponse, error) {
|
||||
resourceIDs, err := accessibleSearchAppIDs(ctx, userID, permission.OperationRead)
|
||||
func (s *SearchService) ListSearches(ctx context.Context, keywords string, page, pageSize int, terms []dao.OrderTerm, ownerIDs []string) (*ListSearchAppsResponse, error) {
|
||||
scope, err := permission.NewDatabaseChecker(dao.DB).Scope(ctx, permission.Subject{}, permission.ScopeQuery{
|
||||
Kind: permission.ResourceKindSearchApp,
|
||||
Operation: permission.OperationRead,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resourceIDs []string
|
||||
if scope.Mode == permission.ScopeIDs {
|
||||
resourceIDs = scope.ResourceIDs
|
||||
}
|
||||
searches, total, err := s.searchDAO.ListByResourceIDs(ctx, dao.DB, resourceIDs, ownerIDs, page, pageSize, terms, keywords)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -95,7 +94,7 @@ func (s *SearchService) ListSearches(ctx context.Context, userID string, keyword
|
||||
searchApps := make([]map[string]interface{}, len(searches))
|
||||
checker := permission.NewDatabaseChecker(dao.DB)
|
||||
for i, search := range searches {
|
||||
access, err := checker.ResolveAccess(ctx, permission.Subject{UserID: userID}, permission.ResourceRef{
|
||||
access, err := checker.ResolveAccess(ctx, permission.Subject{}, permission.ResourceRef{
|
||||
Kind: permission.ResourceKindSearchApp,
|
||||
ID: search.ID,
|
||||
})
|
||||
@@ -153,12 +152,12 @@ type CreateSearchResponse struct {
|
||||
// 5. Set fields: id, name, description, tenant_id, created_by
|
||||
// 6. Save to database within DB.atomic() transaction
|
||||
// 7. Return {search_id: id} on success
|
||||
func (s *SearchService) CreateSearch(ctx context.Context, userID string, name string, description *string) (*CreateSearchResponse, error) {
|
||||
tenantID, err := requireCurrentTenantID(ctx, userID)
|
||||
func (s *SearchService) CreateSearch(ctx context.Context, name string, description *string) (*CreateSearchResponse, error) {
|
||||
identity, err := ResolveIdentity(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkFeatureAction(ctx, userID, "search", permission.ActionWrite); err != nil {
|
||||
if err := permission.NewDatabaseChecker(dao.DB).CheckFeature(ctx, permission.Subject{UserID: identity.UserID}, "search", permission.ActionWrite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := common.ValidateName(name); err != nil {
|
||||
@@ -171,7 +170,7 @@ func (s *SearchService) CreateSearch(ctx context.Context, userID string, name st
|
||||
// Generate unique name (same as Python duplicate_name)
|
||||
// search.name is a 128-byte column; keep generated names within it.
|
||||
uniqueName, err := common.UniqueName(name, 128, func(candidate string) (bool, error) {
|
||||
existing, err := s.searchDAO.GetByNameAndTenant(ctx, dao.DB, candidate, tenantID)
|
||||
existing, err := s.searchDAO.GetByNameAndTenant(ctx, dao.DB, candidate, identity.TenantID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -185,9 +184,9 @@ func (s *SearchService) CreateSearch(ctx context.Context, userID string, name st
|
||||
// Create search entity
|
||||
search := &entity.Search{
|
||||
ID: searchID,
|
||||
TenantID: tenantID,
|
||||
TenantID: identity.TenantID,
|
||||
Name: uniqueName,
|
||||
CreatedBy: userID,
|
||||
CreatedBy: identity.UserID,
|
||||
SearchConfig: make(entity.JSONMap),
|
||||
}
|
||||
|
||||
@@ -204,7 +203,7 @@ func (s *SearchService) CreateSearch(ctx context.Context, userID string, name st
|
||||
if err := s.searchDAO.Create(ctx, tx, search); err != nil {
|
||||
return err
|
||||
}
|
||||
return grantCreatedResourceOwner(ctx, tx, userID, tenantID, permission.ResourceKindSearchApp, search.ID)
|
||||
return grantCreatedResourceOwner(ctx, tx, identity.UserID, identity.TenantID, permission.ResourceKindSearchApp, search.ID)
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("failed to create search: %w", err)
|
||||
}
|
||||
@@ -214,8 +213,11 @@ func (s *SearchService) CreateSearch(ctx context.Context, userID string, name st
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SearchService) GetSearchDetail(ctx context.Context, userID string, searchID string) (*entity.Search, error) {
|
||||
if err := checkSearchAppPermission(ctx, userID, searchID, permission.OperationRead); err != nil {
|
||||
func (s *SearchService) GetSearchDetail(ctx context.Context, searchID string) (*entity.Search, error) {
|
||||
if err := permission.NewDatabaseChecker(dao.DB).CheckResource(ctx, permission.Subject{}, permission.ResourceRef{
|
||||
Kind: permission.ResourceKindSearchApp,
|
||||
ID: searchID,
|
||||
}, permission.OperationRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
search, err := s.searchDAO.GetByID(ctx, dao.DB, searchID)
|
||||
@@ -228,8 +230,8 @@ func (s *SearchService) GetSearchDetail(ctx context.Context, userID string, sear
|
||||
|
||||
// GetSearchShareDetail returns the joined share-detail payload for public
|
||||
// searchbot pages after verifying the caller can access the search app.
|
||||
func (s *SearchService) GetSearchShareDetail(ctx context.Context, userID, searchID string) (*SearchShareDetail, error) {
|
||||
if _, err := s.GetSearchDetail(ctx, userID, searchID); err != nil {
|
||||
func (s *SearchService) GetSearchShareDetail(ctx context.Context, searchID string) (*SearchShareDetail, error) {
|
||||
if _, err := s.GetSearchDetail(ctx, searchID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -254,8 +256,11 @@ func (s *SearchService) GetSearchShareDetail(ctx context.Context, userID, search
|
||||
}
|
||||
|
||||
// DeleteSearch deletes a search app by ID
|
||||
func (s *SearchService) DeleteSearch(ctx context.Context, userID string, searchID string) error {
|
||||
if err := checkSearchAppPermission(ctx, userID, searchID, permission.OperationDelete); err != nil {
|
||||
func (s *SearchService) DeleteSearch(ctx context.Context, searchID string) error {
|
||||
if err := permission.NewDatabaseChecker(dao.DB).CheckResource(ctx, permission.Subject{}, permission.ResourceRef{
|
||||
Kind: permission.ResourceKindSearchApp,
|
||||
ID: searchID,
|
||||
}, permission.OperationDelete); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.searchDAO.DeleteByID(ctx, dao.DB, searchID); err != nil {
|
||||
@@ -266,7 +271,7 @@ func (s *SearchService) DeleteSearch(ctx context.Context, userID string, searchI
|
||||
}
|
||||
|
||||
type SearchCompletionPlan struct {
|
||||
UserID string
|
||||
Identity Identity
|
||||
SearchID string
|
||||
Question string
|
||||
DatasetIDs []string
|
||||
@@ -274,11 +279,7 @@ type SearchCompletionPlan struct {
|
||||
Options AskStreamOptions
|
||||
}
|
||||
|
||||
func (s *SearchService) PrepareCompletion(ctx context.Context, userID, searchID string, req *SearchCompletionsRequest) (*SearchCompletionPlan, common.ErrorCode, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return nil, common.CodeBadRequest, fmt.Errorf("user id is required")
|
||||
}
|
||||
func (s *SearchService) PrepareCompletion(ctx context.Context, searchID string, req *SearchCompletionsRequest) (*SearchCompletionPlan, common.ErrorCode, error) {
|
||||
searchID = strings.TrimSpace(searchID)
|
||||
if searchID == "" {
|
||||
return nil, common.CodeBadRequest, fmt.Errorf("search_id is required")
|
||||
@@ -295,7 +296,7 @@ func (s *SearchService) PrepareCompletion(ctx context.Context, userID, searchID
|
||||
return nil, common.CodeArgumentError, fmt.Errorf("question is required")
|
||||
}
|
||||
|
||||
searchDetail, err := s.GetDetail(ctx, userID, searchID)
|
||||
searchDetail, err := s.GetDetail(ctx, searchID)
|
||||
if err != nil || searchDetail == nil {
|
||||
if permissionresponse.IsPermissionError(err) {
|
||||
code, permissionErr := permissionresponse.NormalizeHidden(err)
|
||||
@@ -314,27 +315,29 @@ func (s *SearchService) PrepareCompletion(ctx context.Context, userID, searchID
|
||||
}
|
||||
|
||||
for _, datasetID := range datasetIDs {
|
||||
if err := CheckDatasetAccess(ctx, permission.Subject{UserID: userID}, datasetID, permission.OperationUse); err != nil {
|
||||
if err := permission.NewDatabaseChecker(dao.DB).CheckResource(ctx, permission.Subject{}, permission.ResourceRef{
|
||||
Kind: permission.ResourceKindDataset,
|
||||
ID: datasetID,
|
||||
}, permission.OperationUse); err != nil {
|
||||
code, permissionErr := permissionresponse.Normalize(err)
|
||||
return nil, code, permissionErr
|
||||
}
|
||||
}
|
||||
identity, err := ResolveIdentity(ctx)
|
||||
if err != nil {
|
||||
code, normalizedErr := permissionresponse.Normalize(err)
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
|
||||
modelID, _ := stringFromSearchConfig(searchConfig["chat_id"])
|
||||
if modelID == "" {
|
||||
tenantSvc := s.tenantService
|
||||
if tenantSvc == nil {
|
||||
tenantSvc = NewTenantService()
|
||||
}
|
||||
var defaultModelName string
|
||||
defaultModelName, err = tenantSvc.GetDefaultModelNameForUser(ctx, userID, entity.ModelTypeChat)
|
||||
if err == nil {
|
||||
modelID = strings.TrimSpace(defaultModelName)
|
||||
if info, infoErr := NewModelFactory().ResolveDefaultInfo(ctx, identity, entity.ModelTypeChat); infoErr == nil {
|
||||
modelID = ModelReference(info)
|
||||
}
|
||||
}
|
||||
|
||||
return &SearchCompletionPlan{
|
||||
UserID: userID,
|
||||
Identity: identity,
|
||||
SearchID: searchID,
|
||||
Question: question,
|
||||
DatasetIDs: datasetIDs,
|
||||
@@ -518,8 +521,11 @@ type UpdateSearchRequest struct {
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
}
|
||||
|
||||
func (s *SearchService) UpdateSearch(ctx context.Context, userID string, searchID string, req *UpdateSearchRequest) (*entity.Search, error) {
|
||||
if err := checkSearchAppPermission(ctx, userID, searchID, permission.OperationUpdate); err != nil {
|
||||
func (s *SearchService) UpdateSearch(ctx context.Context, searchID string, req *UpdateSearchRequest) (*entity.Search, error) {
|
||||
if err := permission.NewDatabaseChecker(dao.DB).CheckResource(ctx, permission.Subject{}, permission.ResourceRef{
|
||||
Kind: permission.ResourceKindSearchApp,
|
||||
ID: searchID,
|
||||
}, permission.OperationUpdate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -589,8 +595,11 @@ func (s *SearchService) UpdateSearch(ctx context.Context, userID string, searchI
|
||||
}
|
||||
|
||||
// GetDetail returns Search App configuration to a caller that is executing it.
|
||||
func (s *SearchService) GetDetail(ctx context.Context, userID, searchID string) (map[string]interface{}, error) {
|
||||
if err := checkSearchAppPermission(ctx, userID, searchID, permission.OperationRun); err != nil {
|
||||
func (s *SearchService) GetDetail(ctx context.Context, searchID string) (map[string]interface{}, error) {
|
||||
if err := permission.NewDatabaseChecker(dao.DB).CheckResource(ctx, permission.Subject{}, permission.ResourceRef{
|
||||
Kind: permission.ResourceKindSearchApp,
|
||||
ID: searchID,
|
||||
}, permission.OperationRun); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
search, err := s.searchDAO.GetByID(ctx, dao.DB, searchID)
|
||||
@@ -609,7 +618,7 @@ func (s *SearchService) GetDetail(ctx context.Context, userID, searchID string)
|
||||
"update_time": search.UpdateTime,
|
||||
"search_config": map[string]interface{}(search.SearchConfig),
|
||||
}
|
||||
access, err := permission.NewDatabaseChecker(dao.DB).ResolveAccess(ctx, permission.Subject{UserID: userID}, permission.ResourceRef{
|
||||
access, err := permission.NewDatabaseChecker(dao.DB).ResolveAccess(ctx, permission.Subject{}, permission.ResourceRef{
|
||||
Kind: permission.ResourceKindSearchApp,
|
||||
ID: search.ID,
|
||||
})
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/permission"
|
||||
)
|
||||
|
||||
func checkSearchAppPermission(ctx context.Context, userID, searchID string, operation permission.Operation) error {
|
||||
return permission.NewDatabaseChecker(dao.DB).CheckResource(
|
||||
ctx,
|
||||
permission.Subject{UserID: userID},
|
||||
permission.ResourceRef{Kind: permission.ResourceKindSearchApp, ID: searchID},
|
||||
operation,
|
||||
)
|
||||
}
|
||||
|
||||
func accessibleSearchAppIDs(ctx context.Context, userID string, operation permission.Operation) ([]string, error) {
|
||||
scope, err := permission.NewDatabaseChecker(dao.DB).Scope(ctx, permission.Subject{UserID: userID}, permission.ScopeQuery{
|
||||
Kind: permission.ResourceKindSearchApp,
|
||||
Operation: operation,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if scope.Mode != permission.ScopeIDs {
|
||||
return []string{}, nil
|
||||
}
|
||||
return scope.ResourceIDs, nil
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func TestSearchServiceGetSearchShareDetail(t *testing.T) {
|
||||
}
|
||||
|
||||
ctx := serviceTestContext(t, "tenant-1", "tenant-1")
|
||||
detail, err := NewSearchService().GetSearchShareDetail(ctx, "tenant-1", "search-1")
|
||||
detail, err := NewSearchService().GetSearchShareDetail(ctx, "search-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSearchShareDetail failed: %v", err)
|
||||
}
|
||||
@@ -147,7 +147,7 @@ func TestSearchServiceGetSearchShareDetailRejectsUnauthorizedUser(t *testing.T)
|
||||
}
|
||||
|
||||
ctx := serviceTestContext(t, "user-2", "tenant-1", "tenant-1")
|
||||
_, err := NewSearchService().GetSearchShareDetail(ctx, "user-2", "search-1")
|
||||
_, err := NewSearchService().GetSearchShareDetail(ctx, "search-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected permission error")
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/permission"
|
||||
@@ -57,7 +58,7 @@ func TestSearchServiceCreateRejectsEmptyName(t *testing.T) {
|
||||
setupSearchServiceTestDB(t)
|
||||
|
||||
ctx := serviceTestContext(t, "tenant-1", "tenant-1")
|
||||
_, err := NewSearchService().CreateSearch(ctx, "tenant-1", " ", nil)
|
||||
_, err := NewSearchService().CreateSearch(ctx, " ", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected empty name validation error")
|
||||
}
|
||||
@@ -74,7 +75,7 @@ func TestSearchServiceUpdateRejectsUnauthorizedSearchID(t *testing.T) {
|
||||
Name: "New Name",
|
||||
SearchConfig: map[string]interface{}{},
|
||||
}
|
||||
_, err := NewSearchService().UpdateSearch(ctx, "user-2", "invalid_search_id", req)
|
||||
_, err := NewSearchService().UpdateSearch(ctx, "invalid_search_id", req)
|
||||
if err == nil {
|
||||
t.Fatal("expected authorization error")
|
||||
}
|
||||
@@ -86,7 +87,7 @@ func TestSearchServiceUpdateRejectsUnauthorizedSearchID(t *testing.T) {
|
||||
func TestSearchServiceCreateAndUpdateRoundTrip(t *testing.T) {
|
||||
setupSearchServiceTestDB(t)
|
||||
ctx := serviceTestContext(t, "tenant-1", "tenant-1", "user-2")
|
||||
created, err := NewSearchService().CreateSearch(ctx, "tenant-1", "My Search", nil)
|
||||
created, err := NewSearchService().CreateSearch(ctx, "My Search", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSearch failed: %v", err)
|
||||
}
|
||||
@@ -99,7 +100,7 @@ func TestSearchServiceCreateAndUpdateRoundTrip(t *testing.T) {
|
||||
Name: "Hijacked Name",
|
||||
SearchConfig: map[string]interface{}{},
|
||||
}
|
||||
_, err = NewSearchService().UpdateSearch(serviceTestContext(t, "user-2", "tenant-1", "tenant-1"), "user-2", created.SearchID, req)
|
||||
_, err = NewSearchService().UpdateSearch(serviceTestContext(t, "user-2", "tenant-1", "tenant-1"), created.SearchID, req)
|
||||
if !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
t.Fatalf("expected permission denied, got %v", err)
|
||||
}
|
||||
@@ -109,7 +110,7 @@ func TestSearchServiceCreateAndUpdateRoundTrip(t *testing.T) {
|
||||
Name: "Updated Name",
|
||||
SearchConfig: map[string]interface{}{"summary": true, "keywords_similarity_weight": 0.7},
|
||||
}
|
||||
updated, err := NewSearchService().UpdateSearch(ctx, "tenant-1", created.SearchID, req)
|
||||
updated, err := NewSearchService().UpdateSearch(ctx, created.SearchID, req)
|
||||
if err != nil {
|
||||
t.Fatalf("owner UpdateSearch failed: %v", err)
|
||||
}
|
||||
@@ -134,6 +135,39 @@ func TestSearchServiceCreateAndUpdateRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCompletionKeepsActiveTenantIdentity(t *testing.T) {
|
||||
setupSearchServiceTestDB(t)
|
||||
ctx := serviceTestContext(t, "user-1", "team-1")
|
||||
status := string(entity.StatusValid)
|
||||
if err := dao.DB.Create(&entity.UserTenant{
|
||||
ID: "team-owner", UserID: "team-1", TenantID: "team-1", Role: string(permission.RoleOwner), Status: &status,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create tenant owner membership: %v", err)
|
||||
}
|
||||
if err := dao.DB.Create(&entity.Knowledgebase{
|
||||
ID: "kb-1", TenantID: "team-1", CreatedBy: "user-1", Name: "dataset", Permission: "team", Status: &status,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create dataset: %v", err)
|
||||
}
|
||||
if err := dao.DB.Create(&entity.Search{
|
||||
ID: "search-1", TenantID: "team-1", CreatedBy: "user-1", Name: "search",
|
||||
SearchConfig: entity.JSONMap{"kb_ids": []interface{}{"kb-1"}}, Status: &status,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create search: %v", err)
|
||||
}
|
||||
|
||||
plan, code, err := NewSearchService().PrepareCompletion(ctx, "search-1", &SearchCompletionsRequest{Question: "question"})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareCompletion() error = %v", err)
|
||||
}
|
||||
if code != common.CodeSuccess {
|
||||
t.Fatalf("PrepareCompletion() code = %v, want success", code)
|
||||
}
|
||||
if plan.Identity != (Identity{UserID: "user-1", TenantID: "team-1"}) {
|
||||
t.Fatalf("Identity = %+v, want user-1 in team-1", plan.Identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSearchConfigResponseOmitsVectorSimilarityWeight(t *testing.T) {
|
||||
searchConfig := map[string]interface{}{
|
||||
"similarity_threshold": 0.2,
|
||||
@@ -165,7 +199,7 @@ func TestSearchServiceListSearchesReturnsOwnerDisplayFields(t *testing.T) {
|
||||
createSearchServiceTestSearch(t, "search-1", "user-1", "Search One")
|
||||
|
||||
ctx := serviceTestContext(t, "user-1", "user-1")
|
||||
result, err := NewSearchService().ListSearches(ctx, "user-1", "", 0, 0, []dao.OrderTerm{{Column: "create_time", Desc: true}}, nil)
|
||||
result, err := NewSearchService().ListSearches(ctx, "", 0, 0, []dao.OrderTerm{{Column: "create_time", Desc: true}}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSearches failed: %v", err)
|
||||
}
|
||||
@@ -183,7 +217,7 @@ func TestSearchServiceListSearchesNicknameFallsBackToTenantID(t *testing.T) {
|
||||
createSearchServiceTestSearch(t, "search-1", "user-1", "Search One")
|
||||
|
||||
ctx := serviceTestContext(t, "user-1", "user-1")
|
||||
result, err := NewSearchService().ListSearches(ctx, "user-1", "", 0, 0, []dao.OrderTerm{{Column: "create_time", Desc: true}}, nil)
|
||||
result, err := NewSearchService().ListSearches(ctx, "", 0, 0, []dao.OrderTerm{{Column: "create_time", Desc: true}}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSearches failed: %v", err)
|
||||
}
|
||||
@@ -219,7 +253,7 @@ func TestSearchServiceListSearchesUsesPermissionScope(t *testing.T) {
|
||||
}
|
||||
|
||||
ctx := serviceTestContext(t, "member-1", "team-tenant", "team-owner")
|
||||
result, err := NewSearchService().ListSearches(ctx, "member-1", "", 0, 0, nil, nil)
|
||||
result, err := NewSearchService().ListSearches(ctx, "", 0, 0, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSearches failed: %v", err)
|
||||
}
|
||||
@@ -262,10 +296,10 @@ func TestSearchServiceSeparatesSearchReadFromRunPermission(t *testing.T) {
|
||||
|
||||
service := NewSearchService()
|
||||
ctx := serviceTestContext(t, "member-1", "team-tenant", "team-owner")
|
||||
if _, err := service.GetSearchDetail(ctx, "member-1", search.ID); !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
if _, err := service.GetSearchDetail(ctx, search.ID); !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
t.Fatalf("tenant member should not be able to read another user's Search App metadata, got %v", err)
|
||||
}
|
||||
if _, err := service.GetDetail(ctx, "member-1", search.ID); !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
if _, err := service.GetDetail(ctx, search.ID); !errors.Is(err, permission.ErrPermissionDenied) {
|
||||
t.Fatalf("tenant member should not be able to run another user's Search App, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -303,7 +337,7 @@ func TestSearchServiceListSearchesPaginatesOwnerFilter(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := NewSearchService().ListSearches(ctx, "tenant-1", "", tt.page, tt.pageSize, []dao.OrderTerm{{Column: "create_time", Desc: true}}, []string{"tenant-1"})
|
||||
result, err := NewSearchService().ListSearches(ctx, "", tt.page, tt.pageSize, []dao.OrderTerm{{Column: "create_time", Desc: true}}, []string{"tenant-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListSearches failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -955,7 +955,7 @@ func (s *SkillIndexerService) generateEmbedding(ctx context.Context, text, embdI
|
||||
return nil, fmt.Errorf("embedding model ID not configured")
|
||||
}
|
||||
|
||||
embeddingModel, err := s.modelFactory.NewEmbeddingModel(ctx, ModelAccess{TenantID: tenantID}, embdID)
|
||||
embeddingModel, err := s.modelFactory.NewEmbeddingModel(ctx, Identity{TenantID: tenantID}, embdID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get embedding model: %w", err)
|
||||
}
|
||||
@@ -986,7 +986,7 @@ func (s *SkillIndexerService) generateEmbeddings(ctx context.Context, texts []st
|
||||
}
|
||||
|
||||
common.Info(fmt.Sprintf("Getting embedding model for %s", embdID))
|
||||
embeddingModel, err := s.modelFactory.NewEmbeddingModel(ctx, ModelAccess{TenantID: tenantID}, embdID)
|
||||
embeddingModel, err := s.modelFactory.NewEmbeddingModel(ctx, Identity{TenantID: tenantID}, embdID)
|
||||
if err != nil {
|
||||
common.Error(fmt.Sprintf("Failed to get embedding model: %v", err), err)
|
||||
return nil, fmt.Errorf("failed to get embedding model: %w", err)
|
||||
@@ -1021,7 +1021,7 @@ func (s *SkillIndexerService) getEmbeddingDimension(ctx context.Context, tenantI
|
||||
return 0, fmt.Errorf("embedding model ID not configured")
|
||||
}
|
||||
|
||||
embeddingModel, err := s.modelFactory.NewEmbeddingModel(ctx, ModelAccess{TenantID: tenantID}, embdID)
|
||||
embeddingModel, err := s.modelFactory.NewEmbeddingModel(ctx, Identity{TenantID: tenantID}, embdID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get embedding model: %w", err)
|
||||
}
|
||||
|
||||
@@ -660,7 +660,7 @@ func (s *SkillSearchService) getEmbedding(ctx context.Context, text, embdID, ten
|
||||
return nil, fmt.Errorf("embedding model ID not configured")
|
||||
}
|
||||
|
||||
embeddingModel, err := s.modelFactory.NewEmbeddingModel(ctx, ModelAccess{TenantID: tenantID}, embdID)
|
||||
embeddingModel, err := s.modelFactory.NewEmbeddingModel(ctx, Identity{TenantID: tenantID}, embdID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get embedding model: %w", err)
|
||||
}
|
||||
|
||||
@@ -165,11 +165,13 @@ func (s *SkillSpaceService) CreateSpace(ctx context.Context, userID string, req
|
||||
}
|
||||
defaultEmbdID := req.EmbdID
|
||||
if defaultEmbdID == "" {
|
||||
defaultEmbdID, _, err = NewTenantService().GetDefaultModelRefsForUser(ctx, userID, tenantID, entity.ModelTypeEmbedding)
|
||||
if err != nil {
|
||||
common.Error("Failed to resolve default embedding model for skill space", err,
|
||||
info, modelErr := NewModelFactory().ResolveDefaultInfo(ctx, Identity{UserID: userID, TenantID: tenantID}, entity.ModelTypeEmbedding)
|
||||
if modelErr == nil {
|
||||
defaultEmbdID = ModelReference(info)
|
||||
} else if !errors.Is(modelErr, ErrNoDefaultModel) {
|
||||
common.Error("Failed to resolve default embedding model for skill space", modelErr,
|
||||
zap.String("tenantID", tenantID), zap.String("userID", userID))
|
||||
return nil, common.CodeOperatingError, fmt.Errorf("failed to resolve default embedding model: %w", err)
|
||||
return nil, common.CodeOperatingError, fmt.Errorf("failed to resolve default embedding model: %w", modelErr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,9 +74,9 @@ func resolveDefaultTenantIDEE(context.Context, string) (string, bool, error) {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// resolveUserAuthTenant keeps the token's tenant unchanged.
|
||||
func resolveUserAuthTenant(_ context.Context, _ *entity.User, tenantID string) (string, error) {
|
||||
return tenantID, nil
|
||||
// resolveUserAuthTenant discards tenant context because community credentials identify only a user.
|
||||
func resolveUserAuthTenant(_ context.Context, _ *entity.User, _ string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// LoginChannel is one authentication channel advertised to the login page.
|
||||
|
||||
+8
-532
@@ -35,14 +35,10 @@ import (
|
||||
|
||||
// TenantService tenant service
|
||||
type TenantService struct {
|
||||
tenantDAO *dao.TenantDAO
|
||||
userTenantDAO *dao.UserTenantDAO
|
||||
userDAO *dao.UserDAO
|
||||
modelProviderDAO *dao.TenantModelProviderDAO
|
||||
modelInstanceDAO *dao.TenantModelInstanceDAO
|
||||
modelDAO *dao.TenantModelDAO
|
||||
kbDAO *dao.KnowledgebaseDAO
|
||||
docEngine engine.DocEngine
|
||||
userTenantDAO *dao.UserTenantDAO
|
||||
userDAO *dao.UserDAO
|
||||
kbDAO *dao.KnowledgebaseDAO
|
||||
docEngine engine.DocEngine
|
||||
}
|
||||
|
||||
type tenantInviteRole struct {
|
||||
@@ -108,55 +104,13 @@ func (s *TenantService) checkTenantInviteEnabled(ctx context.Context, tenantID s
|
||||
// NewTenantService create tenant service
|
||||
func NewTenantService() *TenantService {
|
||||
return &TenantService{
|
||||
tenantDAO: dao.NewTenantDAO(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
userDAO: dao.NewUserDAO(),
|
||||
modelProviderDAO: dao.NewTenantModelProviderDAO(),
|
||||
modelInstanceDAO: dao.NewTenantModelInstanceDAO(),
|
||||
modelDAO: dao.NewTenantModelDAO(),
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
docEngine: engine.Get(),
|
||||
userTenantDAO: dao.NewUserTenantDAO(),
|
||||
userDAO: dao.NewUserDAO(),
|
||||
kbDAO: dao.NewKnowledgebaseDAO(),
|
||||
docEngine: engine.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
// TenantInfoResponse tenant information response
|
||||
type TenantInfoResponse struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
LLMID string `json:"llm_id"`
|
||||
EmbDID string `json:"embd_id"`
|
||||
RerankID string `json:"rerank_id"`
|
||||
ASRID string `json:"asr_id"`
|
||||
Img2TxtID string `json:"img2txt_id"`
|
||||
TTSID string `json:"tts_id"`
|
||||
ParserIDs string `json:"parser_ids"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// GetTenantInfo returns the user's own tenant and its owner membership settings.
|
||||
func (s *TenantService) GetTenantInfo(ctx context.Context, userID string) (*TenantInfoResponse, error) {
|
||||
membership, tenantID, err := s.currentUserTenant(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenant, err := s.tenantDAO.GetByID(ctx, dao.DB, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TenantInfoResponse{
|
||||
TenantID: tenant.ID,
|
||||
Name: tenant.Name,
|
||||
LLMID: membership.LLMID,
|
||||
EmbDID: membership.EmbdID,
|
||||
RerankID: membership.RerankID,
|
||||
ASRID: membership.ASRID,
|
||||
Img2TxtID: membership.Img2TxtID,
|
||||
TTSID: ptrStringValue(membership.TTSID),
|
||||
ParserIDs: tenant.ParserIDs,
|
||||
Role: membership.Role,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TenantListItem tenant list item response
|
||||
type TenantListItem struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
@@ -169,66 +123,6 @@ type TenantListItem struct {
|
||||
DeltaSeconds float64 `json:"delta_seconds"`
|
||||
}
|
||||
|
||||
// TenantLLMService tenant LLM service
|
||||
// This service handles operations related to tenant-specific LLM configurations
|
||||
type TenantLLMService struct {
|
||||
modelProviderDAO *dao.TenantModelProviderDAO
|
||||
modelInstanceDAO *dao.TenantModelInstanceDAO
|
||||
modelDAO *dao.TenantModelDAO
|
||||
}
|
||||
|
||||
// NewTenantLLMService creates a new TenantLLMService instance
|
||||
func NewTenantLLMService() *TenantLLMService {
|
||||
return &TenantLLMService{
|
||||
modelProviderDAO: dao.NewTenantModelProviderDAO(),
|
||||
modelInstanceDAO: dao.NewTenantModelInstanceDAO(),
|
||||
modelDAO: dao.NewTenantModelDAO(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetAPIKeyFromInstance returns the API key for the given composite model name
|
||||
// by looking it up in the tenant_model_instance table. compositeModelName is in
|
||||
// "model@instance@provider" or "model@provider" format.
|
||||
func (s *TenantLLMService) GetAPIKeyFromInstance(ctx context.Context, tenantID, compositeModelName string) (string, error) {
|
||||
parts := strings.Split(compositeModelName, "@")
|
||||
if len(parts) < 2 {
|
||||
return "", fmt.Errorf("invalid model name format: %s", compositeModelName)
|
||||
}
|
||||
|
||||
var providerName, instanceName string
|
||||
switch len(parts) {
|
||||
case 2:
|
||||
instanceName = "default"
|
||||
providerName = parts[1]
|
||||
case 3:
|
||||
instanceName = parts[1]
|
||||
providerName = parts[2]
|
||||
default:
|
||||
return "", fmt.Errorf("invalid model name format: %s", compositeModelName)
|
||||
}
|
||||
|
||||
provider, err := s.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, tenantID, providerName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("provider %q not found: %w", providerName, err)
|
||||
}
|
||||
if provider == nil {
|
||||
return "", fmt.Errorf("provider %q not found", providerName)
|
||||
}
|
||||
|
||||
instance, err := s.modelInstanceDAO.GetByProviderIDAndInstanceName(ctx, dao.DB, provider.ID, instanceName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("instance %q not found: %w", instanceName, err)
|
||||
}
|
||||
if instance == nil {
|
||||
return "", fmt.Errorf("instance %q not found", instanceName)
|
||||
}
|
||||
|
||||
if instance.APIKey == "" {
|
||||
return "", fmt.Errorf("no API key configured for model %s", compositeModelName)
|
||||
}
|
||||
return instance.APIKey, nil
|
||||
}
|
||||
|
||||
// GetTenantList get tenant list for a user
|
||||
func (s *TenantService) GetTenantList(ctx context.Context, userID string) ([]*TenantListItem, error) {
|
||||
tenants, err := s.userTenantDAO.GetTenantsByUserID(ctx, dao.DB, userID)
|
||||
@@ -363,424 +257,6 @@ func (s *TenantService) DeleteChunkStore(ctx context.Context, kbID string) (comm
|
||||
return common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
type ModelItem struct {
|
||||
ModelProvider *string `json:"model_provider"`
|
||||
ModelInstance *string `json:"model_instance"`
|
||||
ModelName *string `json:"model_name"`
|
||||
ModelID string `json:"model_id"`
|
||||
ModelType string `json:"model_type"`
|
||||
Enable bool `json:"enable"`
|
||||
}
|
||||
|
||||
func tenantDefaultModelFields(modelType string) (string, string, entity.ModelType, error) {
|
||||
switch modelType {
|
||||
case "chat":
|
||||
return "llm_id", "tenant_llm_id", entity.ModelTypeChat, nil
|
||||
case "embedding":
|
||||
return "embd_id", "tenant_embd_id", entity.ModelTypeEmbedding, nil
|
||||
case "rerank":
|
||||
return "rerank_id", "tenant_rerank_id", entity.ModelTypeRerank, nil
|
||||
case "asr", "speech2text":
|
||||
return "asr_id", "tenant_asr_id", entity.ModelTypeSpeech2Text, nil
|
||||
case "vision", "image2text":
|
||||
return "img2txt_id", "tenant_img2txt_id", entity.ModelTypeImage2Text, nil
|
||||
case "tts":
|
||||
return "tts_id", "tenant_tts_id", entity.ModelTypeTTS, nil
|
||||
case "ocr":
|
||||
return "ocr_id", "tenant_ocr_id", entity.ModelTypeOCR, nil
|
||||
case "decision":
|
||||
// The decision model is its own type rather than a chat model: it is
|
||||
// spoken to in the System One protocol (one request, several questions)
|
||||
// and is chosen independently of the answering model.
|
||||
return "decision_id", "tenant_decision_id", entity.ModelTypeDecision, nil
|
||||
default:
|
||||
return "", "", 0, fmt.Errorf("model type %s is invalid", modelType)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrStringValue(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// GetDefaultModelName returns the selected model reference for a tenant. Request
|
||||
// contexts use that user's membership; trusted background work uses the owner.
|
||||
func (s *TenantService) GetDefaultModelName(ctx context.Context, tenantID string, modelType entity.ModelType) (string, error) {
|
||||
return s.GetDefaultModelNameForTenant(ctx, tenantID, modelType)
|
||||
}
|
||||
|
||||
// GetDefaultModelNameForUser returns the user's setting in the current Tenant.
|
||||
func (s *TenantService) GetDefaultModelNameForUser(ctx context.Context, userID string, modelType entity.ModelType) (string, error) {
|
||||
membership, _, err := s.currentUserTenant(ctx, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !isDefaultModelType(modelType) {
|
||||
return "", fmt.Errorf("invalid model type: %s", modelType)
|
||||
}
|
||||
modelName, _ := defaultModelRefs(membership, modelType)
|
||||
return modelName, nil
|
||||
}
|
||||
|
||||
// GetDefaultModelNameForTenant resolves the request user's membership setting
|
||||
// when present and falls back to the tenant owner for background work.
|
||||
func (s *TenantService) GetDefaultModelNameForTenant(ctx context.Context, tenantID string, modelType entity.ModelType) (string, error) {
|
||||
if !isDefaultModelType(modelType) {
|
||||
return "", fmt.Errorf("invalid model type: %s", modelType)
|
||||
}
|
||||
membership, err := s.defaultModelMembershipForTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
modelName, _ := defaultModelRefs(membership, modelType)
|
||||
return modelName, nil
|
||||
}
|
||||
|
||||
// GetDefaultModelRefsForUser returns the model reference and resolved tenant
|
||||
// model ID configured by userID in tenantID.
|
||||
func (s *TenantService) GetDefaultModelRefsForUser(ctx context.Context, userID, tenantID string, modelType entity.ModelType) (string, string, error) {
|
||||
if !isDefaultModelType(modelType) {
|
||||
return "", "", fmt.Errorf("invalid model type: %s", modelType)
|
||||
}
|
||||
membership, err := s.defaultModelMembership(ctx, userID, tenantID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
modelName, modelID := defaultModelRefs(membership, modelType)
|
||||
return modelName, modelID, nil
|
||||
}
|
||||
|
||||
// GetDefaultModelRefsForTenant resolves model settings for the request user or,
|
||||
// without a request identity, the tenant owner.
|
||||
func (s *TenantService) GetDefaultModelRefsForTenant(ctx context.Context, tenantID string, modelType entity.ModelType) (string, string, error) {
|
||||
if !isDefaultModelType(modelType) {
|
||||
return "", "", fmt.Errorf("invalid model type: %s", modelType)
|
||||
}
|
||||
membership, err := s.defaultModelMembershipForTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
modelName, modelID := defaultModelRefs(membership, modelType)
|
||||
return modelName, modelID, nil
|
||||
}
|
||||
|
||||
func isDefaultModelType(modelType entity.ModelType) bool {
|
||||
switch modelType {
|
||||
case entity.ModelTypeChat, entity.ModelTypeEmbedding, entity.ModelTypeRerank,
|
||||
entity.ModelTypeSpeech2Text, entity.ModelTypeImage2Text, entity.ModelTypeTTS,
|
||||
entity.ModelTypeOCR, entity.ModelTypeDecision:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TenantService) defaultModelMembership(ctx context.Context, userID, tenantID string) (*entity.UserTenant, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return s.userTenantDAO.GetOwnerByTenantID(ctx, dao.DB, tenantID)
|
||||
}
|
||||
membership, err := s.userTenantDAO.FilterByUserIDAndTenantID(ctx, dao.DB, userID, tenantID)
|
||||
if err == nil {
|
||||
return membership, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) || userID != tenantID {
|
||||
return nil, err
|
||||
}
|
||||
return s.userTenantDAO.GetOwnerByTenantID(ctx, dao.DB, tenantID)
|
||||
}
|
||||
|
||||
func (s *TenantService) defaultModelMembershipForTenant(ctx context.Context, tenantID string) (*entity.UserTenant, error) {
|
||||
if subject, ok := permission.SubjectFromContext(ctx); ok && strings.TrimSpace(subject.UserID) != "" &&
|
||||
(strings.TrimSpace(subject.TenantID) == "" || strings.TrimSpace(subject.TenantID) == tenantID) {
|
||||
return s.defaultModelMembership(ctx, subject.UserID, tenantID)
|
||||
}
|
||||
return s.userTenantDAO.GetOwnerByTenantID(ctx, dao.DB, tenantID)
|
||||
}
|
||||
|
||||
func (s *TenantService) GetModelInfo(ctx context.Context, tenantID string, defaultModel string, modelType string) (*string, *string, *string, bool, error) {
|
||||
// Mirror Python's _get_model_info: right-anchored rsplit so that model
|
||||
// names containing '@' (e.g. LM Studio IDs like
|
||||
// "text-embedding-nomic-embed-text-v1.5@q8_0") remain intact.
|
||||
// The composite key is: modelName@instanceName@providerName or
|
||||
// modelName@providerName.
|
||||
parts := rsplitN(defaultModel, "@", 2)
|
||||
var modelName, instanceName, providerName string
|
||||
switch len(parts) {
|
||||
case 3:
|
||||
modelName, instanceName, providerName = parts[0], parts[1], parts[2]
|
||||
case 2:
|
||||
modelName, providerName = parts[0], parts[1]
|
||||
instanceName = "default"
|
||||
default:
|
||||
modelName = parts[0]
|
||||
providerName = ""
|
||||
instanceName = "default"
|
||||
}
|
||||
|
||||
// Special case: OCR with infiniflow@default@deepdoc is always enabled.
|
||||
if modelType == "ocr" && providerName == "infiniflow" && instanceName == "default" && modelName == "deepdoc" {
|
||||
return &providerName, &instanceName, &modelName, true, nil
|
||||
}
|
||||
|
||||
// Special case: TEI Builtin embedding model.
|
||||
composeProfiles := common.GetEnv(common.EnvComposeProfiles)
|
||||
teiModel := common.GetEnv(common.EnvTEIModel)
|
||||
if modelType == "embedding" && strings.Contains(composeProfiles, "tei-") && teiModel != "" &&
|
||||
modelName == teiModel && (providerName == "" || providerName == "Builtin") {
|
||||
return &providerName, &instanceName, &modelName, true, nil
|
||||
}
|
||||
|
||||
// Check if the provider exists for the tenant.
|
||||
modelProvider, err := s.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, tenantID, providerName)
|
||||
if err != nil {
|
||||
return nil, nil, nil, false, err
|
||||
}
|
||||
|
||||
// Check if the instance exists.
|
||||
modelInstance, err := s.modelInstanceDAO.GetByProviderIDAndInstanceName(ctx, dao.DB, modelProvider.ID, instanceName)
|
||||
if err != nil {
|
||||
return nil, nil, nil, false, err
|
||||
}
|
||||
|
||||
// Check if the model exists and is active. Model type validity is
|
||||
// enforced on the save path against the tenant_model record, so models
|
||||
// added online that are absent from the static catalog still resolve.
|
||||
modelEntity, err := s.modelDAO.GetModelByProviderIDAndInstanceIDAndModelName(ctx, dao.DB, modelProvider.ID, modelInstance.ID, modelName)
|
||||
if err != nil {
|
||||
if !dao.IsNotFoundErr(err) {
|
||||
return nil, nil, nil, false, err
|
||||
}
|
||||
}
|
||||
if modelEntity == nil {
|
||||
return nil, nil, nil, false, fmt.Errorf("model %s isn't available", modelName)
|
||||
}
|
||||
if modelEntity.Status != "active" {
|
||||
return nil, nil, nil, false, fmt.Errorf("model %s isn't available", modelName)
|
||||
}
|
||||
|
||||
return &providerName, &instanceName, &modelName, true, nil
|
||||
}
|
||||
|
||||
// rsplitN splits s by sep from the right, limiting to n+1 parts (mirrors
|
||||
// Python's str.rsplit(sep, maxsplit)).
|
||||
func rsplitN(s, sep string, n int) []string {
|
||||
if n <= 0 {
|
||||
return []string{s}
|
||||
}
|
||||
result := make([]string, 0, n+1)
|
||||
remaining := s
|
||||
for i := 0; i < n; i++ {
|
||||
idx := strings.LastIndex(remaining, sep)
|
||||
if idx < 0 {
|
||||
result = append(result, remaining)
|
||||
// Reverse the collected parts.
|
||||
for left, right := 0, len(result)-1; left < right; left, right = left+1, right-1 {
|
||||
result[left], result[right] = result[right], result[left]
|
||||
}
|
||||
return result
|
||||
}
|
||||
result = append(result, remaining[idx+len(sep):])
|
||||
remaining = remaining[:idx]
|
||||
}
|
||||
result = append(result, remaining)
|
||||
for left, right := 0, len(result)-1; left < right; left, right = left+1, right-1 {
|
||||
result[left], result[right] = result[right], result[left]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *TenantService) ListTenantDefaultModels(ctx context.Context, userID string) ([]ModelItem, error) {
|
||||
membership, tenantID, err := s.currentUserTenant(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return listDefaultModels(ctx, s, membership, tenantID), nil
|
||||
}
|
||||
|
||||
func listDefaultModels(ctx context.Context, s *TenantService, membership *entity.UserTenant, tenantID string) []ModelItem {
|
||||
type defaultModel struct {
|
||||
modelType string
|
||||
modelID string
|
||||
rowID *string
|
||||
}
|
||||
models := []defaultModel{
|
||||
{modelType: "chat", modelID: membership.LLMID, rowID: membership.TenantLLMID},
|
||||
{modelType: "embedding", modelID: membership.EmbdID, rowID: membership.TenantEmbdID},
|
||||
{modelType: "rerank", modelID: membership.RerankID, rowID: membership.TenantRerankID},
|
||||
{modelType: "asr", modelID: membership.ASRID, rowID: membership.TenantASRID},
|
||||
{modelType: "vision", modelID: membership.Img2TxtID, rowID: membership.TenantImg2TxtID},
|
||||
{modelType: "ocr", modelID: ptrStringValue(membership.OCRID), rowID: membership.TenantOCRID},
|
||||
{modelType: "tts", modelID: ptrStringValue(membership.TTSID), rowID: membership.TenantTTSID},
|
||||
{modelType: "decision", modelID: ptrStringValue(membership.DecisionID), rowID: membership.TenantDecisionID},
|
||||
}
|
||||
result := make([]ModelItem, 0, len(models))
|
||||
for _, model := range models {
|
||||
if strings.TrimSpace(model.modelID) == "" {
|
||||
continue
|
||||
}
|
||||
provider, instance, name, enabled, err := s.GetModelInfo(ctx, tenantID, model.modelID, model.modelType)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, ModelItem{
|
||||
ModelProvider: provider,
|
||||
ModelInstance: instance,
|
||||
ModelName: name,
|
||||
ModelID: ptrStringValue(model.rowID),
|
||||
ModelType: model.modelType,
|
||||
Enable: enabled,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *TenantService) checkModelAvailable(ctx context.Context, tenantID, providerName, instanceName, modelName, modelType string) error {
|
||||
_, _, modelTypeBit, err := tenantDefaultModelFields(modelType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Static bypass: deepdoc is a built-in model that doesn't need DB checks (mirrors Python _check_model_available).
|
||||
if providerName == "infiniflow" && instanceName == "default" && modelName == "deepdoc" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Static bypass: OCR with infiniflow@default@deepdoc is always enabled (mirrors Python _check_model_available).
|
||||
if modelType == "ocr" && providerName == "infiniflow" && instanceName == "default" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Static bypass: TEI Builtin embedding model when COMPOSE_PROFILES includes tei- (mirrors Python _check_model_available).
|
||||
composeProfiles := common.GetEnv(common.EnvComposeProfiles)
|
||||
teiModel := common.GetEnv(common.EnvTEIModel)
|
||||
if modelType == "embedding" && strings.Contains(composeProfiles, "tei-") && teiModel != "" &&
|
||||
modelName == teiModel && (providerName == "" || providerName == "Builtin") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if the provider and instance exists
|
||||
modelProvider, err := s.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, tenantID, providerName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
modelInstance, err := s.modelInstanceDAO.GetByProviderIDAndInstanceName(ctx, dao.DB, modelProvider.ID, instanceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate model availability through the DB (TenantModel table)
|
||||
modelEntity, err := s.modelDAO.GetModelByProviderIDAndInstanceIDAndModelName(ctx, dao.DB, modelProvider.ID, modelInstance.ID, modelName)
|
||||
if err != nil {
|
||||
if dao.IsNotFoundErr(err) {
|
||||
return fmt.Errorf("model %s isn't available", modelName)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if modelEntity.Status != "active" {
|
||||
return fmt.Errorf("model %s isn't available", modelName)
|
||||
}
|
||||
if !entity.ModelType(modelEntity.ModelType).Has(modelTypeBit) {
|
||||
return fmt.Errorf("model %s isn't a %s model", modelName, modelType)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TenantService) SetTenantDefaultModels(ctx context.Context, userID, modelProvider, modelInstance, modelName, modelType, modelID string) error {
|
||||
membership, tenantID, err := s.currentUserTenant(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var defaultModel string
|
||||
modelTypeID, tenantModelTypeID, modelTypeBit, err := tenantDefaultModelFields(modelType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var tenantModelID interface{}
|
||||
if modelID != "" {
|
||||
modelEntity, err := s.modelDAO.GetByID(ctx, dao.DB, modelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model ID %s is invalid", modelID)
|
||||
}
|
||||
instanceEntity, err := s.modelInstanceDAO.GetByID(ctx, dao.DB, modelEntity.InstanceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("instance for model %s not found: %w", modelID, err)
|
||||
}
|
||||
providerEntity, err := s.modelProviderDAO.GetByID(ctx, dao.DB, instanceEntity.ProviderID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("provider for model %s not found: %w", modelID, err)
|
||||
}
|
||||
if providerEntity.TenantID != tenantID {
|
||||
return fmt.Errorf("model %s does not belong to your tenant", modelID)
|
||||
}
|
||||
if modelEntity.Status != "active" {
|
||||
return fmt.Errorf("model %s isn't available", modelEntity.ModelName)
|
||||
}
|
||||
if !entity.ModelType(modelEntity.ModelType).Has(modelTypeBit) {
|
||||
return fmt.Errorf("model %s isn't a %s model", modelEntity.ModelName, modelType)
|
||||
}
|
||||
modelProvider = providerEntity.ProviderName
|
||||
modelInstance = instanceEntity.InstanceName
|
||||
modelName = modelEntity.ModelName
|
||||
tenantModelID = modelID
|
||||
}
|
||||
|
||||
if modelProvider == "" && modelInstance == "" && modelName == "" {
|
||||
defaultModel = ""
|
||||
tenantModelID = nil
|
||||
} else if modelProvider != "" && modelInstance != "" && modelName != "" {
|
||||
if err := s.checkModelAvailable(ctx, tenantID, modelProvider, modelInstance, modelName, modelType); err != nil {
|
||||
return err
|
||||
}
|
||||
if modelID == "" {
|
||||
if modelProvider == "Builtin" {
|
||||
tenantModelID = nil
|
||||
} else {
|
||||
provider, err := s.modelProviderDAO.GetByTenantIDAndProviderName(ctx, dao.DB, tenantID, modelProvider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
instance, err := s.modelInstanceDAO.GetByProviderIDAndInstanceName(ctx, dao.DB, provider.ID, modelInstance)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
model, err := s.modelDAO.GetModelByProviderIDAndInstanceIDAndModelName(ctx, dao.DB, provider.ID, instance.ID, modelName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tenantModelID = model.ID
|
||||
}
|
||||
}
|
||||
defaultModel = fmt.Sprintf("%s@%s@%s", modelName, modelInstance, modelProvider)
|
||||
} else {
|
||||
return fmt.Errorf("model provider, instance and name must be specified together")
|
||||
}
|
||||
|
||||
return dao.DB.WithContext(ctx).Model(&entity.UserTenant{}).Where("id = ?", membership.ID).Updates(map[string]interface{}{
|
||||
modelTypeID: defaultModel,
|
||||
tenantModelTypeID: tenantModelID,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *TenantService) currentUserTenant(ctx context.Context, userID string) (*entity.UserTenant, string, error) {
|
||||
membership, handled, err := resolveCurrentUserTenantEE(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if handled {
|
||||
return membership, membership.TenantID, nil
|
||||
}
|
||||
membership, err = s.userTenantDAO.GetOwnerByUserID(ctx, dao.DB, userID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return membership, membership.TenantID, nil
|
||||
}
|
||||
|
||||
// Tenant member role constants.
|
||||
const (
|
||||
TenantRoleOwner = "owner"
|
||||
|
||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user