mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-14 08:58:27 +08:00
fix: support component brwoser (#16848)
### Summary As title Unable to test it since I don't have apiKey for `openai` , `Anthropic` and `Gemini` --- <img width="1130" height="557" alt="image" src="https://github.com/user-attachments/assets/11570c75-68f3-490d-8186-4ecbcd8b8f40" />
This commit is contained in:
@@ -44,16 +44,56 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const componentNameBrowser = "Browser"
|
||||
|
||||
var stagehandNativeProviders = map[string]string{
|
||||
"anthropic": "anthropic",
|
||||
"google": "google",
|
||||
"openai": "openai",
|
||||
}
|
||||
|
||||
var browserFactoryDefaultBaseURL = map[string]string{
|
||||
"302.ai": "https://api.302.ai/v1",
|
||||
"anthropic": "https://api.anthropic.com/",
|
||||
"astraflow": "https://api-us-ca.umodelverse.ai/v1",
|
||||
"astraflow-cn": "https://api.modelverse.cn/v1",
|
||||
"avian": "https://api.avian.io/v1",
|
||||
"cometapi": "https://api.cometapi.com/v1",
|
||||
"dashscope": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"deepseek": "https://api.deepseek.com/v1",
|
||||
"deerapi": "https://api.deerapi.com/v1",
|
||||
"futurmix": "https://futurmix.ai/v1",
|
||||
"giteeai": "https://ai.gitee.com/v1/",
|
||||
"hunyuan": "https://api.hunyuan.cloud.tencent.com/v1",
|
||||
"jiekouai": "https://api.jiekou.ai/openai",
|
||||
"lingyi-ai": "https://api.lingyiwanwu.com/v1",
|
||||
"longcat": "https://api.longcat.chat/openai",
|
||||
"minimax": "https://api.minimaxi.com/v1",
|
||||
"moonshot": "https://api.moonshot.cn/v1",
|
||||
"n1n": "https://api.n1n.ai/v1",
|
||||
"novitaai": "https://api.novita.ai/v3/openai",
|
||||
"openai": "https://api.openai.com/v1",
|
||||
"openrouter": "https://openrouter.ai/api/v1",
|
||||
"perfxcloud": "https://cloud.perfxlab.cn/v1",
|
||||
"ppio": "https://api.ppinfra.com/v3/openai",
|
||||
"siliconflow": "https://api.siliconflow.cn/v1",
|
||||
"stepfun": "https://api.stepfun.com/v1",
|
||||
"tongyi-qianwen": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"upstage": "https://api.upstage.ai/v1/solar",
|
||||
"zhipu-ai": "https://open.bigmodel.cn/api/paas/v4",
|
||||
}
|
||||
|
||||
// browserParam is the static DSL param surface for the Browser
|
||||
// component. Mirrors Python `browser.py:LLMParam + browser knobs`:
|
||||
//
|
||||
@@ -228,10 +268,10 @@ func (b *BrowserComponent) Name() string { return b.name }
|
||||
// StagehandInvoker.RunExtract with a `{"type": "string"}` schema.
|
||||
// The flow:
|
||||
//
|
||||
// 1. Pull tenant_id from `state.Sys["user_id"]`.
|
||||
// 1. Pull tenant_id from `state.Sys["tenant_id"]`.
|
||||
// 2. Resolve the `prompts` template via `runtime.ResolveTemplate`.
|
||||
// 3. Split `llm_id` → `(modelName, factory)` and look up the
|
||||
// tenant LLM config (apiKey, baseURL) from the DAO.
|
||||
// 3. Resolve `llm_id` as a tenant_model id or legacy model@factory
|
||||
// reference and look up the tenant LLM config (apiKey, baseURL).
|
||||
// 4. Build `RunExtractRequest` with `ModelName = "openai/<model>"`,
|
||||
// the resolved apiKey/baseURL/instruction, and
|
||||
// `Schema = {"type": "string"}`.
|
||||
@@ -251,29 +291,33 @@ func (b *BrowserComponent) Invoke(ctx context.Context, inputs map[string]any) (m
|
||||
return nil, errors.New("Browser: nil canvas state")
|
||||
}
|
||||
|
||||
tenantID, _ := state.Sys["user_id"].(string)
|
||||
tenantID, _ := state.Sys["tenant_id"].(string)
|
||||
if tenantID == "" {
|
||||
return nil, errors.New("Browser: tenant_id missing from canvas state (state.Sys[\"user_id\"])")
|
||||
return nil, errors.New("Browser: tenant_id missing from canvas state (state.Sys[\"tenant_id\"])")
|
||||
}
|
||||
|
||||
// 1. Resolve prompts template.
|
||||
resolvedPrompts, err := runtime.ResolveTemplate(b.param.Prompts, state)
|
||||
prompts := b.param.Prompts
|
||||
if v, ok := inputs["prompts"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
prompts = v
|
||||
}
|
||||
resolvedPrompts, err := runtime.ResolveTemplate(prompts, state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Browser: resolve prompts template: %w", err)
|
||||
}
|
||||
|
||||
// 2. Look up tenant model config.
|
||||
modelName, factory := resolveLLMID(b.param.LLMID)
|
||||
apiKey, baseURL, err := resolveTenantLLM(tenantID, modelName, factory)
|
||||
providerName, modelName, apiKey, baseURL, err := resolveBrowserLLM(tenantID, b.param.LLMID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Browser: tenant llm lookup (%q, factory=%q): %w", b.param.LLMID, factory, err)
|
||||
return nil, fmt.Errorf("Browser: tenant llm lookup (%q): %w", b.param.LLMID, err)
|
||||
}
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
|
||||
// 3. Build RunExtractRequest with single-string schema.
|
||||
req := RunExtractRequest{
|
||||
TenantID: tenantID,
|
||||
LLMID: b.param.LLMID,
|
||||
ModelName: "openai/" + modelName,
|
||||
ModelName: stagehandModelName(providerName, modelName),
|
||||
BaseURL: baseURL,
|
||||
APIKey: apiKey,
|
||||
Headless: b.param.Headless,
|
||||
@@ -285,7 +329,8 @@ func (b *BrowserComponent) Invoke(ctx context.Context, inputs map[string]any) (m
|
||||
invoker := getDefaultStagehandInvoker()
|
||||
rawJSON, err := invoker.RunExtract(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Browser: stagehand extract: %w", err)
|
||||
return nil, fmt.Errorf("Browser: stagehand extract (model=%q, base_url=%s): %w",
|
||||
req.ModelName, browserBaseURLForLog(req.BaseURL), err)
|
||||
}
|
||||
|
||||
// 5. Unmarshal the JSON-string result to get the plain text.
|
||||
@@ -302,11 +347,43 @@ func (b *BrowserComponent) Invoke(ctx context.Context, inputs map[string]any) (m
|
||||
"status": 0,
|
||||
"size": len(content),
|
||||
"model_id": b.param.LLMID,
|
||||
"prompt": b.param.Prompts,
|
||||
"prompt": prompts,
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func browserBaseURLForLog(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "<provider default>"
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "<invalid>"
|
||||
}
|
||||
u.User = nil
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func stagehandModelName(providerName, modelName string) string {
|
||||
modelName = strings.TrimSpace(modelName)
|
||||
if modelName == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(modelName, "/") {
|
||||
prefix, _, _ := strings.Cut(modelName, "/")
|
||||
if stagehandNativeProviders[strings.ToLower(strings.TrimSpace(prefix))] != "" {
|
||||
return modelName
|
||||
}
|
||||
return "openai/" + modelName
|
||||
}
|
||||
providerName = strings.ToLower(strings.TrimSpace(providerName))
|
||||
if nativeProvider := stagehandNativeProviders[providerName]; nativeProvider != "" {
|
||||
return nativeProvider + "/" + modelName
|
||||
}
|
||||
return "openai/" + modelName
|
||||
}
|
||||
|
||||
// Stream mirrors Invoke; Browser is a single-shot generator.
|
||||
func (b *BrowserComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
|
||||
out, err := b.Invoke(ctx, inputs)
|
||||
@@ -337,7 +414,7 @@ func (b *BrowserComponent) Inputs() map[string]string {
|
||||
func (b *BrowserComponent) GetInputForm() map[string]any {
|
||||
return map[string]any{
|
||||
"prompts": map[string]any{
|
||||
"type": "text",
|
||||
"type": "paragraph",
|
||||
"name": "Prompts",
|
||||
},
|
||||
"upload_sources": map[string]any{
|
||||
@@ -360,21 +437,107 @@ func (b *BrowserComponent) Outputs() map[string]string {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveTenantLLM looks up the tenant LLM config and returns
|
||||
// (apiKey, baseURL, modelName). baseURL may be empty when the
|
||||
// tenant's provider doesn't configure a custom endpoint (the
|
||||
// stagehand server will then use its openai-compat default).
|
||||
// resolveBrowserLLM resolves the Browser's selected model into the model name
|
||||
// and credentials required by the stagehand runtime. It first tries a
|
||||
// tenant_model.id lookup, then model@factory parsing via resolveTenantLLM.
|
||||
//
|
||||
// Tests override the lookup via `tenantLLMLookupForTest` (a
|
||||
// package-level function variable) so they don't need a real DB.
|
||||
// Production code leaves the variable unset.
|
||||
func resolveBrowserLLM(tenantID, llmID string) (providerName, modelName, apiKey, baseURL string, err error) {
|
||||
if tenantLLMLookupForTest != nil {
|
||||
oldModelName, factory := resolveLLMID(llmID)
|
||||
apiKey, baseURL, err = tenantLLMLookupForTest(tenantID, oldModelName, factory)
|
||||
baseURL = browserOpenAICompatibleBaseURL(baseURL, factory)
|
||||
return factory, oldModelName, apiKey, baseURL, err
|
||||
}
|
||||
|
||||
providerName, modelName, apiKey, baseURL, err = resolveTenantModelBrowserLLM(tenantID, llmID)
|
||||
if err == nil {
|
||||
baseURL = browserOpenAICompatibleBaseURL(baseURL, providerName)
|
||||
return providerName, modelName, apiKey, baseURL, nil
|
||||
}
|
||||
modelErr := err
|
||||
|
||||
oldModelName, factory := resolveLLMID(llmID)
|
||||
apiKey, baseURL, oldErr := resolveTenantLLM(tenantID, oldModelName, factory)
|
||||
if oldErr == nil {
|
||||
baseURL = browserOpenAICompatibleBaseURL(baseURL, factory)
|
||||
return factory, oldModelName, apiKey, baseURL, nil
|
||||
}
|
||||
return "", "", "", "", fmt.Errorf("tenant_model lookup: %v; tenant_llm fallback: %w", modelErr, oldErr)
|
||||
}
|
||||
|
||||
func resolveTenantModelBrowserLLM(tenantID, modelID string) (providerName, modelName, apiKey, baseURL string, err error) {
|
||||
modelRow, err := dao.NewTenantModelDAO().GetByID(modelID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
return "", "", "", "", fmt.Errorf("tenant model id=%s lookup failed: %w", modelID, err)
|
||||
}
|
||||
if modelRow.Status != "active" {
|
||||
return "", "", "", "", fmt.Errorf("tenant model id=%s is disabled", modelID)
|
||||
}
|
||||
if !entity.ModelType(modelRow.ModelType).Has(entity.ModelTypeChat) {
|
||||
return "", "", "", "", fmt.Errorf("tenant model id=%s cannot be used as %s model", modelID, entity.ModelTypeChat.String())
|
||||
}
|
||||
|
||||
provider, err := dao.NewTenantModelProviderDAO().GetByID(modelRow.ProviderID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", "", "", "", fmt.Errorf("provider id=%s not found for model id=%s", modelRow.ProviderID, modelID)
|
||||
}
|
||||
return "", "", "", "", err
|
||||
}
|
||||
if provider == nil {
|
||||
return "", "", "", "", fmt.Errorf("provider id=%s not found for model id=%s", modelRow.ProviderID, modelID)
|
||||
}
|
||||
if provider.TenantID != tenantID {
|
||||
return "", "", "", "", fmt.Errorf("tenant %s has no access to provider owned by tenant %s", tenantID, provider.TenantID)
|
||||
}
|
||||
|
||||
instance, err := dao.NewTenantModelInstanceDAO().GetByID(modelRow.InstanceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", "", "", "", fmt.Errorf("instance id=%s not found for model id=%s", modelRow.InstanceID, modelID)
|
||||
}
|
||||
return "", "", "", "", err
|
||||
}
|
||||
if instance == nil {
|
||||
return "", "", "", "", fmt.Errorf("instance id=%s not found for model id=%s", modelRow.InstanceID, modelID)
|
||||
}
|
||||
|
||||
apiKey = instance.APIKey
|
||||
if strings.TrimSpace(instance.Extra) != "" {
|
||||
var extra map[string]string
|
||||
if err := json.Unmarshal([]byte(instance.Extra), &extra); err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
baseURL = extra["base_url"]
|
||||
}
|
||||
return provider.ProviderName, modelRow.ModelName, apiKey, baseURL, nil
|
||||
}
|
||||
|
||||
func browserOpenAICompatibleBaseURL(baseURL, provider string) string {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
if baseURL != "" {
|
||||
return baseURL
|
||||
}
|
||||
provider = strings.TrimSpace(provider)
|
||||
if provider == "" {
|
||||
return ""
|
||||
}
|
||||
return browserFactoryDefaultBaseURL[strings.ToLower(provider)]
|
||||
}
|
||||
|
||||
// resolveTenantLLM looks up the legacy tenant_llm config and returns
|
||||
// (apiKey, baseURL). baseURL may be empty when the tenant's provider doesn't
|
||||
// configure a custom endpoint.
|
||||
//
|
||||
// TODO(v2): this helper can move to `internal/dao` so the LLM
|
||||
// component (`llm.go`) and other future components can share it.
|
||||
func resolveTenantLLM(tenantID, modelName, factory string) (apiKey, baseURL string, err error) {
|
||||
if tenantLLMLookupForTest != nil {
|
||||
return tenantLLMLookupForTest(tenantID, modelName, factory)
|
||||
}
|
||||
dao := dao.NewTenantLLMDAO()
|
||||
var (
|
||||
row *entity.TenantLLM
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
|
||||
"ragflow/internal/agent/canvas"
|
||||
"ragflow/internal/agent/runtime"
|
||||
"ragflow/internal/entity"
|
||||
)
|
||||
|
||||
// mockStagehandInvoker captures RunExtract requests and returns a
|
||||
@@ -145,8 +146,8 @@ func TestBrowser_ResolvesSysQueryTemplate(t *testing.T) {
|
||||
"prompts": "{sys.query}打开百度,搜索'2026年最新AI技术趋势'",
|
||||
})
|
||||
ctx := stateWith(t, map[string]any{
|
||||
"user_id": "tenant-1",
|
||||
"query": "what's the latest",
|
||||
"tenant_id": "tenant-1",
|
||||
"query": "what's the latest",
|
||||
})
|
||||
|
||||
// LLM lookup will fail (no DB seeded in test), so we can't
|
||||
@@ -196,7 +197,7 @@ func TestBrowser_DispatchesToRuntime(t *testing.T) {
|
||||
"llm_id": "deepseek-v4-pro@DeepSeek",
|
||||
"prompts": "do something",
|
||||
})
|
||||
ctx := stateWith(t, map[string]any{"user_id": "tenant-1"})
|
||||
ctx := stateWith(t, map[string]any{"tenant_id": "tenant-1"})
|
||||
|
||||
_, err := c.Invoke(ctx, nil)
|
||||
if err == nil {
|
||||
@@ -213,8 +214,60 @@ func TestBrowser_DispatchesToRuntime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowser_MissingTenant: state.Sys["user_id"] is the only
|
||||
// tenant handle (until the cross-cutting tenant_id fix lands).
|
||||
func TestResolveBrowserLLM_ResolvesTenantModelID(t *testing.T) {
|
||||
db := setupComponentTestDB(t)
|
||||
pushComponentDB(t, db)
|
||||
if err := db.Create(&entity.TenantModelProvider{
|
||||
ID: "provider-1",
|
||||
TenantID: "tenant-1",
|
||||
ProviderName: "OpenAI",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create provider: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.TenantModelInstance{
|
||||
ID: "instance-1",
|
||||
ProviderID: "provider-1",
|
||||
InstanceName: "default",
|
||||
APIKey: "sk-instance",
|
||||
Status: "active",
|
||||
Extra: `{"base_url":"https://instance.example/v1"}`,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create instance: %v", err)
|
||||
}
|
||||
if err := db.Create(&entity.TenantModel{
|
||||
ID: "tenant-model-1",
|
||||
ProviderID: "provider-1",
|
||||
InstanceID: "instance-1",
|
||||
ModelName: "gpt-4o",
|
||||
ModelType: int(entity.ModelTypeChat),
|
||||
Status: "active",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create model: %v", err)
|
||||
}
|
||||
|
||||
prevLookup := tenantLLMLookupForTest
|
||||
tenantLLMLookupForTest = nil
|
||||
t.Cleanup(func() { tenantLLMLookupForTest = prevLookup })
|
||||
|
||||
provider, model, apiKey, baseURL, err := resolveBrowserLLM("tenant-1", "tenant-model-1")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveBrowserLLM: %v", err)
|
||||
}
|
||||
if provider != "OpenAI" {
|
||||
t.Fatalf("provider=%q, want OpenAI", provider)
|
||||
}
|
||||
if model != "gpt-4o" {
|
||||
t.Fatalf("model=%q, want gpt-4o", model)
|
||||
}
|
||||
if apiKey != "sk-instance" {
|
||||
t.Fatalf("apiKey=%q, want sk-instance", apiKey)
|
||||
}
|
||||
if baseURL != "https://instance.example/v1" {
|
||||
t.Fatalf("baseURL=%q, want https://instance.example/v1", baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowser_MissingTenant: state.Sys["tenant_id"] is required.
|
||||
// Missing tenant_id must error.
|
||||
func TestBrowser_MissingTenant(t *testing.T) {
|
||||
mock := &mockStagehandInvoker{rawJSON: `"ok"`}
|
||||
@@ -224,7 +277,7 @@ func TestBrowser_MissingTenant(t *testing.T) {
|
||||
"llm_id": "gpt-4o@OpenAI",
|
||||
"prompts": "x",
|
||||
})
|
||||
// state with no user_id
|
||||
// state with no tenant_id
|
||||
state := canvas.NewCanvasState("run-1", "task-1")
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
@@ -235,7 +288,7 @@ func TestBrowser_MissingTenant(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestBrowser_PropagatesRuntimeError: a runtime error surfaces
|
||||
// wrapped as `Browser: stagehand run: ...`.
|
||||
// wrapped with model context.
|
||||
//
|
||||
// We can't easily seed the tenant DAO in a unit test, so this test
|
||||
// verifies the error-wrapping contract by injecting a mock runtime
|
||||
@@ -258,7 +311,7 @@ func TestBrowser_PropagatesRuntimeError(t *testing.T) {
|
||||
"llm_id": "gpt-4o@OpenAI",
|
||||
"prompts": "x",
|
||||
})
|
||||
ctx := stateWith(t, map[string]any{"user_id": "tenant-1"})
|
||||
ctx := stateWith(t, map[string]any{"tenant_id": "tenant-1"})
|
||||
|
||||
_, err := c.Invoke(ctx, nil)
|
||||
if err == nil {
|
||||
@@ -267,11 +320,98 @@ func TestBrowser_PropagatesRuntimeError(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "stagehand extract") {
|
||||
t.Errorf("error should mention stagehand extract; got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), `model="openai/gpt-4o"`) {
|
||||
t.Errorf("error should include model context; got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "base_url=https://api.openai.com/v1") {
|
||||
t.Errorf("error should include base_url context; got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "boom") {
|
||||
t.Errorf("error should include underlying message; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserBaseURLForLog(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "empty", in: "", want: "<provider default>"},
|
||||
{name: "blank", in: " \t\n ", want: "<provider default>"},
|
||||
{name: "redacts_userinfo", in: "https://user:pass@example.com/v1", want: "https://example.com/v1"},
|
||||
{name: "invalid", in: "://bad", want: "<invalid>"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := browserBaseURLForLog(tc.in); got != tc.want {
|
||||
t.Fatalf("browserBaseURLForLog(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserOpenAICompatibleBaseURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
provider string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "explicit_wins",
|
||||
baseURL: " https://gateway.example/v1 ",
|
||||
provider: "DeepSeek",
|
||||
want: "https://gateway.example/v1",
|
||||
},
|
||||
{
|
||||
name: "deepseek_default",
|
||||
provider: "DeepSeek",
|
||||
want: "https://api.deepseek.com/v1",
|
||||
},
|
||||
{
|
||||
name: "siliconflow_default",
|
||||
provider: "SILICONFLOW",
|
||||
want: "https://api.siliconflow.cn/v1",
|
||||
},
|
||||
{
|
||||
name: "unknown_provider",
|
||||
provider: "unknown",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := browserOpenAICompatibleBaseURL(tc.baseURL, tc.provider); got != tc.want {
|
||||
t.Fatalf("browserOpenAICompatibleBaseURL(%q, %q) = %q, want %q", tc.baseURL, tc.provider, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStagehandModelName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
provider string
|
||||
model string
|
||||
want string
|
||||
}{
|
||||
{name: "deepseek_uses_openai_compatible_provider", provider: "DeepSeek", model: "deepseek-v4-pro", want: "openai/deepseek-v4-pro"},
|
||||
{name: "siliconflow_uses_openai_compatible_provider", provider: "SILICONFLOW", model: "Qwen/Qwen3-32B", want: "openai/Qwen/Qwen3-32B"},
|
||||
{name: "openai_native", provider: "OpenAI", model: "gpt-4o", want: "openai/gpt-4o"},
|
||||
{name: "anthropic_native", provider: "Anthropic", model: "claude-sonnet-4-5", want: "anthropic/claude-sonnet-4-5"},
|
||||
{name: "google_native", provider: "Google", model: "gemini-3-flash-preview", want: "google/gemini-3-flash-preview"},
|
||||
{name: "already_prefixed", provider: "DeepSeek", model: "openai/deepseek-v4-pro", want: "openai/deepseek-v4-pro"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := stagehandModelName(tc.provider, tc.model); got != tc.want {
|
||||
t.Fatalf("stagehandModelName(%q, %q) = %q, want %q", tc.provider, tc.model, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowser_RunExtractRequestShape verifies the RunExtractRequest
|
||||
// fields forwarded to the runtime: ModelName, TenantID, APIKey,
|
||||
// Schema, and the resolved Instruction.
|
||||
@@ -289,7 +429,7 @@ func TestBrowser_RunExtractRequestShape(t *testing.T) {
|
||||
"llm_id": "gpt-4o@OpenAI",
|
||||
"prompts": "extract the page title",
|
||||
})
|
||||
ctx := stateWith(t, map[string]any{"user_id": "tenant-1"})
|
||||
ctx := stateWith(t, map[string]any{"tenant_id": "tenant-1"})
|
||||
|
||||
if _, err := c.Invoke(ctx, nil); err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
@@ -332,7 +472,7 @@ func TestBrowser_HeadlessPropagates(t *testing.T) {
|
||||
"prompts": "x",
|
||||
"headless": false,
|
||||
})
|
||||
ctx := stateWith(t, map[string]any{"user_id": "tenant-1"})
|
||||
ctx := stateWith(t, map[string]any{"tenant_id": "tenant-1"})
|
||||
|
||||
if _, err := c.Invoke(ctx, nil); err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
@@ -363,7 +503,7 @@ func TestBrowser_OutputsShape(t *testing.T) {
|
||||
"llm_id": "gpt-4o@OpenAI",
|
||||
"prompts": "x",
|
||||
})
|
||||
ctx := stateWith(t, map[string]any{"user_id": "tenant-1"})
|
||||
ctx := stateWith(t, map[string]any{"tenant_id": "tenant-1"})
|
||||
|
||||
out, err := c.Invoke(ctx, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -390,6 +390,7 @@ func setupComponentTestDB(t *testing.T) *gorm.DB {
|
||||
&entity.TenantLLM{},
|
||||
&entity.TenantModelProvider{},
|
||||
&entity.TenantModelInstance{},
|
||||
&entity.TenantModel{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ import (
|
||||
"math"
|
||||
"ragflow/internal/common"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -427,12 +428,7 @@ func (r *stagehandRuntime) RunTask(ctx context.Context, req RunTaskRequest) (str
|
||||
// OfSessionExecutesAgentConfigModelGenericModelConfigObject
|
||||
// variant is the only path that exposes BaseURL.
|
||||
Model: stagehand.SessionExecuteParamsAgentConfigModelUnion{
|
||||
OfSessionExecutesAgentConfigModelGenericModelConfigObject: &stagehand.SessionExecuteParamsAgentConfigModelGenericModelConfigObject{
|
||||
ModelName: req.ModelName,
|
||||
APIKey: stagehand.String(req.APIKey),
|
||||
BaseURL: stagehand.String(req.BaseURL),
|
||||
Provider: "openai",
|
||||
},
|
||||
OfSessionExecutesAgentConfigModelGenericModelConfigObject: stagehandExecuteModelConfig(req),
|
||||
},
|
||||
},
|
||||
ExecuteOptions: stagehand.SessionExecuteParamsExecuteOptions{
|
||||
@@ -455,6 +451,18 @@ func (r *stagehandRuntime) RunTask(ctx context.Context, req RunTaskRequest) (str
|
||||
return execResp.Data.Result.Message, nil
|
||||
}
|
||||
|
||||
func stagehandExecuteModelConfig(req RunTaskRequest) *stagehand.SessionExecuteParamsAgentConfigModelGenericModelConfigObject {
|
||||
model := &stagehand.SessionExecuteParamsAgentConfigModelGenericModelConfigObject{
|
||||
ModelName: req.ModelName,
|
||||
APIKey: stagehand.String(req.APIKey),
|
||||
Provider: "openai",
|
||||
}
|
||||
if baseURL := strings.TrimSpace(req.BaseURL); baseURL != "" {
|
||||
model.BaseURL = stagehand.String(baseURL)
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
// RunExtractRequest is the input to stagehandRuntime.RunExtract.
|
||||
//
|
||||
// RunExtract is the "structured single-shot" sibling of RunTask:
|
||||
@@ -493,9 +501,9 @@ type RunExtractRequest struct {
|
||||
// 2. Sessions.Start (browser, model, launch options).
|
||||
// 3. defer Sessions.End.
|
||||
// 4. If URL != "", Sessions.Navigate({URL: req.URL}).
|
||||
// 5. Sessions.Extract({Instruction, Schema}). The response
|
||||
// `Data.Result` field is the structured data matching Schema;
|
||||
// we marshal it as JSON and return.
|
||||
// 5. Sessions.Extract({Instruction, Schema, Options.Model}). The
|
||||
// response `Data.Result` field is the structured data matching
|
||||
// Schema; we marshal it as JSON and return.
|
||||
//
|
||||
// RunExtract reuses the same `stagehandRuntime` cache as RunTask
|
||||
// (per `(apiKey, baseURL, modelName)` key), so cold-start cost is
|
||||
@@ -564,7 +572,10 @@ func (r *stagehandRuntime) RunExtract(ctx context.Context, req RunExtractRequest
|
||||
|
||||
extractResp, err := client.Sessions.Extract(ctx, sessionID, stagehand.SessionExtractParams{
|
||||
Instruction: stagehand.String(req.Instruction),
|
||||
Schema: req.Schema,
|
||||
Options: stagehand.SessionExtractParamsOptions{
|
||||
Model: stagehandExtractModelConfig(req),
|
||||
},
|
||||
Schema: req.Schema,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stagehand runtime: RunExtract: Sessions.Extract: %w", err)
|
||||
@@ -583,6 +594,20 @@ func (r *stagehandRuntime) RunExtract(ctx context.Context, req RunExtractRequest
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func stagehandExtractModelConfig(req RunExtractRequest) stagehand.SessionExtractParamsOptionsModelUnion {
|
||||
model := &stagehand.SessionExtractParamsOptionsModelGenericModelConfigObject{
|
||||
ModelName: req.ModelName,
|
||||
APIKey: stagehand.String(req.APIKey),
|
||||
Provider: "openai",
|
||||
}
|
||||
if baseURL := strings.TrimSpace(req.BaseURL); baseURL != "" {
|
||||
model.BaseURL = stagehand.String(baseURL)
|
||||
}
|
||||
return stagehand.SessionExtractParamsOptionsModelUnion{
|
||||
OfSessionExtractsOptionsModelGenericModelConfigObject: model,
|
||||
}
|
||||
}
|
||||
|
||||
// Close drains the sweeper goroutine and closes every cached
|
||||
// client (each Close kills its stagehand-server subprocess).
|
||||
// Safe to call multiple times.
|
||||
|
||||
@@ -69,6 +69,39 @@ func TestStagehandRuntime_ValidatesRequiredFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStagehandRuntime_RunExtractModelConfig(t *testing.T) {
|
||||
model := stagehandExtractModelConfig(RunExtractRequest{
|
||||
ModelName: "openai/gpt-4o",
|
||||
BaseURL: "https://example.test/v1",
|
||||
APIKey: "sk-test",
|
||||
})
|
||||
|
||||
if got := model.GetModelName(); got == nil || *got != "openai/gpt-4o" {
|
||||
t.Fatalf("modelName = %v, want openai/gpt-4o", got)
|
||||
}
|
||||
if got := model.GetAPIKey(); got == nil || *got != "sk-test" {
|
||||
t.Fatalf("apiKey = %v, want sk-test", got)
|
||||
}
|
||||
if got := model.GetBaseURL(); got == nil || *got != "https://example.test/v1" {
|
||||
t.Fatalf("baseURL = %v, want https://example.test/v1", got)
|
||||
}
|
||||
if got := model.GetProvider(); got == nil || *got != "openai" {
|
||||
t.Fatalf("provider = %v, want openai", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStagehandRuntime_RunExtractModelConfigOmitsEmptyBaseURL(t *testing.T) {
|
||||
model := stagehandExtractModelConfig(RunExtractRequest{
|
||||
ModelName: "openai/gpt-4o",
|
||||
BaseURL: " \t\n ",
|
||||
APIKey: "sk-test",
|
||||
})
|
||||
|
||||
if got := model.GetBaseURL(); got != nil {
|
||||
t.Fatalf("baseURL = %q, want omitted", *got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache hit / miss / key formula
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user