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:
Haruko386
2026-07-13 16:57:56 +08:00
committed by GitHub
parent d03b09b6a3
commit 466f33e6b4
5 changed files with 403 additions and 41 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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.

View File

@@ -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
// ---------------------------------------------------------------------------