diff --git a/conf/model.md b/conf/model.md index 45d63da504..1924ac18b3 100644 --- a/conf/model.md +++ b/conf/model.md @@ -153,6 +153,7 @@ A quick test: if `n & (n-1) == 0`, the value is a power of 2 (binary). Otherwise | **Tencent (Hunyuan)** | Decimal (32K, 131K, 262K) | Decimal (8K, 64K) | [Tencent Cloud Docs](https://cloud.tencent.com/document/product/1759) | | **Xiaomi (MiMo)** | Binary (1M) | Binary (8K) | [MiMo Docs](https://huggingface.co/XiaomiMiMo) | | **HuggingFace** | Varies (hosted models) | Varies | [HuggingFace Model Cards](https://huggingface.co/docs/hub/model-cards) | +| **Nace (Drex)** | Binary (262,144 = the state plus one question, per question; decision models only) | None — answers are probabilities, not generated tokens | [Nace Docs](https://console.nace.ai/docs/drex/reference/limits) | ### Key Takeaways diff --git a/conf/models/nace.json b/conf/models/nace.json new file mode 100644 index 0000000000..5f54867803 --- /dev/null +++ b/conf/models/nace.json @@ -0,0 +1,27 @@ +{ + "name": "Nace", + "url": { + "default": "https://console.nace.ai/v1" + }, + "url_suffix": { + "decision": "systemone", + "models": "models" + }, + "class": "nace", + "models": [ + { + "name": "drex-max", + "context_length": 262144, + "model_types": [ + "decision" + ] + }, + { + "name": "drex-v2.0", + "context_length": 262144, + "model_types": [ + "decision" + ] + } + ] +} diff --git a/docs/guides/agent/agent_workflow/flow_components.md b/docs/guides/agent/agent_workflow/flow_components.md index acb0df6b66..5dbd07e981 100644 --- a/docs/guides/agent/agent_workflow/flow_components.md +++ b/docs/guides/agent/agent_workflow/flow_components.md @@ -76,11 +76,11 @@ Connect **Begin → Loop → Message**. Inside Loop, connect its start node to * 4. Set the outer Message to `Loop completed`. Save and click **Run** to start the workflow. The run results should show three executions of Variable assigner, followed by Loop completed. ## Categorize Component -Categorize uses a chat model or a SystemOne decision model to judge user intent or input category and branch the workflow based on classification results. +Categorize uses a chat model or a decision model (SystemOne or Nace) to judge user intent or input category and branch the workflow based on classification results. ### Configuration Method 1. Select content to classify in **Query variables**, usually `sys.query`. -2. Select **Model**, a chat model or an added SystemOne decision model. +2. Select **Model**, a chat model or an added decision model (SystemOne or Nace). 3. Set **Message window size**. Keep default 1 for single-turn classification. 4. Click **Add category** and enter a **Name**, **Description**, and **Examples**. 5. Connect each category to its downstream component on the canvas. @@ -90,7 +90,7 @@ The output `category_name` contains the selected category name. ### Classification Recommendations For workflows that route inputs to different paths, configure two or more categories. Use easy-to-understand category names, such as Product Consultation, Installation Reservation, After-sales Fault, and Other Questions. Examples improve classification stability; provide two or three typical samples for each category. -With a SystemOne decision model, Categorize scores each category with a probability question and selects the highest-scoring category. Requests contain up to 16 categories each, with a 30-second timeout for the whole classification. Ties use category order; if every score is zero, the configured default category is selected (or the first category when no default is set). Model errors stop the node. Output scores remain 1 for the selected category and 0 for the others. +With a decision model, Categorize scores each category with a probability question and selects the highest-scoring category. Requests contain up to 16 categories each, with a 30-second timeout for the whole classification. Ties use category order; if every score is zero, the configured default category is selected (or the first category when no default is set). Model errors stop the node. Output scores remain 1 for the selected category and 0 for the others. ![Question Classification Component](https://raw.githubusercontent.com/infiniflow/ragflow-docs/main/images/question_classification_component.jpg) diff --git a/internal/agent/component/categorize.go b/internal/agent/component/categorize.go index c2b8a90c6c..de603893ed 100644 --- a/internal/agent/component/categorize.go +++ b/internal/agent/component/categorize.go @@ -69,10 +69,16 @@ func (c *CategorizeComponent) Name() string { return "Categorize" } func (c *CategorizeComponent) Invoke(ctx context.Context, db *gorm.DB, inputs map[string]any) (map[string]any, error) { p := mergeCategorizeParam(c.param, inputs) var err error - p.ModelID, p.Driver, p.APIKey, p.BaseURL, err = resolveModelRef(ctx, db, p.ModelID, p.Driver, p.APIKey, p.BaseURL, entity.ModelTypeChat|entity.ModelTypeDecision) + var modelType entity.ModelType + p.ModelID, p.Driver, p.APIKey, p.BaseURL, modelType, err = resolveModelRefWithType(ctx, db, p.ModelID, p.Driver, p.APIKey, p.BaseURL, entity.ModelTypeChat|entity.ModelTypeDecision) if err != nil { return nil, err } + if modelType == 0 && isDecisionOnlyDriver(p.Driver) { + // The type could not be resolved (an unregistered model name with an + // explicit driver); these drivers only serve decision models. + modelType = entity.ModelTypeDecision + } if p.ModelID == "" { return nil, &ParamError{Field: "model_id", Reason: "required"} } @@ -92,7 +98,7 @@ func (c *CategorizeComponent) Invoke(ctx context.Context, db *gorm.DB, inputs ma history := categorizeHistory(ctx, p.MessageHistoryWindowSize, query) var chosen string var score map[string]float64 - if strings.EqualFold(p.Driver, "SystemOne") { + if modelType.Has(entity.ModelTypeDecision) { chosen, err = categorizeWithDecision(ctx, p, query, history) if err == nil { score = make(map[string]float64, len(p.Categories)) diff --git a/internal/agent/component/categorize_decision.go b/internal/agent/component/categorize_decision.go index 1d3f32bcb9..66e057e151 100644 --- a/internal/agent/component/categorize_decision.go +++ b/internal/agent/component/categorize_decision.go @@ -9,6 +9,32 @@ import ( "ragflow/internal/entity/models" ) +// categorizeDecisionTimeout bounds one classification. Nace holds a request up +// to 55 s upstream and asks callers for 60 s or more, so it gets a longer cap +// than SystemOne. +// categorizeDecisionMaxNaceBudget caps the total Nace classification time so a +// workflow with very many categories cannot wait for minutes. +const categorizeDecisionMaxNaceBudget = 5 * time.Minute + +func categorizeDecisionTimeout(driver string) time.Duration { + if strings.EqualFold(driver, "Nace") { + return 90 * time.Second + } + return 30 * time.Second +} + +// categorizeDecisionBudget bounds a whole classification. Nace can hold each +// request for up to 55 s, so its budget grows with the number of requests the +// categories split into, up to categorizeDecisionMaxNaceBudget. +func categorizeDecisionBudget(driver string, categories int) time.Duration { + timeout := categorizeDecisionTimeout(driver) + if strings.EqualFold(driver, "Nace") { + chunks := max(1, (categories+models.MaxDecisionQuestions-1)/models.MaxDecisionQuestions) + timeout = min(timeout*time.Duration(chunks), categorizeDecisionMaxNaceBudget) + } + return timeout +} + func categorizeWithDecision(ctx context.Context, p CategorizeParam, query string, history []categorizeHistoryMessage) (string, error) { driver, err := models.GetPreconfiguredDriver(p.Driver, p.BaseURL) if err != nil { @@ -16,7 +42,8 @@ func categorizeWithDecision(ctx context.Context, p CategorizeParam, query string } // Bound the whole classification, including workflows with more categories // than the endpoint accepts in one request. - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + timeout := categorizeDecisionBudget(p.Driver, len(p.Categories)) + ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() instructions := buildCategorizeDecisionInstructions(p) state := map[string]string{"query": query, "conversation": buildCategorizePrompt(history)} diff --git a/internal/agent/component/categorize_decision_test.go b/internal/agent/component/categorize_decision_test.go index 50257af836..dd3225d43b 100644 --- a/internal/agent/component/categorize_decision_test.go +++ b/internal/agent/component/categorize_decision_test.go @@ -9,6 +9,7 @@ import ( "reflect" "strings" "testing" + "time" "ragflow/internal/agent/canvas" "ragflow/internal/dao" @@ -96,6 +97,81 @@ func TestCategorizeDecisionRegisteredRouting(t *testing.T) { } } +func TestCategorizeNaceDecisionRouting(t *testing.T) { + for _, ref := range []string{categorizeDecisionModelID, "4d2d824e7e5d11f1a845455b140cef91", "decision-model-preview@default@Nace", "decision-model-preview@Nace"} { + t.Run(ref, func(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.URL.Path != "/v1/systemone" || r.Header.Get("Authorization") != "Bearer test-key" { + t.Errorf("unexpected decision request: %s %s", r.Method, r.URL.Path) + } + var request struct { + Model string `json:"model"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Model != "decision-model-preview" { + t.Errorf("unexpected model request: %+v, %v", request, err) + } + _, _ = w.Write([]byte(`{"answers":{"category_0":{"type":"noul","noul":0.1},"category_1":{"type":"noul","noul":0.9}}}`)) + })) + defer srv.Close() + db := setupCategorizeDecisionModel(t, srv.URL+"/v1") + if err := db.Model(&entity.TenantModelProvider{}).Where("id = ?", "decision-provider").Update("provider_name", "Nace").Error; err != nil { + t.Fatal(err) + } + const instanceID = "4d2d824e7e5d11f1a845455b140cef91" + if err := db.Model(&entity.TenantModelInstance{}).Where("id = ?", "decision-instance").Update("id", instanceID).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&entity.TenantModel{}).Where("id = ?", categorizeDecisionModelID).Updates(map[string]any{"instance_id": instanceID, "model_name": "decision-model-preview"}).Error; err != nil { + t.Fatal(err) + } + c := NewCategorizeComponent(CategorizeParam{ModelID: ref, APIKey: "test-key", Categories: []string{"sales", "support"}, CategoryRoutes: map[string]string{"support": "support-route"}}) + out, err := c.Invoke(stateWithTenant("tenant-1"), db, map[string]any{"query": "Login is broken"}) + if err != nil { + t.Fatal(err) + } + if calls != 1 || out["category"] != "support" || !reflect.DeepEqual(out["_next"], []string{"support-route"}) { + t.Fatalf("Invoke = %v; decision calls = %d", out, calls) + } + }) + } +} + +func TestCategorizeNaceCompositeRejectsUnavailableModels(t *testing.T) { + for _, tc := range []struct { + name string + update func(*gorm.DB) error + }{ + {"disabled model", func(db *gorm.DB) error { + return db.Model(&entity.TenantModel{}).Where("id = ?", categorizeDecisionModelID).Update("status", "inactive").Error + }}, + {"disabled instance", func(db *gorm.DB) error { + return db.Model(&entity.TenantModelInstance{}).Where("id = ?", "decision-instance").Update("status", "inactive").Error + }}, + {"mismatched instance", func(db *gorm.DB) error { + return db.Model(&entity.TenantModelInstance{}).Where("id = ?", "decision-instance").Update("provider_id", "other-provider").Error + }}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++; t.Error("unavailable model reached upstream") })) + defer srv.Close() + db := setupCategorizeDecisionModel(t, srv.URL) + if err := db.Model(&entity.TenantModelProvider{}).Where("id = ?", "decision-provider").Update("provider_name", "Nace").Error; err != nil { + t.Fatal(err) + } + if err := tc.update(db); err != nil { + t.Fatal(err) + } + c := NewCategorizeComponent(CategorizeParam{ModelID: "decision-model-preview@default@Nace", Categories: []string{"sales", "support"}}) + if _, err := c.Invoke(stateWithTenant("tenant-1"), db, nil); err == nil || calls != 0 { + t.Fatalf("Invoke error=%v; HTTP calls=%d", err, calls) + } + }) + } +} + func TestCategorizeDecisionSelection(t *testing.T) { for _, tc := range []struct { name string @@ -244,3 +320,47 @@ func TestCategorizeDecisionCanceled(t *testing.T) { t.Fatal("canceled classification succeeded") } } + +// An explicit decision-only driver with a model name the catalog does not know +// has no resolvable type; it must still take the decision path. +func TestCategorizeExplicitDecisionDriverUnknownModel(t *testing.T) { + for _, driver := range []string{"SystemOne", "Nace"} { + t.Run(driver, func(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.URL.Path != "/v1/systemone" { + t.Errorf("unexpected request path %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"answers":{"category_0":{"type":"noul","noul":0.1},"category_1":{"type":"noul","noul":0.9}}}`)) + })) + defer srv.Close() + c := NewCategorizeComponent(CategorizeParam{ + Driver: driver, ModelID: "not-in-any-catalog", APIKey: "k", BaseURL: srv.URL + "/v1", + Categories: []string{"sales", "support"}, + }) + out, err := c.Invoke(stateWithTenant("tenant-1"), setupComponentTestDB(t), map[string]any{"query": "Login is broken"}) + if err != nil || calls != 1 || out["category"] != "support" { + t.Fatalf("Invoke = %v, %v; decision calls = %d", out, err, calls) + } + }) + } +} + +func TestCategorizeDecisionTimeout(t *testing.T) { + if got := categorizeDecisionTimeout("SystemOne"); got != 30*time.Second { + t.Errorf("SystemOne timeout = %v, want 30s", got) + } + if got := categorizeDecisionTimeout("Nace"); got < 60*time.Second { + t.Errorf("Nace timeout = %v, want at least 60s", got) + } + if got := categorizeDecisionBudget("Nace", 33); got != 3*categorizeDecisionTimeout("Nace") { + t.Errorf("Nace budget for 33 categories = %v, want three request budgets", got) + } + if got := categorizeDecisionBudget("Nace", 200); got != 5*time.Minute { + t.Errorf("Nace budget for 200 categories = %v, want the 5m cap", got) + } + if got := categorizeDecisionBudget("SystemOne", 33); got != 30*time.Second { + t.Errorf("SystemOne budget = %v, want 30s", got) + } +} diff --git a/internal/agent/component/llm_credentials.go b/internal/agent/component/llm_credentials.go index 9c7dcd3173..b826947aca 100644 --- a/internal/agent/component/llm_credentials.go +++ b/internal/agent/component/llm_credentials.go @@ -11,6 +11,7 @@ import ( "ragflow/internal/common" "ragflow/internal/dao" "ragflow/internal/entity" + "ragflow/internal/entity/models" "go.uber.org/zap" "gorm.io/gorm" @@ -48,7 +49,22 @@ func resolveChatModelRef(ctx context.Context, db *gorm.DB, modelID, driver, apiK } func resolveModelRef(ctx context.Context, db *gorm.DB, modelID, driver, apiKey, baseURL string, allowedTypes entity.ModelType) (string, string, string, string, error) { + modelID, driver, apiKey, baseURL, _, err := resolveModelRefWithType(ctx, db, modelID, driver, apiKey, baseURL, allowedTypes) + return modelID, driver, apiKey, baseURL, err +} + +func resolveModelRefWithType(ctx context.Context, db *gorm.DB, modelID, driver, apiKey, baseURL string, allowedTypes entity.ModelType) (string, string, string, string, entity.ModelType, error) { originalModelID := modelID + var modelType entity.ModelType + if driver == "" && strings.Contains(modelID, "@") { + resolvedModelID, resolvedDriver, resolvedAPIKey, resolvedBaseURL, resolvedType, ok, err := resolveTenantCompositeModel(ctx, db, modelID, apiKey, baseURL, allowedTypes) + if err != nil { + return "", "", "", "", 0, err + } + if ok { + return resolvedModelID, resolvedDriver, resolvedAPIKey, resolvedBaseURL, resolvedType, nil + } + } if driver == "" && modelID != "" { if m, prov, ok := agentProviderLastSegmentSplit(modelID); ok { driver = prov @@ -56,19 +72,21 @@ func resolveModelRef(ctx context.Context, db *gorm.DB, modelID, driver, apiKey, } } if driver == "" && modelID != "" { - resolvedModelID, resolvedDriver, resolvedAPIKey, resolvedBaseURL, ok, err := resolveTenantModelByID(ctx, db, modelID, apiKey, baseURL, allowedTypes) + resolvedModelID, resolvedDriver, resolvedAPIKey, resolvedBaseURL, resolvedType, ok, err := resolveTenantModelByID(ctx, db, modelID, apiKey, baseURL, allowedTypes) if err != nil { - return "", "", "", "", err + return "", "", "", "", 0, err } if ok { - modelID = resolvedModelID - driver = resolvedDriver - apiKey = resolvedAPIKey - baseURL = resolvedBaseURL + modelID, driver, apiKey, baseURL, modelType = resolvedModelID, resolvedDriver, resolvedAPIKey, resolvedBaseURL, resolvedType + } + } + if modelType == 0 && driver != "" && modelID != "" { + if model, err := dao.GetModelProviderManager().GetModelByName(driver, modelID); err == nil { + modelType = entity.ModelTypeFromStrings(model.ModelTypes) } } apiKey, baseURL = resolveTenantLLMConfig(ctx, db, driver, modelID, apiKey, baseURL, originalModelID) - return modelID, driver, apiKey, baseURL, nil + return modelID, driver, apiKey, baseURL, modelType, nil } // modelImageCapability reports an explicitly known image capability. The @@ -156,6 +174,10 @@ func resolveTenantModelInstanceCredentials(ctx context.Context, db *gorm.DB, tid } apiKey := instance.APIKey + if !savedKeyMayGoTo(provider.ProviderName, instance, baseURL) { + common.Debug("llm credentials: saved key not used with a base_url override of another origin", zap.String("provider", providerName)) + return "", baseURL, false + } if instance.Extra != "" && baseURL == "" { var extra map[string]string if err := json.Unmarshal([]byte(instance.Extra), &extra); err == nil { @@ -195,95 +217,189 @@ func findSoleActiveProviderInstance(ctx context.Context, db *gorm.DB, providerID return active[0] } -func resolveTenantModelByID(ctx context.Context, db *gorm.DB, modelRef, apiKey, baseURL string, allowedTypes entity.ModelType) (string, string, string, string, bool, error) { - if !isBareTenantModelID(modelRef) { - return "", "", apiKey, baseURL, false, nil +// isDecisionOnlyDriver reports drivers that serve decision models only. +func isDecisionOnlyDriver(name string) bool { + return strings.EqualFold(name, "SystemOne") || strings.EqualFold(name, "Nace") +} + +// resolveTenantCompositeModel resolves a model@instance@provider (or +// model@provider) reference to a tenant decision model. It only acts when the +// caller accepts decision models and the reference names one; everything else +// (chat, vision, disabled rows, unknown providers) returns ok=false so the +// pre-existing resolution runs unchanged. +func resolveTenantCompositeModel(ctx context.Context, db *gorm.DB, modelRef, apiKey, baseURL string, allowedTypes entity.ModelType) (string, string, string, string, entity.ModelType, bool, error) { + unresolved := func() (string, string, string, string, entity.ModelType, bool, error) { + return "", "", apiKey, baseURL, 0, false, nil + } + if db == nil || !allowedTypes.Has(entity.ModelTypeDecision) { + return unresolved() } state, err := runtime.GetStateFromContext(ctx) if err != nil || state == nil { - return "", "", apiKey, baseURL, false, nil + return unresolved() + } + tid, _ := state.GetSysString("tenant_id") + modelName, instanceName, providerName := parseLLMIDParts(modelRef) + if tid == "" || instanceName == "" { + return unresolved() + } + explicitInstance := len(strings.Split(strings.TrimSpace(modelRef), "@")) >= 3 + // A three-part reference to a decision-only provider names an instance + // explicitly. When it cannot be resolved, failing is safer than letting + // the legacy model@provider split drop the instance and its credentials. + notFound := func() (string, string, string, string, entity.ModelType, bool, error) { + if explicitInstance && isDecisionOnlyDriver(providerName) { + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("tenant composite model %q not found", modelRef) + } + return unresolved() + } + owner, err := dao.NewUserTenantDAO().GetOwnerByTenantID(ctx, db, tid) + if errors.Is(err, gorm.ErrRecordNotFound) { + return notFound() + } + if err != nil { + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("resolve tenant owner %q: %w", tid, err) + } + provider, err := dao.NewTenantModelProviderDAO().GetByTenantIDAndUserIDAndProviderName(ctx, db, tid, owner.UserID, providerName) + if errors.Is(err, gorm.ErrRecordNotFound) { + return notFound() + } + if err != nil { + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("resolve provider %q: %w", providerName, err) + } + instance, err := dao.NewTenantModelInstanceDAO().GetByProviderIDAndInstanceName(ctx, db, provider.ID, instanceName) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("resolve instance %q: %w", instanceName, err) + } + if errors.Is(err, gorm.ErrRecordNotFound) && instanceName == "default" { + instance = findSoleActiveProviderInstance(ctx, db, provider.ID) + } + if instance == nil { + return notFound() + } + rows, err := dao.NewTenantModelDAO().GetModelsByProviderIDAndInstanceIDAndModelName(ctx, db, provider.ID, instance.ID, modelName) + if err != nil { + return "", "", apiKey, baseURL, 0, false, err + } + if len(rows) == 0 { + // The model row is gone, but the caller falls back to the instance's + // credentials. An explicit reference to a decision-only provider must + // not reach an inactive instance through that fallback. + if explicitInstance && isDecisionOnlyDriver(provider.ProviderName) && !strings.EqualFold(strings.TrimSpace(instance.Status), "active") { + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("tenant composite model %q: instance %q is not active", modelRef, instanceName) + } + return unresolved() + } + var selected *entity.TenantModel + decisionRows := 0 + for _, row := range rows { + if !entity.ModelType(row.ModelType).Has(entity.ModelTypeDecision) { + continue + } + decisionRows++ + if !strings.EqualFold(strings.TrimSpace(row.Status), "active") { + continue + } + if selected != nil { + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("model %q has multiple active decision entries; use tenant_model.id", modelRef) + } + selected = row + } + if decisionRows == 0 { + return unresolved() + } + if selected == nil { + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("model %q has no active decision entry", modelRef) + } + return resolveTenantModelByTenantModelID(ctx, db, tid, selected.ID, apiKey, baseURL, allowedTypes) +} + +func resolveTenantModelByID(ctx context.Context, db *gorm.DB, modelRef, apiKey, baseURL string, allowedTypes entity.ModelType) (string, string, string, string, entity.ModelType, bool, error) { + if !isBareTenantModelID(modelRef) { + return "", "", apiKey, baseURL, 0, false, nil + } + state, err := runtime.GetStateFromContext(ctx) + if err != nil || state == nil { + return "", "", apiKey, baseURL, 0, false, nil } tid, _ := state.GetSysString("tenant_id") if tid == "" { - return "", "", apiKey, baseURL, false, nil + return "", "", apiKey, baseURL, 0, false, nil } - modelName, provider, modelKey, modelBaseURL, ok, err := resolveTenantModelByTenantModelID(ctx, db, tid, modelRef, apiKey, baseURL, allowedTypes) + modelName, provider, modelKey, modelBaseURL, modelType, ok, err := resolveTenantModelByTenantModelID(ctx, db, tid, modelRef, apiKey, baseURL, allowedTypes) if err != nil { - return "", "", apiKey, baseURL, false, err + return "", "", apiKey, baseURL, 0, false, err } if ok { - return modelName, provider, modelKey, modelBaseURL, true, nil + return modelName, provider, modelKey, modelBaseURL, modelType, true, nil } - modelName, provider, instanceKey, instanceBaseURL, ok, err := resolveTenantModelByInstanceID(ctx, db, tid, modelRef, apiKey, baseURL, allowedTypes) + modelName, provider, instanceKey, instanceBaseURL, modelType, ok, err := resolveTenantModelByInstanceID(ctx, db, tid, modelRef, apiKey, baseURL, allowedTypes) if err != nil { - return "", "", apiKey, baseURL, false, err + return "", "", apiKey, baseURL, 0, false, err } if ok { - return modelName, provider, instanceKey, instanceBaseURL, true, nil + return modelName, provider, instanceKey, instanceBaseURL, modelType, true, nil } - return "", "", apiKey, baseURL, false, fmt.Errorf("tenant %s model id %q not found", allowedTypes, modelRef) + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("tenant %s model id %q not found", allowedTypes, modelRef) } -func resolveTenantModelByTenantModelID(ctx context.Context, db *gorm.DB, tid, modelID, apiKey, baseURL string, allowedTypes entity.ModelType) (string, string, string, string, bool, error) { +func resolveTenantModelByTenantModelID(ctx context.Context, db *gorm.DB, tid, modelID, apiKey, baseURL string, allowedTypes entity.ModelType) (string, string, string, string, entity.ModelType, bool, error) { model, err := dao.NewTenantModelDAO().GetByID(ctx, db, modelID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return "", "", apiKey, baseURL, false, nil + return "", "", apiKey, baseURL, 0, false, nil } - return "", "", apiKey, baseURL, false, fmt.Errorf("resolve tenant model id %q: %w", modelID, err) + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("resolve tenant model id %q: %w", modelID, err) } if !strings.EqualFold(strings.TrimSpace(model.Status), "active") { - return "", "", apiKey, baseURL, false, fmt.Errorf("tenant model id %s is disabled", modelID) + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("tenant model id %s is disabled", modelID) } if !entity.ModelType(model.ModelType).Has(allowedTypes) { - return "", "", apiKey, baseURL, false, fmt.Errorf("tenant model id %s cannot be used as %s model", modelID, allowedTypes) + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("tenant model id %s cannot be used as %s model", modelID, allowedTypes) } provider, err := dao.NewTenantModelProviderDAO().GetByID(ctx, db, model.ProviderID) if err != nil { - return "", "", apiKey, baseURL, false, fmt.Errorf("resolve provider for tenant model id %q: %w", modelID, err) + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("resolve provider for tenant model id %q: %w", modelID, err) } if provider.TenantID != tid { - return "", "", apiKey, baseURL, false, fmt.Errorf("tenant %s has no access to model id %s", tid, modelID) + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("tenant %s has no access to model id %s", tid, modelID) } instance, err := dao.NewTenantModelInstanceDAO().GetByID(ctx, db, model.InstanceID) if err != nil { - return "", "", apiKey, baseURL, false, fmt.Errorf("resolve instance for tenant model id %q: %w", modelID, err) + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("resolve instance for tenant model id %q: %w", modelID, err) } if entity.ModelType(model.ModelType).Has(entity.ModelTypeDecision) && allowedTypes.Has(entity.ModelTypeDecision) { if instance.ProviderID != provider.ID || !strings.EqualFold(strings.TrimSpace(instance.Status), "active") { - return "", "", apiKey, baseURL, false, fmt.Errorf("tenant model id %s has an inactive or mismatched instance", modelID) - } - if !strings.EqualFold(provider.ProviderName, "SystemOne") { - return "", "", apiKey, baseURL, false, fmt.Errorf("decision model id %s requires the SystemOne provider", modelID) + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("tenant model id %s has an inactive or mismatched instance", modelID) } } - apiKey, baseURL = fillInstanceCredentials(instance, apiKey, baseURL) - return model.ModelName, provider.ProviderName, apiKey, baseURL, true, nil + apiKey, baseURL = fillInstanceCredentials(provider.ProviderName, instance, apiKey, baseURL) + return model.ModelName, provider.ProviderName, apiKey, baseURL, entity.ModelType(model.ModelType), true, nil } -func resolveTenantModelByInstanceID(ctx context.Context, db *gorm.DB, tid, instanceID, apiKey, baseURL string, allowedTypes entity.ModelType) (string, string, string, string, bool, error) { +func resolveTenantModelByInstanceID(ctx context.Context, db *gorm.DB, tid, instanceID, apiKey, baseURL string, allowedTypes entity.ModelType) (string, string, string, string, entity.ModelType, bool, error) { instance, err := dao.NewTenantModelInstanceDAO().GetByID(ctx, db, instanceID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return "", "", apiKey, baseURL, false, nil + return "", "", apiKey, baseURL, 0, false, nil } - return "", "", apiKey, baseURL, false, fmt.Errorf("resolve tenant model instance id %q: %w", instanceID, err) + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("resolve tenant model instance id %q: %w", instanceID, err) } provider, err := dao.NewTenantModelProviderDAO().GetByID(ctx, db, instance.ProviderID) if err != nil { - return "", "", apiKey, baseURL, false, fmt.Errorf("resolve provider for tenant model instance id %q: %w", instanceID, err) + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("resolve provider for tenant model instance id %q: %w", instanceID, err) } if provider.TenantID != tid { - return "", "", apiKey, baseURL, false, fmt.Errorf("tenant %s has no access to model instance id %s", tid, instanceID) + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("tenant %s has no access to model instance id %s", tid, instanceID) } models, err := dao.NewTenantModelDAO().GetModelsByInstanceID(ctx, db, instance.ID) if err != nil { - return "", "", apiKey, baseURL, false, fmt.Errorf("resolve models for tenant model instance id %q: %w", instanceID, err) + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("resolve models for tenant model instance id %q: %w", instanceID, err) } candidates := make([]*entity.TenantModel, 0, len(models)) for _, model := range models { @@ -299,25 +415,56 @@ func resolveTenantModelByInstanceID(ctx context.Context, db *gorm.DB, tid, insta candidates = append(candidates, model) } if len(candidates) == 0 { - return "", "", apiKey, baseURL, false, fmt.Errorf("tenant model instance id %s has no active %s model", instanceID, allowedTypes) + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("tenant model instance id %s has no active %s model", instanceID, allowedTypes) } if len(candidates) > 1 { - return "", "", apiKey, baseURL, false, fmt.Errorf("tenant model instance id %s has %d active %s models; use tenant_model.id or model@instance@provider", instanceID, len(candidates), allowedTypes) + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("tenant model instance id %s has %d active %s models; use tenant_model.id or model@instance@provider", instanceID, len(candidates), allowedTypes) } if entity.ModelType(candidates[0].ModelType).Has(entity.ModelTypeDecision) { - if !strings.EqualFold(strings.TrimSpace(instance.Status), "active") || !strings.EqualFold(provider.ProviderName, "SystemOne") { - return "", "", apiKey, baseURL, false, fmt.Errorf("decision model instance id %s requires an active SystemOne instance", instanceID) + if !strings.EqualFold(strings.TrimSpace(instance.Status), "active") { + return "", "", apiKey, baseURL, 0, false, fmt.Errorf("decision model instance id %s requires an active instance", instanceID) } } - apiKey, baseURL = fillInstanceCredentials(instance, apiKey, baseURL) - return candidates[0].ModelName, provider.ProviderName, apiKey, baseURL, true, nil + apiKey, baseURL = fillInstanceCredentials(provider.ProviderName, instance, apiKey, baseURL) + return candidates[0].ModelName, provider.ProviderName, apiKey, baseURL, entity.ModelType(candidates[0].ModelType), true, nil } -func fillInstanceCredentials(instance *entity.TenantModelInstance, apiKey, baseURL string) (string, string) { +// naceDefaultOrigin is where a saved Nace instance points when it stores no +// base_url of its own. +const naceDefaultOrigin = "https://console.nace.ai" + +// instanceBaseURL returns the base_url saved on the instance, if any. +func instanceBaseURL(instance *entity.TenantModelInstance) string { + if instance == nil || instance.Extra == "" { + return "" + } + var extra map[string]string + if err := json.Unmarshal([]byte(instance.Extra), &extra); err != nil { + return "" + } + return extra["base_url"] +} + +// savedKeyMayGoTo reports whether the instance's saved key may be sent to the +// override base URL. A workflow can supply base_url without an api_key; for +// Nace the saved key is then only combined with an override that has the same +// origin as the saved instance, so a workflow cannot send it elsewhere. +func savedKeyMayGoTo(providerName string, instance *entity.TenantModelInstance, override string) bool { + if !strings.EqualFold(providerName, "Nace") || strings.TrimSpace(override) == "" { + return true + } + saved := instanceBaseURL(instance) + if saved == "" { + saved = naceDefaultOrigin + } + return models.SameOrigin(saved, override) +} + +func fillInstanceCredentials(providerName string, instance *entity.TenantModelInstance, apiKey, baseURL string) (string, string) { if instance == nil { return apiKey, baseURL } - if apiKey == "" { + if apiKey == "" && savedKeyMayGoTo(providerName, instance, baseURL) { apiKey = instance.APIKey } if baseURL == "" && instance.Extra != "" { diff --git a/internal/agent/component/llm_credentials_test.go b/internal/agent/component/llm_credentials_test.go new file mode 100644 index 0000000000..7428727a54 --- /dev/null +++ b/internal/agent/component/llm_credentials_test.go @@ -0,0 +1,193 @@ +package component + +import ( + "errors" + "fmt" + "strings" + "testing" + + "ragflow/internal/entity" + + "gorm.io/gorm" +) + +func TestCompositeModelPropagatesLookupErrors(t *testing.T) { + for _, tc := range []struct { + name, table, instance string + }{ + {"provider", "tenant_model_provider", "default"}, + {"default instance", "tenant_model_instance", "default"}, + {"named instance", "tenant_model_instance", "custom"}, + } { + t.Run(tc.name, func(t *testing.T) { + db := setupCategorizeDecisionModel(t, "https://example.invalid/v1") + lookupErr := errors.New("lookup unavailable") + queries := make(map[string]int) + if err := db.Callback().Query().Before("gorm:query").Register("test:lookup_error", func(tx *gorm.DB) { + queries[tx.Statement.Table]++ + if tx.Statement.Table == tc.table && queries[tc.table] == 1 { + tx.AddError(lookupErr) + } + }); err != nil { + t.Fatal(err) + } + ref := fmt.Sprintf("decision-model-preview@%s@SystemOne", tc.instance) + _, _, _, _, _, err := resolveModelRefWithType(stateWithTenant("tenant-1"), db, ref, "", "", "", entity.ModelTypeDecision) + if !errors.Is(err, lookupErr) { + t.Fatalf("resolveModelRefWithType error = %v, want %v", err, lookupErr) + } + if queries["tenant_model_provider"] != 1 || queries[tc.table] != 1 || queries["tenant_model"] != 0 { + t.Fatalf("lookup error triggered further resolution: %v", queries) + } + }) + } +} + +func TestCompositeModelMissingRecordsRemainUnresolved(t *testing.T) { + // Two-part model@provider references keep the legacy fallback. + for _, ref := range []string{"decision-model-preview@missing-provider"} { + t.Run(ref, func(t *testing.T) { + db := setupCategorizeDecisionModel(t, "https://example.invalid/v1") + _, _, key, baseURL, _, ok, err := resolveTenantCompositeModel(stateWithTenant("tenant-1"), db, ref, "override-key", "https://example.invalid/override", entity.ModelTypeDecision) + if err != nil || ok || key != "override-key" || baseURL != "https://example.invalid/override" { + t.Fatalf("missing record resolution = %t, %v; overrides preserved = %t", ok, err, key == "override-key" && baseURL == "https://example.invalid/override") + } + }) + } +} + +func TestCompositeModelRejectsUnresolvedInstanceReference(t *testing.T) { + for _, ref := range []string{ + "decision-model-preview@default@Nace", + "decision-model-preview@missing-instance@SystemOne", + } { + t.Run(ref, func(t *testing.T) { + db := setupCategorizeDecisionModel(t, "https://example.invalid/v1") + _, _, _, _, _, err := resolveModelRefWithType(stateWithTenant("tenant-1"), db, ref, "", "override-key", "https://example.invalid/override", entity.ModelTypeDecision) + if err == nil { + t.Fatal("unresolved three-part reference must not fall back to the legacy split") + } + }) + } +} + +func TestCompositeModelMissingDefaultUsesSoleActiveInstance(t *testing.T) { + db := setupCategorizeDecisionModel(t, "https://example.invalid/v1") + if err := db.Model(&entity.TenantModelInstance{}).Where("id = ?", "decision-instance").Update("instance_name", "custom").Error; err != nil { + t.Fatal(err) + } + model, driver, _, baseURL, modelType, ok, err := resolveTenantCompositeModel(stateWithTenant("tenant-1"), db, "decision-model-preview@default@SystemOne", "", "", entity.ModelTypeDecision) + if err != nil || !ok || model != "decision-model-preview" || driver != "SystemOne" || baseURL != "https://example.invalid/v1" || modelType != entity.ModelTypeDecision { + t.Fatalf("default fallback = %q, %q, %q, %v, %t, %v", model, driver, baseURL, modelType, ok, err) + } +} + +func TestCompositeModelMissingModelRowChecksInstanceStatus(t *testing.T) { + ref := "gone-model@default@SystemOne" + db := setupCategorizeDecisionModel(t, "https://example.invalid/v1") + // Active instance: the fallback to the instance credentials is kept. + if _, _, _, _, _, err := resolveModelRefWithType(stateWithTenant("tenant-1"), db, ref, "", "", "", entity.ModelTypeDecision); err != nil { + t.Fatalf("active instance, missing model row: %v", err) + } + if err := db.Model(&entity.TenantModelInstance{}).Where("id = ?", "decision-instance").Update("status", "inactive").Error; err != nil { + t.Fatal(err) + } + if _, _, key, _, _, err := resolveModelRefWithType(stateWithTenant("tenant-1"), db, ref, "", "", "", entity.ModelTypeDecision); err == nil { + t.Fatalf("inactive instance with a missing model row resolved (key %q)", key) + } +} + +func TestNaceSavedKeyIsNotSentToAnotherOrigin(t *testing.T) { + naceInstance := func(extra string) *entity.TenantModelInstance { + return &entity.TenantModelInstance{APIKey: "saved-key", Extra: extra} + } + for _, tc := range []struct { + name, provider, extra, override string + wantKey string + }{ + {"no override", "Nace", "", "", "saved-key"}, + {"default origin", "Nace", "", "https://console.nace.ai/v1", "saved-key"}, + {"default port spelled out", "Nace", "", "https://console.nace.ai:443/v1", "saved-key"}, + {"other host", "Nace", "", "https://attacker.example/v1", ""}, + {"subdomain", "Nace", "", "https://x.console.nace.ai/v1", ""}, + {"other port", "Nace", "", "https://console.nace.ai:8443/v1", ""}, + {"other scheme", "Nace", "", "http://console.nace.ai/v1", ""}, + {"saved origin", "Nace", `{"base_url":"https://nace.internal:9000/v1"}`, "https://nace.internal:9000/other", "saved-key"}, + {"saved origin, other host", "Nace", `{"base_url":"https://nace.internal:9000/v1"}`, "https://console.nace.ai/v1", ""}, + {"other providers keep behaviour", "OpenAI", "", "https://proxy.example/v1", "saved-key"}, + } { + t.Run(tc.name, func(t *testing.T) { + key, baseURL := fillInstanceCredentials(tc.provider, naceInstance(tc.extra), "", tc.override) + if key != tc.wantKey || (tc.override != "" && baseURL != tc.override) { + t.Fatalf("key=%q baseURL=%q, want key %q", key, baseURL, tc.wantKey) + } + }) + } +} + +func TestNaceLegacyFallbackDoesNotSendSavedKeyToAnotherOrigin(t *testing.T) { + db := setupCategorizeDecisionModel(t, "https://console.nace.ai/v1") + if err := db.Model(&entity.TenantModelProvider{}).Where("id = ?", "decision-provider").Update("provider_name", "Nace").Error; err != nil { + t.Fatal(err) + } + if key, _, ok := resolveTenantModelInstanceCredentials(stateWithTenant("tenant-1"), db, "tenant-1", "decision-model-preview@default@Nace", "https://attacker.example/v1"); ok || key != "" { + t.Fatalf("saved key released to another origin: %q, %t", key, ok) + } + if key, _, ok := resolveTenantModelInstanceCredentials(stateWithTenant("tenant-1"), db, "tenant-1", "decision-model-preview@default@Nace", "https://console.nace.ai/v1"); !ok || key != "test-key" { + t.Fatalf("same-origin override lost the saved key: %q, %t", key, ok) + } +} + +// setupLegacyProviderModels creates a tenant with a non-decision provider and +// one model row, as an LLM or Agent node would reference it. +func setupLegacyProviderModels(t *testing.T, modelType int, status string) *gorm.DB { + t.Helper() + db := setupComponentTestDB(t) + for _, row := range []any{ + &entity.TenantModelProvider{ID: "zhipu-provider", UserID: "tenant-1", TenantID: "tenant-1", ProviderName: "ZHIPU-AI"}, + &entity.TenantModelInstance{ID: "zhipu-instance", ProviderID: "zhipu-provider", InstanceName: "default", APIKey: "saved-key", Status: "active", Extra: `{"base_url":"https://saved.example/v1"}`}, + &entity.TenantModel{ID: "zhipu-model", ProviderID: "zhipu-provider", InstanceID: "zhipu-instance", ModelName: "glm-4v", ModelType: modelType, Status: status}, + } { + if err := db.Create(row).Error; err != nil { + t.Fatal(err) + } + } + return db +} + +// Non-decision references must resolve as they did before decision routing: +// vision-only and disabled rows, and providers with no tenant row, are neither +// rejected nor matched, and explicit credentials are left alone. +func TestCompositeModelLeavesNonDecisionReferencesUntouched(t *testing.T) { + for _, tc := range []struct { + name string + modelType int + status string + ref string + apiKey string + baseURL string + wantKey string + wantBase string + }{ + {"vision-only three-part (agent)", int(entity.ModelTypeImage2Text), "active", "glm-4v@default@ZHIPU-AI", "", "", "saved-key", "https://saved.example/v1"}, + {"vision-only two-part (agent)", int(entity.ModelTypeImage2Text), "active", "glm-4v@ZHIPU-AI", "", "", "saved-key", "https://saved.example/v1"}, + {"disabled chat row", int(entity.ModelTypeChat), "inactive", "glm-4v@default@ZHIPU-AI", "", "", "saved-key", "https://saved.example/v1"}, + {"explicit key keeps provider default base", int(entity.ModelTypeChat), "active", "glm-4v@default@ZHIPU-AI", "explicit", "", "explicit", ""}, + {"no tenant provider row, explicit credentials", int(entity.ModelTypeChat), "active", "glm-4v@default@OTHER-PROVIDER", "explicit", "https://wf.example/v1", "explicit", "https://wf.example/v1"}, + } { + t.Run(tc.name, func(t *testing.T) { + db := setupLegacyProviderModels(t, tc.modelType, tc.status) + model, driver, key, baseURL, err := resolveChatModelRef(stateWithTenant("tenant-1"), db, tc.ref, "", tc.apiKey, tc.baseURL) + if err != nil { + t.Fatalf("resolveChatModelRef: %v", err) + } + wantDriver := "ZHIPU-AI" + if strings.Contains(tc.ref, "OTHER") { + wantDriver = "OTHER-PROVIDER" + } + if model != "glm-4v" || driver != wantDriver || key != tc.wantKey || baseURL != tc.wantBase { + t.Fatalf("got %q %q key=%q base=%q; want glm-4v %s key=%q base=%q", model, driver, key, baseURL, wantDriver, tc.wantKey, tc.wantBase) + } + }) + } +} diff --git a/internal/entity/models/factory.go b/internal/entity/models/factory.go index 08ca9b5c8b..c2bca79141 100644 --- a/internal/entity/models/factory.go +++ b/internal/entity/models/factory.go @@ -205,6 +205,9 @@ func (f *ModelFactory) CreateModelDriver(providerName string, baseURL map[string return NewEmpirioLabsModel(baseURL, urlSuffix), nil case "runcrate": return NewRuncrateModel(baseURL, urlSuffix), nil + case "nace": + // System One protocol, like "systemone", with Nace's own model catalog. + return NewNaceModel(baseURL, urlSuffix), nil case "worldrouter": return NewWorldRouterModel(baseURL, urlSuffix), nil case "cloudgpu": diff --git a/internal/entity/models/nace.go b/internal/entity/models/nace.go new file mode 100644 index 0000000000..1ba13a82cb --- /dev/null +++ b/internal/entity/models/nace.go @@ -0,0 +1,206 @@ +// +// 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 models + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "ragflow/internal/common" + "regexp" + "strings" +) + +// naceClientHeader identifies the integration host to Nace for usage +// attribution. It carries the product name and version only. +const naceClientHeader = "X-Nace-Client" + +var naceClientDisallowed = regexp.MustCompile(`[^A-Za-z0-9 ._@+-]`) + +// naceClientValue returns "ragflow/" limited to the characters and +// length Nace accepts. +func naceClientValue() string { + return naceClientValueFor(common.GetRAGFlowVersion()) +} + +// naceClientValueFor builds the header value from a version string, falling +// back to "unknown" when nothing usable is left after cleaning. +func naceClientValueFor(version string) string { + version = naceClientDisallowed.ReplaceAllString(strings.TrimSpace(version), "-") + if version == "" { + version = "unknown" + } + value := "ragflow/" + version + if len(value) > 64 { + value = value[:64] + } + return value +} + +// naceClientTransport adds the attribution header to every request this driver +// sends to Nace (decide, model discovery and connection checks). +type naceClientTransport struct { + base http.RoundTripper +} + +func (t naceClientTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.Header.Set(naceClientHeader, naceClientValue()) + return t.base.RoundTrip(req) +} + +// NaceModel drives Nace (https://console.nace.ai/v1), which serves the Drex +// decision models (drex-max, drex-v2.0) over the TypeSafe System One protocol. +// The embedded SystemOneModel already speaks the decide call; this driver adds +// the catalog, because GET /models on Nace answers {"models": [{"name": ...}]} +// rather than the OpenAI {"data": [{"id": ...}]} shape the generic lister +// expects, and the generic lister rejects that body as malformed. +type NaceModel struct { + *SystemOneModel +} + +// NewNaceModel creates a Nace driver. +func NewNaceModel(baseURL map[string]string, urlSuffix URLSuffix) *NaceModel { + m := &NaceModel{SystemOneModel: NewSystemOneModel(baseURL, urlSuffix)} + client := *m.baseModel.httpClient + client.CheckRedirect = refuseCrossOriginRedirect + base := client.Transport + if base == nil { + base = http.DefaultTransport + } + client.Transport = naceClientTransport{base: base} + m.baseModel.httpClient = &client + return m +} + +// SameOrigin reports whether two URLs share scheme, host and port (default +// ports are normalised). Unparseable URLs are never the same origin. +func SameOrigin(a, b string) bool { + ua, errA := url.Parse(strings.TrimSpace(a)) + ub, errB := url.Parse(strings.TrimSpace(b)) + if errA != nil || errB != nil { + return false + } + return sameURLOrigin(ua, ub) +} + +func sameURLOrigin(a, b *url.URL) bool { + if a.Hostname() == "" || b.Hostname() == "" { + return false + } + return strings.EqualFold(a.Scheme, b.Scheme) && + strings.EqualFold(a.Hostname(), b.Hostname()) && + originPort(a) == originPort(b) +} + +func originPort(u *url.URL) string { + if p := u.Port(); p != "" { + return p + } + if strings.EqualFold(u.Scheme, "https") { + return "443" + } + return "80" +} + +// refuseCrossOriginRedirect stops a redirect from carrying the Authorization +// header to another scheme, host or port. Go itself keeps the header for the +// same hostname on a different port or scheme and for subdomains. +func refuseCrossOriginRedirect(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + if req.Header.Get("Authorization") != "" && len(via) > 0 && !sameURLOrigin(req.URL, via[0].URL) { + return fmt.Errorf("refusing redirect to %s://%s with credentials", req.URL.Scheme, req.URL.Host) + } + return nil +} + +// Name returns the driver name. +func (m *NaceModel) Name() string { + return "nace" +} + +// NewInstance returns a driver bound to the given base URL. +func (m *NaceModel) NewInstance(baseURL map[string]string) ModelDriver { + return NewNaceModel(baseURL, m.baseModel.URLSuffix) +} + +// naceModelList is the body of GET /models on Nace. alias_for is set on names +// that only point to another model (drex-latest, and retired versions served +// by a newer one). +type naceModelList struct { + Models []struct { + Name string `json:"name"` + AliasFor *string `json:"alias_for"` + } `json:"models"` +} + +// ListModels asks which models the key can call and returns them as decision +// models; every model Nace serves is one. +// +// Aliases are skipped so a user adds the model that is actually billed and +// reported, not a pointer that moves to a newer version on an announced date. +// The catalog answers 401 without a valid key, which is why the web gates this +// listing on the key, as it does for SystemOne. +func (m *NaceModel) ListModels(ctx context.Context, apiConfig *APIConfig) ([]ListModelResponse, error) { + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + baseURL, err := m.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + body, err := m.baseModel.doGetRequest(ctx, joinURL(baseURL, m.baseModel.URLSuffix.Models), apiConfig, nonStreamCallTimeout) + if err != nil { + return nil, fmt.Errorf("nace: %w", err) + } + var list naceModelList + if err := json.Unmarshal(body, &list); err != nil { + return nil, fmt.Errorf("nace: decode model list: %w", err) + } + if list.Models == nil { + return nil, fmt.Errorf("nace: invalid model list format") + } + pm := GetProviderManager() + var models []ListModelResponse + for _, model := range list.Models { + name := strings.TrimSpace(model.Name) + if name == "" || model.AliasFor != nil { + continue + } + response := ListModelResponse{Name: name, ModelTypes: []string{"decision"}} + if pm != nil { + if entity, err := pm.GetModelByName("Nace", name); err == nil && entity != nil { + response.ContextLength = entity.ContextLength + } + } + models = append(models, response) + } + return models, nil +} + +// CheckConnection verifies the key against the model catalog. The embedded +// driver's check goes through the generic OpenAI-shaped lister, which cannot +// read Nace's catalog, so it is routed through ListModels here. +func (m *NaceModel) CheckConnection(ctx context.Context, apiConfig *APIConfig) error { + _, err := m.ListModels(ctx, apiConfig) + return err +} diff --git a/internal/entity/models/nace_test.go b/internal/entity/models/nace_test.go new file mode 100644 index 0000000000..5b8ebd6703 --- /dev/null +++ b/internal/entity/models/nace_test.go @@ -0,0 +1,351 @@ +// +// 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 models + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "ragflow/internal/common" + "regexp" + "strings" + "testing" +) + +// newNaceForTest creates a NaceModel instance for testing. +func newNaceForTest(baseURL string) *NaceModel { + return NewNaceModel( + map[string]string{"default": baseURL}, + URLSuffix{Decision: "systemone", Models: "models"}, + ) +} + +// TestNaceName tests the driver name. +func TestNaceName(t *testing.T) { + if got := newNaceForTest("http://unused").Name(); got != "nace" { + t.Errorf("Name()=%q", got) + } +} + +// TestNaceFactory tests creating the driver from factory. +func TestNaceFactory(t *testing.T) { + driver, err := NewModelFactory().CreateModelDriver("nace", map[string]string{"default": "http://unused"}, URLSuffix{}) + if err != nil { + t.Fatalf("CreateModelDriver: %v", err) + } + if _, ok := driver.(*NaceModel); !ok { + t.Fatalf("driver type=%T, want *NaceModel", driver) + } + if _, ok := driver.NewInstance(map[string]string{"default": "http://other"}).(*NaceModel); !ok { + t.Fatal("NewInstance did not return *NaceModel") + } +} + +// The shipped config must resolve to the Nace driver and declare the System +// One decision path. CreateModelDriver dispatches on strings.ToLower(name), so +// the "Nace" provider name has to match the "nace" case; anything else silently +// falls through to DummyModel, and a missing suffix would send decide calls to +// the bare base URL. +func TestNaceProviderConfig(t *testing.T) { + dir, restore := setupProviderTestDir(t, "nace.json") + defer restore() + + if err := InitProviderManager(dir); err != nil { + t.Fatalf("InitProviderManager: %v", err) + } + + provider := GetProviderManager().FindProvider("Nace") + if provider == nil { + t.Fatal("Nace provider not found") + } + if _, ok := provider.ModelDriver.(*NaceModel); !ok { + t.Fatalf("ModelDriver=%T, want *models.NaceModel", provider.ModelDriver) + } + if provider.URLSuffix.Decision != "systemone" { + t.Errorf("url_suffix.decision=%q, want systemone", provider.URLSuffix.Decision) + } + names := make([]string, 0, len(provider.Models)) + for _, model := range provider.Models { + names = append(names, model.Name) + if !model.ModelTypeMap["decision"] { + t.Errorf("%s is not a decision model: %v", model.Name, model.ModelTypes) + } + } + if len(names) != 2 || names[0] != "drex-max" || names[1] != "drex-v2.0" { + t.Errorf("models=%v, want [drex-max drex-v2.0]", names) + } +} + +// TestNaceListModelsSkipsAliases tests model listing against Nace's own +// catalog shape, and that names which only point to another model are not +// offered. +func TestNaceListModelsSkipsAliases(t *testing.T) { + withSSRFBypass(t) + dir, restore := setupProviderTestDir(t, "nace.json") + defer restore() + if err := InitProviderManager(dir); err != nil { + t.Fatalf("InitProviderManager: %v", err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/models" { + t.Errorf("%s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer sk-gw-test" { + t.Errorf("Authorization=%q", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "models": []map[string]any{ + {"name": "drex-v2.0", "release_date": "2026-10-06", "alias_for": nil}, + {"name": "drex-max", "release_date": "2026-10-13", "alias_for": nil}, + {"name": "drex-latest", "release_date": "2026-10-06", "alias_for": "drex-v2.0"}, + {"name": "drex-v1.0", "release_date": "2026-09-24", "alias_for": "drex-max"}, + }, + "request_id": "req_list", + }) + })) + defer srv.Close() + + model := newNaceForTest(srv.URL + "/v1") + list, err := model.ListModels(t.Context(), &APIConfig{ApiKey: &testAPIKey}) + if err != nil { + t.Fatalf("ListModels: %v", err) + } + if joinModelNames(list, ",") != "drex-v2.0,drex-max" { + t.Errorf("models=%v", list) + } + for _, item := range list { + if len(item.ModelTypes) != 1 || item.ModelTypes[0] != "decision" { + t.Errorf("%s types=%v, want [decision]", item.Name, item.ModelTypes) + } + if item.ContextLength == nil || *item.ContextLength != 262144 { + t.Errorf("%s context_length=%v, want 262144", item.Name, item.ContextLength) + } + } + if err := model.CheckConnection(t.Context(), &APIConfig{ApiKey: &testAPIKey}); err != nil { + t.Fatalf("CheckConnection: %v", err) + } +} + +// A rejected key must fail the listing and the connection check rather than +// offer models that cannot be called. +func TestNaceListModelsFailsOnBadKey(t *testing.T) { + withSSRFBypass(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"type":"authentication_error","message":"invalid api key"},"request_id":"req_bad"}`)) + })) + defer srv.Close() + + model := newNaceForTest(srv.URL + "/v1") + list, err := model.ListModels(t.Context(), &APIConfig{ApiKey: &testAPIKey}) + if err == nil { + t.Fatalf("ListModels returned %v, want an error", list) + } + if list != nil { + t.Errorf("models=%v, want nil on failure", list) + } + if err := model.CheckConnection(t.Context(), &APIConfig{ApiKey: &testAPIKey}); err == nil { + t.Fatal("CheckConnection did not fail") + } +} + +// TestNaceDecide tests the decide call with the payload Categorize sends and +// the response Nace returns, whose request id is named request_id. +func TestNaceDecide(t *testing.T) { + withSSRFBypass(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/systemone" { + t.Errorf("%s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer sk-gw-test" { + t.Errorf("Authorization=%q", got) + } + var request struct { + Model string `json:"model"` + State map[string]string `json:"state"` + Questions map[string]struct{ Type, Instructions string } `json:"questions"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + } + if request.Model != "drex-max" || request.State["query"] != "my printer will not turn on" || len(request.Questions) != 2 || request.Questions["category_0"].Type != "noul" || request.Questions["category_0"].Instructions != "Is \"Hardware fault\" the single best category for the current query in the state?" { + t.Errorf("incorrect decision payload: %+v", request) + } + _, _ = w.Write([]byte(`{"model":"drex-max","answers":{"category_0":{"type":"noul","noul":0.918},"category_1":{"type":"noul","noul":0.0015}},"usage":{"input_tokens":208,"output_tokens":75},"evaluation_time_ms":79.5,"request_id":"req_6861473053f6606cda19ed4affd42b81"}`)) + })) + defer srv.Close() + + usage := &common.ModelUsage{} + name := "drex-max" + result, err := newNaceForTest(srv.URL+"/v1").Decide(t.Context(), &name, &DecideRequest{ + State: map[string]string{"query": "my printer will not turn on", "conversation": ""}, + Questions: map[string]Question{ + "category_0": Noul{Instructions: "Is \"Hardware fault\" the single best category for the current query in the state?"}, + "category_1": Noul{Instructions: "Is \"Billing question\" the single best category for the current query in the state?"}, + }, + }, &APIConfig{ApiKey: &testAPIKey}, usage) + if err != nil { + t.Fatalf("Decide: %v", err) + } + if got := result.Answers["category_0"]; got.Noul == nil || *got.Noul != 0.918 { + t.Errorf("category_0=%+v", got) + } + if got := result.Answers["category_1"]; got.Noul == nil || *got.Noul != 0.0015 { + t.Errorf("category_1=%+v", got) + } + assertModelUsage(t, usage, 208, 75, 283) + if usage.RequestID != "req_6861473053f6606cda19ed4affd42b81" { + t.Errorf("RequestID=%q", usage.RequestID) + } +} + +// Nace serves no chat completions; the slot is the decision one. +func TestNaceRefusesChat(t *testing.T) { + if _, err := newNaceForTest("http://unused").ChatWithMessages( + context.Background(), "drex-max", nil, nil, nil, nil, + ); err == nil { + t.Fatal("ChatWithMessages did not refuse") + } +} + +// The Authorization header must not follow a redirect to another origin, even +// to the same hostname on another port or scheme, which net/http would allow. +func TestNaceDoesNotForwardKeyOnCrossOriginRedirect(t *testing.T) { + withSSRFBypass(t) + var leaked int + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" { + leaked++ + } + _, _ = w.Write([]byte(`{"models":[]}`)) + })) + defer other.Close() + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL+"/v1/models", http.StatusTemporaryRedirect) + })) + defer origin.Close() + + model := newNaceForTest(origin.URL + "/v1") + if _, err := model.ListModels(t.Context(), &APIConfig{ApiKey: &testAPIKey}); err == nil { + t.Fatal("ListModels followed a cross-origin redirect") + } + if leaked != 0 { + t.Fatalf("Authorization reached the redirect target %d time(s)", leaked) + } +} + +func TestNaceFollowsSameOriginRedirect(t *testing.T) { + withSSRFBypass(t) + mux := http.NewServeMux() + mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/v1/models2", http.StatusTemporaryRedirect) + }) + mux.HandleFunc("/v1/models2", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + t.Error("same-origin redirect dropped Authorization") + } + _, _ = w.Write([]byte(`{"models":[{"name":"drex-max"}]}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + list, err := newNaceForTest(srv.URL+"/v1").ListModels(t.Context(), &APIConfig{ApiKey: &testAPIKey}) + if err != nil || len(list) != 1 { + t.Fatalf("ListModels = %v, %v", list, err) + } +} + +func TestSameOrigin(t *testing.T) { + for _, tc := range []struct { + a, b string + want bool + }{ + {"https://console.nace.ai", "https://console.nace.ai/v1", true}, + {"https://console.nace.ai", "https://CONSOLE.nace.ai:443/x", true}, + {"https://console.nace.ai", "http://console.nace.ai", false}, + {"https://console.nace.ai", "https://console.nace.ai:8443", false}, + {"https://console.nace.ai", "https://nace.ai", false}, + {"", "https://console.nace.ai", false}, + } { + if got := SameOrigin(tc.a, tc.b); got != tc.want { + t.Errorf("SameOrigin(%q, %q)=%t, want %t", tc.a, tc.b, got, tc.want) + } + } +} + +// TestNaceClientHeader checks that decide and model discovery identify RAGFlow +// to Nace as "ragflow/", and that the plain SystemOne driver does not +// send the header. +func TestNaceClientHeader(t *testing.T) { + withSSRFBypass(t) + want := naceClientValue() + if !regexp.MustCompile(`^ragflow/[A-Za-z0-9 ._@+-]+$`).MatchString(want) || len(want) > 64 { + t.Fatalf("header value %q is not name/version within 64 chars", want) + } + seen := map[string]string{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen[r.Method+" "+r.URL.Path] = r.Header.Get("X-Nace-Client") + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`{"models":[]}`)) + return + } + _, _ = w.Write([]byte(`{"model":"drex-max","answers":{"category_0":{"type":"noul","noul":0.5}}}`)) + })) + defer srv.Close() + + decide := func(driver interface { + Decide(context.Context, *string, *DecideRequest, *APIConfig, *common.ModelUsage) (*DecideResponse, error) + }) { + name := "drex-max" + _, err := driver.Decide(t.Context(), &name, &DecideRequest{ + State: map[string]string{"query": "q"}, + Questions: map[string]Question{"category_0": Noul{Instructions: "Is it?"}}, + }, &APIConfig{ApiKey: &testAPIKey}, &common.ModelUsage{}) + if err != nil { + t.Fatalf("Decide: %v", err) + } + } + nace := newNaceForTest(srv.URL + "/v1") + decide(nace) + if _, err := nace.ListModels(t.Context(), &APIConfig{ApiKey: &testAPIKey}); err != nil { + t.Fatalf("ListModels: %v", err) + } + if seen["POST /v1/systemone"] != want || seen["GET /v1/models"] != want { + t.Errorf("Nace headers=%v, want %q on both", seen, want) + } + + clear(seen) + decide(newSystemOneForTest(srv.URL + "/v1")) + if got := seen["POST /v1/systemone"]; got != "" { + t.Errorf("SystemOne sent X-Nace-Client=%q", got) + } +} + +func TestNaceClientValueFor(t *testing.T) { + long := strings.Repeat("a", 100) + for version, want := range map[string]string{ + "v0.27.1-14-gabc123": "ragflow/v0.27.1-14-gabc123", + "v1.0 beta/x:y": "ragflow/v1.0 beta-x-y", + "": "ragflow/unknown", + " \n": "ragflow/unknown", + long: ("ragflow/" + long)[:64], + } { + if got := naceClientValueFor(version); got != want { + t.Errorf("naceClientValueFor(%q)=%q, want %q", version, got, want) + } + } +} diff --git a/internal/entity/models/systemone.go b/internal/entity/models/systemone.go index 91f0642fd2..718b9702d5 100644 --- a/internal/entity/models/systemone.go +++ b/internal/entity/models/systemone.go @@ -155,7 +155,11 @@ func (m *SystemOneModel) Decide(ctx context.Context, modelName *string, request return nil, fmt.Errorf("systemone: invalid probability for question %q", id) } } - recordResponseUsage(modelUsage, response.ID, &TokenUsage{ + requestID := response.ID + if requestID == "" { + requestID = response.RequestID + } + recordResponseUsage(modelUsage, requestID, &TokenUsage{ PromptTokens: response.Usage.InputTokens, CompletionTokens: response.Usage.OutputTokens, TotalTokens: response.Usage.InputTokens + response.Usage.OutputTokens, }, "decision") diff --git a/internal/entity/models/types.go b/internal/entity/models/types.go index 13fedb5e1b..a1d6fb7634 100644 --- a/internal/entity/models/types.go +++ b/internal/entity/models/types.go @@ -238,9 +238,11 @@ type DecisionAnswer struct { } type DecideResponse struct { - ID string `json:"id"` - Answers map[string]DecisionAnswer `json:"answers"` - Usage struct { + ID string `json:"id"` + // RequestID is the same identifier under the name Nace uses for it. + RequestID string `json:"request_id,omitempty"` + Answers map[string]DecisionAnswer `json:"answers"` + Usage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` } `json:"usage"` diff --git a/web/src/assets/svg/llm/nace.svg b/web/src/assets/svg/llm/nace.svg new file mode 100644 index 0000000000..6817dfd4e9 --- /dev/null +++ b/web/src/assets/svg/llm/nace.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/web/src/components/svg-icon.tsx b/web/src/components/svg-icon.tsx index f62a0b2406..6724665c3a 100644 --- a/web/src/components/svg-icon.tsx +++ b/web/src/components/svg-icon.tsx @@ -123,6 +123,7 @@ const svgIcons = [ LLMFactory.EmpirioLabs, LLMFactory.AIStupidLevel, LLMFactory.Runcrate, + LLMFactory.Nace, LLMFactory.WorldRouter, LLMFactory.CloudGPU, LLMFactory.Obscurify, diff --git a/web/src/constants/llm.ts b/web/src/constants/llm.ts index 24a16c66bf..fff0f6ed1d 100644 --- a/web/src/constants/llm.ts +++ b/web/src/constants/llm.ts @@ -123,6 +123,7 @@ export enum LLMFactory { Obscurify = 'Obscurify.ai', Cheapaimodel = 'cheapaimodel', SystemOne = 'SystemOne', + Nace = 'Nace', HostingerRouter = 'Hostinger Router', Wally = 'Wally', Synap = 'Synap', @@ -226,6 +227,7 @@ export const IconMap = { [LLMFactory.EmpirioLabs]: 'empiriolabs', [LLMFactory.AIStupidLevel]: 'aistupidlevel', [LLMFactory.Runcrate]: 'runcrate', + [LLMFactory.Nace]: 'nace', [LLMFactory.WorldRouter]: 'worldrouter', [LLMFactory.CloudGPU]: 'cloudgpu', [LLMFactory.Obscurify]: 'obscurify', @@ -281,6 +283,7 @@ export const APIMapUrl = { [LLMFactory.AIStupidLevel]: 'https://aistupidlevel.info/router/keys', [LLMFactory.Runcrate]: 'https://www.runcrate.ai/dashboard/api-keys?utm_source=ragflow&utm_medium=directory&utm_campaign=gpu_pricing', + [LLMFactory.Nace]: 'https://console.nace.ai/dashboard/api-keys', [LLMFactory.WorldRouter]: 'https://worldrouter.ai/dashboard/api-keys?utm_source=ragflow&utm_medium=provider&utm_campaign=integration', [LLMFactory.CloudGPU]: 'https://cloudgpu.app/api/console', diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 6455e1d3ac..842fb22178 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -3009,7 +3009,7 @@ Best for: Documents with flowing, contextually connected content — such as boo answerDescription: `A component that serves as the interface between human and bot, receiving user inputs and displaying the agent's responses.`, retrievalDescription: `A component that retrieves information from specified datasets (datasets). Ensure that the datasets you select use the same embedding model.`, generateDescription: `A component that prompts the LLM to generate responses. Ensure the prompt is set correctly.`, - categorizeDescription: `Uses a chat model (LLM) or a SystemOne decision model to classify the current input into one predefined category and route the workflow to its connected component. Specify a name, description, and examples for each category.`, + categorizeDescription: `Uses a chat model (LLM) or a SystemOne or Nace decision model to classify the current input into one predefined category and route the workflow to its connected component. Specify a name, description, and examples for each category.`, relevantDescription: `A component that uses the LLM to assess whether the upstream output is relevant to the user's latest query. Ensure you specify the next component for each judge result.`, messageDescription: 'This component returns the final data output of the workflow along with predefined message content. ', diff --git a/web/src/locales/tr.ts b/web/src/locales/tr.ts index 48a95bac9d..c4df1ddd67 100644 --- a/web/src/locales/tr.ts +++ b/web/src/locales/tr.ts @@ -2976,7 +2976,7 @@ En uygun olduğu durumlar: Anlatı bütünlüğünün bitişik paragrafları bir retrievalDescription: `Belirtilen datasets içinden bilgi alan bir bileşen.`, generateDescription: `LLM'yi yanıt üretmeye yönlendiren bir bileşen.`, categorizeDescription: - 'Geçerli girdiyi önceden tanımlanmış kategorilerden birine sınıflandırmak ve iş akışını o kategoriye bağlı bileşene yönlendirmek için bir sohbet modeli (LLM) veya bir SystemOne karar modeli kullanır. Her kategori için bir ad, açıklama ve örnekler belirtin.', + 'Geçerli girdiyi önceden tanımlanmış kategorilerden birine sınıflandırmak ve iş akışını o kategoriye bağlı bileşene yönlendirmek için bir sohbet modeli (LLM) veya bir SystemOne veya Nace karar modeli kullanır. Her kategori için bir ad, açıklama ve örnekler belirtin.', relevantDescription: `Yukarı akış çıktısının kullanıcının son sorgusuna uygun olup olmadığını değerlendirmek için LLM kullanan bir bileşen.`, rewriteQuestionDescription: `Önceki diyalogların bağlamına dayanarak Etkileşim bileşeninden bir kullanıcı sorgusunu yeniden yazan bir bileşen.`, messageDescription: diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 9892774bed..c130297e43 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -2609,7 +2609,7 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取 Entities 和 R answerDescription: `该组件用作机器人与人类之间的接口。它接收用户的输入并显示机器人的计算结果。`, retrievalDescription: `此组件用于从知识库中检索相关信息。选择知识库。如果没有检索到任何内容,将返回“空响应”。`, generateDescription: `此组件用于调用 LLM 生成文本,请注意提示的设置。`, - categorizeDescription: `使用聊天模型(LLM)或 SystemOne 决策模型,将当前输入归入一个预定义类别,并将工作流路由到该类别连接的下游组件。请为每个类别指定名称、描述和示例。`, + categorizeDescription: `使用聊天模型(LLM)或 SystemOne 或 Nace 决策模型,将当前输入归入一个预定义类别,并将工作流路由到该类别连接的下游组件。请为每个类别指定名称、描述和示例。`, relevantDescription: `该组件用来判断upstream的输出是否与用户最新的问题相关,‘是’代表相关,‘否’代表不相关。`, messageDescription: '该组件用来返回工作流最后产生的数据内容和原先设置的文本内容。', diff --git a/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.test.tsx b/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.test.tsx index b36f0d90d5..0e6b8ec2f5 100644 --- a/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.test.tsx +++ b/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.test.tsx @@ -338,78 +338,84 @@ describe('API-key-gated model listing (SystemOne / RAGcon)', () => { ); }); - it('defers the RAGcon auto-fetch when base_url is set but api_key is missing', async () => { - let apiKey = ''; - const baseUrl = 'https://api.ragcon.ai/v1'; - const resolveCreds = () => ({ apiKey, baseUrl }); + it.each(['RAGcon', 'Nace'])( + 'defers the %s draft auto-fetch without an API key', + async (providerName) => { + let apiKey = ''; + const baseUrl = 'https://api.ragcon.ai/v1'; + const resolveCreds = () => ({ apiKey, baseUrl }); - const { rerender } = renderHook( - ({ apiKeyValue }) => - useModelsCatalog({ - providerName: 'RAGcon', - instanceName: DRAFT_INSTANCE_SENTINEL, - hideActions: false, - resolveCreds, - instanceModels: undefined, - apiKeyValue, - baseUrlValue: baseUrl, + const { rerender } = renderHook( + ({ apiKeyValue }) => + useModelsCatalog({ + providerName, + instanceName: DRAFT_INSTANCE_SENTINEL, + hideActions: false, + resolveCreds, + instanceModels: undefined, + apiKeyValue, + baseUrlValue: baseUrl, + }), + { initialProps: { apiKeyValue: apiKey } }, + ); + + // base_url alone must not trigger the fetch — the key gate still applies. + expect(mockListProviderModels).not.toHaveBeenCalled(); + + apiKey = 'draft-key'; + rerender({ apiKeyValue: apiKey }); + + await waitFor(() => + expect(mockListProviderModels).toHaveBeenCalledWith({ + provider_name: providerName, + api_key: apiKey, + base_url: baseUrl, }), - { initialProps: { apiKeyValue: apiKey } }, - ); + ); + }, + ); - // base_url alone must not trigger the fetch — the key gate still applies. - expect(mockListProviderModels).not.toHaveBeenCalled(); + it.each(['RAGcon', 'Nace'])( + 'auto-fetches a saved %s instance using server-stored credentials', + async (providerName) => { + let apiKey = ''; + let hasStoredCredentials = false; + const baseUrl = 'https://api.ragcon.ai/v1'; + const resolveCreds = () => ({ apiKey, baseUrl, hasStoredCredentials }); - apiKey = 'draft-key'; - rerender({ apiKeyValue: apiKey }); + const { rerender } = renderHook( + ({ apiKeyValue, instanceDetailsLoaded }) => + useModelsCatalog({ + providerName, + instanceName: 'ragcon-instance', + hideActions: false, + resolveCreds, + instanceModels: undefined, + instance: { id: 'instance-id', is_owner: true } as any, + apiKeyValue, + baseUrlValue: baseUrl, + regionValue: undefined, + instanceDetailsLoaded, + }), + { initialProps: { apiKeyValue: apiKey, instanceDetailsLoaded: false } }, + ); - await waitFor(() => - expect(mockListProviderModels).toHaveBeenCalledWith({ - provider_name: 'RAGcon', - api_key: apiKey, - base_url: baseUrl, - }), - ); - }); + expect(mockListProviderModels).not.toHaveBeenCalled(); - it('auto-fetches a saved RAGcon instance using server-stored credentials', async () => { - let apiKey = ''; - let hasStoredCredentials = false; - const baseUrl = 'https://api.ragcon.ai/v1'; - const resolveCreds = () => ({ apiKey, baseUrl, hasStoredCredentials }); + hasStoredCredentials = true; + rerender({ apiKeyValue: apiKey, instanceDetailsLoaded: true }); - const { rerender } = renderHook( - ({ apiKeyValue, instanceDetailsLoaded }) => - useModelsCatalog({ - providerName: 'RAGcon', - instanceName: 'ragcon-instance', - hideActions: false, - resolveCreds, - instanceModels: undefined, - instance: { id: 'instance-id', is_owner: true } as any, - apiKeyValue, - baseUrlValue: baseUrl, - regionValue: undefined, - instanceDetailsLoaded, + await waitFor(() => + expect(mockListProviderModels).toHaveBeenCalledWith({ + provider_name: providerName, + base_url: 'https://api.ragcon.ai/v1', + region: undefined, + use_saved_credentials: true, + instance_name: 'ragcon-instance', }), - { initialProps: { apiKeyValue: apiKey, instanceDetailsLoaded: false } }, - ); - - expect(mockListProviderModels).not.toHaveBeenCalled(); - - hasStoredCredentials = true; - rerender({ apiKeyValue: apiKey, instanceDetailsLoaded: true }); - - await waitFor(() => - expect(mockListProviderModels).toHaveBeenCalledWith({ - provider_name: 'RAGcon', - base_url: 'https://api.ragcon.ai/v1', - region: undefined, - use_saved_credentials: true, - instance_name: 'ragcon-instance', - }), - ); - }); + ); + }, + ); it('does not send endpoint overrides with saved credentials for a non-owner', async () => { const resolveCreds = () => ({ diff --git a/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.ts b/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.ts index 1ded6d9aac..83050e84dc 100644 --- a/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.ts +++ b/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.ts @@ -74,6 +74,7 @@ export const API_KEY_GATED_LIST_PROVIDERS: string[] = [ LLMFactory.SystemOne, LLMFactory.RAGcon, LLMFactory.VolcEngine, + LLMFactory.Nace, LLMFactory.DaoXE, LLMFactory.RadeonCloud, LLMFactory.DemonRoute, @@ -415,7 +416,7 @@ export function useModelsCatalog({ // Auto-fetch the provider's available-models catalog when this section // mounts (effectively "when the card is expanded"). For VolcEngine and the - // providers in API_KEY_GATED_LIST_PROVIDERS (SystemOne, RAGcon) we + // providers in API_KEY_GATED_LIST_PROVIDERS (SystemOne, RAGcon, Nace) we // wait until an api_key is available (typed in the draft form or loaded // from instance details). For providers whose form includes a // `base_url` field (e.g. Ollama, Xinference, LocalAI) we defer until a