mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-11-10 13:24:05 +08:00
feat(llm): add Nace as a decision model provider (#21501)
This commit is contained in:
1 parent
8d074e5a57
commit
e4bd0801d5
21 files changed
+1236
-125
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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/<version>" 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
|
||||
}
|
||||
@@ -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/<version>", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 200 210" fill="none">
|
||||
<path d="M199.164 90.0223L173.316 155.667C172.301 158.246 170.443 160.407 168.046 161.799L89.0973 207.412C86.9732 208.67 84.5141 209.245 82.0526 209.059C79.123 208.857 76.367 207.596 74.2984 205.512L74.1211 205.258L3.44637 132.049C1.82858 130.374 0.719711 128.273 0.248694 125.992C-0.222324 123.711 -0.0361329 121.343 0.785615 119.164C0.801123 119.107 0.822348 119.052 0.848967 118.999L26.6076 53.3545C26.642 53.23 26.6932 53.1106 26.7596 52.9998C27.2443 51.8726 27.898 50.8258 28.6982 49.8956C29.6186 48.8405 30.7006 47.9382 31.9038 47.2221L110.865 1.60921C110.878 1.60143 110.894 1.59733 110.909 1.59733C110.925 1.59733 110.94 1.60143 110.953 1.60921C113.331 0.254001 116.093 -0.266455 118.8 0.130804C121.508 0.528062 124.004 1.82017 125.892 3.80117L196.554 77.0479C198.182 78.7437 199.292 80.8689 199.755 83.1738C200.217 85.4788 200.012 87.8678 199.164 90.0603V90.0223Z" fill="black"/>
|
||||
<path d="M111.068 135.192L82.0526 209.059C79.123 208.857 76.367 207.596 74.2984 205.512L74.1211 205.258L3.44637 132.049C1.82858 130.374 0.719711 128.273 0.248694 125.992C-0.222324 123.711 -0.0361329 121.343 0.785615 119.164C0.801123 119.107 0.822348 119.052 0.848967 118.999L26.6076 53.3545C26.642 53.2299 26.6932 53.1106 26.7596 52.9997C27.2443 51.8725 27.898 50.8257 28.6982 49.8955L111.068 135.192Z" fill="#333333"/>
|
||||
<path d="M199.164 90.0223L173.316 155.667C172.301 158.246 170.443 160.407 168.046 161.799L89.0973 207.412C86.9732 208.67 84.5141 209.245 82.0526 209.059L111.068 135.255L199.886 83.9912C200.16 86.0303 199.912 88.1056 199.164 90.0223Z" fill="#131313"/>
|
||||
<path d="M122.331 93.1643C125.575 96.543 125.25 99.3559 121.356 101.603C117.462 103.85 113.534 102.925 109.572 98.8279L106.544 95.6984C102.549 91.5679 102.426 88.4172 106.177 86.2464C109.978 84.0924 113.386 84.6499 116.566 87.9568L127.628 81.6217C124.15 77.6869 119.27 75.2716 114.032 74.8938C108.75 74.6988 103.528 76.0649 99.0181 78.8216C93.6966 81.8878 90.7655 85.5748 90.2249 89.8827C89.6843 94.1906 91.8172 98.8364 96.6234 103.82C101.421 108.804 106.57 111.705 112.068 112.524C117.702 113.32 123.436 112.128 128.286 109.154C133.253 106.274 136.286 102.925 137.384 99.1067C138.482 95.2887 136.999 91.268 132.936 87.0446L122.331 93.1643Z" fill="white"/>
|
||||
<path d="M71.3336 55.2299C71.3601 54.5387 71.5747 53.8678 71.9542 53.2895C72.3338 52.7112 72.864 52.2475 73.4876 51.9483C74.0747 51.6045 74.7251 51.3824 75.3999 51.2953C76.0747 51.2083 76.7602 51.258 77.4154 51.4415C78.913 51.8611 80.2614 52.6957 81.3051 53.8488L98.1819 71.2958L109.585 64.7072L91.4667 45.9172C88.7033 42.7886 85.1027 40.5157 81.0897 39.3667C77.4569 38.527 73.6393 39.1602 70.4721 41.1279C68.4375 42.2638 66.7777 43.9677 65.6954 46.0313C64.6692 47.9741 64.275 50.1888 64.5677 52.3664L59.5503 48.4386L50.1743 53.8361L78.1756 82.851L89.5788 76.2625L73.1075 59.2337C72.5569 58.7206 72.1158 58.1013 71.811 57.4132C71.5061 56.7251 71.3437 55.9824 71.3336 55.2299Z" fill="white"/>
|
||||
<path d="M138.587 34.4631L136.433 32.1824L127.45 22.9078C126.025 21.3174 124.26 20.0689 122.285 19.255C120.311 18.4412 118.179 18.083 116.047 18.2071C111.292 18.5305 106.688 20.0046 102.629 22.5023C97.561 25.4165 94.4061 28.4954 93.2278 31.6756C92.0494 34.8558 92.7843 37.8333 95.4451 40.5448C95.6893 40.8086 95.9564 41.0503 96.2433 41.267L107.482 34.7925L107.254 34.5771C106.876 34.2579 106.614 33.822 106.511 33.338C106.408 32.854 106.468 32.3494 106.684 31.9037C107.276 30.8781 108.157 30.0496 109.218 29.5217C110.423 28.7785 111.836 28.4452 113.247 28.5714C114.679 28.7085 116.008 29.3776 116.972 30.4466L118.328 31.8783C114.432 34.0062 110.782 36.5556 107.444 39.4805C105.284 41.2108 103.884 43.7154 103.541 46.4618C103.377 48.8691 104.644 51.5299 107.342 54.3047C112.647 59.7275 118.843 60.3948 125.93 56.3066C128.032 55.168 129.86 53.5856 131.289 51.6693C132.194 50.3618 132.879 48.9152 133.316 47.3867L135.42 49.5407L139.639 47.1333L146.81 42.9775L138.587 34.4631ZM126.817 43.3322C126.618 44.0876 126.265 44.7933 125.778 45.4041C125.292 46.015 124.683 46.5177 123.991 46.8799C121.381 48.375 119.227 48.1469 117.453 46.3731C116.92 45.8459 116.596 45.1427 116.542 44.3944C116.488 43.6461 116.709 42.9038 117.162 42.3059C118.049 40.7475 120.114 38.999 123.358 37.1365L125.005 38.847L125.765 39.6072C126.267 40.0816 126.63 40.6838 126.815 41.349C127 42.0143 127.001 42.7174 126.817 43.3829V43.3322Z" fill="white"/>
|
||||
<path d="M171.543 67.1526L160.469 73.4877C160.935 73.9556 161.297 74.5171 161.531 75.1352C161.765 75.7533 161.865 76.4138 161.825 77.0734C161.673 78.3404 160.558 79.6074 158.72 80.6337C157.819 81.2176 156.809 81.614 155.751 81.799C154.693 81.984 153.609 81.954 152.563 81.7107C150.225 81.0175 148.133 79.6726 146.532 77.8336L169.262 64.7199L167.45 62.8574C162.5 57.7386 157.356 54.7822 152.018 53.9882C146.339 53.2535 140.581 54.5215 135.737 57.5739C130.407 60.6569 127.476 64.3524 126.943 68.6603C126.373 72.9429 128.527 77.6055 133.279 82.5976C138.03 87.5897 143.415 90.5925 148.939 91.2894C154.775 91.9377 160.658 90.5985 165.638 87.4883C170.998 84.4095 174.102 81.0772 175.002 77.4915C175.901 73.9058 174.723 70.6242 171.543 67.1526ZM142.908 64.3271C144.43 63.4149 146.24 63.1117 147.976 63.4782C149.832 63.8919 151.517 64.8648 152.803 66.2656L141.654 72.6768C138.879 69.2305 139.373 66.4304 142.908 64.3651V64.3271Z" fill="white"/>
|
||||
<path d="M85.6004 180.158L79.0118 173.329L72.5247 189.813L79.1132 196.642L85.6004 180.158Z" fill="white"/>
|
||||
<path d="M73.2595 168.616C71.647 165.665 69.6155 162.964 67.2285 160.596C64.2974 157.546 61.8647 155.671 59.9304 154.97C57.9918 154.235 56.7248 154.653 56.1293 156.237C56.0591 156.386 56.0081 156.544 55.9773 156.706L62.4644 163.446V163.32C62.6545 162.8 63.0599 162.712 63.6301 162.99C64.35 163.374 64.9974 163.879 65.5433 164.485C66.241 165.18 66.8 166.001 67.1904 166.905C67.3589 167.243 67.4569 167.612 67.4787 167.988C67.5005 168.365 67.4455 168.742 67.3172 169.097L67.0004 169.921C64.8291 167.603 62.5188 165.42 60.0824 163.383C58.7754 162.175 57.1555 161.359 55.4071 161.026C54.1401 160.9 53.2532 161.66 52.6197 163.256C51.3526 166.361 52.835 170.01 56.9402 174.267C58.0933 175.486 59.396 176.555 60.8173 177.447C61.6783 177.972 62.5965 178.397 63.5541 178.714L63.0726 179.981L65.4926 182.515L69.6865 186.747L71.587 181.894L72.0938 180.627L74.1591 175.356C74.9066 173.443 74.6279 171.188 73.2595 168.616ZM65.518 173.798L65.3279 174.203C65.0745 174.875 64.5297 175.065 63.7061 174.85C62.7332 174.491 61.863 173.9 61.1721 173.126C59.6516 171.568 59.1068 170.276 59.5249 169.325C59.7783 168.654 60.4372 168.616 61.4888 169.161C63.1144 170.17 64.5938 171.398 65.8854 172.81L65.518 173.798Z" fill="white"/>
|
||||
<path d="M50.8205 159.113L44.2319 152.284L41.9386 158.112L48.5271 164.941L50.8205 159.113Z" fill="white"/>
|
||||
<path d="M88.6665 172.379L82.078 165.55L79.7847 171.391L86.3605 178.22L88.6665 172.379Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
@@ -123,6 +123,7 @@ const svgIcons = [
|
||||
LLMFactory.EmpirioLabs,
|
||||
LLMFactory.AIStupidLevel,
|
||||
LLMFactory.Runcrate,
|
||||
LLMFactory.Nace,
|
||||
LLMFactory.WorldRouter,
|
||||
LLMFactory.CloudGPU,
|
||||
LLMFactory.Obscurify,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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. ',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2609,7 +2609,7 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取 Entities 和 R
|
||||
answerDescription: `该组件用作机器人与人类之间的接口。它接收用户的输入并显示机器人的计算结果。`,
|
||||
retrievalDescription: `此组件用于从知识库中检索相关信息。选择知识库。如果没有检索到任何内容,将返回“空响应”。`,
|
||||
generateDescription: `此组件用于调用 LLM 生成文本,请注意提示的设置。`,
|
||||
categorizeDescription: `使用聊天模型(LLM)或 SystemOne 决策模型,将当前输入归入一个预定义类别,并将工作流路由到该类别连接的下游组件。请为每个类别指定名称、描述和示例。`,
|
||||
categorizeDescription: `使用聊天模型(LLM)或 SystemOne 或 Nace 决策模型,将当前输入归入一个预定义类别,并将工作流路由到该类别连接的下游组件。请为每个类别指定名称、描述和示例。`,
|
||||
relevantDescription: `该组件用来判断upstream的输出是否与用户最新的问题相关,‘是’代表相关,‘否’代表不相关。`,
|
||||
messageDescription:
|
||||
'该组件用来返回工作流最后产生的数据内容和原先设置的文本内容。',
|
||||
|
||||
+70
-64
@@ -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 = () => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user