mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
fix(tools): improve web search provider fallback (#2629)
- centralize web search provider readiness and resolution logic - fall back when the configured provider is unavailable or invalid - allow native-search-capable models to use built-in search without the client tool - simplify the tools page and add direct access to web search settings - add backend, agent, and integration tests for the new selection behavior
This commit is contained in:
+37
-78
@@ -261,6 +261,8 @@ func buildToolSupport(cfg *config.Config) []toolSupportItem {
|
||||
status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseRegex)
|
||||
case "tool_search_tool_bm25":
|
||||
status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseBM25)
|
||||
case "web_search":
|
||||
status, reasonCode = resolveWebSearchToolSupport(cfg)
|
||||
case "i2c", "spi":
|
||||
status, reasonCode = resolveHardwareToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey))
|
||||
default:
|
||||
@@ -304,6 +306,13 @@ func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string
|
||||
return "enabled", ""
|
||||
}
|
||||
|
||||
func resolveWebSearchToolSupport(cfg *config.Config) (string, string) {
|
||||
if !cfg.Tools.IsToolEnabled("web") {
|
||||
return "disabled", ""
|
||||
}
|
||||
return "enabled", ""
|
||||
}
|
||||
|
||||
func applyToolState(cfg *config.Config, toolName string, enabled bool) error {
|
||||
switch toolName {
|
||||
case "read_file":
|
||||
@@ -507,6 +516,7 @@ func normalizeWebSearchAPIKeys(apiKeys []string, apiKey string) ([]string, bool)
|
||||
}
|
||||
|
||||
func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse {
|
||||
opts := picotools.WebSearchToolOptionsFromConfig(cfg)
|
||||
current := resolveCurrentWebSearchProvider(cfg)
|
||||
settings := map[string]webSearchProviderConfig{
|
||||
"sogou": {
|
||||
@@ -563,59 +573,53 @@ func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse {
|
||||
{
|
||||
ID: "sogou",
|
||||
Label: "Sogou",
|
||||
Configured: cfg.Tools.Web.Sogou.Enabled,
|
||||
Configured: picotools.WebSearchProviderReady(opts, "sogou"),
|
||||
Current: current == "sogou",
|
||||
},
|
||||
{
|
||||
ID: "duckduckgo",
|
||||
Label: "DuckDuckGo",
|
||||
Configured: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||
Configured: picotools.WebSearchProviderReady(opts, "duckduckgo"),
|
||||
Current: current == "duckduckgo",
|
||||
},
|
||||
{
|
||||
ID: "brave",
|
||||
Label: "Brave Search",
|
||||
Configured: cfg.Tools.Web.Brave.Enabled &&
|
||||
len(cfg.Tools.Web.Brave.APIKeys.Values()) > 0,
|
||||
ID: "brave",
|
||||
Label: "Brave Search",
|
||||
Configured: picotools.WebSearchProviderReady(opts, "brave"),
|
||||
Current: current == "brave",
|
||||
RequiresAuth: true,
|
||||
},
|
||||
{
|
||||
ID: "tavily",
|
||||
Label: "Tavily",
|
||||
Configured: cfg.Tools.Web.Tavily.Enabled &&
|
||||
len(cfg.Tools.Web.Tavily.APIKeys.Values()) > 0,
|
||||
ID: "tavily",
|
||||
Label: "Tavily",
|
||||
Configured: picotools.WebSearchProviderReady(opts, "tavily"),
|
||||
Current: current == "tavily",
|
||||
RequiresAuth: true,
|
||||
},
|
||||
{
|
||||
ID: "perplexity",
|
||||
Label: "Perplexity",
|
||||
Configured: cfg.Tools.Web.Perplexity.Enabled &&
|
||||
len(cfg.Tools.Web.Perplexity.APIKeys.Values()) > 0,
|
||||
ID: "perplexity",
|
||||
Label: "Perplexity",
|
||||
Configured: picotools.WebSearchProviderReady(opts, "perplexity"),
|
||||
Current: current == "perplexity",
|
||||
RequiresAuth: true,
|
||||
},
|
||||
{
|
||||
ID: "searxng",
|
||||
Label: "SearXNG",
|
||||
Configured: cfg.Tools.Web.SearXNG.Enabled &&
|
||||
strings.TrimSpace(cfg.Tools.Web.SearXNG.BaseURL) != "",
|
||||
Current: current == "searxng",
|
||||
ID: "searxng",
|
||||
Label: "SearXNG",
|
||||
Configured: picotools.WebSearchProviderReady(opts, "searxng"),
|
||||
Current: current == "searxng",
|
||||
},
|
||||
{
|
||||
ID: "glm_search",
|
||||
Label: "GLM Search",
|
||||
Configured: cfg.Tools.Web.GLMSearch.Enabled &&
|
||||
cfg.Tools.Web.GLMSearch.APIKey.String() != "",
|
||||
ID: "glm_search",
|
||||
Label: "GLM Search",
|
||||
Configured: picotools.WebSearchProviderReady(opts, "glm_search"),
|
||||
Current: current == "glm_search",
|
||||
RequiresAuth: true,
|
||||
},
|
||||
{
|
||||
ID: "baidu_search",
|
||||
Label: "Baidu Search",
|
||||
Configured: cfg.Tools.Web.BaiduSearch.Enabled &&
|
||||
cfg.Tools.Web.BaiduSearch.APIKey.String() != "",
|
||||
ID: "baidu_search",
|
||||
Label: "Baidu Search",
|
||||
Configured: picotools.WebSearchProviderReady(opts, "baidu_search"),
|
||||
Current: current == "baidu_search",
|
||||
RequiresAuth: true,
|
||||
},
|
||||
@@ -637,57 +641,12 @@ func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse {
|
||||
}
|
||||
|
||||
func resolveCurrentWebSearchProvider(cfg *config.Config) string {
|
||||
selected := normalizeWebSearchProvider(cfg.Tools.Web.Provider)
|
||||
if selected != "" && selected != "auto" && webSearchProviderConfigured(cfg, selected) {
|
||||
return selected
|
||||
if cfg == nil || !cfg.Tools.IsToolEnabled("web") {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, name := range []string{"perplexity", "brave", "searxng", "tavily"} {
|
||||
if webSearchProviderConfigured(cfg, name) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
if webSearchProviderConfigured(cfg, "sogou") && webSearchProviderConfigured(cfg, "duckduckgo") {
|
||||
if picotools.GetPreferredWebSearchLanguage() == "en" {
|
||||
return "duckduckgo"
|
||||
}
|
||||
return "sogou"
|
||||
}
|
||||
if webSearchProviderConfigured(cfg, "sogou") {
|
||||
return "sogou"
|
||||
}
|
||||
if webSearchProviderConfigured(cfg, "duckduckgo") {
|
||||
return "duckduckgo"
|
||||
}
|
||||
|
||||
for _, name := range []string{"baidu_search", "glm_search"} {
|
||||
if webSearchProviderConfigured(cfg, name) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func webSearchProviderConfigured(cfg *config.Config, name string) bool {
|
||||
switch name {
|
||||
case "sogou":
|
||||
return cfg.Tools.Web.Sogou.Enabled
|
||||
case "duckduckgo":
|
||||
return cfg.Tools.Web.DuckDuckGo.Enabled
|
||||
case "brave":
|
||||
return cfg.Tools.Web.Brave.Enabled && len(cfg.Tools.Web.Brave.APIKeys.Values()) > 0
|
||||
case "tavily":
|
||||
return cfg.Tools.Web.Tavily.Enabled && len(cfg.Tools.Web.Tavily.APIKeys.Values()) > 0
|
||||
case "perplexity":
|
||||
return cfg.Tools.Web.Perplexity.Enabled && len(cfg.Tools.Web.Perplexity.APIKeys.Values()) > 0
|
||||
case "searxng":
|
||||
return cfg.Tools.Web.SearXNG.Enabled && strings.TrimSpace(cfg.Tools.Web.SearXNG.BaseURL) != ""
|
||||
case "glm_search":
|
||||
return cfg.Tools.Web.GLMSearch.Enabled && cfg.Tools.Web.GLMSearch.APIKey.String() != ""
|
||||
case "baidu_search":
|
||||
return cfg.Tools.Web.BaiduSearch.Enabled && cfg.Tools.Web.BaiduSearch.APIKey.String() != ""
|
||||
default:
|
||||
return false
|
||||
selected, err := picotools.ResolveWebSearchProviderName(picotools.WebSearchToolOptionsFromConfig(cfg), "")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
@@ -198,6 +198,66 @@ func TestHandleUpdateToolState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListTools_ReportsWebSearchEnabledWhenToolIsOn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
preferNative bool
|
||||
}{
|
||||
{name: "without prefer_native", preferNative: false},
|
||||
{name: "with prefer_native", preferNative: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.Tools.Web.PreferNative = tt.preferNative
|
||||
cfg.Tools.Web.Provider = "brave"
|
||||
cfg.Tools.Web.Sogou.Enabled = false
|
||||
cfg.Tools.Web.DuckDuckGo.Enabled = false
|
||||
cfg.Tools.Web.Brave.Enabled = true
|
||||
cfg.Tools.Web.Brave.SetAPIKeys(nil)
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tools", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp toolSupportResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
for _, tool := range resp.Tools {
|
||||
if tool.Name != "web_search" {
|
||||
continue
|
||||
}
|
||||
if tool.Status != "enabled" || tool.ReasonCode != "" {
|
||||
t.Fatalf("web_search = %#v, want enabled with no reason code", tool)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
t.Fatal("expected web_search in response")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetWebSearchConfig(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
@@ -206,6 +266,7 @@ func TestHandleGetWebSearchConfig(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.Tools.Web.PreferNative = false
|
||||
cfg.Tools.Web.Provider = "sogou"
|
||||
cfg.Tools.Web.Sogou.Enabled = true
|
||||
cfg.Tools.Web.Sogou.MaxResults = 6
|
||||
@@ -242,6 +303,48 @@ func TestHandleGetWebSearchConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetWebSearchConfig_DoesNotExposeNativeAsCurrentService(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.Tools.Web.PreferNative = true
|
||||
cfg.Tools.Web.Provider = "brave"
|
||||
cfg.Tools.Web.Sogou.Enabled = false
|
||||
cfg.Tools.Web.DuckDuckGo.Enabled = false
|
||||
cfg.Tools.Web.Brave.Enabled = true
|
||||
cfg.Tools.Web.Brave.SetAPIKeys(nil)
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tools/web-search-config", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp webSearchConfigResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if !resp.PreferNative {
|
||||
t.Fatal("prefer_native should remain true in response")
|
||||
}
|
||||
if resp.CurrentService != "" {
|
||||
t.Fatalf("current_service = %q, want empty when no external provider is ready", resp.CurrentService)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateWebSearchConfig(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
@@ -393,6 +496,27 @@ func TestResolveCurrentWebSearchProvider_PrefersConfiguredProvidersBeforeSogou(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCurrentWebSearchProvider_FallsBackWhenExplicitProviderUnavailable(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Tools.Web.Provider = "brave"
|
||||
cfg.Tools.Web.Brave.Enabled = true
|
||||
cfg.Tools.Web.Sogou.Enabled = true
|
||||
|
||||
if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" {
|
||||
t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCurrentWebSearchProvider_FallsBackWhenProviderIsUnknown(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Tools.Web.Provider = "totally_unknown"
|
||||
cfg.Tools.Web.Sogou.Enabled = true
|
||||
|
||||
if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" {
|
||||
t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCurrentWebSearchProvider_UsesPreferredLanguageForSogouAndDuckDuckGo(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Tools.Web.Provider = "auto"
|
||||
@@ -413,3 +537,22 @@ func TestResolveCurrentWebSearchProvider_UsesPreferredLanguageForSogouAndDuckDuc
|
||||
t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCurrentWebSearchProvider_IgnoresPreferNativeInConfigView(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.ModelList = []*config.ModelConfig{{
|
||||
ModelName: "custom-default",
|
||||
Model: "openai/gpt-4o",
|
||||
APIKeys: config.SimpleSecureStrings("sk-default"),
|
||||
}}
|
||||
cfg.Agents.Defaults.ModelName = "custom-default"
|
||||
cfg.Tools.Web.PreferNative = true
|
||||
cfg.Tools.Web.Provider = "brave"
|
||||
cfg.Tools.Web.Sogou.Enabled = false
|
||||
cfg.Tools.Web.DuckDuckGo.Enabled = false
|
||||
cfg.Tools.Web.Brave.Enabled = true
|
||||
|
||||
if got := resolveCurrentWebSearchProvider(cfg); got != "" {
|
||||
t.Fatalf("resolveCurrentWebSearchProvider() = %q, want empty when only native search would be available", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user